mirror of
https://github.com/btouchard/ackify.git
synced 2026-05-24 02:11:12 -05:00
feat: migrate to Vue.js SPA with API-first architecture
Major refactoring to modernize the application architecture: Backend changes: - Restructure API with v1 versioning and modular handlers - Add comprehensive OpenAPI specification - Implement RESTful endpoints for documents, signatures, admin - Add checksum verification system for document integrity - Add server-side runtime injection of ACKIFY_BASE_URL and meta tags - Generate dynamic Open Graph/Twitter Card meta tags for unfurling - Remove legacy HTML template handlers - Isolate backend source on dedicated folder - Improve tests suite Frontend changes: - Migrate from Go templates to Vue.js 3 SPA with TypeScript - Add Tailwind CSS with shadcn/vue components - Implement i18n support (fr, en, es, de, it) - Add admin dashboard for document and signer management - Add signature tracking with file checksum verification - Add embed page with sign button linking to main app - Implement dark mode and accessibility features - Auto load file to compute checksum Infrastructure: - Update Dockerfile for SPA build process - Simplify deployment with embedded frontend assets - Add migration for checksum_verifications table This enables better UX, proper link previews on social platforms, and provides a foundation for future enhancements.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import AppShell from './components/layout/AppShell.vue'
|
||||
import NotificationToast from './components/NotificationToast.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const isEmbedPage = computed(() => route.meta.isEmbed === true)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Check authentication status on app mount
|
||||
onMounted(() => {
|
||||
authStore.checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<template v-if="isEmbedPage">
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<AppShell>
|
||||
<router-view />
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<NotificationToast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,83 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { documentService } from '@/services/documents'
|
||||
import { extractError } from '@/services/http'
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const documentUrl = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
errorMessage.value = null
|
||||
|
||||
if (!documentUrl.value.trim()) {
|
||||
const homeRoute = '/'
|
||||
if (isAuthenticated.value) {
|
||||
await router.push(homeRoute)
|
||||
} else {
|
||||
await authStore.startOAuthLogin(homeRoute)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
const response = await documentService.createDocument({
|
||||
reference: documentUrl.value.trim(),
|
||||
})
|
||||
|
||||
const homeRoute = `/?doc=${response.docId}`
|
||||
if (isAuthenticated.value) {
|
||||
await router.push(homeRoute)
|
||||
} else {
|
||||
await authStore.startOAuthLogin(homeRoute)
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = extractError(error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="w-full rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900 p-4 text-sm text-red-800 dark:text-red-200"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
v-model="documentUrl"
|
||||
type="text"
|
||||
placeholder="URL, PATH ou RÉFÉRENCE du document à lire (optionnel)"
|
||||
class="flex-1 h-11"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<Button
|
||||
@click="handleSubmit"
|
||||
size="lg"
|
||||
class="group whitespace-nowrap"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting">Chargement...</span>
|
||||
<span v-else>Commencer</span>
|
||||
<ArrowRight v-if="!isSubmitting" :size="16" class="ml-2 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<transition-group
|
||||
name="notification"
|
||||
tag="div"
|
||||
class="fixed top-4 right-4 z-50 space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="max-w-sm w-full bg-card text-card-foreground shadow-lg rounded-lg pointer-events-auto ring-1 ring-border overflow-hidden"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
v-if="notification.type === 'success'"
|
||||
class="h-6 w-6 text-green-500 dark:text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="notification.type === 'error'"
|
||||
class="h-6 w-6 text-destructive"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else-if="notification.type === 'warning'"
|
||||
class="h-6 w-6 text-yellow-500 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
v-else
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ notification.title }}
|
||||
</p>
|
||||
<p v-if="notification.message" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
@click="removeNotification(notification.id)"
|
||||
class="bg-transparent rounded-md inline-flex text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-colors"
|
||||
aria-label="Fermer la notification"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const notifications = computed(() => uiStore.notifications)
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
uiStore.removeNotification(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-enter-active,
|
||||
.notification-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.notification-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.notification-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="sign-button-container">
|
||||
<button
|
||||
v-if="!isSigned"
|
||||
@click="handleSign"
|
||||
:disabled="loading || disabled || !docId"
|
||||
:class="buttonClasses"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="-ml-1 mr-3 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
{{ loading ? 'Confirmation en cours...' : 'Confirmer la lecture' }}
|
||||
</button>
|
||||
|
||||
<div v-else class="signed-status">
|
||||
<div class="flex items-center justify-center space-x-2 text-green-700">
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-semibold">Lecture confirmée</span>
|
||||
</div>
|
||||
<p v-if="signedAt" class="mt-2 text-sm text-gray-600 text-center">
|
||||
Le {{ formatDate(signedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-4 text-red-600 text-sm text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSignatureStore } from '@/stores/signatures'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Signature {
|
||||
userEmail: string
|
||||
signedAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
docId?: string
|
||||
referer?: string
|
||||
disabled?: boolean
|
||||
signatures?: Signature[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
signed: [docId: string]
|
||||
error: [error: string]
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const signatureStore = useSignatureStore()
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSigned = ref(false)
|
||||
const signedAt = ref<string | null>(null)
|
||||
|
||||
// Check if current user has signed based on signatures list
|
||||
async function checkIfSigned() {
|
||||
// Initialize auth store if not already done (for public pages)
|
||||
if (!authStore.initialized) {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch {
|
||||
// Ignore errors - user is not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.signatures || !authStore.user?.email) {
|
||||
isSigned.value = false
|
||||
signedAt.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const userSignature = props.signatures.find(
|
||||
sig => sig.userEmail === authStore.user?.email
|
||||
)
|
||||
|
||||
isSigned.value = !!userSignature
|
||||
signedAt.value = userSignature?.signedAt || null
|
||||
}
|
||||
|
||||
// Watch for changes in signatures prop or auth state
|
||||
watch(() => [props.signatures, authStore.user], checkIfSigned, { immediate: true, deep: true })
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return [
|
||||
'inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white transition-colors',
|
||||
loading.value || props.disabled || !props.docId
|
||||
? 'bg-indigo-400 cursor-not-allowed'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
]
|
||||
})
|
||||
|
||||
async function handleSign() {
|
||||
if (!props.docId) {
|
||||
error.value = 'Document ID manquant'
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!authStore.initialized) {
|
||||
try {
|
||||
await authStore.checkAuth()
|
||||
} catch {
|
||||
// Ignore errors - user is not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to OAuth login
|
||||
if (!authStore.isAuthenticated) {
|
||||
try {
|
||||
await authStore.startOAuthLogin(window.location.pathname + window.location.search)
|
||||
} catch (err: any) {
|
||||
error.value = 'Impossible de démarrer l\'authentification'
|
||||
emit('error', error.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await signatureStore.createSignature({
|
||||
docId: props.docId,
|
||||
referer: props.referer,
|
||||
})
|
||||
|
||||
isSigned.value = true
|
||||
signedAt.value = new Date().toISOString()
|
||||
emit('signed', props.docId)
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error?.message || 'Impossible de confirmer la lecture'
|
||||
error.value = errorMessage
|
||||
emit('error', errorMessage)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-button-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signed-status {
|
||||
padding: 1rem;
|
||||
background-color: #f0fdf4;
|
||||
border: 2px solid #86efac;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="signature-list">
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else-if="signatures.length === 0" class="empty-state">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mt-2 text-muted-foreground">{{ emptyMessage || 'Aucune confirmation trouvée' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="signature in signatures"
|
||||
:key="signature.id"
|
||||
:class="[
|
||||
'signature-card shadow rounded-lg p-4 hover:shadow-md transition-shadow bg-card text-card-foreground border border-border',
|
||||
isDeleted ? 'opacity-50' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-lg font-medium text-foreground">
|
||||
{{ signature.docTitle || signature.docId }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="!isDeleted"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Confirmé
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Document supprimé{{ signature.docDeletedAt ? ` le ${formatDate(signature.docDeletedAt)}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="signature.docTitle">
|
||||
<span class="font-medium">ID:</span> {{ signature.docId }}
|
||||
</p>
|
||||
<p v-if="signature.docUrl">
|
||||
<span class="font-medium">Document:</span>
|
||||
<a :href="signature.docUrl" target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded">
|
||||
{{ signature.docUrl }}
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="showUserInfo">
|
||||
<span class="font-medium">Lecteur:</span> {{ signature.userName || signature.userEmail }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">Date:</span> {{ formatDate(signature.signedAt) }}
|
||||
</p>
|
||||
<p v-if="signature.serviceInfo" class="flex items-center">
|
||||
<span class="font-medium mr-2">Origine:</span>
|
||||
<span class="inline-flex items-center space-x-1">
|
||||
<span v-html="signature.serviceInfo.icon"></span>
|
||||
<span>{{ signature.serviceInfo.name }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showDetails" class="mt-3 pt-3 border-t border-border">
|
||||
<details class="text-xs text-muted-foreground">
|
||||
<summary class="cursor-pointer hover:text-foreground font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded">
|
||||
Détails de vérification
|
||||
</summary>
|
||||
<div class="mt-2 space-y-1 font-mono bg-muted p-2 rounded border border-border">
|
||||
<p><span class="font-semibold">ID:</span> {{ signature.id }}</p>
|
||||
<p><span class="font-semibold">Nonce:</span> {{ signature.nonce }}</p>
|
||||
<p class="break-all">
|
||||
<span class="font-semibold">Hash:</span> {{ signature.payloadHash }}
|
||||
</p>
|
||||
<p class="break-all">
|
||||
<span class="font-semibold">Confirmation:</span>
|
||||
{{ signature.signature.substring(0, 64) }}...
|
||||
</p>
|
||||
<p v-if="signature.prevHash" class="break-all">
|
||||
<span class="font-semibold">Hash précédent:</span> {{ signature.prevHash }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions" class="ml-4">
|
||||
<button
|
||||
@click="$emit('view-details', signature)"
|
||||
class="text-primary hover:text-primary/80 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1"
|
||||
>
|
||||
Voir détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Signature } from '@/services/signatures'
|
||||
|
||||
interface Props {
|
||||
signatures: Signature[]
|
||||
loading?: boolean
|
||||
showUserInfo?: boolean
|
||||
showDetails?: boolean
|
||||
showActions?: boolean
|
||||
emptyMessage?: string
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showUserInfo: false,
|
||||
showDetails: true,
|
||||
showActions: false,
|
||||
isDeleted: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'view-details': [signature: Signature]
|
||||
}>()
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.focus\:not-sr-only:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Github, Mail } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="mt-auto border-t border-border/40 clay-card backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="h-8 w-8 text-primary" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-foreground">Ackify</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('footer.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-foreground">{{ t('footer.navigation.title') }}</h3>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<router-link to="/" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('nav.home') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/signatures" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('nav.myConfirmations') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-foreground">{{ t('footer.resources.title') }}</h3>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a href="https://github.com/btouchard/ackify-ce" target="_blank" rel="noopener noreferrer" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.resources.documentation') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/btouchard/ackify-ce" target="_blank" rel="noopener noreferrer" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.resources.apiReference') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/btouchard/ackify-ce/issues" target="_blank" rel="noopener noreferrer" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.resources.support') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal & Contact -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-foreground">{{ t('footer.legal.title') }}</h3>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a href="#" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.legal.terms') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.legal.privacy') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
{{ t('footer.legal.contact') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Social Icons -->
|
||||
<div class="mt-6 flex space-x-4">
|
||||
<a href="https://github.com/btouchard/ackify-ce" target="_blank" rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors"
|
||||
aria-label="GitHub">
|
||||
<Github :size="20" />
|
||||
</a>
|
||||
<a href="mailto:support@ackify.eu" class="text-muted-foreground hover:text-primary transition-colors" aria-label="Email">
|
||||
<Mail :size="20" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 border-t border-border/40 pt-8">
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
© {{ new Date().getFullYear() }} Ackify. {{ t('footer.copyright') }}
|
||||
<span class="text-xs">{{ t('footer.license') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,273 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Menu, X, ChevronDown, User, LogOut, Shield, FileSignature } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
import LanguageSelect from './LanguageSelect.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const mobileMenuOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value
|
||||
}
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
userMenuOpen.value = !userMenuOpen.value
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
authStore.startOAuthLogin()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await authStore.logout()
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
// Close user menu when clicking outside
|
||||
const closeUserMenu = () => {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 w-full border-b border-border/40 clay-card backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<nav class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" :aria-label="t('nav.mainNavigation')">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<router-link to="/" class="flex items-center space-x-2 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md">
|
||||
<svg class="h-8 w-8 text-primary" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12L11 14L15 10M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-foreground">Ackify</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation (only visible when authenticated) -->
|
||||
<div v-if="isAuthenticated" class="hidden md:flex md:items-center md:space-x-6">
|
||||
<router-link
|
||||
to="/"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/') ? 'text-primary' : 'text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.home') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/signatures"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/signatures') ? 'text-primary' : 'text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.myConfirmations') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
:class="[
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
isActive('/admin') ? 'text-primary' : 'text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.admin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Language + Theme toggle + Auth -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<LanguageSelect />
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Desktop Auth -->
|
||||
<div v-if="isAuthenticated" class="hidden md:block relative">
|
||||
<button
|
||||
@click="toggleUserMenu"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="userMenuOpen"
|
||||
>
|
||||
<User :size="18" />
|
||||
<span class="text-foreground">{{ user?.email?.split('@')[0] }}</span>
|
||||
<ChevronDown :size="16" class="text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
@click.stop
|
||||
v-click-outside="closeUserMenu"
|
||||
class="absolute right-0 mt-2 w-56 origin-top-right clay-card rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground border-b border-border/40 mb-2">
|
||||
<p class="font-medium text-foreground">{{ user?.name }}</p>
|
||||
<p class="text-xs truncate">{{ user?.email }}</p>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
to="/signatures"
|
||||
@click="userMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<FileSignature :size="16" />
|
||||
<span>{{ t('nav.myConfirmations') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
@click="userMenuOpen = false"
|
||||
class="flex items-center space-x-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Shield :size="16" />
|
||||
<span>{{ t('nav.administration') }}</span>
|
||||
</router-link>
|
||||
|
||||
<div class="border-t border-border/40 my-2"></div>
|
||||
|
||||
<button
|
||||
@click="logout"
|
||||
class="flex w-full items-center space-x-2 rounded-md px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
<span>{{ t('nav.logout') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<Button v-else @click="login" variant="default" size="sm" class="hidden md:inline-flex">
|
||||
{{ t('nav.login') }}
|
||||
</Button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
@click="toggleMobileMenu"
|
||||
class="md:hidden rounded-md p-2 hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
:aria-label="t('nav.mobileMenu')"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<Menu v-if="!mobileMenuOpen" :size="24" />
|
||||
<X v-else :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-border/40">
|
||||
<div class="space-y-1 px-4 pb-3 pt-2">
|
||||
<!-- Navigation links (only when authenticated) -->
|
||||
<template v-if="isAuthenticated">
|
||||
<router-link
|
||||
to="/"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.home') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/signatures"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/signatures') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.myConfirmations') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
@click="closeMobileMenu"
|
||||
:class="[
|
||||
'block rounded-md px-3 py-2 text-base font-medium transition-colors',
|
||||
isActive('/admin') ? 'bg-accent text-primary' : 'hover:bg-accent'
|
||||
]"
|
||||
>
|
||||
{{ t('nav.administration') }}
|
||||
</router-link>
|
||||
|
||||
<div class="border-t border-border/40 pt-3 mt-3">
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground mb-2">
|
||||
<p class="font-medium text-foreground">{{ user?.name }}</p>
|
||||
<p class="text-xs">{{ user?.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="w-full text-left rounded-md px-3 py-2 text-base font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Login button (when not authenticated) -->
|
||||
<Button v-else @click="login" variant="default" class="w-full">
|
||||
{{ t('nav.login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Click outside directive will be added via composable */
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppFooter from './AppFooter.vue'
|
||||
import SkipToContent from '../accessibility/SkipToContent.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-background">
|
||||
<SkipToContent />
|
||||
<AppHeader />
|
||||
|
||||
<main id="main-content" class="flex-1 min-h-[60vh]">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { setLocale } from '@/i18n'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
interface Language {
|
||||
code: string
|
||||
name: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
const languages: Language[] = [
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'it', name: 'Italiano', flag: '🇮🇹' }
|
||||
]
|
||||
|
||||
const currentLanguage = computed(() => {
|
||||
return languages.find(lang => lang.code === locale.value) || languages[0]
|
||||
})
|
||||
|
||||
const toggleDropdown = () => {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
const selectLanguage = (langCode: string) => {
|
||||
setLocale(langCode)
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeydown = (event: KeyboardEvent, langCode: string) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
selectLanguage(langCode)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
@click="toggleDropdown"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="rounded-md px-3 py-2 text-sm font-medium hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
:aria-label="t('language.select')"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="dropdownOpen"
|
||||
>
|
||||
<span class="mr-1.5 text-lg leading-none" aria-hidden="true">{{ currentLanguage?.flag || '🇫🇷' }}</span>
|
||||
<span class="hidden sm:inline">{{ currentLanguage?.name || 'Français' }}</span>
|
||||
<ChevronDown :size="16" class="ml-1 text-muted-foreground" />
|
||||
</Button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
@click.stop
|
||||
v-click-outside="closeDropdown"
|
||||
class="absolute right-0 mt-2 w-48 origin-top-right clay-card rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
:aria-label="t('language.select')"
|
||||
>
|
||||
<div class="p-1">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
@click="selectLanguage(lang.code)"
|
||||
@keydown="(e) => handleKeydown(e, lang.code)"
|
||||
:class="[
|
||||
'flex w-full items-center space-x-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
locale === lang.code
|
||||
? 'bg-accent text-primary font-medium'
|
||||
: 'hover:bg-accent/50'
|
||||
]"
|
||||
role="menuitem"
|
||||
:tabindex="0"
|
||||
>
|
||||
<span class="text-lg leading-none" aria-hidden="true">{{ lang.flag }}</span>
|
||||
<span class="flex-1 text-left">{{ lang.name }}</span>
|
||||
<span
|
||||
v-if="locale === lang.code"
|
||||
class="ml-auto h-1.5 w-1.5 rounded-full bg-primary"
|
||||
aria-label="selected"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure emojis render consistently */
|
||||
button span {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Moon, Sun } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme.value === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
// Watch theme changes and apply immediately
|
||||
watch(theme, () => {
|
||||
applyTheme()
|
||||
}, { immediate: false })
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
// Set initial theme
|
||||
theme.value = savedTheme || (prefersDark ? 'dark' : 'light')
|
||||
|
||||
// Apply immediately
|
||||
applyTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
@click="toggleTheme"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="t('theme.toggle')"
|
||||
>
|
||||
<Sun v-if="theme === 'dark'" :size="20" class="text-muted-foreground" />
|
||||
<Moon v-else :size="20" class="text-muted-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
success: 'border-l-4 border-green-400 bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-400',
|
||||
destructive: 'border-l-4 border-red-400 bg-red-50 text-red-900 dark:bg-red-900/20 dark:text-red-400',
|
||||
warning: 'border-l-4 border-yellow-400 bg-yellow-50 text-yellow-900 dark:bg-yellow-900/20 dark:text-yellow-400',
|
||||
info: 'border-l-4 border-blue-400 bg-blue-50 text-blue-900 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface AlertProps {
|
||||
variant?: VariantProps<typeof alertVariants>['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<AlertProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AlertDescriptionProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<AlertDescriptionProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AlertTitleProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<AlertTitleProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success:
|
||||
'border-transparent bg-green-500 text-white hover:bg-green-600',
|
||||
warning:
|
||||
'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: VariantProps<typeof badgeVariants>['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<BadgeProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { Primitive, type PrimitiveProps } from 'radix-vue'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 clay-button',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground clay-button',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps extends PrimitiveProps {
|
||||
variant?: VariantProps<typeof buttonVariants>['variant']
|
||||
size?: VariantProps<typeof buttonVariants>['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<CardProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('clay-card rounded-lg text-card-foreground', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardContentProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<CardContentProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardDescriptionProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<CardDescriptionProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardHeaderProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<CardHeaderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CardTitleProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<CardTitleProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="cn('text-2xl font-semibold leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import Card from './Card.vue'
|
||||
import CardHeader from './CardHeader.vue'
|
||||
import CardTitle from './CardTitle.vue'
|
||||
import CardContent from './CardContent.vue'
|
||||
import Button from './Button.vue'
|
||||
import Alert from './Alert.vue'
|
||||
import AlertDescription from './AlertDescription.vue'
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'default' | 'destructive' | 'warning'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<ConfirmDialogProps>(), {
|
||||
confirmText: 'Confirmer',
|
||||
cancelText: 'Annuler',
|
||||
variant: 'default',
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="emit('cancel')">
|
||||
<Card :class="['max-w-md w-full', variant === 'destructive' ? 'border-destructive' : variant === 'warning' ? 'border-orange-500' : 'border-primary']">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle :class="{ 'text-destructive': variant === 'destructive', 'text-orange-600': variant === 'warning' }">
|
||||
{{ title }}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="icon" @click="emit('cancel')" :disabled="loading">
|
||||
<X :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<Alert :variant="variant === 'destructive' ? 'destructive' : 'default'"
|
||||
:class="variant === 'warning' ? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20' : variant === 'destructive' ? 'border-destructive' : ''">
|
||||
<AlertDescription :class="{ 'text-orange-800 dark:text-orange-200': variant === 'warning' }">
|
||||
{{ message }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<Button type="button" variant="outline" @click="emit('cancel')" :disabled="loading">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
@click="emit('confirm')"
|
||||
:variant="variant === 'default' ? 'default' : 'destructive'"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type InputHTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InputProps {
|
||||
class?: InputHTMLAttributes['class']
|
||||
type?: string
|
||||
modelValue?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<InputProps>(), {
|
||||
type: 'text'
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emits('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
@input="handleInput"
|
||||
:class="cn(
|
||||
'flex h-10 w-full rounded-md clay-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelProps {
|
||||
for?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<LabelProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
:for="props.for"
|
||||
:class="cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type TextareaHTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextareaProps {
|
||||
class?: TextareaHTMLAttributes['class']
|
||||
modelValue?: string
|
||||
rows?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
rows: 4
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
emits('update:modelValue', target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
:rows="rows"
|
||||
@input="handleInput"
|
||||
:class="cn(
|
||||
'flex min-h-[80px] w-full rounded-md clay-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableBodyProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableCellProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableCellProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td :class="cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableHeadProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableHeaderProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableHeaderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead :class="cn('[&_tr]:border-b', props.class)">
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableRowProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<TableRowProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr :class="cn('border-b border-border/40 transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
export function useClickOutside(elementRef: Ref<HTMLElement | null>, callback: () => void) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (elementRef.value && !elementRef.value.contains(event.target as Node)) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
}
|
||||
|
||||
// Extended HTMLElement type for directive
|
||||
interface HTMLElementWithClickOutside extends HTMLElement {
|
||||
clickOutsideEvent?: (event: Event) => void
|
||||
}
|
||||
|
||||
// Directive version
|
||||
export const vClickOutside = {
|
||||
mounted(el: HTMLElement, binding: { value: () => void }) {
|
||||
const element = el as HTMLElementWithClickOutside
|
||||
|
||||
element.clickOutsideEvent = (event: Event) => {
|
||||
if (!(el === event.target || el.contains(event.target as Node))) {
|
||||
binding.value()
|
||||
}
|
||||
}
|
||||
|
||||
// Add a small delay to avoid immediate trigger from the click that opened the element
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', element.clickOutsideEvent!)
|
||||
}, 50)
|
||||
},
|
||||
unmounted(el: HTMLElement) {
|
||||
const element = el as HTMLElementWithClickOutside
|
||||
if (element.clickOutsideEvent) {
|
||||
document.removeEventListener('click', element.clickOutsideEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* Composable to manage document.title with i18n support
|
||||
* @param titleKey - i18n key for the page title
|
||||
* @param params - optional parameters for i18n interpolation
|
||||
*/
|
||||
export function usePageTitle(titleKey?: string, params?: Record<string, any>) {
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const updateTitle = () => {
|
||||
if (titleKey) {
|
||||
const translatedTitle = params ? t(titleKey, params) : t(titleKey)
|
||||
const appName = t('app.name')
|
||||
document.title = `${translatedTitle} - ${appName}`
|
||||
} else {
|
||||
document.title = t('app.name')
|
||||
}
|
||||
}
|
||||
|
||||
// Update title on mount and when locale changes
|
||||
updateTitle()
|
||||
|
||||
watch(locale, () => {
|
||||
updateTitle()
|
||||
})
|
||||
|
||||
return {
|
||||
updateTitle
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
// Add more env variables as needed
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import en from './locales/en.json'
|
||||
import fr from './locales/fr.json'
|
||||
import es from './locales/es.json'
|
||||
import de from './locales/de.json'
|
||||
import it from './locales/it.json'
|
||||
|
||||
const getBrowserLocale = (): string | undefined => {
|
||||
const navigatorLocale =
|
||||
navigator.languages !== undefined
|
||||
? navigator.languages[0]
|
||||
: navigator.language
|
||||
|
||||
if (!navigatorLocale) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const trimmedLocale = navigatorLocale.trim().split(/-|_/)[0]
|
||||
return trimmedLocale
|
||||
}
|
||||
|
||||
const getInitialLocale = (): string => {
|
||||
const savedLocale = localStorage.getItem('locale')
|
||||
|
||||
if (savedLocale && ['en', 'fr', 'es', 'de', 'it'].includes(savedLocale)) {
|
||||
return savedLocale
|
||||
}
|
||||
|
||||
const browserLocale = getBrowserLocale()
|
||||
if (browserLocale && ['en', 'fr', 'es', 'de', 'it'].includes(browserLocale)) {
|
||||
return browserLocale
|
||||
}
|
||||
|
||||
return 'fr'
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getInitialLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
fr,
|
||||
es,
|
||||
de,
|
||||
it
|
||||
},
|
||||
datetimeFormats: {
|
||||
en: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
},
|
||||
long: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
},
|
||||
fr: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
},
|
||||
long: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
},
|
||||
es: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
},
|
||||
long: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
},
|
||||
de: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
},
|
||||
long: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
},
|
||||
it: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
},
|
||||
long: {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
}
|
||||
}
|
||||
},
|
||||
numberFormats: {
|
||||
en: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}
|
||||
},
|
||||
fr: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}
|
||||
},
|
||||
es: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}
|
||||
},
|
||||
de: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}
|
||||
},
|
||||
it: {
|
||||
currency: {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const setLocale = (locale: string) => {
|
||||
i18n.global.locale.value = locale as any
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
localStorage.setItem('locale', locale)
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('lang', i18n.global.locale.value)
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"myConfirmations": "Meine Bestätigungen",
|
||||
"admin": "Admin",
|
||||
"administration": "Verwaltung",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"mobileMenu": "Mobilmenü",
|
||||
"mainNavigation": "Hauptnavigation"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Design wechseln"
|
||||
},
|
||||
"language": {
|
||||
"select": "Sprache auswählen",
|
||||
"fr": "Französisch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"de": "Deutsch",
|
||||
"it": "Italienisch"
|
||||
},
|
||||
"auth": {
|
||||
"user": {
|
||||
"connectedAs": "Angemeldet als"
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Lesebestätigung",
|
||||
"subtitle": "Bestätigen Sie Ihre Lesung mit einer kryptographischen Ed25519-Bestätigung",
|
||||
"loading": {
|
||||
"title": "Dokument wird geladen...",
|
||||
"description": "Bitte warten Sie, während wir das Dokument zur Signierung vorbereiten."
|
||||
},
|
||||
"noDocument": {
|
||||
"title": "Kein Dokument angegeben",
|
||||
"description": "Um ein Dokument zu signieren, fügen Sie den Parameter {code} zur URL hinzu",
|
||||
"examples": "Beispiele:"
|
||||
},
|
||||
"success": {
|
||||
"title": "Lesung erfolgreich bestätigt!",
|
||||
"description": "Ihre Bestätigung wurde kryptographisch und sicher gespeichert."
|
||||
},
|
||||
"error": {
|
||||
"title": "Ein Fehler ist aufgetreten",
|
||||
"authRequired": "Sie müssen angemeldet sein, um ein Dokument zu erstellen.",
|
||||
"loadFailed": "Fehler beim Laden des Dokuments",
|
||||
"loginButton": "Anmelden"
|
||||
},
|
||||
"document": {
|
||||
"title": "Zu bestätigendes Dokument",
|
||||
"id": "ID"
|
||||
},
|
||||
"info": {
|
||||
"description": "Durch die Bestätigung der Lesung dieses Dokuments bestätigen Sie, dass Sie dessen Inhalt gelesen haben und akzeptieren, es kryptographisch und unwiderruflich zu validieren.",
|
||||
"recorded": "Ihre Bestätigung wird mit den folgenden Informationen gespeichert:",
|
||||
"email": "Ihre E-Mail-Adresse",
|
||||
"timestamp": "Präziser Zeitstempel der Bestätigung",
|
||||
"signature": "Kryptographische Ed25519-Bestätigung",
|
||||
"hash": "SHA-256-Hash des Inhalts"
|
||||
},
|
||||
"confirmations": {
|
||||
"title": "Bestehende Bestätigungen",
|
||||
"count": "{count} Bestätigung | {count} Bestätigungen",
|
||||
"recorded": "gespeichert"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Bestätigungen",
|
||||
"description": "Seien Sie der Erste, der die Lesung dieses Dokuments bestätigt"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Wie funktioniert es?",
|
||||
"subtitle": "Ackify ermöglicht es Ihnen, kryptographisch zu beweisen, dass Sie ein Dokument gelesen haben",
|
||||
"step1": {
|
||||
"title": "1. Greifen Sie auf das Dokument zu",
|
||||
"description": "Fügen Sie {code} zur Adresse dieser Seite hinzu"
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Authentifizieren Sie sich",
|
||||
"description": "Melden Sie sich über OAuth2 an, um Ihre Identität zu bestätigen"
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Bestätigen Sie die Lesung",
|
||||
"description": "Ihre Bestätigung wird mit einer Ed25519-Signatur gespeichert"
|
||||
},
|
||||
"features": {
|
||||
"crypto": {
|
||||
"title": "Kryptographische Sicherheit",
|
||||
"description": "Unwiderrufliche Ed25519-Signaturen garantieren Authentizität"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Sofort",
|
||||
"description": "Bestätigung mit zwei Klicks und sofortiger kryptographischer Verifizierung"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Präziser Zeitstempel",
|
||||
"description": "Jede Bestätigung wird zeitgestempelt und verkettet, um Integrität zu gewährleisten"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signButton": {
|
||||
"confirm": "Meine Lesung bestätigen",
|
||||
"alreadySigned": "Bereits bestätigt",
|
||||
"mustLogin": "Anmelden zum Bestätigen",
|
||||
"signing": "Bestätigung läuft...",
|
||||
"verified": "Verifiziert",
|
||||
"error": {
|
||||
"title": "Bestätigung fehlgeschlagen",
|
||||
"notAuthenticated": "Sie müssen angemeldet sein, um ein Dokument zu bestätigen.",
|
||||
"alreadySigned": "Sie haben dieses Dokument bereits bestätigt.",
|
||||
"generic": "Bei der Bestätigung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"signatureList": {
|
||||
"loading": "Bestätigungen werden geladen...",
|
||||
"email": "E-Mail",
|
||||
"date": "Datum",
|
||||
"signature": "Signatur",
|
||||
"hash": "Hash",
|
||||
"nonce": "Nonce",
|
||||
"verificationStatus": "Verifizierungsstatus",
|
||||
"verified": "Verifiziert",
|
||||
"notVerified": "Nicht verifiziert",
|
||||
"showDetails": "Details anzeigen",
|
||||
"hideDetails": "Details verbergen",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"previousHash": "Vorheriger Hash"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Meine Lesebestätigungen",
|
||||
"subtitle": "Liste aller Dokumente, deren Lesung Sie kryptographisch bestätigt haben",
|
||||
"loading": "Ihre Bestätigungen werden geladen...",
|
||||
"empty": {
|
||||
"title": "Noch keine Bestätigungen",
|
||||
"description": "Sie haben noch keine Dokumente bestätigt. Beginnen Sie mit der Bestätigung eines Dokuments, um es hier anzuzeigen."
|
||||
},
|
||||
"count": "{count} Bestätigung | {count} Bestätigungen",
|
||||
"results": "{count} Ergebnis | {count} Ergebnisse",
|
||||
"document": "Dokument",
|
||||
"signedAt": "Bestätigt am",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"stats": {
|
||||
"total": "Gesamt",
|
||||
"totalConfirmations": "Gesamtbestätigungen",
|
||||
"unique": "Eindeutige",
|
||||
"uniqueDocuments": "Eindeutige Dokumente",
|
||||
"last": "Letzte",
|
||||
"lastConfirmation": "Letzte Bestätigung"
|
||||
},
|
||||
"allConfirmations": "Alle meine Bestätigungen",
|
||||
"about": {
|
||||
"title": "Über Bestätigungen",
|
||||
"description": "Jede Bestätigung wird kryptographisch mit Ed25519 aufgezeichnet und verkettet, um Integrität zu gewährleisten. Bestätigungen sind unwiderruflich und präzise zeitgestempelt."
|
||||
},
|
||||
"search": "Suchen...",
|
||||
"error": {
|
||||
"title": "Fehler beim Laden der Bestätigungen",
|
||||
"description": "Ihre Bestätigungen können nicht geladen werden. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Verwaltung",
|
||||
"subtitle": "Dokumente und erwartete Leser verwalten",
|
||||
"loading": "Daten werden geladen...",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalDocuments": "Gesamtdokumente",
|
||||
"totalSignatures": "Gesamtbestätigungen",
|
||||
"recentActivity": "Letzte Aktivität",
|
||||
"stats": {
|
||||
"documents": "Dokumente",
|
||||
"readers": "Leser",
|
||||
"active": "Aktiv",
|
||||
"expected": "Erwartet",
|
||||
"signed": "Signiert",
|
||||
"pending": "Ausstehend",
|
||||
"completion": "Fertigstellung"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Alle Dokumente",
|
||||
"new": "Neues Dokument erstellen",
|
||||
"newDescription": "Eine Dokumentreferenz vorbereiten, um Lesebestätigungen zu verfolgen",
|
||||
"search": "Suchen...",
|
||||
"searchPlaceholder": "Nach ID, Titel oder URL suchen...",
|
||||
"id": "Dokument-ID",
|
||||
"idLabel": "Dokument-ID",
|
||||
"idHelper": "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche",
|
||||
"idPlaceholder": "z.B.: sicherheitsrichtlinie-2025",
|
||||
"signatures": "Bestätigungen",
|
||||
"created": "Erstellt",
|
||||
"createdOn": "Erstellt am",
|
||||
"by": "Von",
|
||||
"url": "URL",
|
||||
"document": "Dokument",
|
||||
"actions": "Aktionen",
|
||||
"view": "Ansehen",
|
||||
"manage": "Verwalten",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"empty": "Keine Dokumente gefunden"
|
||||
},
|
||||
"documentDetail": {
|
||||
"title": "Dokumentdetails",
|
||||
"metadata": "Dokumentmetadaten und Prüfsumme",
|
||||
"checksum": "Prüfsumme",
|
||||
"algorithm": "Algorithmus",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Sicherheitsrichtlinie 2025",
|
||||
"urlLabel": "URL",
|
||||
"urlPlaceholder": "https://example.com/doc.pdf",
|
||||
"checksumLabel": "Prüfsumme",
|
||||
"checksumPlaceholder": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"algorithmLabel": "Algorithmus",
|
||||
"descriptionLabel": "Beschreibung",
|
||||
"descriptionPlaceholder": "Dokumentbeschreibung...",
|
||||
"signatures": "Bestätigungen",
|
||||
"back": "Zurück zur Liste",
|
||||
"expectedSigners": "Erwartete Unterzeichner",
|
||||
"addExpectedSigner": "Erwarteten Unterzeichner hinzufügen",
|
||||
"addSigners": "Erwartete Leser hinzufügen",
|
||||
"emailsLabel": "E-Mails (eine pro Zeile)",
|
||||
"emailsPlaceholder": "Maria Schmidt <maria.schmidt@example.com>\njohn.mueller@example.com\nSophie Weber <sophie@example.com>",
|
||||
"emailLabel": "E-Mail *",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Vollständiger Name",
|
||||
"reader": "Leser",
|
||||
"user": "Benutzer",
|
||||
"status": "Status",
|
||||
"confirmedOn": "Bestätigt am",
|
||||
"noExpectedSigners": "Keine erwarteten Leser",
|
||||
"noSignatures": "Keine Bestätigungen",
|
||||
"reminders": "E-Mail-Erinnerungen",
|
||||
"remindersSent": "Gesendete Erinnerungen",
|
||||
"toRemind": "Zu erinnern",
|
||||
"lastReminder": "Letzte Erinnerung",
|
||||
"sendReminder": "Erinnerung senden",
|
||||
"metadataWarning": {
|
||||
"title": "⚠️ Warnung: Signaturungültigkeit",
|
||||
"description": "Sie sind dabei, kritische Dokumentinformationen (URL, Prüfsumme, Algorithmus oder Beschreibung) zu ändern.",
|
||||
"warning": "Diese Änderung führt zur Ungültigkeit aller bestehenden Signaturen, da sie kryptographisch mit dem aktuellen Dokumentinhalt verknüpft sind.",
|
||||
"currentSignatures": "Aktuelle Signaturen, die ungültig werden:",
|
||||
"confirm": "Ich verstehe, fortfahren",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"dangerZone": "Gefahrenbereich",
|
||||
"dangerZoneDescription": "Irreversible Aktionen für dieses Dokument",
|
||||
"deleteDocument": "Dieses Dokument löschen",
|
||||
"deleteWarning": "Diese Aktion ist irreversibel!",
|
||||
"deleteWillRemove": "Diese Aktion wird dauerhaft entfernen:",
|
||||
"deleteItem1": "Alle Dokumentmetadaten",
|
||||
"deleteItem2": "Die Liste der erwarteten Leser",
|
||||
"deleteItem3": "Alle kryptographischen Bestätigungen",
|
||||
"deleteItem4": "Den Erinnerungsverlauf"
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Dokumentreferenz",
|
||||
"label": "Referenz (URL, Pfad oder ID)",
|
||||
"placeholder": "https://example.com/doc.pdf oder /pfad/zum/doc",
|
||||
"submit": "Bestätigen",
|
||||
"creating": "Wird erstellt..."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"loading": "Signaturinformationen werden geladen...",
|
||||
"title": "Signatur für",
|
||||
"signedBy": "Signiert von",
|
||||
"on": "am",
|
||||
"verified": "Verifiziert",
|
||||
"viewAll": "Alle Bestätigungen anzeigen",
|
||||
"error": "Signaturinformationen können nicht geladen werden"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Seite nicht gefunden",
|
||||
"description": "Die Seite, die Sie suchen, existiert nicht oder wurde verschoben.",
|
||||
"home": "Zurück zur Startseite"
|
||||
},
|
||||
"footer": {
|
||||
"madeWith": "Erstellt mit",
|
||||
"by": "von",
|
||||
"links": {
|
||||
"privacy": "Datenschutz",
|
||||
"terms": "Bedingungen",
|
||||
"contact": "Kontakt"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Erfolg",
|
||||
"error": "Fehler",
|
||||
"info": "Information",
|
||||
"warning": "Warnung"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"close": "Schließen",
|
||||
"confirm": "Bestätigen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"search": "Suchen",
|
||||
"filter": "Filtern",
|
||||
"sort": "Sortieren",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"previous": "Zurück",
|
||||
"skipToContent": "Zum Hauptinhalt springen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"myConfirmations": "My confirmations",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"login": "Log in",
|
||||
"logout": "Log out",
|
||||
"mobileMenu": "Mobile menu",
|
||||
"mainNavigation": "Main navigation"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme"
|
||||
},
|
||||
"language": {
|
||||
"select": "Select language",
|
||||
"fr": "French",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"de": "German",
|
||||
"it": "Italian"
|
||||
},
|
||||
"auth": {
|
||||
"user": {
|
||||
"connectedAs": "Connected as"
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Reading Confirmation",
|
||||
"subtitle": "Certify your reading with an Ed25519 cryptographic confirmation",
|
||||
"loading": {
|
||||
"title": "Loading document...",
|
||||
"description": "Please wait while we prepare the document for signing."
|
||||
},
|
||||
"noDocument": {
|
||||
"title": "No document specified",
|
||||
"description": "To sign a document, add the {code} parameter to the URL",
|
||||
"examples": "Examples:"
|
||||
},
|
||||
"success": {
|
||||
"title": "Reading confirmed successfully!",
|
||||
"description": "Your confirmation has been recorded cryptographically and securely."
|
||||
},
|
||||
"error": {
|
||||
"title": "An error occurred",
|
||||
"authRequired": "You must be logged in to create a document.",
|
||||
"loadFailed": "Failed to load document",
|
||||
"loginButton": "Log in"
|
||||
},
|
||||
"document": {
|
||||
"title": "Document to confirm",
|
||||
"id": "ID"
|
||||
},
|
||||
"info": {
|
||||
"description": "By confirming the reading of this document, you certify that you have read its content and agree to validate it cryptographically and non-repudiably.",
|
||||
"recorded": "Your confirmation will be recorded with the following information:",
|
||||
"email": "Your email address",
|
||||
"timestamp": "Precise timestamp of the confirmation",
|
||||
"signature": "Ed25519 cryptographic confirmation",
|
||||
"hash": "SHA-256 hash of the content"
|
||||
},
|
||||
"confirmations": {
|
||||
"title": "Existing confirmations",
|
||||
"count": "{count} confirmation | {count} confirmations",
|
||||
"recorded": "recorded"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No confirmations yet",
|
||||
"description": "Be the first to confirm your reading of this document"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "How does it work?",
|
||||
"subtitle": "Ackify allows you to cryptographically prove that you have read a document",
|
||||
"step1": {
|
||||
"title": "1. Access the document",
|
||||
"description": "Add {code} to this page's address"
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Authenticate",
|
||||
"description": "Log in via OAuth2 to confirm your identity"
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Confirm reading",
|
||||
"description": "Your confirmation is recorded with an Ed25519 signature"
|
||||
},
|
||||
"features": {
|
||||
"crypto": {
|
||||
"title": "Cryptographic security",
|
||||
"description": "Non-repudiable Ed25519 signatures guaranteeing authenticity"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant",
|
||||
"description": "Two-click confirmation with immediate cryptographic verification"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Precise timestamp",
|
||||
"description": "Each confirmation is timestamped and chained to ensure integrity"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signButton": {
|
||||
"confirm": "Confirm my reading",
|
||||
"alreadySigned": "Already confirmed",
|
||||
"mustLogin": "Log in to confirm",
|
||||
"signing": "Confirming...",
|
||||
"verified": "Verified",
|
||||
"error": {
|
||||
"title": "Confirmation failed",
|
||||
"notAuthenticated": "You must be logged in to confirm a document.",
|
||||
"alreadySigned": "You have already confirmed this document.",
|
||||
"generic": "An error occurred during confirmation. Please try again."
|
||||
}
|
||||
},
|
||||
"signatureList": {
|
||||
"loading": "Loading confirmations...",
|
||||
"email": "Email",
|
||||
"date": "Date",
|
||||
"signature": "Signature",
|
||||
"hash": "Hash",
|
||||
"nonce": "Nonce",
|
||||
"verificationStatus": "Verification status",
|
||||
"verified": "Verified",
|
||||
"notVerified": "Not verified",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"previousHash": "Previous hash"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "My reading confirmations",
|
||||
"subtitle": "List of all documents you have cryptographically confirmed reading",
|
||||
"loading": "Loading your confirmations...",
|
||||
"empty": {
|
||||
"title": "No confirmations yet",
|
||||
"description": "You haven't confirmed any documents yet. Start by confirming a document to see it appear here."
|
||||
},
|
||||
"count": "{count} confirmation | {count} confirmations",
|
||||
"results": "{count} result | {count} results",
|
||||
"document": "Document",
|
||||
"signedAt": "Confirmed on",
|
||||
"viewDetails": "View details",
|
||||
"stats": {
|
||||
"total": "Total",
|
||||
"totalConfirmations": "Total confirmations",
|
||||
"unique": "Unique",
|
||||
"uniqueDocuments": "Unique documents",
|
||||
"last": "Last",
|
||||
"lastConfirmation": "Last confirmation"
|
||||
},
|
||||
"allConfirmations": "All my confirmations",
|
||||
"about": {
|
||||
"title": "About confirmations",
|
||||
"description": "Each confirmation is cryptographically recorded with Ed25519 and chained to ensure integrity. Confirmations are non-repudiable and precisely timestamped."
|
||||
},
|
||||
"search": "Search...",
|
||||
"error": {
|
||||
"title": "Error loading confirmations",
|
||||
"description": "Unable to load your confirmations. Please try again."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "Manage documents and expected readers",
|
||||
"loading": "Loading data...",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalDocuments": "Total documents",
|
||||
"totalSignatures": "Total confirmations",
|
||||
"recentActivity": "Recent activity",
|
||||
"stats": {
|
||||
"documents": "Documents",
|
||||
"readers": "Readers",
|
||||
"active": "Active",
|
||||
"expected": "Expected",
|
||||
"signed": "Signed",
|
||||
"pending": "Pending",
|
||||
"completion": "Completion"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "All documents",
|
||||
"new": "Create new document",
|
||||
"newDescription": "Prepare a document reference to track reading confirmations",
|
||||
"search": "Search...",
|
||||
"searchPlaceholder": "Search by ID, title, or URL...",
|
||||
"id": "Document ID",
|
||||
"idLabel": "Document ID",
|
||||
"idHelper": "Letters, numbers, dashes and underscores only",
|
||||
"idPlaceholder": "e.g: security-policy-2025",
|
||||
"signatures": "Confirmations",
|
||||
"created": "Created",
|
||||
"createdOn": "Created on",
|
||||
"by": "By",
|
||||
"url": "URL",
|
||||
"document": "Document",
|
||||
"actions": "Actions",
|
||||
"view": "View",
|
||||
"manage": "Manage",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"empty": "No documents found"
|
||||
},
|
||||
"documentDetail": {
|
||||
"title": "Document details",
|
||||
"metadata": "Document metadata and checksum",
|
||||
"checksum": "Checksum",
|
||||
"algorithm": "Algorithm",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Security Policy 2025",
|
||||
"urlLabel": "URL",
|
||||
"urlPlaceholder": "https://example.com/doc.pdf",
|
||||
"checksumLabel": "Checksum",
|
||||
"checksumPlaceholder": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"algorithmLabel": "Algorithm",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Document description...",
|
||||
"signatures": "Confirmations",
|
||||
"back": "Back to list",
|
||||
"expectedSigners": "Expected signers",
|
||||
"addExpectedSigner": "Add expected signer",
|
||||
"addSigners": "Add expected readers",
|
||||
"emailsLabel": "Emails (one per line)",
|
||||
"emailsPlaceholder": "Mary Smith <mary.smith@example.com>\njohn.doe@example.com\nSophie Johnson <sophie@example.com>",
|
||||
"emailLabel": "Email *",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Full name",
|
||||
"reader": "Reader",
|
||||
"user": "User",
|
||||
"status": "Status",
|
||||
"confirmedOn": "Confirmed on",
|
||||
"noExpectedSigners": "No expected readers",
|
||||
"noSignatures": "No confirmations",
|
||||
"reminders": "Email reminders",
|
||||
"remindersSent": "Reminders sent",
|
||||
"toRemind": "To remind",
|
||||
"lastReminder": "Last reminder",
|
||||
"sendReminder": "Send reminder",
|
||||
"metadataWarning": {
|
||||
"title": "⚠️ Warning: Signature invalidation",
|
||||
"description": "You are about to modify critical document information (URL, checksum, algorithm, or description).",
|
||||
"warning": "This modification will invalidate all existing signatures, as they are cryptographically linked to the current document content.",
|
||||
"currentSignatures": "Current signatures that will be invalidated:",
|
||||
"confirm": "I understand, continue",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"dangerZone": "Danger zone",
|
||||
"dangerZoneDescription": "Irreversible actions on this document",
|
||||
"deleteDocument": "Delete this document",
|
||||
"deleteWarning": "This action is irreversible!",
|
||||
"deleteWillRemove": "This action will permanently remove:",
|
||||
"deleteItem1": "All document metadata",
|
||||
"deleteItem2": "The list of expected readers",
|
||||
"deleteItem3": "All cryptographic confirmations",
|
||||
"deleteItem4": "The reminder history"
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Document reference",
|
||||
"label": "Reference (URL, path or ID)",
|
||||
"placeholder": "https://example.com/doc.pdf or /path/to/doc",
|
||||
"submit": "Confirm",
|
||||
"creating": "Creating..."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"loading": "Loading signature information...",
|
||||
"title": "Signature for",
|
||||
"signedBy": "Signed by",
|
||||
"on": "on",
|
||||
"verified": "Verified",
|
||||
"viewAll": "View all confirmations",
|
||||
"error": "Unable to load signature information"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "The page you are looking for does not exist or has been moved.",
|
||||
"home": "Back to home"
|
||||
},
|
||||
"footer": {
|
||||
"madeWith": "Made with",
|
||||
"by": "by",
|
||||
"links": {
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms",
|
||||
"contact": "Contact"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"info": "Information",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"skipToContent": "Skip to main content"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"myConfirmations": "Mis confirmaciones",
|
||||
"admin": "Admin",
|
||||
"administration": "Administración",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"mobileMenu": "Menú móvil",
|
||||
"mainNavigation": "Navegación principal"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema"
|
||||
},
|
||||
"language": {
|
||||
"select": "Seleccionar idioma",
|
||||
"fr": "Francés",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"de": "Alemán",
|
||||
"it": "Italiano"
|
||||
},
|
||||
"auth": {
|
||||
"user": {
|
||||
"connectedAs": "Conectado como"
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Confirmación de Lectura",
|
||||
"subtitle": "Certifique su lectura con una confirmación criptográfica Ed25519",
|
||||
"loading": {
|
||||
"title": "Cargando documento...",
|
||||
"description": "Por favor espere mientras preparamos el documento para la firma."
|
||||
},
|
||||
"noDocument": {
|
||||
"title": "Ningún documento especificado",
|
||||
"description": "Para firmar un documento, agregue el parámetro {code} a la URL",
|
||||
"examples": "Ejemplos:"
|
||||
},
|
||||
"success": {
|
||||
"title": "¡Lectura confirmada con éxito!",
|
||||
"description": "Su confirmación ha sido registrada de manera criptográfica y segura."
|
||||
},
|
||||
"error": {
|
||||
"title": "Ha ocurrido un error",
|
||||
"authRequired": "Debe iniciar sesión para crear un documento.",
|
||||
"loadFailed": "Error al cargar el documento",
|
||||
"loginButton": "Iniciar sesión"
|
||||
},
|
||||
"document": {
|
||||
"title": "Documento a confirmar",
|
||||
"id": "ID"
|
||||
},
|
||||
"info": {
|
||||
"description": "Al confirmar la lectura de este documento, usted certifica haber leído su contenido y acepta validarlo de manera criptográfica y no repudiable.",
|
||||
"recorded": "Su confirmación será registrada con la siguiente información:",
|
||||
"email": "Su dirección de correo electrónico",
|
||||
"timestamp": "Marca de tiempo precisa de la confirmación",
|
||||
"signature": "Confirmación criptográfica Ed25519",
|
||||
"hash": "Hash SHA-256 del contenido"
|
||||
},
|
||||
"confirmations": {
|
||||
"title": "Confirmaciones existentes",
|
||||
"count": "{count} confirmación | {count} confirmaciones",
|
||||
"recorded": "registrada | registradas"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ninguna confirmación por el momento",
|
||||
"description": "Sea el primero en confirmar su lectura de este documento"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "¿Cómo funciona?",
|
||||
"subtitle": "Ackify le permite demostrar criptográficamente que ha leído un documento",
|
||||
"step1": {
|
||||
"title": "1. Acceda al documento",
|
||||
"description": "Agregue {code} a la dirección de esta página"
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Autentíquese",
|
||||
"description": "Inicie sesión a través de OAuth2 para confirmar su identidad"
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Confirme la lectura",
|
||||
"description": "Su confirmación se registra con una firma Ed25519"
|
||||
},
|
||||
"features": {
|
||||
"crypto": {
|
||||
"title": "Seguridad criptográfica",
|
||||
"description": "Firmas Ed25519 no repudiables que garantizan la autenticidad"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instantáneo",
|
||||
"description": "Confirmación en dos clics con verificación criptográfica inmediata"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Marca de tiempo precisa",
|
||||
"description": "Cada confirmación está marcada temporalmente y encadenada para garantizar la integridad"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signButton": {
|
||||
"confirm": "Confirmar mi lectura",
|
||||
"alreadySigned": "Ya confirmado",
|
||||
"mustLogin": "Iniciar sesión para confirmar",
|
||||
"signing": "Confirmando...",
|
||||
"verified": "Verificado",
|
||||
"error": {
|
||||
"title": "Error en la confirmación",
|
||||
"notAuthenticated": "Debe iniciar sesión para confirmar un documento.",
|
||||
"alreadySigned": "Ya ha confirmado este documento.",
|
||||
"generic": "Ha ocurrido un error durante la confirmación. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
},
|
||||
"signatureList": {
|
||||
"loading": "Cargando confirmaciones...",
|
||||
"email": "Correo electrónico",
|
||||
"date": "Fecha",
|
||||
"signature": "Firma",
|
||||
"hash": "Hash",
|
||||
"nonce": "Nonce",
|
||||
"verificationStatus": "Estado de verificación",
|
||||
"verified": "Verificado",
|
||||
"notVerified": "No verificado",
|
||||
"showDetails": "Mostrar detalles",
|
||||
"hideDetails": "Ocultar detalles",
|
||||
"copy": "Copiar",
|
||||
"copied": "¡Copiado!",
|
||||
"previousHash": "Hash anterior"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Mis confirmaciones de lectura",
|
||||
"subtitle": "Lista de todos los documentos cuya lectura ha confirmado criptográficamente",
|
||||
"loading": "Cargando sus confirmaciones...",
|
||||
"empty": {
|
||||
"title": "Ninguna confirmación por el momento",
|
||||
"description": "Aún no ha confirmado ningún documento. Comience confirmando un documento para verlo aparecer aquí."
|
||||
},
|
||||
"count": "{count} confirmación | {count} confirmaciones",
|
||||
"results": "{count} resultado | {count} resultados",
|
||||
"document": "Documento",
|
||||
"signedAt": "Confirmado el",
|
||||
"viewDetails": "Ver detalles",
|
||||
"stats": {
|
||||
"total": "Total",
|
||||
"totalConfirmations": "Total confirmaciones",
|
||||
"unique": "Únicos",
|
||||
"uniqueDocuments": "Documentos únicos",
|
||||
"last": "Último",
|
||||
"lastConfirmation": "Última confirmación"
|
||||
},
|
||||
"allConfirmations": "Todas mis confirmaciones",
|
||||
"about": {
|
||||
"title": "Acerca de las confirmaciones",
|
||||
"description": "Cada confirmación se registra criptográficamente con Ed25519 y se encadena para garantizar la integridad. Las confirmaciones son no repudiables y están marcadas temporalmente con precisión."
|
||||
},
|
||||
"search": "Buscar...",
|
||||
"error": {
|
||||
"title": "Error al cargar las confirmaciones",
|
||||
"description": "No se pueden cargar sus confirmaciones. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administración",
|
||||
"subtitle": "Gestionar documentos y lectores esperados",
|
||||
"loading": "Cargando datos...",
|
||||
"dashboard": {
|
||||
"title": "Panel de control",
|
||||
"totalDocuments": "Documentos totales",
|
||||
"totalSignatures": "Confirmaciones totales",
|
||||
"recentActivity": "Actividad reciente",
|
||||
"stats": {
|
||||
"documents": "Documentos",
|
||||
"readers": "Lectores",
|
||||
"active": "Activos",
|
||||
"expected": "Esperados",
|
||||
"signed": "Firmados",
|
||||
"pending": "Pendientes",
|
||||
"completion": "Finalización"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Todos los documentos",
|
||||
"new": "Crear nuevo documento",
|
||||
"newDescription": "Preparar la referencia de un documento para rastrear las confirmaciones de lectura",
|
||||
"search": "Buscar...",
|
||||
"searchPlaceholder": "Buscar por ID, título o URL...",
|
||||
"id": "ID del documento",
|
||||
"idLabel": "ID del documento",
|
||||
"idHelper": "Solo letras, números, guiones y guiones bajos",
|
||||
"idPlaceholder": "ej: politica-seguridad-2025",
|
||||
"signatures": "Confirmaciones",
|
||||
"created": "Creado",
|
||||
"createdOn": "Creado el",
|
||||
"by": "Por",
|
||||
"url": "URL",
|
||||
"document": "Documento",
|
||||
"actions": "Acciones",
|
||||
"view": "Ver",
|
||||
"manage": "Gestionar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"empty": "No se encontraron documentos"
|
||||
},
|
||||
"documentDetail": {
|
||||
"title": "Detalles del documento",
|
||||
"metadata": "Metadatos y checksum del documento",
|
||||
"checksum": "Checksum",
|
||||
"algorithm": "Algoritmo",
|
||||
"titleLabel": "Título",
|
||||
"titlePlaceholder": "Política de Seguridad 2025",
|
||||
"urlLabel": "URL",
|
||||
"urlPlaceholder": "https://example.com/doc.pdf",
|
||||
"checksumLabel": "Checksum",
|
||||
"checksumPlaceholder": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"algorithmLabel": "Algoritmo",
|
||||
"descriptionLabel": "Descripción",
|
||||
"descriptionPlaceholder": "Descripción del documento...",
|
||||
"signatures": "Confirmaciones",
|
||||
"back": "Volver a la lista",
|
||||
"expectedSigners": "Firmantes esperados",
|
||||
"addExpectedSigner": "Agregar firmante esperado",
|
||||
"addSigners": "Agregar lectores esperados",
|
||||
"emailsLabel": "Correos electrónicos (uno por línea)",
|
||||
"emailsPlaceholder": "María García <maria.garcia@example.com>\njuan.martinez@example.com\nSofía Rodríguez <sofia@example.com>",
|
||||
"emailLabel": "Correo electrónico *",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"nameLabel": "Nombre",
|
||||
"namePlaceholder": "Nombre completo",
|
||||
"reader": "Lector",
|
||||
"user": "Usuario",
|
||||
"status": "Estado",
|
||||
"confirmedOn": "Confirmado el",
|
||||
"noExpectedSigners": "Ningún lector esperado",
|
||||
"noSignatures": "Ninguna confirmación",
|
||||
"reminders": "Recordatorios por correo electrónico",
|
||||
"remindersSent": "Recordatorios enviados",
|
||||
"toRemind": "Por recordar",
|
||||
"lastReminder": "Último recordatorio",
|
||||
"sendReminder": "Enviar recordatorio",
|
||||
"metadataWarning": {
|
||||
"title": "⚠️ Atención: Invalidación de firmas",
|
||||
"description": "Está a punto de modificar información crítica del documento (URL, checksum, algoritmo o descripción).",
|
||||
"warning": "Esta modificación resultará en la invalidación de todas las firmas existentes, ya que están vinculadas criptográficamente al contenido actual del documento.",
|
||||
"currentSignatures": "Firmas actuales que serán invalidadas:",
|
||||
"confirm": "Entiendo, continuar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"dangerZone": "Zona de peligro",
|
||||
"dangerZoneDescription": "Acciones irreversibles en este documento",
|
||||
"deleteDocument": "Eliminar este documento",
|
||||
"deleteWarning": "¡Esta acción es irreversible!",
|
||||
"deleteWillRemove": "Esta acción eliminará permanentemente:",
|
||||
"deleteItem1": "Todos los metadatos del documento",
|
||||
"deleteItem2": "La lista de lectores esperados",
|
||||
"deleteItem3": "Todas las confirmaciones criptográficas",
|
||||
"deleteItem4": "El historial de recordatorios"
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Referencia del documento",
|
||||
"label": "Referencia (URL, ruta o ID)",
|
||||
"placeholder": "https://example.com/doc.pdf o /ruta/al/doc",
|
||||
"submit": "Confirmar",
|
||||
"creating": "Creando..."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"loading": "Cargando información de firma...",
|
||||
"title": "Firma para",
|
||||
"signedBy": "Firmado por",
|
||||
"on": "el",
|
||||
"verified": "Verificado",
|
||||
"viewAll": "Ver todas las confirmaciones",
|
||||
"error": "No se puede cargar la información de firma"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Página no encontrada",
|
||||
"description": "La página que busca no existe o ha sido movida.",
|
||||
"home": "Volver al inicio"
|
||||
},
|
||||
"footer": {
|
||||
"madeWith": "Hecho con",
|
||||
"by": "por",
|
||||
"links": {
|
||||
"privacy": "Privacidad",
|
||||
"terms": "Términos",
|
||||
"contact": "Contacto"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Éxito",
|
||||
"error": "Error",
|
||||
"info": "Información",
|
||||
"warning": "Advertencia"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"close": "Cerrar",
|
||||
"confirm": "Confirmar",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"search": "Buscar",
|
||||
"filter": "Filtrar",
|
||||
"sort": "Ordenar",
|
||||
"actions": "Acciones",
|
||||
"details": "Detalles",
|
||||
"back": "Volver",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"skipToContent": "Ir al contenido principal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"myConfirmations": "Mes confirmations",
|
||||
"admin": "Admin",
|
||||
"administration": "Administration",
|
||||
"login": "Se connecter",
|
||||
"logout": "Déconnexion",
|
||||
"mobileMenu": "Menu mobile",
|
||||
"mainNavigation": "Navigation principale"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Changer de thème"
|
||||
},
|
||||
"language": {
|
||||
"select": "Sélectionner la langue",
|
||||
"fr": "Français",
|
||||
"en": "Anglais",
|
||||
"es": "Espagnol",
|
||||
"de": "Allemand",
|
||||
"it": "Italien"
|
||||
},
|
||||
"auth": {
|
||||
"user": {
|
||||
"connectedAs": "Connecté en tant que"
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Confirmation de Lecture",
|
||||
"subtitle": "Certifiez votre lecture avec une confirmation cryptographique Ed25519",
|
||||
"loading": {
|
||||
"title": "Chargement du document...",
|
||||
"description": "Veuillez patienter pendant que nous préparons le document pour la signature."
|
||||
},
|
||||
"noDocument": {
|
||||
"title": "Aucun document spécifié",
|
||||
"description": "Pour signer un document, ajoutez le paramètre {code} à l'URL",
|
||||
"examples": "Exemples :"
|
||||
},
|
||||
"success": {
|
||||
"title": "Lecture confirmée avec succès !",
|
||||
"description": "Votre confirmation a été enregistrée de manière cryptographique et sécurisée."
|
||||
},
|
||||
"error": {
|
||||
"title": "Une erreur est survenue",
|
||||
"authRequired": "Vous devez être connecté pour créer un document.",
|
||||
"loadFailed": "Échec du chargement du document",
|
||||
"loginButton": "Se connecter"
|
||||
},
|
||||
"document": {
|
||||
"title": "Document à confirmer",
|
||||
"id": "ID"
|
||||
},
|
||||
"info": {
|
||||
"description": "En confirmant la lecture de ce document, vous certifiez avoir pris connaissance de son contenu et acceptez de le valider de manière cryptographique et non répudiable.",
|
||||
"recorded": "Votre confirmation sera enregistrée avec les informations suivantes :",
|
||||
"email": "Votre adresse email",
|
||||
"timestamp": "Horodatage précis de la confirmation",
|
||||
"signature": "Confirmation cryptographique Ed25519",
|
||||
"hash": "Hash SHA-256 du contenu"
|
||||
},
|
||||
"confirmations": {
|
||||
"title": "Confirmations existantes",
|
||||
"count": "{count} confirmation | {count} confirmations",
|
||||
"recorded": "enregistrée | enregistrées"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune confirmation pour le moment",
|
||||
"description": "Soyez le premier à confirmer votre lecture de ce document"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Comment ça fonctionne ?",
|
||||
"subtitle": "Ackify vous permet de prouver cryptographiquement que vous avez lu un document",
|
||||
"step1": {
|
||||
"title": "1. Accédez au document",
|
||||
"description": "Ajoutez {code} à l'adresse de cette page"
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Authentifiez-vous",
|
||||
"description": "Connectez-vous via OAuth2 pour confirmer votre identité"
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Confirmez la lecture",
|
||||
"description": "Votre confirmation est enregistrée avec une signature Ed25519"
|
||||
},
|
||||
"features": {
|
||||
"crypto": {
|
||||
"title": "Sécurité cryptographique",
|
||||
"description": "Signatures Ed25519 non répudiables garantissant l'authenticité"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instantané",
|
||||
"description": "Confirmation en deux clics avec vérification cryptographique immédiate"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Horodatage précis",
|
||||
"description": "Chaque confirmation est horodatée et chaînée pour garantir l'intégrité"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signButton": {
|
||||
"confirm": "Confirmer ma lecture",
|
||||
"alreadySigned": "Déjà confirmé",
|
||||
"mustLogin": "Se connecter pour confirmer",
|
||||
"signing": "Confirmation en cours...",
|
||||
"verified": "Vérifié",
|
||||
"error": {
|
||||
"title": "Échec de la confirmation",
|
||||
"notAuthenticated": "Vous devez être connecté pour confirmer un document.",
|
||||
"alreadySigned": "Vous avez déjà confirmé ce document.",
|
||||
"generic": "Une erreur est survenue lors de la confirmation. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"signatureList": {
|
||||
"loading": "Chargement des confirmations...",
|
||||
"email": "Email",
|
||||
"date": "Date",
|
||||
"signature": "Signature",
|
||||
"hash": "Hash",
|
||||
"nonce": "Nonce",
|
||||
"verificationStatus": "Statut de vérification",
|
||||
"verified": "Vérifié",
|
||||
"notVerified": "Non vérifié",
|
||||
"showDetails": "Afficher les détails",
|
||||
"hideDetails": "Masquer les détails",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié !",
|
||||
"previousHash": "Hash précédent"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Mes confirmations de lecture",
|
||||
"subtitle": "Liste de tous les documents dont vous avez confirmé la lecture cryptographiquement",
|
||||
"loading": "Chargement de vos confirmations...",
|
||||
"empty": {
|
||||
"title": "Aucune confirmation pour le moment",
|
||||
"description": "Vous n'avez pas encore confirmé de document. Commencez par confirmer un document pour le voir apparaître ici."
|
||||
},
|
||||
"count": "{count} confirmation | {count} confirmations",
|
||||
"results": "{count} résultat | {count} résultats",
|
||||
"document": "Document",
|
||||
"signedAt": "Confirmé le",
|
||||
"viewDetails": "Voir les détails",
|
||||
"stats": {
|
||||
"total": "Total",
|
||||
"totalConfirmations": "Total confirmations",
|
||||
"unique": "Uniques",
|
||||
"uniqueDocuments": "Documents uniques",
|
||||
"last": "Dernier",
|
||||
"lastConfirmation": "Dernière confirmation"
|
||||
},
|
||||
"allConfirmations": "Toutes mes confirmations",
|
||||
"about": {
|
||||
"title": "À propos des confirmations",
|
||||
"description": "Chaque confirmation est enregistrée de manière cryptographique avec Ed25519 et chaînée pour garantir l'intégrité. Les confirmations sont non répudiables et horodatées de façon précise."
|
||||
},
|
||||
"search": "Rechercher...",
|
||||
"error": {
|
||||
"title": "Erreur de chargement des confirmations",
|
||||
"description": "Impossible de charger vos confirmations. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "Gérer les documents et les lecteurs attendus",
|
||||
"loading": "Chargement des données...",
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"totalDocuments": "Documents totaux",
|
||||
"totalSignatures": "Confirmations totales",
|
||||
"recentActivity": "Activité récente",
|
||||
"stats": {
|
||||
"documents": "Documents",
|
||||
"readers": "Lecteurs",
|
||||
"active": "Actifs",
|
||||
"expected": "Attendus",
|
||||
"signed": "Signés",
|
||||
"pending": "En attente",
|
||||
"completion": "Complétude"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Tous les documents",
|
||||
"new": "Créer un nouveau document",
|
||||
"newDescription": "Préparer la référence d'un document pour suivre les confirmations de lecture",
|
||||
"search": "Rechercher...",
|
||||
"searchPlaceholder": "Rechercher par ID, titre ou URL...",
|
||||
"id": "ID du document",
|
||||
"idLabel": "ID du document",
|
||||
"idHelper": "Lettres, chiffres, tirets et underscores uniquement",
|
||||
"idPlaceholder": "ex: politique-securite-2025",
|
||||
"signatures": "Confirmations",
|
||||
"created": "Créé",
|
||||
"createdOn": "Créé le",
|
||||
"by": "Par",
|
||||
"url": "URL",
|
||||
"document": "Document",
|
||||
"actions": "Actions",
|
||||
"view": "Voir",
|
||||
"manage": "Gérer",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"empty": "Aucun document trouvé"
|
||||
},
|
||||
"documentDetail": {
|
||||
"title": "Détails du document",
|
||||
"metadata": "Métadonnées et checksum du document",
|
||||
"checksum": "Checksum",
|
||||
"algorithm": "Algorithme",
|
||||
"titleLabel": "Titre",
|
||||
"titlePlaceholder": "Politique de sécurité 2025",
|
||||
"urlLabel": "URL",
|
||||
"urlPlaceholder": "https://example.com/doc.pdf",
|
||||
"checksumLabel": "Checksum",
|
||||
"checksumPlaceholder": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"algorithmLabel": "Algorithme",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Description du document...",
|
||||
"signatures": "Confirmations",
|
||||
"back": "Retour à la liste",
|
||||
"expectedSigners": "Signataires attendus",
|
||||
"addExpectedSigner": "Ajouter un signataire attendu",
|
||||
"addSigners": "Ajouter des lecteurs attendus",
|
||||
"emailsLabel": "Emails (un par ligne)",
|
||||
"emailsPlaceholder": "Marie Dupont <marie.dupont@example.com>\njean.martin@example.com\nSophie Bernard <sophie@example.com>",
|
||||
"emailLabel": "Email *",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Nom complet",
|
||||
"reader": "Lecteur",
|
||||
"user": "Utilisateur",
|
||||
"status": "Statut",
|
||||
"confirmedOn": "Confirmé le",
|
||||
"noExpectedSigners": "Aucun lecteur attendu",
|
||||
"noSignatures": "Aucune confirmation",
|
||||
"reminders": "Relances email",
|
||||
"remindersSent": "Relances envoyées",
|
||||
"toRemind": "À relancer",
|
||||
"lastReminder": "Dernière relance",
|
||||
"sendReminder": "Envoyer une relance",
|
||||
"metadataWarning": {
|
||||
"title": "⚠️ Attention : Invalidation des signatures",
|
||||
"description": "Vous êtes sur le point de modifier des informations critiques du document (URL, checksum, algorithme ou description).",
|
||||
"warning": "Cette modification entraînera l'invalidation de toutes les signatures existantes, car elles sont liées cryptographiquement au contenu actuel du document.",
|
||||
"currentSignatures": "Signatures actuelles qui seront invalidées :",
|
||||
"confirm": "Je comprends, continuer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"dangerZone": "Zone de danger",
|
||||
"dangerZoneDescription": "Actions irréversibles sur ce document",
|
||||
"deleteDocument": "Supprimer ce document",
|
||||
"deleteWarning": "Cette action est irréversible !",
|
||||
"deleteWillRemove": "Cette action supprimera définitivement :",
|
||||
"deleteItem1": "Toutes les métadonnées du document",
|
||||
"deleteItem2": "La liste des lecteurs attendus",
|
||||
"deleteItem3": "Toutes les confirmations cryptographiques",
|
||||
"deleteItem4": "L'historique des relances"
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Référence du document",
|
||||
"label": "Référence (URL, chemin ou ID)",
|
||||
"placeholder": "https://example.com/doc.pdf ou /chemin/vers/doc",
|
||||
"submit": "Confirmer",
|
||||
"creating": "Création en cours..."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"loading": "Chargement des informations de signature...",
|
||||
"title": "Signature pour",
|
||||
"signedBy": "Signé par",
|
||||
"on": "le",
|
||||
"verified": "Vérifié",
|
||||
"viewAll": "Voir toutes les confirmations",
|
||||
"error": "Impossible de charger les informations de signature"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page non trouvée",
|
||||
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
||||
"home": "Retour à l'accueil"
|
||||
},
|
||||
"footer": {
|
||||
"madeWith": "Fait avec",
|
||||
"by": "par",
|
||||
"links": {
|
||||
"privacy": "Confidentialité",
|
||||
"terms": "Conditions",
|
||||
"contact": "Contact"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Succès",
|
||||
"error": "Erreur",
|
||||
"info": "Information",
|
||||
"warning": "Avertissement"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"search": "Rechercher",
|
||||
"filter": "Filtrer",
|
||||
"sort": "Trier",
|
||||
"actions": "Actions",
|
||||
"details": "Détails",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent",
|
||||
"skipToContent": "Aller au contenu principal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Ackify"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"myConfirmations": "Le mie conferme",
|
||||
"admin": "Admin",
|
||||
"administration": "Amministrazione",
|
||||
"login": "Accedi",
|
||||
"logout": "Disconnetti",
|
||||
"mobileMenu": "Menu mobile",
|
||||
"mainNavigation": "Navigazione principale"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambia tema"
|
||||
},
|
||||
"language": {
|
||||
"select": "Seleziona lingua",
|
||||
"fr": "Francese",
|
||||
"en": "Inglese",
|
||||
"es": "Spagnolo",
|
||||
"de": "Tedesco",
|
||||
"it": "Italiano"
|
||||
},
|
||||
"auth": {
|
||||
"user": {
|
||||
"connectedAs": "Connesso come"
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Conferma di Lettura",
|
||||
"subtitle": "Certifica la tua lettura con una conferma crittografica Ed25519",
|
||||
"loading": {
|
||||
"title": "Caricamento documento...",
|
||||
"description": "Attendere mentre prepariamo il documento per la firma."
|
||||
},
|
||||
"noDocument": {
|
||||
"title": "Nessun documento specificato",
|
||||
"description": "Per firmare un documento, aggiungi il parametro {code} all'URL",
|
||||
"examples": "Esempi:"
|
||||
},
|
||||
"success": {
|
||||
"title": "Lettura confermata con successo!",
|
||||
"description": "La tua conferma è stata registrata in modo crittografico e sicuro."
|
||||
},
|
||||
"error": {
|
||||
"title": "Si è verificato un errore",
|
||||
"authRequired": "Devi essere connesso per creare un documento.",
|
||||
"loadFailed": "Caricamento del documento fallito",
|
||||
"loginButton": "Accedi"
|
||||
},
|
||||
"document": {
|
||||
"title": "Documento da confermare",
|
||||
"id": "ID"
|
||||
},
|
||||
"info": {
|
||||
"description": "Confermando la lettura di questo documento, certifichi di aver preso visione del suo contenuto e accetti di validarlo in modo crittografico e non ripudiabile.",
|
||||
"recorded": "La tua conferma sarà registrata con le seguenti informazioni:",
|
||||
"email": "Il tuo indirizzo email",
|
||||
"timestamp": "Timestamp preciso della conferma",
|
||||
"signature": "Conferma crittografica Ed25519",
|
||||
"hash": "Hash SHA-256 del contenuto"
|
||||
},
|
||||
"confirmations": {
|
||||
"title": "Conferme esistenti",
|
||||
"count": "{count} conferma | {count} conferme",
|
||||
"recorded": "registrata | registrate"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nessuna conferma per il momento",
|
||||
"description": "Sii il primo a confermare la tua lettura di questo documento"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Come funziona?",
|
||||
"subtitle": "Ackify ti consente di provare crittograficamente di aver letto un documento",
|
||||
"step1": {
|
||||
"title": "1. Accedi al documento",
|
||||
"description": "Aggiungi {code} all'indirizzo di questa pagina"
|
||||
},
|
||||
"step2": {
|
||||
"title": "2. Autenticati",
|
||||
"description": "Accedi tramite OAuth2 per confermare la tua identità"
|
||||
},
|
||||
"step3": {
|
||||
"title": "3. Conferma la lettura",
|
||||
"description": "La tua conferma viene registrata con una firma Ed25519"
|
||||
},
|
||||
"features": {
|
||||
"crypto": {
|
||||
"title": "Sicurezza crittografica",
|
||||
"description": "Firme Ed25519 non ripudiabili che garantiscono l'autenticità"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Istantaneo",
|
||||
"description": "Conferma in due clic con verifica crittografica immediata"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Timestamp preciso",
|
||||
"description": "Ogni conferma è timestampata e concatenata per garantire l'integrità"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"signButton": {
|
||||
"confirm": "Conferma la mia lettura",
|
||||
"alreadySigned": "Già confermato",
|
||||
"mustLogin": "Accedi per confermare",
|
||||
"signing": "Conferma in corso...",
|
||||
"verified": "Verificato",
|
||||
"error": {
|
||||
"title": "Conferma fallita",
|
||||
"notAuthenticated": "Devi essere connesso per confermare un documento.",
|
||||
"alreadySigned": "Hai già confermato questo documento.",
|
||||
"generic": "Si è verificato un errore durante la conferma. Riprova."
|
||||
}
|
||||
},
|
||||
"signatureList": {
|
||||
"loading": "Caricamento conferme...",
|
||||
"email": "Email",
|
||||
"date": "Data",
|
||||
"signature": "Firma",
|
||||
"hash": "Hash",
|
||||
"nonce": "Nonce",
|
||||
"verificationStatus": "Stato di verifica",
|
||||
"verified": "Verificato",
|
||||
"notVerified": "Non verificato",
|
||||
"showDetails": "Mostra dettagli",
|
||||
"hideDetails": "Nascondi dettagli",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato!",
|
||||
"previousHash": "Hash precedente"
|
||||
},
|
||||
"signatures": {
|
||||
"title": "Le mie conferme di lettura",
|
||||
"subtitle": "Elenco di tutti i documenti di cui hai confermato la lettura crittograficamente",
|
||||
"loading": "Caricamento delle tue conferme...",
|
||||
"empty": {
|
||||
"title": "Nessuna conferma per il momento",
|
||||
"description": "Non hai ancora confermato alcun documento. Inizia confermando un documento per vederlo apparire qui."
|
||||
},
|
||||
"count": "{count} conferma | {count} conferme",
|
||||
"results": "{count} risultato | {count} risultati",
|
||||
"document": "Documento",
|
||||
"signedAt": "Confermato il",
|
||||
"viewDetails": "Vedi dettagli",
|
||||
"stats": {
|
||||
"total": "Totale",
|
||||
"totalConfirmations": "Conferme totali",
|
||||
"unique": "Unici",
|
||||
"uniqueDocuments": "Documenti unici",
|
||||
"last": "Ultimo",
|
||||
"lastConfirmation": "Ultima conferma"
|
||||
},
|
||||
"allConfirmations": "Tutte le mie conferme",
|
||||
"about": {
|
||||
"title": "Informazioni sulle conferme",
|
||||
"description": "Ogni conferma è registrata crittograficamente con Ed25519 e concatenata per garantire l'integrità. Le conferme sono non ripudiabili e timestampate con precisione."
|
||||
},
|
||||
"search": "Cerca...",
|
||||
"error": {
|
||||
"title": "Errore nel caricamento delle conferme",
|
||||
"description": "Impossibile caricare le tue conferme. Riprova."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Amministrazione",
|
||||
"subtitle": "Gestisci documenti e lettori attesi",
|
||||
"loading": "Caricamento dati...",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"totalDocuments": "Documenti totali",
|
||||
"totalSignatures": "Conferme totali",
|
||||
"recentActivity": "Attività recente",
|
||||
"stats": {
|
||||
"documents": "Documenti",
|
||||
"readers": "Lettori",
|
||||
"active": "Attivi",
|
||||
"expected": "Attesi",
|
||||
"signed": "Firmati",
|
||||
"pending": "In sospeso",
|
||||
"completion": "Completamento"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Tutti i documenti",
|
||||
"new": "Crea nuovo documento",
|
||||
"newDescription": "Prepara il riferimento di un documento per tracciare le conferme di lettura",
|
||||
"search": "Cerca...",
|
||||
"searchPlaceholder": "Cerca per ID, titolo o URL...",
|
||||
"id": "ID del documento",
|
||||
"idLabel": "ID del documento",
|
||||
"idHelper": "Solo lettere, numeri, trattini e underscore",
|
||||
"idPlaceholder": "es: politica-sicurezza-2025",
|
||||
"signatures": "Conferme",
|
||||
"created": "Creato",
|
||||
"createdOn": "Creato il",
|
||||
"by": "Da",
|
||||
"url": "URL",
|
||||
"document": "Documento",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"manage": "Gestisci",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"empty": "Nessun documento trovato"
|
||||
},
|
||||
"documentDetail": {
|
||||
"title": "Dettagli del documento",
|
||||
"metadata": "Metadati e checksum del documento",
|
||||
"checksum": "Checksum",
|
||||
"algorithm": "Algoritmo",
|
||||
"titleLabel": "Titolo",
|
||||
"titlePlaceholder": "Politica di Sicurezza 2025",
|
||||
"urlLabel": "URL",
|
||||
"urlPlaceholder": "https://example.com/doc.pdf",
|
||||
"checksumLabel": "Checksum",
|
||||
"checksumPlaceholder": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"algorithmLabel": "Algoritmo",
|
||||
"descriptionLabel": "Descrizione",
|
||||
"descriptionPlaceholder": "Descrizione del documento...",
|
||||
"signatures": "Conferme",
|
||||
"back": "Torna all'elenco",
|
||||
"expectedSigners": "Firmatari attesi",
|
||||
"addExpectedSigner": "Aggiungi firmatario atteso",
|
||||
"addSigners": "Aggiungi lettori attesi",
|
||||
"emailsLabel": "Email (una per riga)",
|
||||
"emailsPlaceholder": "Maria Rossi <maria.rossi@example.com>\ngiovanni.bianchi@example.com\nSofia Verdi <sofia@example.com>",
|
||||
"emailLabel": "Email *",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"nameLabel": "Nome",
|
||||
"namePlaceholder": "Nome completo",
|
||||
"reader": "Lettore",
|
||||
"user": "Utente",
|
||||
"status": "Stato",
|
||||
"confirmedOn": "Confermato il",
|
||||
"noExpectedSigners": "Nessun lettore atteso",
|
||||
"noSignatures": "Nessuna conferma",
|
||||
"reminders": "Promemoria email",
|
||||
"remindersSent": "Promemoria inviati",
|
||||
"toRemind": "Da ricordare",
|
||||
"lastReminder": "Ultimo promemoria",
|
||||
"sendReminder": "Invia promemoria",
|
||||
"metadataWarning": {
|
||||
"title": "⚠️ Attenzione: Invalidazione delle firme",
|
||||
"description": "Stai per modificare informazioni critiche del documento (URL, checksum, algoritmo o descrizione).",
|
||||
"warning": "Questa modifica comporterà l'invalidazione di tutte le firme esistenti, poiché sono legate crittograficamente al contenuto attuale del documento.",
|
||||
"currentSignatures": "Firme attuali che saranno invalidate:",
|
||||
"confirm": "Capisco, continua",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"dangerZone": "Zona pericolosa",
|
||||
"dangerZoneDescription": "Azioni irreversibili su questo documento",
|
||||
"deleteDocument": "Elimina questo documento",
|
||||
"deleteWarning": "Questa azione è irreversibile!",
|
||||
"deleteWillRemove": "Questa azione rimuoverà definitivamente:",
|
||||
"deleteItem1": "Tutti i metadati del documento",
|
||||
"deleteItem2": "L'elenco dei lettori attesi",
|
||||
"deleteItem3": "Tutte le conferme crittografiche",
|
||||
"deleteItem4": "La cronologia dei promemoria"
|
||||
},
|
||||
"documentForm": {
|
||||
"title": "Riferimento del documento",
|
||||
"label": "Riferimento (URL, percorso o ID)",
|
||||
"placeholder": "https://example.com/doc.pdf o /percorso/al/doc",
|
||||
"submit": "Conferma",
|
||||
"creating": "Creazione in corso..."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"loading": "Caricamento informazioni firma...",
|
||||
"title": "Firma per",
|
||||
"signedBy": "Firmato da",
|
||||
"on": "il",
|
||||
"verified": "Verificato",
|
||||
"viewAll": "Vedi tutte le conferme",
|
||||
"error": "Impossibile caricare le informazioni della firma"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Pagina non trovata",
|
||||
"description": "La pagina che stai cercando non esiste o è stata spostata.",
|
||||
"home": "Torna alla home"
|
||||
},
|
||||
"footer": {
|
||||
"madeWith": "Fatto con",
|
||||
"by": "da",
|
||||
"links": {
|
||||
"privacy": "Privacy",
|
||||
"terms": "Termini",
|
||||
"contact": "Contatto"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Successo",
|
||||
"error": "Errore",
|
||||
"info": "Informazione",
|
||||
"warning": "Avviso"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Caricamento...",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"close": "Chiudi",
|
||||
"confirm": "Conferma",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"search": "Cerca",
|
||||
"filter": "Filtra",
|
||||
"sort": "Ordina",
|
||||
"actions": "Azioni",
|
||||
"details": "Dettagli",
|
||||
"back": "Indietro",
|
||||
"next": "Avanti",
|
||||
"previous": "Precedente",
|
||||
"skipToContent": "Vai al contenuto principale"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { vClickOutside } from './composables/useClickOutside'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
|
||||
app.directive('click-outside', vClickOutside)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,184 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground p-4">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="bg-destructive/10 dark:bg-destructive/20 border border-destructive/50 rounded-lg p-4">
|
||||
<p class="text-destructive text-sm">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Document info and signatures -->
|
||||
<div v-else-if="documentData" class="max-w-2xl mx-auto">
|
||||
<!-- Document header with signatures -->
|
||||
<div v-if="documentData.signatures.length > 0">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-foreground mb-2">
|
||||
Document: {{ documentData.title }}
|
||||
</h2>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ documentData.signatures.length }} confirmation(s)
|
||||
</span>
|
||||
<span v-if="documentData.metadata?.title">{{ documentData.metadata.title }}</span>
|
||||
</div>
|
||||
<!-- Sign button -->
|
||||
<a
|
||||
:href="signUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
Signer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signatures list (compact) -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="signature in documentData.signatures"
|
||||
:key="signature.id"
|
||||
class="bg-card text-card-foreground rounded-md px-3 py-2 border border-border flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ signature.userEmail }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap ml-2">{{ formatDateCompact(signature.signedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state - No signatures yet -->
|
||||
<div v-else class="text-center py-8">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-muted-foreground mb-4">Aucune signature pour ce document</p>
|
||||
<a
|
||||
:href="signUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background text-base font-medium"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
Signer ce document
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Footer branding -->
|
||||
<div class="mt-8 pt-4 border-t border-border text-center">
|
||||
<a
|
||||
href="https://github.com/btouchard/ackify-ce"
|
||||
target="_blank"
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded"
|
||||
>
|
||||
Powered by Ackify
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { documentService } from '@/services/documents'
|
||||
import http, { extractError } from '@/services/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
usePageTitle('embed.title')
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const documentData = ref<any>(null)
|
||||
const resolvedDocId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const docRef = computed(() => route.query.doc as string)
|
||||
|
||||
const signUrl = computed(() => {
|
||||
const baseUrl = (window as any).ACKIFY_BASE_URL || window.location.origin
|
||||
return `${baseUrl}/?doc=${encodeURIComponent(docRef.value)}`
|
||||
})
|
||||
|
||||
// Methods
|
||||
function formatDateCompact(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDocument() {
|
||||
if (!docRef.value) {
|
||||
error.value = 'ID de document manquant'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
// First, find or create the document to get the docID
|
||||
const doc = await documentService.findOrCreateDocument(docRef.value)
|
||||
resolvedDocId.value = doc.docId
|
||||
|
||||
// If the docRef is not the same as the docID, redirect to clean URL
|
||||
if (docRef.value !== doc.docId) {
|
||||
await router.replace({
|
||||
name: route.name as string,
|
||||
query: { doc: doc.docId }
|
||||
})
|
||||
return // Router will trigger watch and reload
|
||||
}
|
||||
|
||||
// Then fetch signatures using the resolved docID
|
||||
const response = await http.get(`/documents/${doc.docId}/signatures`)
|
||||
|
||||
// Build document data from signatures response
|
||||
const signatures = response.data.data || []
|
||||
documentData.value = {
|
||||
id: doc.docId,
|
||||
title: doc.title || `Document ${doc.docId}`,
|
||||
signatures: signatures,
|
||||
metadata: {}
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
error.value = extractError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in doc query param (for navigation)
|
||||
watch(() => route.query.doc, () => {
|
||||
if (route.query.doc) {
|
||||
loadDocument()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadDocument()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('notFound.title')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background text-foreground flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-6xl font-bold text-muted-foreground">404</h1>
|
||||
<p class="text-xl text-foreground mt-4">{{ t('notFound.title') }}</p>
|
||||
<p class="text-sm text-muted-foreground mt-2">{{ t('notFound.description') }}</p>
|
||||
<router-link
|
||||
to="/"
|
||||
class="mt-6 inline-block text-primary hover:text-primary/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1"
|
||||
>
|
||||
{{ t('notFound.home') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,530 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, watch} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useSignatureStore} from '@/stores/signatures'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {usePageTitle} from '@/composables/usePageTitle'
|
||||
|
||||
const {t} = useI18n()
|
||||
usePageTitle('sign.title')
|
||||
|
||||
import {AlertTriangle, CheckCircle2, FileText, Info, Users, Loader2, Shield, Zap, Clock} from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertTitle from '@/components/ui/AlertTitle.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import SignButton from '@/components/SignButton.vue'
|
||||
import SignatureList from '@/components/SignatureList.vue'
|
||||
import {documentService, type FindOrCreateDocumentResponse} from '@/services/documents'
|
||||
import {detectReference} from '@/services/referenceDetector'
|
||||
import {calculateFileChecksum} from '@/services/checksumCalculator'
|
||||
import {updateDocumentMetadata} from '@/services/admin'
|
||||
import DocumentForm from "@/components/DocumentForm.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const signatureStore = useSignatureStore()
|
||||
|
||||
const docId = ref<string | undefined>(undefined)
|
||||
const user = computed(() => authStore.user)
|
||||
const currentDocument = ref<FindOrCreateDocumentResponse | null>(null)
|
||||
|
||||
const documentSignatures = ref<any[]>([])
|
||||
const loadingSignatures = ref(false)
|
||||
const loadingDocument = ref(false)
|
||||
const showSuccessMessage = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const needsAuth = ref(false)
|
||||
const calculatingChecksum = ref(false)
|
||||
|
||||
// Check if current user has signed this document
|
||||
const userHasSigned = computed(() => {
|
||||
if (!user.value?.email || documentSignatures.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
return documentSignatures.value.some(sig => sig.userEmail === user.value?.email)
|
||||
})
|
||||
|
||||
async function loadDocumentSignatures() {
|
||||
if (!docId.value) return
|
||||
|
||||
loadingSignatures.value = true
|
||||
try {
|
||||
documentSignatures.value = await signatureStore.fetchDocumentSignatures(docId.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load document signatures:', error)
|
||||
} finally {
|
||||
loadingSignatures.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocumentReference(ref: string) {
|
||||
try {
|
||||
loadingDocument.value = true
|
||||
errorMessage.value = null
|
||||
needsAuth.value = false
|
||||
|
||||
console.log('Loading document for reference:', ref)
|
||||
|
||||
// Detect reference type
|
||||
const refInfo = detectReference(ref)
|
||||
console.log('Reference detected as:', refInfo)
|
||||
|
||||
// Call find-or-create API
|
||||
const doc = await documentService.findOrCreateDocument(ref)
|
||||
console.log('Document loaded:', doc)
|
||||
|
||||
docId.value = doc.docId
|
||||
currentDocument.value = doc
|
||||
|
||||
// If the ref is not the same as the docID, redirect to clean URL
|
||||
if (ref !== doc.docId) {
|
||||
await router.replace({
|
||||
name: route.name as string,
|
||||
query: { doc: doc.docId }
|
||||
})
|
||||
// Continue loading even after redirect
|
||||
}
|
||||
|
||||
// If new document AND downloadable URL → calculate checksum
|
||||
if (doc.isNew && refInfo.isDownloadable && refInfo.type === 'url' && !doc.checksum) {
|
||||
await calculateAndUpdateChecksum(doc.docId, refInfo.value)
|
||||
}
|
||||
|
||||
// Load signatures
|
||||
await loadDocumentSignatures()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load/create document:', error)
|
||||
|
||||
// Handle 401 Unauthorized - user needs to authenticate
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage.value = t('sign.error.authRequired')
|
||||
needsAuth.value = true
|
||||
} else {
|
||||
errorMessage.value = error.message || t('sign.error.loadFailed')
|
||||
needsAuth.value = false
|
||||
}
|
||||
} finally {
|
||||
loadingDocument.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoginClick() {
|
||||
authStore.startOAuthLogin(route.fullPath)
|
||||
}
|
||||
|
||||
async function calculateAndUpdateChecksum(docId: string, url: string) {
|
||||
try {
|
||||
calculatingChecksum.value = true
|
||||
console.log('Calculating checksum for:', url)
|
||||
|
||||
const checksumData = await calculateFileChecksum(url)
|
||||
console.log('Checksum calculated:', checksumData.checksum)
|
||||
|
||||
// Update document metadata with checksum (if user is admin)
|
||||
if (authStore.isAdmin) {
|
||||
await updateDocumentMetadata(docId, {
|
||||
checksum: checksumData.checksum,
|
||||
checksumAlgorithm: checksumData.algorithm
|
||||
})
|
||||
|
||||
// Update local document reference
|
||||
if (currentDocument.value) {
|
||||
currentDocument.value.checksum = checksumData.checksum
|
||||
currentDocument.value.checksumAlgorithm = checksumData.algorithm
|
||||
}
|
||||
|
||||
console.log('Checksum updated in database')
|
||||
} else {
|
||||
console.log('Checksum calculated but not saved (user not admin)')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Checksum calculation failed:', error)
|
||||
// Don't fail the whole operation if checksum fails
|
||||
} finally {
|
||||
calculatingChecksum.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSigned() {
|
||||
showSuccessMessage.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
// Reload signatures to show the new one
|
||||
await loadDocumentSignatures()
|
||||
|
||||
// Hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
showSuccessMessage.value = false
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function handleError(error: string) {
|
||||
errorMessage.value = error
|
||||
showSuccessMessage.value = false
|
||||
}
|
||||
|
||||
// Helper to wait for auth to be initialized by App.vue
|
||||
async function waitForAuth() {
|
||||
// If already initialized, return immediately
|
||||
if (authStore.initialized) return
|
||||
|
||||
// Otherwise wait for initialized to become true
|
||||
return new Promise<void>((resolve) => {
|
||||
const stopWatch = watch(
|
||||
() => authStore.initialized,
|
||||
(isInit) => {
|
||||
if (isInit) {
|
||||
stopWatch()
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for route query changes (only for changes, not initial mount)
|
||||
watch(() => route.query.doc, async (newRef, oldRef) => {
|
||||
// Only process if the doc query parameter actually changed
|
||||
if (newRef === oldRef) return
|
||||
|
||||
// Reset state
|
||||
showSuccessMessage.value = false
|
||||
errorMessage.value = null
|
||||
needsAuth.value = false
|
||||
docId.value = undefined
|
||||
currentDocument.value = null
|
||||
documentSignatures.value = []
|
||||
|
||||
// If we have a reference, load/create the document
|
||||
if (newRef && typeof newRef === 'string') {
|
||||
// Wait for App.vue to finish checking auth
|
||||
await waitForAuth()
|
||||
await handleDocumentReference(newRef)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// CRITICAL: Wait for App.vue to finish auth check before doing anything
|
||||
// App.vue calls checkAuth() which will set initialized=true when done
|
||||
await waitForAuth()
|
||||
|
||||
// Now handle the document reference if present in URL
|
||||
const ref = route.query.doc as string | undefined
|
||||
if (ref) {
|
||||
await handleDocumentReference(ref)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/4 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/4 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
{{ t('sign.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{{ t('sign.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message (shown independently of docId state) -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<Alert v-if="errorMessage && !loadingDocument" variant="destructive" class="clay-card mb-6">
|
||||
<div class="flex items-start">
|
||||
<AlertTriangle :size="20" class="mr-3 mt-0.5"/>
|
||||
<div class="flex-1">
|
||||
<AlertTitle>{{ t('sign.error.title') }}</AlertTitle>
|
||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||
<div v-if="needsAuth" class="mt-4">
|
||||
<Button @click="handleLoginClick" variant="default">
|
||||
{{ t('sign.error.loginButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</transition>
|
||||
|
||||
<!-- Loading state -->
|
||||
<Card v-if="loadingDocument" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<Loader2 :size="48" class="mx-auto mb-4 animate-spin text-primary"/>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('sign.loading.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('sign.loading.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- No Document: Show help message -->
|
||||
<Card v-else-if="!docId" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<FileText :size="48" class="mx-auto mb-4 text-muted-foreground"/>
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('sign.noDocument.title') }}</h2>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
{{ t('sign.noDocument.description', { code: '?doc=' }) }}
|
||||
</p>
|
||||
<div class="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>{{ t('sign.noDocument.examples') }}</strong></p>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=https://example.com/policy.pdf</code>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=/path/to/document</code>
|
||||
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=my-unique-ref</code>
|
||||
<DocumentForm />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Main Content when doc ID is present -->
|
||||
<div v-else-if="docId" class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<Alert v-if="showSuccessMessage" variant="success" class="clay-card">
|
||||
<div class="flex items-start">
|
||||
<CheckCircle2 :size="20" class="mr-3 mt-0.5 text-green-600 dark:text-green-400"/>
|
||||
<div class="flex-1">
|
||||
<AlertTitle>{{ t('sign.success.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ t('sign.success.description') }}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</transition>
|
||||
|
||||
<!-- Document Info Card -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileText :size="28" class="text-primary"/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<CardTitle>
|
||||
{{ t('sign.document.title') }}<template v-if="currentDocument?.title"> : {{ currentDocument.title }}</template>
|
||||
</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
<template v-if="currentDocument?.url">
|
||||
<a
|
||||
:href="currentDocument.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline font-mono text-xs"
|
||||
>
|
||||
{{ currentDocument.url }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-mono text-xs">{{ docId }}</span>
|
||||
</template>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<!-- Sign Button Component -->
|
||||
<div class="pb-4">
|
||||
<SignButton
|
||||
:doc-id="docId"
|
||||
:signatures="documentSignatures"
|
||||
@signed="handleSigned"
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info Box (only shown if user hasn't signed yet) -->
|
||||
<Alert v-if="!userHasSigned" variant="info" class="border-l-4">
|
||||
<div class="flex items-start">
|
||||
<Info :size="18" class="mr-3 mt-0.5"/>
|
||||
<div class="flex-1 space-y-2 text-sm">
|
||||
<p>
|
||||
{{ t('sign.info.description') }}
|
||||
</p>
|
||||
<p class="font-medium">
|
||||
{{ t('sign.info.recorded') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<li>{{ t('sign.info.email') }} : <strong class="text-foreground">{{ user?.email }}</strong></li>
|
||||
<li>{{ t('sign.info.timestamp') }}</li>
|
||||
<li>{{ t('sign.info.signature') }}</li>
|
||||
<li>{{ t('sign.info.hash') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Existing Confirmations -->
|
||||
<Card v-if="documentSignatures.length > 0" class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2">
|
||||
<Users :size="20" class="text-primary"/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{{ t('sign.confirmations.title') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('sign.confirmations.count', { count: documentSignatures.length }, documentSignatures.length) }}
|
||||
{{ t('sign.confirmations.recorded', {}, documentSignatures.length) }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<SignatureList
|
||||
:signatures="documentSignatures"
|
||||
:loading="loadingSignatures"
|
||||
:show-user-info="true"
|
||||
:show-details="true"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Empty State -->
|
||||
<Card v-else-if="!loadingSignatures" class="clay-card">
|
||||
<CardContent class="py-12 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Users :size="28" class="text-muted-foreground"/>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">
|
||||
{{ t('sign.empty.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.empty.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- How it Works Section (always visible) -->
|
||||
<div class="mt-16 pt-12 border-t border-border/40">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="mb-3 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
||||
{{ t('sign.howItWorks.title') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl mx-auto">
|
||||
{{ t('sign.howItWorks.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Steps Grid -->
|
||||
<div class="grid gap-8 md:grid-cols-3 mb-12">
|
||||
<!-- Step 1 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step1.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step1.description', { code: '?doc=URL' }) }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Shield :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step2.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step2.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<Card class="clay-card-hover text-center">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<CheckCircle2 :size="24" class="text-primary" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">{{ t('sign.howItWorks.step3.title') }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.step3.description') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Shield :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.crypto.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.features.crypto.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Zap :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.instant.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.features.instant.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="rounded-lg bg-primary/10 p-2 mt-1">
|
||||
<Clock :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-foreground mb-1">{{ t('sign.howItWorks.features.timestamp.title') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('sign.howItWorks.features.timestamp.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSignatureStore } from '@/stores/signatures'
|
||||
import { FileSignature, FileCheck, Clock, Search, Info } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import SignatureList from '@/components/SignatureList.vue'
|
||||
import {usePageTitle} from "@/composables/usePageTitle.ts";
|
||||
|
||||
const { t } = useI18n()
|
||||
usePageTitle('signatures.title')
|
||||
const signatureStore = useSignatureStore()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredSignatures = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return signatureStore.userSignatures
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return signatureStore.userSignatures.filter((sig: any) =>
|
||||
sig.docId.toLowerCase().includes(query) ||
|
||||
sig.docTitle?.toLowerCase().includes(query) ||
|
||||
sig.docUrl?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const activeSignatures = computed(() => {
|
||||
return filteredSignatures.value.filter((sig: any) => !sig.docDeletedAt)
|
||||
})
|
||||
|
||||
const deletedSignatures = computed(() => {
|
||||
return filteredSignatures.value.filter((sig: any) => sig.docDeletedAt)
|
||||
})
|
||||
|
||||
const uniqueDocumentsCount = computed(() => {
|
||||
const docIds = new Set(signatureStore.userSignatures.map((sig: any) => sig.docId))
|
||||
return docIds.size
|
||||
})
|
||||
|
||||
const lastSignatureDate = computed(() => {
|
||||
if (signatureStore.userSignatures.length === 0) return null
|
||||
|
||||
const latest = signatureStore.userSignatures[0]
|
||||
if (!latest) return null
|
||||
const date = new Date(latest.signedAt)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshSignatures() {
|
||||
try {
|
||||
await signatureStore.fetchUserSignatures()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh signatures:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshSignatures()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/3 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/3 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
{{ t('signatures.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{{ t('signatures.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Pills Mobile (compact horizontal full-width) -->
|
||||
<div class="sm:hidden mb-6 grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-primary/10 text-primary">
|
||||
<FileSignature :size="18" />
|
||||
<span class="text-xl font-bold">{{ signatureStore.getUserSignaturesCount }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.total') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<FileCheck :size="18" />
|
||||
<span class="text-xl font-bold">{{ uniqueDocumentsCount }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.unique') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<Clock :size="18" />
|
||||
<span class="text-sm font-bold">{{ lastSignatureDate || 'N/A' }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('signatures.stats.last') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards Desktop -->
|
||||
<div class="hidden sm:grid mb-8 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Total Confirmations -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileSignature :size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.totalConfirmations') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">
|
||||
{{ signatureStore.getUserSignaturesCount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Unique Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-green-500/10 p-3">
|
||||
<FileCheck :size="24" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.uniqueDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ uniqueDocumentsCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Last Confirmation -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-blue-500/10 p-3">
|
||||
<Clock :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('signatures.stats.lastConfirmation') }}</p>
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ lastSignatureDate || 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Signatures List -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ t('signatures.allConfirmations') }}</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
{{ t('signatures.results', { count: filteredSignatures.length }) }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('signatures.search')"
|
||||
class="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div v-if="signatureStore.loading" class="flex justify-center py-8">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredSignatures.length === 0" class="text-center py-8">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-muted-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="mt-2 text-muted-foreground">Vous n'avez pas encore confirmé la lecture de documents</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Active signatures -->
|
||||
<SignatureList
|
||||
v-if="activeSignatures.length > 0"
|
||||
:signatures="activeSignatures"
|
||||
:loading="false"
|
||||
:show-user-info="false"
|
||||
:show-details="true"
|
||||
:show-actions="false"
|
||||
:is-deleted="false"
|
||||
/>
|
||||
|
||||
<!-- Separator if both active and deleted exist -->
|
||||
<div v-if="activeSignatures.length > 0 && deletedSignatures.length > 0" class="py-4">
|
||||
<hr class="border-border" />
|
||||
<p class="text-center text-sm text-muted-foreground mt-4 mb-2">
|
||||
Documents supprimés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deleted signatures -->
|
||||
<SignatureList
|
||||
v-if="deletedSignatures.length > 0"
|
||||
:signatures="deletedSignatures"
|
||||
:loading="false"
|
||||
:show-user-info="false"
|
||||
:show-details="true"
|
||||
:show-actions="false"
|
||||
:is-deleted="true"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Info Card -->
|
||||
<Alert variant="info" class="mt-6 clay-card border-l-4">
|
||||
<div class="flex items-start">
|
||||
<Info :size="20" class="mr-3 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h3 class="mb-2 font-medium">{{ t('signatures.about.title') }}</h3>
|
||||
<AlertDescription>
|
||||
{{ t('signatures.about.description') }}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,495 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { listDocuments, type Document } from '@/services/admin'
|
||||
import { extractError } from '@/services/http'
|
||||
import { FileText, Users, CheckCircle, ExternalLink, Settings, Loader2, Plus, Search } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
usePageTitle('admin.title')
|
||||
const documents = ref<Document[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const newDocId = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
// Pagination & Filter
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
const totalDocsCount = ref(0)
|
||||
|
||||
// Computed
|
||||
const filteredDocuments = computed(() => {
|
||||
if (!searchQuery.value.trim()) return documents.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return documents.value.filter(doc =>
|
||||
doc.docId.toLowerCase().includes(query) ||
|
||||
doc.title?.toLowerCase().includes(query) ||
|
||||
doc.url?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.ceil(totalDocsCount.value / perPage.value) || 1)
|
||||
|
||||
// Computed KPIs
|
||||
const totalDocuments = computed(() => totalDocsCount.value)
|
||||
const totalSigners = computed(() => {
|
||||
// For now, return 0 as expectedSigners might not be in the Document type yet
|
||||
return 0
|
||||
})
|
||||
const activeDocuments = computed(() => documents.value.length)
|
||||
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const offset = (currentPage.value - 1) * perPage.value
|
||||
const response = await listDocuments(perPage.value, offset)
|
||||
documents.value = response.data
|
||||
|
||||
// Extract pagination metadata if available
|
||||
if (response.meta) {
|
||||
totalDocsCount.value = response.meta.total || documents.value.length
|
||||
} else {
|
||||
totalDocsCount.value = documents.value.length
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to load documents:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
loadDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
loadDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
async function createDocument() {
|
||||
if (!newDocId.value.trim()) return
|
||||
|
||||
try {
|
||||
creating.value = true
|
||||
error.value = ''
|
||||
// Navigate to document detail page (will be created next)
|
||||
await router.push({ name: 'admin-document', params: { docId: newDocId.value.trim() } })
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to create document:', err)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/4 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/4 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="mb-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
{{ t('admin.title') }}
|
||||
</h1>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{{ t('admin.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Document Section -->
|
||||
<Card class="clay-card mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('admin.documents.new') }}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ t('admin.documents.newDescription') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="createDocument">
|
||||
<!-- Desktop layout -->
|
||||
<div class="hidden md:flex flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="newDocId" class="block text-sm font-medium text-foreground mb-2">
|
||||
{{ t('admin.documents.idLabel') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="newDocId"
|
||||
id="newDocId"
|
||||
type="text"
|
||||
required
|
||||
pattern="[a-zA-Z0-9\-_]+"
|
||||
:placeholder="t('admin.documents.idPlaceholder')"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ t('admin.documents.idHelper') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pt-7">
|
||||
<Button type="submit" :disabled="!newDocId || creating">
|
||||
<FileText :size="16" class="mr-2" v-if="!creating" />
|
||||
<Loader2 :size="16" class="mr-2 animate-spin" v-else />
|
||||
{{ creating ? t('admin.documentForm.creating') : t('common.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layout with icon button -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<label for="newDocIdMobile" class="block text-sm font-medium text-foreground">
|
||||
{{ t('admin.documents.idLabel') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="newDocId"
|
||||
id="newDocIdMobile"
|
||||
type="text"
|
||||
required
|
||||
pattern="[a-zA-Z0-9\-_]+"
|
||||
:placeholder="t('admin.documents.idPlaceholder')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button type="submit" size="icon" :disabled="!newDocId || creating" class="shrink-0">
|
||||
<Loader2 :size="20" class="animate-spin" v-if="creating" />
|
||||
<Plus :size="20" v-else />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('admin.documents.idHelper') }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-6 clay-card">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 :size="48" class="animate-spin text-primary" />
|
||||
<p class="mt-4 text-muted-foreground">{{ t('admin.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div v-else>
|
||||
<!-- KPI Pills Mobile (compact horizontal full-width) -->
|
||||
<div class="md:hidden mb-6 grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-primary/10 text-primary">
|
||||
<FileText :size="18" />
|
||||
<span class="text-xl font-bold">{{ totalDocuments }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.documents') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<Users :size="18" />
|
||||
<span class="text-xl font-bold">{{ totalSigners }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.readers') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1 px-3 py-3 rounded-lg bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<CheckCircle :size="18" />
|
||||
<span class="text-xl font-bold">{{ activeDocuments }}</span>
|
||||
<span class="text-xs whitespace-nowrap">{{ t('admin.dashboard.stats.active') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards Desktop (unchanged) -->
|
||||
<div class="hidden md:grid mb-8 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Total Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-primary/10 p-3">
|
||||
<FileText :size="24" class="text-primary" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.dashboard.totalDocuments') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ totalDocuments }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Total Expected Readers -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-blue-500/10 p-3">
|
||||
<Users :size="24" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.dashboard.stats.expected') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ totalSigners }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Active Documents -->
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-green-500/10 p-3">
|
||||
<CheckCircle :size="24" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ t('admin.documents.actions') }}</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ activeDocuments }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Documents Table -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ t('admin.documents.title') }}</CardTitle>
|
||||
<CardDescription class="mt-2">
|
||||
{{ t('admin.subtitle') }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<!-- Search Filter -->
|
||||
<div class="mb-6 relative">
|
||||
<Search :size="18" class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.documents.search')"
|
||||
class="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<!-- Desktop Table (hidden on mobile) -->
|
||||
<div v-if="filteredDocuments.length > 0" class="hidden md:block rounded-md border border-border/40">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ t('admin.documents.document') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.url') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.createdOn') }}</TableHead>
|
||||
<TableHead>{{ t('admin.documents.by') }}</TableHead>
|
||||
<TableHead class="text-right">{{ t('admin.documents.actions') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="doc in filteredDocuments" :key="doc.docId">
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium text-foreground">{{ doc.title }}</div>
|
||||
<div class="text-xs font-mono text-muted-foreground">{{ doc.docId }}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
v-if="doc.url"
|
||||
:href="doc.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center space-x-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span class="max-w-[200px] truncate">{{ doc.url }}</span>
|
||||
<ExternalLink :size="14" />
|
||||
</a>
|
||||
<span v-else class="text-xs text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatDate(doc.createdAt) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<span class="text-xs">{{ doc.createdBy }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<router-link
|
||||
:to="{ name: 'admin-document', params: { docId: doc.docId } }"
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings :size="16" class="mr-1" />
|
||||
Gérer
|
||||
</Button>
|
||||
</router-link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards (hidden on desktop) -->
|
||||
<div v-if="filteredDocuments.length > 0" class="md:hidden space-y-4">
|
||||
<Card v-for="doc in filteredDocuments" :key="doc.docId" class="clay-card-hover">
|
||||
<CardContent class="p-4">
|
||||
<!-- Document Title & ID -->
|
||||
<div class="mb-3">
|
||||
<h3 class="font-medium text-foreground text-base">{{ doc.title }}</h3>
|
||||
<p class="text-xs font-mono text-muted-foreground mt-1">{{ doc.docId }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div v-if="doc.url" class="mb-3">
|
||||
<a
|
||||
:href="doc.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center space-x-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink :size="14" />
|
||||
<span class="truncate max-w-[250px]">{{ doc.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mb-3">
|
||||
<div class="flex items-center space-x-1">
|
||||
<FileText :size="14" />
|
||||
<span>{{ formatDate(doc.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Users :size="14" />
|
||||
<span class="text-xs">{{ doc.createdBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 pt-2 border-t border-border/40">
|
||||
<router-link
|
||||
:to="{ name: 'admin-document', params: { docId: doc.docId } }"
|
||||
class="flex-1"
|
||||
>
|
||||
<Button variant="outline" size="sm" class="w-full">
|
||||
<Settings :size="16" class="mr-2" />
|
||||
Gérer
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-12">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<FileText :size="28" class="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-foreground">
|
||||
{{ searchQuery ? 'Aucun résultat' : 'Aucun document' }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ searchQuery ? 'Essayez une autre recherche' : 'Les documents apparaîtront ici une fois créés' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="filteredDocuments.length > 0 && !searchQuery && totalPages > 1" class="flex items-center justify-between mt-6 pt-4 border-t border-border/40">
|
||||
<!-- Mobile Pagination -->
|
||||
<div class="md:hidden flex items-center justify-between w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="prevPage"
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Page {{ currentPage }}/{{ totalPages }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="nextPage"
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Pagination -->
|
||||
<div class="hidden md:flex items-center justify-between w-full">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ totalDocuments }} document{{ totalDocuments > 1 ? 's' : '' }} au total
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="prevPage"
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Page {{ currentPage }} sur {{ totalPages }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="nextPage"
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,406 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Back link -->
|
||||
<div class="mb-6">
|
||||
<router-link to="/admin" class="text-primary hover:text-primary/80 flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1 -ml-2 transition-colors">
|
||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au tableau de bord
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="error" class="bg-destructive/10 dark:bg-destructive/20 border border-destructive/50 text-destructive px-4 py-3 rounded mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div v-if="successMessage" class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-400 px-4 py-3 rounded mb-6">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p class="mt-2 text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Document details -->
|
||||
<div v-else-if="documentData">
|
||||
<!-- Document info -->
|
||||
<div class="bg-card text-card-foreground shadow-md rounded-lg p-6 mb-6 border border-border" v-if="documentData.document">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-2">{{ documentData.document.title }}</h1>
|
||||
<p class="text-muted-foreground mb-4">{{ documentData.document.description }}</p>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-muted-foreground">ID:</span>
|
||||
<span class="text-foreground ml-2">{{ documentData.document.docId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-muted-foreground">URL:</span>
|
||||
<a
|
||||
:href="documentData.document.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary/80 ml-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded"
|
||||
>
|
||||
{{ documentData.document.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-muted-foreground">Créé le:</span>
|
||||
<span class="text-foreground ml-2">{{ formatDate(documentData.document.createdAt) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-muted-foreground">Par:</span>
|
||||
<span class="text-foreground ml-2">{{ documentData.document.createdBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="text-blue-900 dark:text-blue-100 text-2xl font-bold">{{ documentData.stats.expectedCount }}</div>
|
||||
<div class="text-blue-700 dark:text-blue-300 text-sm">Attendus</div>
|
||||
</div>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div class="text-green-900 dark:text-green-100 text-2xl font-bold">{{ documentData.stats.signedCount }}</div>
|
||||
<div class="text-green-700 dark:text-green-300 text-sm">Signés</div>
|
||||
</div>
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div class="text-orange-900 dark:text-orange-100 text-2xl font-bold">{{ documentData.stats.pendingCount }}</div>
|
||||
<div class="text-orange-700 dark:text-orange-300 text-sm">En attente</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div class="text-purple-900 dark:text-purple-100 text-2xl font-bold">{{ documentData.stats.completionRate.toFixed(0) }}%</div>
|
||||
<div class="text-purple-700 dark:text-purple-300 text-sm">Complétude</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add signer form -->
|
||||
<div class="bg-card text-card-foreground shadow-md rounded-lg p-6 mb-6 border border-border">
|
||||
<h2 class="text-xl font-bold text-foreground mb-4">Ajouter un signataire attendu</h2>
|
||||
<form @submit.prevent="handleAddSigner" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-foreground mb-1">Email *</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="newSigner.email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-foreground mb-1">Nom</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="newSigner.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
placeholder="Nom complet"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="adding"
|
||||
class="w-full bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-colors"
|
||||
>
|
||||
{{ adding ? 'Ajout...' : 'Ajouter' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Reminders section -->
|
||||
<div class="bg-card text-card-foreground shadow-md rounded-lg p-6 mb-6 border border-border" v-if="documentData.stats.pendingCount > 0">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-foreground">Relances email</h2>
|
||||
<button
|
||||
@click="confirmSendReminders"
|
||||
:disabled="sendingReminders"
|
||||
class="bg-green-600 dark:bg-green-700 text-white px-4 py-2 rounded-md hover:bg-green-700 dark:hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-colors"
|
||||
>
|
||||
{{ sendingReminders ? 'Envoi...' : `Relancer tous (${documentData.stats.pendingCount})` }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Envoyez une relance par email aux signataires qui n'ont pas encore signé le document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Signers list -->
|
||||
<div class="bg-card text-card-foreground shadow-md rounded-lg overflow-hidden border border-border">
|
||||
<div class="px-6 py-4 border-b border-border">
|
||||
<h2 class="text-xl font-bold text-foreground">Signataires attendus</h2>
|
||||
</div>
|
||||
<div v-if="documentData.expectedSigners.length === 0" class="p-6 text-center text-muted-foreground">
|
||||
Aucun signataire attendu pour ce document
|
||||
</div>
|
||||
<table v-else class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-muted">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Signataire
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Ajouté
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Relances
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-card divide-y divide-border">
|
||||
<tr v-for="signer in documentData.expectedSigners" :key="signer.id" class="hover:bg-accent transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-foreground">{{ signer.name || signer.email }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ signer.email }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
v-if="signer.hasSigned"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400"
|
||||
>
|
||||
Signé {{ formatDate(signer.signedAt!) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400"
|
||||
>
|
||||
En attente ({{ signer.daysSinceAdded }}j)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{{ formatDate(signer.addedAt) }}<br />
|
||||
<span class="text-xs">par {{ signer.addedBy }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{{ signer.reminderCount }} relance(s)
|
||||
<span v-if="signer.lastReminderSent" class="block text-xs">
|
||||
Dernière: il y a {{ signer.daysSinceLastReminder }}j
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
v-if="!signer.hasSigned"
|
||||
@click="confirmRemoveSigner(signer.email)"
|
||||
:disabled="removing === signer.email"
|
||||
class="text-destructive hover:text-destructive/80 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded px-2 py-1 transition-colors"
|
||||
>
|
||||
{{ removing === signer.email ? 'Suppression...' : 'Retirer' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Signer Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
v-if="showRemoveSignerModal"
|
||||
title="⚠️ Retirer le signataire attendu"
|
||||
:message="`Êtes-vous sûr de vouloir retirer ${signerToRemove} des signataires attendus ?`"
|
||||
confirm-text="Retirer"
|
||||
cancel-text="Annuler"
|
||||
variant="warning"
|
||||
@confirm="handleRemoveSigner"
|
||||
@cancel="cancelRemoveSigner"
|
||||
/>
|
||||
|
||||
<!-- Send Reminders Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
v-if="showSendRemindersModal"
|
||||
title="📧 Envoyer des relances"
|
||||
:message="remindersMessage"
|
||||
confirm-text="Envoyer"
|
||||
cancel-text="Annuler"
|
||||
variant="default"
|
||||
:loading="sendingReminders"
|
||||
@confirm="handleSendReminders"
|
||||
@cancel="cancelSendReminders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import {
|
||||
getDocumentStatus,
|
||||
addExpectedSigner,
|
||||
removeExpectedSigner,
|
||||
sendReminders,
|
||||
type DocumentStatus,
|
||||
} from '@/services/admin'
|
||||
import { extractError } from '@/services/http'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const docId = route.params.docId as string
|
||||
usePageTitle('admin.documentDetail.title', { docId })
|
||||
|
||||
const documentData = ref<DocumentStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const adding = ref(false)
|
||||
const removing = ref('')
|
||||
const sendingReminders = ref(false)
|
||||
const error = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
// Modals
|
||||
const showRemoveSignerModal = ref(false)
|
||||
const showSendRemindersModal = ref(false)
|
||||
const signerToRemove = ref('')
|
||||
const remindersMessage = ref('')
|
||||
|
||||
const newSigner = ref<{
|
||||
email: string
|
||||
name: string
|
||||
notes?: string
|
||||
}>({
|
||||
email: '',
|
||||
name: '',
|
||||
})
|
||||
|
||||
async function loadDocument() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const response = await getDocumentStatus(docId)
|
||||
documentData.value = response.data
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to load document:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSigner() {
|
||||
try {
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
await addExpectedSigner(docId, newSigner.value)
|
||||
|
||||
successMessage.value = `Signataire ${newSigner.value.email} ajouté avec succès`
|
||||
newSigner.value = { email: '', name: '' }
|
||||
|
||||
// Reload document to refresh signers list
|
||||
await loadDocument()
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to add signer:', err)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemoveSigner(email: string) {
|
||||
signerToRemove.value = email
|
||||
showRemoveSignerModal.value = true
|
||||
}
|
||||
|
||||
async function handleRemoveSigner() {
|
||||
const email = signerToRemove.value
|
||||
if (!email) return
|
||||
|
||||
try {
|
||||
removing.value = email
|
||||
error.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
await removeExpectedSigner(docId, email)
|
||||
|
||||
successMessage.value = `Signataire ${email} retiré avec succès`
|
||||
showRemoveSignerModal.value = false
|
||||
signerToRemove.value = ''
|
||||
|
||||
// Reload document to refresh signers list
|
||||
await loadDocument()
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to remove signer:', err)
|
||||
} finally {
|
||||
removing.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRemoveSigner() {
|
||||
showRemoveSignerModal.value = false
|
||||
signerToRemove.value = ''
|
||||
}
|
||||
|
||||
function confirmSendReminders() {
|
||||
remindersMessage.value = `Envoyer une relance par email aux ${documentData.value?.stats.pendingCount} signataire(s) en attente ?`
|
||||
showSendRemindersModal.value = true
|
||||
}
|
||||
|
||||
async function handleSendReminders() {
|
||||
try {
|
||||
sendingReminders.value = true
|
||||
error.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
// Normalize locale to base language code (it-IT -> it, en-US -> en, etc.)
|
||||
const normalizedLocale = locale.value.split('-')[0]
|
||||
console.log('Sending reminders with locale:', normalizedLocale, '(from', locale.value, ')')
|
||||
|
||||
const response = await sendReminders(docId, {}, normalizedLocale)
|
||||
const result = response.data.result
|
||||
|
||||
showSendRemindersModal.value = false
|
||||
|
||||
if (result.failed > 0) {
|
||||
successMessage.value = `${result.successfullySent} relance(s) envoyée(s), ${result.failed} échec(s)`
|
||||
if (result.errors) {
|
||||
error.value = result.errors.join(', ')
|
||||
}
|
||||
} else {
|
||||
successMessage.value = `${result.successfullySent} relance(s) envoyée(s) avec succès`
|
||||
}
|
||||
|
||||
// Reload document to refresh reminder stats
|
||||
await loadDocument()
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to send reminders:', err)
|
||||
} finally {
|
||||
sendingReminders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSendReminders() {
|
||||
showSendRemindersModal.value = false
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocument()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,863 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
getDocumentStatus,
|
||||
updateDocumentMetadata,
|
||||
addExpectedSigner,
|
||||
removeExpectedSigner,
|
||||
sendReminders,
|
||||
deleteDocument,
|
||||
type DocumentStatus,
|
||||
} from '@/services/admin'
|
||||
import { extractError } from '@/services/http'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
CheckCircle,
|
||||
Mail,
|
||||
Shield,
|
||||
Plus,
|
||||
Loader2,
|
||||
Copy,
|
||||
Clock,
|
||||
X,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import CardHeader from '@/components/ui/CardHeader.vue'
|
||||
import CardTitle from '@/components/ui/CardTitle.vue'
|
||||
import CardDescription from '@/components/ui/CardDescription.vue'
|
||||
import CardContent from '@/components/ui/CardContent.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Textarea from '@/components/ui/Textarea.vue'
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import AlertDescription from '@/components/ui/AlertDescription.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// Data
|
||||
const docId = computed(() => route.params.docId as string)
|
||||
usePageTitle('admin.documentDetail.title', { docId: docId.value })
|
||||
const documentStatus = ref<DocumentStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
// Modals
|
||||
const showAddSignersModal = ref(false)
|
||||
const showDeleteConfirmModal = ref(false)
|
||||
const showMetadataWarningModal = ref(false)
|
||||
const showRemoveSignerModal = ref(false)
|
||||
const showSendRemindersModal = ref(false)
|
||||
const signerToRemove = ref('')
|
||||
const remindersMessage = ref('')
|
||||
|
||||
// Metadata form
|
||||
const metadataForm = ref<Partial<{
|
||||
title: string
|
||||
url: string
|
||||
checksum: string
|
||||
checksumAlgorithm: string
|
||||
description: string
|
||||
}>>({
|
||||
title: '',
|
||||
url: '',
|
||||
checksum: '',
|
||||
checksumAlgorithm: 'SHA-256',
|
||||
description: '',
|
||||
})
|
||||
const originalMetadata = ref<Partial<{
|
||||
title: string
|
||||
url: string
|
||||
checksum: string
|
||||
checksumAlgorithm: string
|
||||
description: string
|
||||
}>>({})
|
||||
const savingMetadata = ref(false)
|
||||
|
||||
// Expected signers form
|
||||
const signersEmails = ref('')
|
||||
const addingSigners = ref(false)
|
||||
|
||||
// Reminders
|
||||
const sendMode = ref<'all' | 'selected'>('all')
|
||||
const selectedEmails = ref<string[]>([])
|
||||
const sendingReminders = ref(false)
|
||||
|
||||
// Delete
|
||||
const deletingDocument = ref(false)
|
||||
|
||||
// Computed
|
||||
const shareLink = computed(() => {
|
||||
if (!documentStatus.value) return ''
|
||||
return documentStatus.value.shareLink
|
||||
})
|
||||
|
||||
const stats = computed(() => documentStatus.value?.stats)
|
||||
const reminderStats = computed(() => documentStatus.value?.reminderStats)
|
||||
const expectedSigners = computed(() => documentStatus.value?.expectedSigners || [])
|
||||
const unexpectedSignatures = computed(() => documentStatus.value?.unexpectedSignatures || [])
|
||||
const documentMetadata = computed(() => documentStatus.value?.document)
|
||||
|
||||
// Methods
|
||||
async function loadDocumentStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const response = await getDocumentStatus(docId.value)
|
||||
documentStatus.value = response.data
|
||||
|
||||
// Pre-fill metadata form if document exists
|
||||
if (documentStatus.value.document) {
|
||||
const doc = documentStatus.value.document
|
||||
const metadata = {
|
||||
title: doc.title || '',
|
||||
url: doc.url || '',
|
||||
checksum: doc.checksum || '',
|
||||
checksumAlgorithm: doc.checksumAlgorithm || 'SHA-256',
|
||||
description: doc.description || '',
|
||||
}
|
||||
metadataForm.value = { ...metadata }
|
||||
originalMetadata.value = { ...metadata }
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to load document status:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasCriticalFieldsChanged(): boolean {
|
||||
// Check if critical fields (url, checksum, checksumAlgorithm, description) have changed
|
||||
return (
|
||||
metadataForm.value.url !== originalMetadata.value.url ||
|
||||
metadataForm.value.checksum !== originalMetadata.value.checksum ||
|
||||
metadataForm.value.checksumAlgorithm !== originalMetadata.value.checksumAlgorithm ||
|
||||
metadataForm.value.description !== originalMetadata.value.description
|
||||
)
|
||||
}
|
||||
|
||||
function handleSaveMetadata() {
|
||||
// Check if document has signatures (both expected and unexpected) and critical fields changed
|
||||
const expectedSignaturesCount = stats.value?.signedCount || 0
|
||||
const unexpectedSignaturesCount = unexpectedSignatures.value?.length || 0
|
||||
const totalSignatures = expectedSignaturesCount + unexpectedSignaturesCount
|
||||
const hasSignatures = totalSignatures > 0
|
||||
const criticalFieldsChanged = hasCriticalFieldsChanged()
|
||||
|
||||
if (hasSignatures && criticalFieldsChanged) {
|
||||
// Show warning modal
|
||||
showMetadataWarningModal.value = true
|
||||
} else {
|
||||
// Save directly
|
||||
saveMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetadata() {
|
||||
try {
|
||||
savingMetadata.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
showMetadataWarningModal.value = false
|
||||
await updateDocumentMetadata(docId.value, metadataForm.value)
|
||||
success.value = 'Métadonnées enregistrées avec succès'
|
||||
await loadDocumentStatus()
|
||||
setTimeout(() => (success.value = ''), 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to save metadata:', err)
|
||||
} finally {
|
||||
savingMetadata.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addSigners() {
|
||||
if (!signersEmails.value.trim()) return
|
||||
|
||||
try {
|
||||
addingSigners.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
// Parse emails - support "Name <email>" or "email" format
|
||||
const lines = signersEmails.value.split('\n').filter(l => l.trim())
|
||||
let addedCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
const match = trimmed.match(/^(.+?)\s*<(.+?)>$/)
|
||||
const email = match && match[2] ? match[2].trim() : trimmed
|
||||
const name = match && match[1] ? match[1].trim() : ''
|
||||
|
||||
try {
|
||||
await addExpectedSigner(docId.value, { email, name })
|
||||
addedCount++
|
||||
} catch (err) {
|
||||
console.error(`Failed to add ${email}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
showAddSignersModal.value = false
|
||||
signersEmails.value = ''
|
||||
success.value = `${addedCount} lecteur(s) ajouté(s) avec succès`
|
||||
await loadDocumentStatus()
|
||||
setTimeout(() => (success.value = ''), 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to add signers:', err)
|
||||
} finally {
|
||||
addingSigners.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemoveSigner(email: string) {
|
||||
signerToRemove.value = email
|
||||
showRemoveSignerModal.value = true
|
||||
}
|
||||
|
||||
async function removeSigner() {
|
||||
const email = signerToRemove.value
|
||||
if (!email) return
|
||||
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
await removeExpectedSigner(docId.value, email)
|
||||
success.value = `${email} retiré avec succès`
|
||||
showRemoveSignerModal.value = false
|
||||
signerToRemove.value = ''
|
||||
await loadDocumentStatus()
|
||||
setTimeout(() => (success.value = ''), 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to remove signer:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRemoveSigner() {
|
||||
showRemoveSignerModal.value = false
|
||||
signerToRemove.value = ''
|
||||
}
|
||||
|
||||
function confirmSendReminders() {
|
||||
remindersMessage.value =
|
||||
sendMode.value === 'all'
|
||||
? `Envoyer des relances à ${reminderStats.value?.pendingCount || 0} lecteur(s) en attente de confirmation ?`
|
||||
: `Envoyer des relances à ${selectedEmails.value.length} lecteur(s) sélectionné(s) ?`
|
||||
showSendRemindersModal.value = true
|
||||
}
|
||||
|
||||
async function sendRemindersAction() {
|
||||
try {
|
||||
sendingReminders.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
// Normalize locale to base language code (it-IT -> it, en-US -> en, etc.)
|
||||
const normalizedLocale = locale.value.split('-')[0]
|
||||
console.log('Sending reminders with locale:', normalizedLocale, '(from', locale.value, ')')
|
||||
|
||||
const response = await sendReminders(
|
||||
docId.value,
|
||||
{
|
||||
emails: sendMode.value === 'selected' ? selectedEmails.value : undefined,
|
||||
},
|
||||
normalizedLocale // Pass normalized locale
|
||||
)
|
||||
|
||||
selectedEmails.value = []
|
||||
showSendRemindersModal.value = false
|
||||
|
||||
if (response.data.result) {
|
||||
const result = response.data.result
|
||||
if (result.failed > 0) {
|
||||
success.value = `${result.successfullySent} relance(s) envoyée(s), ${result.failed} échec(s)`
|
||||
} else {
|
||||
success.value = `${result.successfullySent} relance(s) envoyée(s) avec succès`
|
||||
}
|
||||
} else {
|
||||
success.value = 'Relances envoyées avec succès'
|
||||
}
|
||||
|
||||
await loadDocumentStatus()
|
||||
setTimeout(() => (success.value = ''), 3000)
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to send reminders:', err)
|
||||
} finally {
|
||||
sendingReminders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSendReminders() {
|
||||
showSendRemindersModal.value = false
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
success.value = 'Copié dans le presse-papiers'
|
||||
setTimeout(() => (success.value = ''), 2000)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEmailSelection(email: string) {
|
||||
const index = selectedEmails.value.indexOf(email)
|
||||
if (index > -1) {
|
||||
selectedEmails.value.splice(index, 1)
|
||||
} else {
|
||||
selectedEmails.value.push(email)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument() {
|
||||
try {
|
||||
deletingDocument.value = true
|
||||
error.value = ''
|
||||
await deleteDocument(docId.value)
|
||||
showDeleteConfirmModal.value = false
|
||||
// Redirect to admin dashboard
|
||||
router.push('/admin')
|
||||
} catch (err) {
|
||||
error.value = extractError(err)
|
||||
console.error('Failed to delete document:', err)
|
||||
showDeleteConfirmModal.value = false
|
||||
} finally {
|
||||
deletingDocument.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocumentStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-[calc(100vh-4rem)]">
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute left-1/4 top-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
<div class="absolute right-1/4 bottom-0 h-[400px] w-[400px] rounded-full bg-primary/5 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<Button variant="ghost" size="icon" @click="router.push('/admin')" aria-label="Retour">
|
||||
<ArrowLeft :size="20" />
|
||||
</Button>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
Document {{ docId }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 ml-14">
|
||||
<p class="text-sm text-muted-foreground font-mono">{{ shareLink }}</p>
|
||||
<Button @click="copyToClipboard(shareLink)" variant="ghost" size="icon" aria-label="Copier le lien">
|
||||
<Copy :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-6 clay-card">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert v-if="success" class="mb-6 clay-card bg-green-50 border-green-200 dark:bg-green-900/20">
|
||||
<AlertDescription class="text-green-800 dark:text-green-200">{{ success }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 :size="48" class="animate-spin text-primary" />
|
||||
<p class="mt-4 text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="documentStatus" class="space-y-8">
|
||||
<!-- Stats Cards -->
|
||||
<div v-if="stats && stats.expectedCount > 0" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-blue-500/10 p-3">
|
||||
<Users :size="24" class="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Attendus</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ stats.expectedCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-green-500/10 p-3">
|
||||
<CheckCircle :size="24" class="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Confirmés</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ stats.signedCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-orange-500/10 p-3">
|
||||
<Clock :size="24" class="text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">En attente</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ stats.pendingCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="clay-card-hover">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="rounded-lg bg-purple-500/10 p-3">
|
||||
<Shield :size="24" class="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Complétion</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ Math.round(stats.completionRate) }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Document Metadata -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>📄 Informations sur le document</CardTitle>
|
||||
<CardDescription>Métadonnées et checksum du document</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="handleSaveMetadata" class="space-y-4">
|
||||
<!-- Titre et URL côte à côte -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Titre</label>
|
||||
<Input v-model="metadataForm.title" placeholder="Politique de sécurité 2025" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">URL</label>
|
||||
<Input v-model="metadataForm.url" type="url" placeholder="https://example.com/doc.pdf" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checksum et Algorithme côte à côte -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Checksum</label>
|
||||
<Input v-model="metadataForm.checksum" placeholder="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" class="font-mono text-sm" />
|
||||
</div>
|
||||
<div class="md:min-w-[140px]">
|
||||
<label class="block text-sm font-medium mb-2">Algorithme</label>
|
||||
<select v-model="metadataForm.checksumAlgorithm" class="flex h-10 w-full rounded-md clay-input px-3 py-2 text-sm">
|
||||
<option value="SHA-256">SHA-256</option>
|
||||
<option value="SHA-512">SHA-512</option>
|
||||
<option value="MD5">MD5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Description</label>
|
||||
<Textarea v-model="metadataForm.description" :rows="4" placeholder="Description du document..." />
|
||||
</div>
|
||||
<div v-if="documentMetadata" class="text-xs text-muted-foreground pt-2 border-t">
|
||||
Créé par {{ documentMetadata.createdBy }} le {{ formatDate(documentMetadata.createdAt) }}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" :disabled="savingMetadata">
|
||||
{{ savingMetadata ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Expected Readers -->
|
||||
<Card class="clay-card">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>✓ Lecteurs attendus</CardTitle>
|
||||
<CardDescription v-if="stats">{{ stats.signedCount }} / {{ stats.expectedCount }} confirmés</CardDescription>
|
||||
</div>
|
||||
<Button @click="showAddSignersModal = true" size="sm">
|
||||
<Plus :size="16" class="mr-2" />
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Expected Signers Table -->
|
||||
<div v-if="expectedSigners.length > 0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<input type="checkbox" class="rounded"
|
||||
@change="(e: any) => selectedEmails = e.target.checked ? expectedSigners.filter(s => !s.hasSigned).map(s => s.email) : []" />
|
||||
</TableHead>
|
||||
<TableHead>Lecteur</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Confirmé le</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="signer in expectedSigners" :key="signer.email">
|
||||
<TableCell>
|
||||
<input v-if="!signer.hasSigned" type="checkbox" class="rounded"
|
||||
:checked="selectedEmails.includes(signer.email)"
|
||||
@change="toggleEmailSelection(signer.email)" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium">{{ signer.userName || signer.name || signer.email }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ signer.email }}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="signer.hasSigned ? 'default' : 'secondary'">
|
||||
{{ signer.hasSigned ? '✓ Confirmé' : '⏳ En attente' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ signer.signedAt ? formatDate(signer.signedAt) : '-' }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button @click="confirmRemoveSigner(signer.email)" variant="ghost" size="sm">
|
||||
<Trash2 :size="14" class="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Users :size="48" class="mx-auto mb-4 opacity-50" />
|
||||
<p>Aucun lecteur attendu</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmations complémentaires (toujours visible si présents) -->
|
||||
<div v-if="unexpectedSignatures.length > 0" class="mt-8 pt-8 border-t border-border">
|
||||
<h3 class="text-lg font-semibold mb-4 flex items-center">
|
||||
<span class="mr-2">⚠</span>
|
||||
Confirmations de lecture complémentaires
|
||||
<Badge variant="secondary" class="ml-2">{{ unexpectedSignatures.length }}</Badge>
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Utilisateurs ayant confirmé mais non présents dans la liste des lecteurs attendus
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Utilisateur</TableHead>
|
||||
<TableHead>Confirmé le</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(sig, idx) in unexpectedSignatures" :key="idx">
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium">{{ sig.userName || sig.userEmail }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ sig.userEmail }}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatDate(sig.signedAtUTC) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Email Reminders -->
|
||||
<Card v-if="reminderStats && stats && stats.expectedCount > 0" class="clay-card">
|
||||
<CardHeader>
|
||||
<CardTitle>📧 Relances par email</CardTitle>
|
||||
<CardDescription>Envoyer des rappels aux lecteurs en attente de confirmation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="bg-muted rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground">Relances envoyées</p>
|
||||
<p class="text-2xl font-bold">{{ reminderStats.totalSent }}</p>
|
||||
</div>
|
||||
<div class="bg-muted rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground">À relancer</p>
|
||||
<p class="text-2xl font-bold">{{ reminderStats.pendingCount }}</p>
|
||||
</div>
|
||||
<div v-if="reminderStats.lastSentAt" class="bg-muted rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground">Dernière relance</p>
|
||||
<p class="text-sm font-bold">{{ formatDate(reminderStats.lastSentAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Form -->
|
||||
<div v-if="reminderStats.pendingCount > 0" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center space-x-2">
|
||||
<input type="radio" v-model="sendMode" value="all" class="rounded-full" />
|
||||
<span>Envoyer à tous les lecteurs en attente ({{ reminderStats.pendingCount }})</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2">
|
||||
<input type="radio" v-model="sendMode" value="selected" class="rounded-full" />
|
||||
<span>Envoyer uniquement aux sélectionnés ({{ selectedEmails.length }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button @click="confirmSendReminders" :disabled="sendingReminders || (sendMode === 'selected' && selectedEmails.length === 0)">
|
||||
<Mail :size="16" class="mr-2" />
|
||||
{{ sendingReminders ? 'Envoi...' : 'Envoyer les relances' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="text-center py-4 text-muted-foreground">
|
||||
✓ Tous les lecteurs attendus ont été contactés ou ont confirmé
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<Card class="clay-card border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-destructive">⚠️ Zone de danger</CardTitle>
|
||||
<CardDescription>Actions irréversibles sur ce document</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-between p-4 bg-destructive/5 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-foreground mb-1">Supprimer ce document</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Cette action supprimera définitivement le document, ses métadonnées, les lecteurs attendus et toutes les confirmations associées.<br>
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@click="showDeleteConfirmModal = true"
|
||||
variant="destructive"
|
||||
class="ml-4"
|
||||
>
|
||||
<Trash2 :size="16" class="mr-2" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Chain Integrity - Feature not yet available in API v1 -->
|
||||
<!-- TODO: Add chain integrity verification endpoint to API v1 -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Add Signers Modal -->
|
||||
<div v-if="showAddSignersModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showAddSignersModal = false">
|
||||
<Card class="max-w-2xl w-full">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle>Ajouter des lecteurs attendus</CardTitle>
|
||||
<Button variant="ghost" size="icon" @click="showAddSignersModal = false">
|
||||
<X :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="addSigners" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Emails (un par ligne)</label>
|
||||
<Textarea v-model="signersEmails" :rows="8"
|
||||
placeholder="Marie Dupont <marie.dupont@example.com> jean.martin@example.com Sophie Bernard <sophie@example.com>" />
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
Formats acceptés : "Nom Prénom <email@example.com>" ou "email@example.com"
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" @click="showAddSignersModal = false">Annuler</Button>
|
||||
<Button type="submit" :disabled="addingSigners || !signersEmails.trim()">
|
||||
{{ addingSigners ? 'Ajout...' : 'Ajouter' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteConfirmModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showDeleteConfirmModal = false">
|
||||
<Card class="max-w-md w-full border-destructive">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-destructive">⚠️ Confirmer la suppression</CardTitle>
|
||||
<Button variant="ghost" size="icon" @click="showDeleteConfirmModal = false">
|
||||
<X :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<Alert variant="destructive" class="border-destructive">
|
||||
<AlertDescription>
|
||||
<p class="font-semibold mb-2">Cette action est irréversible !</p>
|
||||
<p class="text-sm">
|
||||
La suppression de ce document entraînera la perte définitive de :
|
||||
</p>
|
||||
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
|
||||
<li>Toutes les métadonnées du document</li>
|
||||
<li>La liste des lecteurs attendus</li>
|
||||
<li>Toutes les confirmations cryptographiques</li>
|
||||
<li>L'historique des relances</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="bg-muted p-3 rounded-lg">
|
||||
<p class="text-sm font-mono text-muted-foreground">
|
||||
Document ID: <span class="text-foreground font-semibold">{{ docId }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="outline" @click="showDeleteConfirmModal = false">
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleDeleteDocument"
|
||||
variant="destructive"
|
||||
:disabled="deletingDocument"
|
||||
>
|
||||
<Trash2 v-if="!deletingDocument" :size="16" class="mr-2" />
|
||||
<Loader2 v-else :size="16" class="mr-2 animate-spin" />
|
||||
{{ deletingDocument ? 'Suppression...' : 'Supprimer définitivement' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Warning Modal -->
|
||||
<div v-if="showMetadataWarningModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" @click.self="showMetadataWarningModal = false">
|
||||
<Card class="max-w-lg w-full border-orange-500">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-orange-600">{{ t('admin.documentDetail.metadataWarning.title') }}</CardTitle>
|
||||
<Button variant="ghost" size="icon" @click="showMetadataWarningModal = false">
|
||||
<X :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
<Alert class="border-orange-500 bg-orange-50 dark:bg-orange-900/20">
|
||||
<AlertDescription>
|
||||
<p class="text-sm text-orange-800 dark:text-orange-200 mb-3">
|
||||
{{ t('admin.documentDetail.metadataWarning.description') }}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-orange-900 dark:text-orange-100">
|
||||
{{ t('admin.documentDetail.metadataWarning.warning') }}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="bg-muted p-4 rounded-lg">
|
||||
<p class="text-sm font-medium mb-2">
|
||||
{{ t('admin.documentDetail.metadataWarning.currentSignatures') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle :size="20" class="text-green-600" />
|
||||
<span class="text-sm">
|
||||
<span class="font-bold text-lg">{{ (stats?.signedCount || 0) + (unexpectedSignatures?.length || 0) }}</span>
|
||||
<span class="text-muted-foreground ml-1">
|
||||
{{ ((stats?.signedCount || 0) + (unexpectedSignatures?.length || 0)) > 1 ? 'signatures' : 'signature' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="outline" @click="showMetadataWarningModal = false">
|
||||
{{ t('admin.documentDetail.metadataWarning.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
@click="saveMetadata"
|
||||
variant="destructive"
|
||||
:disabled="savingMetadata"
|
||||
>
|
||||
<Loader2 v-if="savingMetadata" :size="16" class="mr-2 animate-spin" />
|
||||
{{ savingMetadata ? 'Enregistrement...' : t('admin.documentDetail.metadataWarning.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Remove Signer Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
v-if="showRemoveSignerModal"
|
||||
title="⚠️ Retirer le lecteur attendu"
|
||||
:message="`Retirer ${signerToRemove} de la liste des lecteurs attendus ?`"
|
||||
confirm-text="Retirer"
|
||||
cancel-text="Annuler"
|
||||
variant="warning"
|
||||
@confirm="removeSigner"
|
||||
@cancel="cancelRemoveSigner"
|
||||
/>
|
||||
|
||||
<!-- Send Reminders Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
v-if="showSendRemindersModal"
|
||||
title="📧 Envoyer des relances"
|
||||
:message="remindersMessage"
|
||||
confirm-text="Envoyer"
|
||||
cancel-text="Annuler"
|
||||
variant="default"
|
||||
:loading="sendingReminders"
|
||||
@confirm="sendRemindersAction"
|
||||
@cancel="cancelSendReminders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const SignPage = () => import('@/pages/SignPage.vue')
|
||||
const SignaturesPage = () => import('@/pages/SignaturesPage.vue')
|
||||
const AdminDashboard = () => import('@/pages/admin/AdminDashboard.vue')
|
||||
const AdminDocumentDetail = () => import('@/pages/admin/AdminDocumentDetail.vue')
|
||||
const EmbedPage = () => import('@/pages/EmbedPage.vue')
|
||||
const NotFoundPage = () => import('@/pages/NotFoundPage.vue')
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'sign',
|
||||
component: SignPage,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/signatures',
|
||||
name: 'signatures',
|
||||
component: SignaturesPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: AdminDashboard,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/docs/:docId',
|
||||
name: 'admin-document',
|
||||
component: AdminDocumentDetail,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/embed',
|
||||
name: 'embed',
|
||||
component: EmbedPage,
|
||||
meta: { requiresAuth: false, isEmbed: true }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: NotFoundPage
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
return { top: 0, behavior: 'smooth' }
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
try {
|
||||
// Only check auth if the route requires it
|
||||
if (to.meta.requiresAuth || to.meta.requiresAdmin) {
|
||||
if (!authStore.initialized) {
|
||||
await authStore.checkAuth()
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
sessionStorage.setItem('redirectAfterLogin', to.fullPath)
|
||||
await authStore.startOAuthLogin(to.fullPath)
|
||||
return false
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next({ name: 'sign' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (from.path === '/api/v1/auth/callback') {
|
||||
const redirectPath = sessionStorage.getItem('redirectAfterLogin')
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem('redirectAfterLogin')
|
||||
next(redirectPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('Navigation guard error:', error)
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import http, { type ApiResponse } from './http'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface Document {
|
||||
docId: string
|
||||
title: string
|
||||
url: string
|
||||
checksum?: string
|
||||
checksumAlgorithm?: string
|
||||
description: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
export interface ExpectedSigner {
|
||||
id: number
|
||||
docId: string
|
||||
email: string
|
||||
name: string
|
||||
addedAt: string
|
||||
addedBy: string
|
||||
notes?: string
|
||||
hasSigned: boolean
|
||||
signedAt?: string
|
||||
userName?: string
|
||||
lastReminderSent?: string
|
||||
reminderCount: number
|
||||
daysSinceAdded: number
|
||||
daysSinceLastReminder?: number
|
||||
}
|
||||
|
||||
export interface DocumentStats {
|
||||
docId: string
|
||||
expectedCount: number
|
||||
signedCount: number
|
||||
pendingCount: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
export interface ReminderStats {
|
||||
totalSent: number
|
||||
pendingCount: number
|
||||
lastSentAt?: string
|
||||
}
|
||||
|
||||
export interface UnexpectedSignature {
|
||||
userEmail: string
|
||||
userName?: string
|
||||
signedAtUTC: string
|
||||
}
|
||||
|
||||
export interface DocumentStatus {
|
||||
docId: string
|
||||
document?: Document
|
||||
expectedSigners: ExpectedSigner[]
|
||||
unexpectedSignatures: UnexpectedSignature[]
|
||||
stats: DocumentStats
|
||||
reminderStats?: ReminderStats
|
||||
shareLink: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENTS
|
||||
// ============================================================================
|
||||
|
||||
// List all documents
|
||||
export async function listDocuments(limit = 100, offset = 0): Promise<ApiResponse<Document[]>> {
|
||||
const response = await http.get('/admin/documents', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Get document details
|
||||
export async function getDocument(docId: string): Promise<ApiResponse<Document>> {
|
||||
const response = await http.get(`/admin/documents/${docId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Get complete document status (main endpoint used by AdminDocumentDetail)
|
||||
export async function getDocumentStatus(docId: string): Promise<ApiResponse<DocumentStatus>> {
|
||||
const response = await http.get(`/admin/documents/${docId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Update document metadata
|
||||
export async function updateDocumentMetadata(
|
||||
docId: string,
|
||||
metadata: Partial<{
|
||||
title: string
|
||||
url: string
|
||||
checksum: string
|
||||
checksumAlgorithm: string
|
||||
description: string
|
||||
}>
|
||||
): Promise<ApiResponse<{ message: string; document: Document }>> {
|
||||
const response = await http.put(`/admin/documents/${docId}/metadata`, metadata)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Delete document
|
||||
export async function deleteDocument(docId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await http.delete(`/admin/documents/${docId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPECTED SIGNERS
|
||||
// ============================================================================
|
||||
|
||||
// Add expected signer (single)
|
||||
export async function addExpectedSigner(
|
||||
docId: string,
|
||||
request: { email: string; name: string; notes?: string }
|
||||
): Promise<ApiResponse<{ message: string; email: string }>> {
|
||||
const response = await http.post(`/admin/documents/${docId}/signers`, request)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Remove expected signer
|
||||
export async function removeExpectedSigner(
|
||||
docId: string,
|
||||
email: string
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
const response = await http.delete(`/admin/documents/${docId}/signers/${encodeURIComponent(email)}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// REMINDERS
|
||||
// ============================================================================
|
||||
|
||||
export interface ReminderSendResult {
|
||||
totalAttempted: number
|
||||
successfullySent: number
|
||||
failed: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface ReminderLog {
|
||||
id: number
|
||||
docId: string
|
||||
recipientEmail: string
|
||||
sentAt: string
|
||||
sentBy: string
|
||||
templateUsed: string
|
||||
status: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
// Send reminders
|
||||
export async function sendReminders(
|
||||
docId: string,
|
||||
request: { emails?: string[] } = {},
|
||||
locale?: string
|
||||
): Promise<ApiResponse<{ message: string; result: ReminderSendResult }>> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
// Send Accept-Language header if locale is provided
|
||||
if (locale) {
|
||||
headers['Accept-Language'] = locale
|
||||
}
|
||||
|
||||
const response = await http.post(`/admin/documents/${docId}/reminders`, request, { headers })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Get reminder history
|
||||
export async function getReminderHistory(docId: string): Promise<ApiResponse<ReminderLog[]>> {
|
||||
const response = await http.get(`/admin/documents/${docId}/reminders`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LEGACY - These endpoints are not yet migrated to API v1
|
||||
// They will return empty/stub responses until backend support is added
|
||||
// ============================================================================
|
||||
|
||||
export interface ChecksumVerificationHistory {
|
||||
id: number
|
||||
docId: string
|
||||
verifiedBy: string
|
||||
verifiedAt: string
|
||||
isValid: boolean
|
||||
algorithm: string
|
||||
calculatedChecksum: string
|
||||
storedChecksum: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
// Verify checksum (TODO: migrate to API v1)
|
||||
export async function verifyChecksum(
|
||||
_docId: string,
|
||||
_request: { calculated_checksum: string }
|
||||
): Promise<ApiResponse<any>> {
|
||||
// For now, return stub - needs backend implementation in API v1
|
||||
return Promise.reject(new Error('Checksum verification not yet available in API v1'))
|
||||
}
|
||||
|
||||
// Get checksum verification history (TODO: migrate to API v1)
|
||||
export async function getChecksumVerificationHistory(
|
||||
_docId: string,
|
||||
_limit = 10
|
||||
): Promise<ApiResponse<{ verifications: ChecksumVerificationHistory[] }>> {
|
||||
// Return empty history for now
|
||||
return Promise.resolve({
|
||||
data: { verifications: [] },
|
||||
success: true,
|
||||
error: null,
|
||||
meta: {}
|
||||
} as any)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
export interface ChecksumResult {
|
||||
checksum: string
|
||||
algorithm: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file and calculates its SHA-256 checksum
|
||||
*/
|
||||
export async function calculateFileChecksum(
|
||||
url: string,
|
||||
maxSize: number = 50 * 1024 * 1024
|
||||
): Promise<ChecksumResult> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength) {
|
||||
const size = parseInt(contentLength, 10)
|
||||
if (size > maxSize) {
|
||||
throw new Error(`File too large: ${(size / 1024 / 1024).toFixed(2)}MB (max: ${(maxSize / 1024 / 1024).toFixed(0)}MB)`)
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
if (arrayBuffer.byteLength > maxSize) {
|
||||
throw new Error(`File too large: ${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)}MB (max: ${(maxSize / 1024 / 1024).toFixed(0)}MB)`)
|
||||
}
|
||||
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
|
||||
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const checksum = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
return {
|
||||
checksum,
|
||||
algorithm: 'SHA-256',
|
||||
size: arrayBuffer.byteLength
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checksum calculation failed:', error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to calculate checksum: ${error.message}`)
|
||||
}
|
||||
|
||||
throw new Error('Failed to calculate checksum: Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import http, { type ApiResponse } from './http'
|
||||
|
||||
export interface CreateDocumentRequest {
|
||||
reference: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface CreateDocumentResponse {
|
||||
docId: string
|
||||
url?: string
|
||||
title: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface FindOrCreateDocumentResponse {
|
||||
docId: string
|
||||
url?: string
|
||||
title: string
|
||||
checksum?: string
|
||||
checksumAlgorithm?: string
|
||||
description?: string
|
||||
createdAt: string
|
||||
isNew: boolean // true if created, false if found
|
||||
}
|
||||
|
||||
/**
|
||||
* Document service for managing documents
|
||||
*/
|
||||
export const documentService = {
|
||||
/**
|
||||
* Create a new document with a unique ID
|
||||
*/
|
||||
async createDocument(request: CreateDocumentRequest): Promise<CreateDocumentResponse> {
|
||||
const response = await http.post<ApiResponse<CreateDocumentResponse>>('/documents', request)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Find an existing document by reference or create it if not found
|
||||
* @param reference URL, path, or document ID
|
||||
* @returns Document information with isNew flag
|
||||
*/
|
||||
async findOrCreateDocument(reference: string): Promise<FindOrCreateDocumentResponse> {
|
||||
const response = await http.get<ApiResponse<FindOrCreateDocumentResponse>>(
|
||||
'/documents/find-or-create',
|
||||
{ params: { ref: reference } }
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
}
|
||||
|
||||
export default documentService
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import axios, { type AxiosInstance, AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T
|
||||
meta?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
let csrfToken: string | null = null
|
||||
|
||||
http.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
if (config.method && ['post', 'put', 'patch', 'delete'].includes(config.method.toLowerCase())) {
|
||||
if (!csrfToken) {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/csrf`, { withCredentials: true })
|
||||
csrfToken = response.data.data?.token || response.data.token
|
||||
console.log('Fetched CSRF token:', csrfToken ? 'success' : 'failed')
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CSRF token:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (csrfToken && config.headers) {
|
||||
config.headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<ApiError>) => {
|
||||
if (error.response?.status === 401) {
|
||||
csrfToken = null
|
||||
|
||||
if (!window.location.pathname.startsWith('/')) {
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 403 && error.response?.data?.error?.code === 'CSRF_INVALID') {
|
||||
csrfToken = null
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default http
|
||||
|
||||
export const extractError = (error: any): string => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<ApiError>
|
||||
if (axiosError.response?.data?.error?.message) {
|
||||
return axiosError.response.data.error.message
|
||||
}
|
||||
if (axiosError.message) {
|
||||
return axiosError.message
|
||||
}
|
||||
}
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
|
||||
export const resetCsrfToken = () => {
|
||||
csrfToken = null
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
export type ReferenceType = 'url' | 'path' | 'reference'
|
||||
|
||||
export interface ReferenceInfo {
|
||||
type: ReferenceType
|
||||
value: string
|
||||
isDownloadable: boolean
|
||||
fileExtension?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the type of document reference
|
||||
*/
|
||||
export function detectReference(ref: string): ReferenceInfo {
|
||||
if (ref.startsWith('http://') || ref.startsWith('https://')) {
|
||||
const ext = getFileExtension(ref)
|
||||
const downloadableExts = ['pdf', 'doc', 'docx', 'txt', 'html', 'xml', 'md', 'odt', 'rtf']
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
value: ref,
|
||||
isDownloadable: ext ? downloadableExts.includes(ext.toLowerCase()) : false,
|
||||
fileExtension: ext
|
||||
}
|
||||
}
|
||||
|
||||
if (ref.includes('/') || ref.includes('\\')) {
|
||||
return {
|
||||
type: 'path',
|
||||
value: ref,
|
||||
isDownloadable: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'reference',
|
||||
value: ref,
|
||||
isDownloadable: false
|
||||
}
|
||||
}
|
||||
|
||||
function getFileExtension(url: string): string | undefined {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
const match = pathname.match(/\.([a-z0-9]+)$/i)
|
||||
return match?.[1]?.toLowerCase()
|
||||
} catch {
|
||||
const match = url.match(/\.([a-z0-9]+)$/i)
|
||||
return match?.[1]?.toLowerCase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import http, { type ApiResponse } from './http'
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
icon: string
|
||||
type: string
|
||||
referrer: string
|
||||
}
|
||||
|
||||
export interface Signature {
|
||||
id: number
|
||||
docId: string
|
||||
userSub: string
|
||||
userEmail: string
|
||||
userName?: string
|
||||
signedAt: string
|
||||
payloadHash: string
|
||||
signature: string
|
||||
nonce: string
|
||||
createdAt: string
|
||||
referer?: string
|
||||
prevHash?: string
|
||||
serviceInfo?: ServiceInfo
|
||||
// Document metadata
|
||||
docTitle?: string
|
||||
docUrl?: string
|
||||
docDeletedAt?: string
|
||||
}
|
||||
|
||||
export interface SignatureStatus {
|
||||
docId: string
|
||||
userEmail: string
|
||||
isSigned: boolean
|
||||
signedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateSignatureRequest {
|
||||
docId: string
|
||||
referer?: string
|
||||
}
|
||||
|
||||
export interface CreateSignatureResponse {
|
||||
id: number
|
||||
docId: string
|
||||
userEmail: string
|
||||
signedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature service for managing document signatures
|
||||
*/
|
||||
export const signatureService = {
|
||||
/**
|
||||
* Create a signature for a document
|
||||
*/
|
||||
async createSignature(request: CreateSignatureRequest): Promise<Signature> {
|
||||
const response = await http.post<ApiResponse<Signature>>('/signatures', request)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user's signatures
|
||||
*/
|
||||
async getUserSignatures(): Promise<Signature[]> {
|
||||
const response = await http.get<ApiResponse<Signature[]>>('/signatures')
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get signatures for a specific document
|
||||
*/
|
||||
async getDocumentSignatures(docId: string): Promise<Signature[]> {
|
||||
const response = await http.get<ApiResponse<Signature[]>>(`/documents/${docId}/signatures`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get signature status for a document (current user)
|
||||
*/
|
||||
async getSignatureStatus(docId: string): Promise<SignatureStatus> {
|
||||
const response = await http.get<ApiResponse<SignatureStatus>>(
|
||||
`/documents/${docId}/signatures/status`
|
||||
)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has signed a document
|
||||
*/
|
||||
async hasUserSigned(docId: string): Promise<boolean> {
|
||||
try {
|
||||
const status = await this.getSignatureStatus(docId)
|
||||
return status.isSigned
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default signatureService
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/**
|
||||
* Extracts a title from a URL by fetching the page (if CORS allows)
|
||||
*/
|
||||
export async function extractTitleFromUrl(url: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch URL for title extraction:', response.status)
|
||||
return extractTitleFromPath(url)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (!contentType.includes('text/html')) {
|
||||
return extractTitleFromPath(url)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
|
||||
const title = doc.querySelector('title')?.textContent?.trim()
|
||||
|
||||
if (title && title.length > 0) {
|
||||
return title
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract title from URL:', error)
|
||||
}
|
||||
|
||||
return extractTitleFromPath(url)
|
||||
}
|
||||
|
||||
export function extractTitleFromPath(pathOrUrl: string): string {
|
||||
try {
|
||||
const url = new URL(pathOrUrl)
|
||||
const pathname = url.pathname
|
||||
|
||||
const segments = pathname.split('/').filter(s => s.trim())
|
||||
const lastSegment = segments[segments.length - 1] || url.hostname
|
||||
|
||||
const withoutExt = lastSegment.replace(/\.[^.]+$/, '')
|
||||
|
||||
return withoutExt
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
} catch (error) {
|
||||
const segments = pathOrUrl.split(/[/\\]/).filter(s => s.trim())
|
||||
const lastSegment = segments[segments.length - 1] || pathOrUrl
|
||||
|
||||
const withoutExt = lastSegment.replace(/\.[^.]+$/, '')
|
||||
|
||||
return withoutExt
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import http, { resetCsrfToken } from '@/services/http'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => user.value?.isAdmin ?? false)
|
||||
|
||||
async function checkAuth() {
|
||||
if (initialized.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await http.get('/users/me')
|
||||
user.value = response.data.data
|
||||
} catch (error) {
|
||||
user.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
try {
|
||||
const response = await http.get('/users/me')
|
||||
user.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startOAuthLogin(redirectTo?: string) {
|
||||
try {
|
||||
console.log('Starting OAuth login...', { redirectTo })
|
||||
const response = await http.post('/auth/start', { redirectTo })
|
||||
console.log('OAuth response:', response.data)
|
||||
|
||||
if (response.data.data?.redirectUrl) {
|
||||
console.log('Redirecting to:', response.data.data.redirectUrl)
|
||||
window.location.href = response.data.data.redirectUrl
|
||||
} else if (response.data.redirectUrl) {
|
||||
console.log('Redirecting to:', response.data.redirectUrl)
|
||||
window.location.href = response.data.redirectUrl
|
||||
} else {
|
||||
console.error('No redirect URL in response:', response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth login error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
const response = await http.get('/auth/logout')
|
||||
user.value = null
|
||||
resetCsrfToken()
|
||||
|
||||
if (response.data.redirectUrl) {
|
||||
window.location.href = response.data.redirectUrl
|
||||
} else {
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
user.value = null
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(userData: User) {
|
||||
user.value = userData
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
initialized,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
checkAuth,
|
||||
fetchCurrentUser,
|
||||
startOAuthLogin,
|
||||
logout,
|
||||
setUser,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import signatureService, {
|
||||
type Signature,
|
||||
type SignatureStatus,
|
||||
type CreateSignatureRequest,
|
||||
} from '@/services/signatures'
|
||||
|
||||
export const useSignatureStore = defineStore('signatures', () => {
|
||||
const userSignatures = ref<Signature[]>([])
|
||||
const documentSignatures = ref<Map<string, Signature[]>>(new Map())
|
||||
const signatureStatuses = ref<Map<string, SignatureStatus>>(new Map())
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const getUserSignaturesCount = computed(() => userSignatures.value.length)
|
||||
|
||||
const getDocumentSignatures = computed(() => {
|
||||
return (docId: string) => documentSignatures.value.get(docId) || []
|
||||
})
|
||||
|
||||
const getSignatureStatus = computed(() => {
|
||||
return (docId: string) => signatureStatuses.value.get(docId)
|
||||
})
|
||||
|
||||
const isDocumentSigned = computed(() => {
|
||||
return (docId: string) => {
|
||||
const status = signatureStatuses.value.get(docId)
|
||||
return status?.isSigned || false
|
||||
}
|
||||
})
|
||||
|
||||
async function createSignature(request: CreateSignatureRequest): Promise<Signature> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const signature = await signatureService.createSignature(request)
|
||||
|
||||
if (!userSignatures.value.find((s: Signature) => s.id === signature.id)) {
|
||||
userSignatures.value.unshift(signature)
|
||||
}
|
||||
|
||||
const docSigs = documentSignatures.value.get(request.docId) || []
|
||||
if (!docSigs.find((s: Signature) => s.id === signature.id)) {
|
||||
documentSignatures.value.set(request.docId, [signature, ...docSigs])
|
||||
}
|
||||
|
||||
signatureStatuses.value.set(request.docId, {
|
||||
docId: signature.docId,
|
||||
userEmail: signature.userEmail,
|
||||
isSigned: true,
|
||||
signedAt: signature.signedAt,
|
||||
})
|
||||
|
||||
return signature
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || 'Failed to create signature'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserSignatures(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const signatures = await signatureService.getUserSignatures()
|
||||
userSignatures.value = signatures
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || 'Failed to fetch signatures'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDocumentSignatures(docId: string): Promise<Signature[]> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const signatures = await signatureService.getDocumentSignatures(docId)
|
||||
documentSignatures.value.set(docId, signatures)
|
||||
return signatures
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || 'Failed to fetch document signatures'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSignatureStatus(docId: string): Promise<SignatureStatus> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const status = await signatureService.getSignatureStatus(docId)
|
||||
signatureStatuses.value.set(docId, status)
|
||||
return status
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error?.message || 'Failed to fetch signature status'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUserSigned(docId: string): Promise<boolean> {
|
||||
try {
|
||||
const status = await fetchSignatureStatus(docId)
|
||||
return status.isSigned
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function clearCache(): void {
|
||||
userSignatures.value = []
|
||||
documentSignatures.value.clear()
|
||||
signatureStatuses.value.clear()
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
userSignatures,
|
||||
documentSignatures,
|
||||
signatureStatuses,
|
||||
loading,
|
||||
error,
|
||||
getUserSignaturesCount,
|
||||
getDocumentSignatures,
|
||||
getSignatureStatus,
|
||||
isDocumentSigned,
|
||||
createSignature,
|
||||
fetchUserSignatures,
|
||||
fetchDocumentSignatures,
|
||||
fetchSignatureStatus,
|
||||
checkUserSigned,
|
||||
clearError,
|
||||
clearCache,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
const notifications = ref<Notification[]>([])
|
||||
const loading = ref(false)
|
||||
const locale = ref<'en' | 'fr'>('fr')
|
||||
|
||||
function showNotification(notification: Omit<Notification, 'id'>) {
|
||||
const id = `notification-${Date.now()}`
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id,
|
||||
duration: notification.duration || 5000,
|
||||
}
|
||||
|
||||
notifications.value.push(newNotification)
|
||||
|
||||
if (newNotification.duration && newNotification.duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, newNotification.duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function removeNotification(id: string) {
|
||||
const index = notifications.value.findIndex(n => n.id === id)
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
function showSuccess(title: string, message?: string) {
|
||||
return showNotification({ type: 'success', title, message })
|
||||
}
|
||||
|
||||
function showError(title: string, message?: string) {
|
||||
return showNotification({ type: 'error', title, message })
|
||||
}
|
||||
|
||||
function showWarning(title: string, message?: string) {
|
||||
return showNotification({ type: 'warning', title, message })
|
||||
}
|
||||
|
||||
function showInfo(title: string, message?: string) {
|
||||
return showNotification({ type: 'info', title, message })
|
||||
}
|
||||
|
||||
function setLoading(isLoading: boolean) {
|
||||
loading.value = isLoading
|
||||
}
|
||||
|
||||
function setLocale(newLocale: 'en' | 'fr') {
|
||||
locale.value = newLocale
|
||||
document.cookie = `lang=${newLocale};path=/;max-age=31536000`
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
loading,
|
||||
locale,
|
||||
showNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
setLoading,
|
||||
setLocale,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Roboto+Mono:wght@400;500;600&family=Lora:wght@400;500;600&display=swap');
|
||||
|
||||
@theme {
|
||||
/* Claymorphism Theme - Real colors from tweakcn */
|
||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||
--font-mono: Roboto Mono, monospace;
|
||||
--font-serif: Lora, serif;
|
||||
--radius: 1.25rem;
|
||||
|
||||
/* Shadows - Claymorphism specific */
|
||||
--shadow-color: hsl(240 4% 60%);
|
||||
--shadow-opacity: 0.18;
|
||||
--shadow-blur: 10px;
|
||||
--shadow-spread: 4px;
|
||||
--shadow-offset-x: 2px;
|
||||
--shadow-offset-y: 2px;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:root {
|
||||
--background: oklch(0.9232 0.0026 48.7171);
|
||||
--foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--card: oklch(0.9699 0.0013 106.4238);
|
||||
--card-foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--popover: oklch(0.9699 0.0013 106.4238);
|
||||
--popover-foreground: oklch(0.2795 0.0368 260.0310);
|
||||
--primary: oklch(0.5854 0.2041 277.1173);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.8687 0.0043 56.3660);
|
||||
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
|
||||
--muted: oklch(0.9232 0.0026 48.7171);
|
||||
--muted-foreground: oklch(0.5510 0.0234 264.3637);
|
||||
--accent: oklch(0.9376 0.0260 321.9388);
|
||||
--accent-foreground: oklch(0.3729 0.0306 259.7328);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.8687 0.0043 56.3660);
|
||||
--input: oklch(0.8687 0.0043 56.3660);
|
||||
--ring: oklch(0.5854 0.2041 277.1173);
|
||||
|
||||
--shadow-color: hsl(240 4% 60%);
|
||||
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
|
||||
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
|
||||
--shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18);
|
||||
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
.dark {
|
||||
--background: oklch(0.2244 0.0074 67.4370);
|
||||
--foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--card: oklch(0.3200 0.0080 59.3379);
|
||||
--card-foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--popover: oklch(0.3200 0.0080 59.3379);
|
||||
--popover-foreground: oklch(0.9288 0.0126 255.5078);
|
||||
--primary: oklch(0.6801 0.1583 276.9349);
|
||||
--primary-foreground: oklch(0.2244 0.0074 67.4370);
|
||||
--secondary: oklch(0.3359 0.0077 59.4197);
|
||||
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
|
||||
--muted: oklch(0.2287 0.0074 67.4469);
|
||||
--muted-foreground: oklch(0.7137 0.0192 261.3246);
|
||||
--accent: oklch(0.3896 0.0074 59.4734);
|
||||
--accent-foreground: oklch(0.8717 0.0093 258.3382);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--destructive-foreground: oklch(0.2244 0.0074 67.4370);
|
||||
--border: oklch(0.3359 0.0077 59.4197);
|
||||
--input: oklch(0.3359 0.0077 59.4197);
|
||||
--ring: oklch(0.6801 0.1583 276.9349);
|
||||
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
|
||||
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
|
||||
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
|
||||
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Dark mode text color overrides for Alert component */
|
||||
.dark .text-blue-900 {
|
||||
color: rgb(191 219 254) !important;
|
||||
}
|
||||
|
||||
.dark .text-green-900 {
|
||||
color: rgb(187 247 208) !important;
|
||||
}
|
||||
|
||||
.dark .text-red-900 {
|
||||
color: rgb(254 202 202) !important;
|
||||
}
|
||||
|
||||
.dark .text-yellow-900 {
|
||||
color: rgb(254 240 138) !important;
|
||||
}
|
||||
|
||||
/* Dark mode background color overrides for Alert component */
|
||||
.dark .bg-blue-50 {
|
||||
background-color: rgb(30 58 138 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .bg-green-50 {
|
||||
background-color: rgb(20 83 45 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .bg-red-50 {
|
||||
background-color: rgb(127 29 29 / 0.2) !important;
|
||||
}
|
||||
|
||||
.dark .bg-yellow-50 {
|
||||
background-color: rgb(113 63 18 / 0.2) !important;
|
||||
}
|
||||
|
||||
/* Claymorphism custom classes */
|
||||
.clay-card {
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dark .clay-card {
|
||||
background: oklch(0.3200 0.0080 59.3379);
|
||||
border-color: oklch(0.3359 0.0077 59.4197);
|
||||
}
|
||||
|
||||
.clay-card-hover {
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.clay-card-hover:hover {
|
||||
background: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.clay-button {
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.clay-button:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.clay-input {
|
||||
background: var(--input);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.clay-input:focus {
|
||||
border-color: var(--ring);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Tailwind utilities using new theme variables */
|
||||
.bg-background {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.text-foreground {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
.text-card-foreground {
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.bg-popover {
|
||||
background-color: var(--popover);
|
||||
}
|
||||
|
||||
.text-popover-foreground {
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text-primary-foreground {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
.text-secondary-foreground {
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
.bg-muted {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.text-muted-foreground {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.text-accent-foreground {
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.bg-destructive {
|
||||
background-color: var(--destructive);
|
||||
}
|
||||
|
||||
.text-destructive {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.text-destructive-foreground {
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
.border-border {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.bg-input {
|
||||
background-color: var(--input);
|
||||
}
|
||||
|
||||
.ring-ring {
|
||||
--tw-ring-color: var(--ring);
|
||||
}
|
||||
|
||||
.ring-offset-background {
|
||||
--tw-ring-offset-color: var(--background);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user