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:
Benjamin
2025-10-20 18:56:11 +02:00
parent e22fe5d9ea
commit e95185f9c7
250 changed files with 35344 additions and 8187 deletions
+37
View File
@@ -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>
+1
View File
@@ -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

+83
View File
@@ -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>
+42
View File
@@ -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>
+146
View File
@@ -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>
+218
View File
@@ -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>
+210
View File
@@ -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>
+109
View File
@@ -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">
&copy; {{ new Date().getFullYear() }} Ackify. {{ t('footer.copyright') }}
<span class="text-xs">{{ t('footer.license') }}</span>
</p>
</div>
</div>
</footer>
</template>
+273
View File
@@ -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>
+19
View File
@@ -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>
+37
View File
@@ -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>
+17
View File
@@ -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>
+43
View File
@@ -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>
+56
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+17
View File
@@ -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>
+36
View File
@@ -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>
+21
View File
@@ -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>
+36
View File
@@ -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>
+19
View File
@@ -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>
+47
View File
@@ -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)
}
}
}
+33
View File
@@ -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
}
}
+11
View File
@@ -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
}
+162
View File
@@ -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)
+7
View File
@@ -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))
}
+319
View File
@@ -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"
}
}
+319
View File
@@ -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"
}
}
+319
View File
@@ -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"
}
}
+319
View File
@@ -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"
}
}
+319
View File
@@ -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"
}
}
+19
View File
@@ -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')
+184
View File
@@ -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>
+24
View File
@@ -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>
+530
View File
@@ -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>
+266
View File
@@ -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>
+495
View File
@@ -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>
+406
View File
@@ -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>&#10;jean.martin@example.com&#10;Sophie Bernard <sophie@example.com>" />
<p class="text-xs text-muted-foreground mt-2">
Formats acceptés : "Nom Prénom &lt;email@example.com&gt;" 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>
+99
View File
@@ -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
+217
View File
@@ -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)
}
+68
View File
@@ -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]}`
}
+53
View File
@@ -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
+97
View File
@@ -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
}
+51
View File
@@ -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()
}
}
+101
View File
@@ -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
+63
View File
@@ -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())
}
}
+100
View File
@@ -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,
}
})
+151
View File
@@ -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,
}
})
+89
View File
@@ -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,
}
})
+271
View File
@@ -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);
}