Enhance Face Recognition Workflow with Remote Capabilities and Consent Management
All checks were successful
Publish FaceAI Container / publish (push) Successful in 5m20s

- Updated `run_face_encoder.bat` to include remote execution parameters for SSH and SCP.
- Refactored `run_face_encoder.ps1` to accept remote execution parameters and handle remote file operations.
- Modified `FaceAiUploadPanel.vue` to introduce consent management UI and error handling for race availability.
- Enhanced `useFaceAiHome.js` to manage consent acceptance and integrate cookie handling for biometric data processing notice.
- Updated `HomeView.vue` to streamline the upload panel and integrate consent handling logic.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
MaddoScientisto 2026-04-24 19:33:38 +02:00
commit 6e37aa16c8
6 changed files with 1163 additions and 129 deletions

View file

@ -6,10 +6,6 @@ const props = defineProps({
type: Boolean,
required: true
},
isWorking: {
type: Boolean,
required: true
},
isProcessingSearch: {
type: Boolean,
required: true
@ -42,7 +38,23 @@ const props = defineProps({
type: String,
required: true
},
canStartSearch: {
panelMode: {
type: String,
required: true
},
errorMessage: {
type: String,
default: ''
},
raceAvailabilityMessage: {
type: String,
default: ''
},
consentAccepted: {
type: Boolean,
required: true
},
consentChecked: {
type: Boolean,
required: true
},
@ -57,6 +69,8 @@ const props = defineProps({
});
const emit = defineEmits([
'toggle-consent',
'accept-consent',
'open-file-picker',
'file-change',
'drag-enter',
@ -64,13 +78,18 @@ const emit = defineEmits([
'drag-leave',
'drop',
'clear-file',
'submit-search',
'retry',
'return-to-legacy'
]);
</script>
<template>
<section class="faceai-panel shadow-sm">
<div v-if="session?.race?.name" class="faceai-race-heading">
<span class="faceai-race-label">{{ t('raceLabel') }}</span>
<h1 class="faceai-race-name">{{ session.race.name }}</h1>
</div>
<div v-if="loading" class="faceai-loading-state">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
@ -82,18 +101,77 @@ const emit = defineEmits([
<a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a>
</div>
<template v-else-if="panelMode === 'unavailable'">
<div class="faceai-state-card faceai-state-card-error">
<h2 class="faceai-section-title">{{ t('uploaderUnavailableTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ raceAvailabilityMessage || errorMessage || t('unavailableDefault') }}</p>
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
</div>
<p v-if="errorMessage && errorMessage !== raceAvailabilityMessage" class="faceai-inline-error mb-0">{{ errorMessage }}</p>
</template>
<template v-else-if="panelMode === 'consent'">
<div class="faceai-state-card faceai-state-card-consent">
<h2 class="faceai-section-title">{{ t('uploaderConsentTitle') }}</h2>
<p class="faceai-disclaimer-text">{{ t('disclaimerBody') }}</p>
<label class="faceai-consent-check">
<input
type="checkbox"
:checked="consentChecked"
@change="emit('toggle-consent', $event.target.checked)"
/>
<span>{{ t('consentCheckbox') }}</span>
</label>
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('accept-consent')">
{{ t('agreeButton') }}
</button>
<button class="btn btn-light faceai-secondary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
<p v-if="errorMessage" class="faceai-inline-error mb-0">{{ errorMessage }}</p>
</div>
</template>
<template v-else-if="panelMode === 'processing'">
<div class="faceai-state-card faceai-state-card-processing" aria-live="polite" aria-busy="true">
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
<h2 class="faceai-section-title">{{ t('uploaderProcessingTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ busyLabel }}</p>
</div>
</template>
<template v-else-if="panelMode === 'error'">
<div class="faceai-state-card faceai-state-card-error">
<h2 class="faceai-section-title">{{ t('uploaderErrorTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ errorMessage }}</p>
<div class="faceai-action-row faceai-action-row-stacked">
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('retry')">
{{ t('retryButton') }}
</button>
<button class="btn btn-light faceai-secondary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="faceai-panel-header">
<div>
<h2 class="faceai-section-title mb-2">{{ t('uploaderTitle') }}</h2>
<h2 class="faceai-section-title mb-2">{{ t('uploaderReadyTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p>
</div>
</div>
<div v-if="isWorking && busyLabel" class="faceai-busy-banner" aria-live="polite">
<span class="faceai-spinner" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<p v-if="errorMessage" class="faceai-inline-error">{{ errorMessage }}</p>
<div
class="faceai-dropzone"
@ -165,16 +243,9 @@ const emit = defineEmits([
<span>{{ t('uploaderDragActive') }}</span>
</div>
<div v-if="isProcessingSearch" class="faceai-processing-overlay" aria-live="polite" aria-busy="true">
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
<strong>{{ busyLabel }}</strong>
</div>
</div>
<div class="faceai-action-row">
<button v-if="selectedFile" class="btn btn-warning" type="button" :disabled="!canStartSearch" @click="emit('submit-search')">
{{ t('uploadButton') }}
</button>
<button class="btn btn-light" type="button" @click="emit('return-to-legacy')">{{ t('backButton') }}</button>
</div>
@ -187,12 +258,35 @@ const emit = defineEmits([
<style scoped>
.faceai-panel {
max-width: 760px;
margin: 0 auto;
border-radius: 28px;
padding: 1.5rem;
background: #fffdf9;
border: 1px solid rgba(212, 189, 154, 0.55);
}
.faceai-race-heading {
margin-bottom: 1.35rem;
}
.faceai-race-label {
display: inline-block;
margin-bottom: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #9a6a19;
}
.faceai-race-name {
margin: 0;
font-size: clamp(1.4rem, 4vw, 2.1rem);
line-height: 1.12;
color: #30261e;
}
.faceai-panel-header {
display: flex;
justify-content: space-between;
@ -206,12 +300,63 @@ const emit = defineEmits([
color: #30261e;
}
.faceai-state-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.faceai-state-card-processing {
align-items: center;
justify-content: center;
min-height: 320px;
text-align: center;
}
.faceai-panel-subtitle,
.faceai-dropzone-copy,
.faceai-subtle-note {
color: #665548;
}
.faceai-disclaimer-text {
margin: 0;
padding: 1rem 1.1rem;
border-radius: 18px;
border: 1px solid rgba(191, 158, 117, 0.28);
background: rgba(255, 249, 238, 0.92);
color: #4b3a2e;
line-height: 1.6;
white-space: pre-line;
}
.faceai-consent-check {
display: flex;
align-items: flex-start;
gap: 0.8rem;
color: #30261e;
font-weight: 600;
}
.faceai-consent-check input {
width: 1.2rem;
height: 1.2rem;
margin-top: 0.12rem;
accent-color: #d58a00;
}
.faceai-inline-error {
margin: 0 0 1rem;
color: #a53e24;
font-weight: 600;
}
.faceai-primary-action,
.faceai-secondary-action {
width: 100%;
}
.faceai-busy-banner {
display: inline-flex;
align-items: center;
@ -372,6 +517,10 @@ const emit = defineEmits([
margin-top: 1.25rem;
}
.faceai-action-row-stacked {
margin-top: 0;
}
.faceai-subtle-note {
margin-top: 0.85rem;
font-size: 0.95rem;

View file

@ -1,6 +1,9 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { legacyUrl } from '../legacyUrls.js';
const FACEAI_DISCLAIMER_COOKIE = 'faceai_biometric_notice_v1';
const FACEAI_DISCLAIMER_COOKIE_MAX_AGE = 60 * 60 * 24 * 180;
const copy = {
it: {
pageTitle: 'Face ID',
@ -25,22 +28,31 @@ const copy = {
uploaderSelected: 'File selezionato',
uploaderReplace: 'Sostituisci',
uploaderRemove: 'Rimuovi',
uploaderReadyTitle: 'Carica il tuo selfie',
uploaderProcessingTitle: 'Ricerca in corso',
uploaderErrorTitle: 'Ricerca non completata',
uploaderUnavailableTitle: 'Face ID non disponibile',
uploaderConsentTitle: 'Prima di continuare',
backButton: 'Torna alla pagina gara',
uploadButton: 'Avvia ricerca Face ID',
retryButton: 'Riprova',
agreeButton: 'Accetto e continuo',
consentCheckbox: 'Confermo di aver letto linformativa sul trattamento dei dati biometrici.',
disclaimerBody: 'Trattamento dati biometrici per ricerca facciale\nI dati biometrici sono trattati nel rispetto del GDPR esclusivamente per la ricerca e conservati solo per il tempo strettamente necessario alla ricerca (30” al massimo). Non vengono ceduti a terzi né utilizzati per finalità pubblicitarie.',
consentRequired: 'Per continuare devi accettare linformativa.',
openSimulator: 'Apri il simulatore legacy',
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
sessionLoading: 'Caricamento della sessione FaceAI…',
submitLoading: 'Invio del selfie e preparazione della ricerca…',
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
redirectLoading: 'Apertura della pagina gara filtrata…',
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.',
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
noFacesFoundMessage: 'Non sono state trovate foto corrispondenti con il selfie caricato. Puoi tornare alla gara oppure riprovare con unaltra immagine.',
readyMessage: 'Seleziona un selfie per avviare subito la ricerca nella gara corrente.',
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
matchesLabel: 'Foto trovate',
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
noFileCta: 'Seleziona unimmagine per sbloccare la ricerca.',
noFileCta: 'Seleziona unimmagine dal dispositivo o trascinala qui per iniziare.',
invalidImage: 'Seleziona un file immagine valido.',
pollError: 'Impossibile leggere lo stato della ricerca.',
searchFailed: 'La ricerca non è andata a buon fine.',
@ -78,22 +90,31 @@ const copy = {
uploaderSelected: 'Selected file',
uploaderReplace: 'Replace',
uploaderRemove: 'Remove',
uploaderReadyTitle: 'Upload your selfie',
uploaderProcessingTitle: 'Search in progress',
uploaderErrorTitle: 'Search not completed',
uploaderUnavailableTitle: 'Face ID unavailable',
uploaderConsentTitle: 'Before you continue',
backButton: 'Back to the race page',
uploadButton: 'Start Face ID search',
retryButton: 'Try again',
agreeButton: 'I agree and continue',
consentCheckbox: 'I confirm that I have read the biometric data processing notice.',
disclaimerBody: 'Biometric data processing for facial search\nBiometric data is processed in compliance with GDPR exclusively for facial search and kept only for the time strictly necessary to perform the search (30 seconds maximum). It is not shared with third parties and is not used for advertising purposes.',
consentRequired: 'You must accept the notice before continuing.',
openSimulator: 'Open the legacy simulator',
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
sessionLoading: 'Loading the FaceAI session…',
submitLoading: 'Uploading the selfie and preparing the search…',
redirectLoading: 'Redirecting to the filtered legacy page…',
redirectLoading: 'Opening the filtered race page…',
processingLoading: 'Biometric search in progress across all race photos…',
unavailableDefault: 'FaceAI is not available for this race.',
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.',
readyMessage: 'Select a selfie to start a search limited to the current race.',
noFacesFoundMessage: 'No matching photos were found for the uploaded selfie. You can go back to the race page or try another image.',
readyMessage: 'Select a selfie to start the search immediately for the current race.',
completedMessage: 'Search completed. Found {count} matching photos.',
failedMessage: 'The search did not complete. Check the message and try again.',
matchesLabel: 'Photos found',
redirectMessage: 'Redirecting to the filtered legacy page…',
noFileCta: 'Select an image to unlock the search action.',
noFileCta: 'Select an image from your device or drag it here to begin.',
invalidImage: 'Select a valid image file.',
pollError: 'Unable to read the search status.',
searchFailed: 'The search failed.',
@ -153,6 +174,25 @@ function buildLegacyReturnUrl(url) {
}
}
function hasAcceptedDisclaimerCookie() {
if (typeof document === 'undefined') {
return false;
}
return document.cookie
.split(';')
.map((part) => part.trim())
.some((part) => part === `${FACEAI_DISCLAIMER_COOKIE}=1`);
}
function persistAcceptedDisclaimerCookie() {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${FACEAI_DISCLAIMER_COOKIE}=1; Max-Age=${FACEAI_DISCLAIMER_COOKIE_MAX_AGE}; Path=/; SameSite=Lax`;
}
export function useFaceAiHome() {
const session = ref(null);
const loading = ref(true);
@ -164,6 +204,9 @@ export function useFaceAiHome() {
const isRedirecting = ref(false);
const isDragging = ref(false);
const fileInput = ref(null);
const consentAccepted = ref(hasAcceptedDisclaimerCookie());
const consentChecked = ref(false);
const panelErrorMode = ref('');
let pollTimer = null;
let dragDepth = 0;
@ -256,13 +299,53 @@ export function useFaceAiHome() {
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
const raceAvailability = computed(() => session.value?.availability || null);
const raceAvailabilityMessage = computed(() => {
if (!raceAvailability.value || raceAvailability.value.available) {
return '';
}
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
});
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
const hasSearchFailure = computed(() => panelErrorMode.value === 'failed' || panelErrorMode.value === 'no-results');
const showConsent = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && session.value?.access?.faceAiAllowed && !consentAccepted.value);
const panelMode = computed(() => {
if (loading.value) {
return 'loading';
}
if (!session.value) {
return 'missing-session';
}
if (!session.value.access?.faceAiAllowed || raceAvailability.value?.available === false) {
return 'unavailable';
}
if (showConsent.value) {
return 'consent';
}
if (isProcessingSearch.value || isRedirecting.value) {
return 'processing';
}
if (hasSearchFailure.value) {
return 'error';
}
return 'upload';
});
const canStartSearch = computed(() => {
if (!session.value || !selectedFile.value) {
return false;
}
if (!consentAccepted.value) {
return false;
}
if (!session.value.access?.faceAiAllowed) {
return false;
}
@ -368,6 +451,11 @@ export function useFaceAiHome() {
selectedFile.value = file;
errorMessage.value = '';
panelErrorMode.value = '';
if (canStartSearch.value) {
submitSearch();
}
}
function clearSelectedFile() {
@ -377,6 +465,38 @@ export function useFaceAiHome() {
}
}
function resetForRetry() {
errorMessage.value = '';
redirectUrl.value = '';
activeSearch.value = null;
isSubmitting.value = false;
isRedirecting.value = false;
panelErrorMode.value = '';
clearSelectedFile();
}
function setConsentChecked(value) {
consentChecked.value = Boolean(value);
if (errorMessage.value === t('consentRequired')) {
errorMessage.value = '';
}
}
function acceptDisclaimer() {
if (consentAccepted.value) {
return;
}
if (!consentChecked.value) {
errorMessage.value = t('consentRequired');
return;
}
consentAccepted.value = true;
errorMessage.value = '';
persistAcceptedDisclaimerCookie();
}
function onFileChange(event) {
setSelectedFile(event.target.files?.[0] || null);
}
@ -439,6 +559,7 @@ export function useFaceAiHome() {
session.value = await response.json();
loading.value = false;
consentAccepted.value = hasAcceptedDisclaimerCookie();
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
reportInvalidRaceAvailability(session.value.availability);
@ -452,6 +573,7 @@ export function useFaceAiHome() {
const payload = await response.json().catch(() => ({}));
errorMessage.value = localizeServerError(payload, 'pollError');
isSubmitting.value = false;
panelErrorMode.value = 'failed';
logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload });
return;
}
@ -461,32 +583,35 @@ export function useFaceAiHome() {
if (activeSearch.value.status === 'failed') {
isSubmitting.value = false;
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
panelErrorMode.value = 'failed';
return;
}
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
if (activeSearch.value.completionCode === 'NO_FACES_FOUND' || Number(activeSearch.value.matchCount || 0) === 0) {
isRedirecting.value = false;
redirectUrl.value = '';
panelErrorMode.value = 'no-results';
clearSelectedFile();
errorMessage.value = t('noFacesFoundMessage');
logFaceAiDebug('Search completed without detectable faces', { searchId });
return;
}
errorMessage.value = '';
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
errorMessage.value = localizeServerError(payload, 'redirectError');
panelErrorMode.value = 'failed';
logFaceAiDebug('Redirect build failed', { searchId, payload });
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
window.setTimeout(() => {
window.location.href = payload.url;
}, 1200);
window.location.replace(payload.url);
return;
}
@ -499,6 +624,7 @@ export function useFaceAiHome() {
errorMessage.value = '';
redirectUrl.value = '';
isRedirecting.value = false;
panelErrorMode.value = '';
if (!selectedFile.value) {
errorMessage.value = t('chooseSelfie');
@ -510,6 +636,11 @@ export function useFaceAiHome() {
return;
}
if (!consentAccepted.value) {
errorMessage.value = t('consentRequired');
return;
}
isSubmitting.value = true;
const formData = new FormData();
@ -526,6 +657,7 @@ export function useFaceAiHome() {
if (!response.ok) {
errorMessage.value = localizeServerError(payload, 'searchCreateError');
isSubmitting.value = false;
panelErrorMode.value = 'failed';
logFaceAiDebug('Search creation failed', { status: response.status, payload });
return;
}
@ -555,6 +687,8 @@ export function useFaceAiHome() {
canPickFile,
canStartSearch,
clearSelectedFile,
consentAccepted,
consentChecked,
currentLocale,
errorMessage,
fileInput,
@ -570,11 +704,16 @@ export function useFaceAiHome() {
onDrop,
onFileChange,
openFilePicker,
panelMode,
raceAvailabilityMessage,
resetForRetry,
redirectUrl,
returnToLegacy,
acceptDisclaimer,
selectedFile,
selectedFileSizeLabel,
session,
setConsentChecked,
simulatorUrl,
statusLabel,
submitSearch,

View file

@ -1,23 +1,18 @@
<script setup>
import LegacyHeader from '../components/LegacyHeader.vue';
import FaceAiFeedbackPanel from '../components/FaceAiFeedbackPanel.vue';
import FaceAiHeroCard from '../components/FaceAiHeroCard.vue';
import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue';
import { useFaceAiHome } from '../composables/useFaceAiHome.js';
const {
activeSearch,
activeSearchStatusLabel,
busyLabel,
canPickFile,
canStartSearch,
clearSelectedFile,
currentLocale,
consentAccepted,
consentChecked,
errorMessage,
fileInput,
isDragging,
isProcessingSearch,
isWorking,
loading,
onDragEnter,
onDragLeave,
@ -25,14 +20,16 @@ const {
onDrop,
onFileChange,
openFilePicker,
redirectUrl,
panelMode,
raceAvailabilityMessage,
resetForRetry,
returnToLegacy,
selectedFile,
selectedFileSizeLabel,
session,
simulatorUrl,
statusLabel,
submitSearch,
acceptDisclaimer,
setConsentChecked,
t
} = useFaceAiHome();
@ -46,18 +43,10 @@ function assignFileInput(element) {
<LegacyHeader />
<div class="container my-3 faceai-page">
<FaceAiHeroCard
:session="session"
:current-locale="currentLocale"
:active-search-status-label="activeSearchStatusLabel"
:t="t"
/>
<div class="row mt-4">
<div class="row mt-4 justify-content-center">
<div class="col-12">
<FaceAiUploadPanel
:loading="loading"
:is-working="isWorking"
:is-processing-search="isProcessingSearch"
:session="session"
:simulator-url="simulatorUrl"
@ -66,9 +55,15 @@ function assignFileInput(element) {
:is-dragging="isDragging"
:selected-file="selectedFile"
:selected-file-size-label="selectedFileSizeLabel"
:can-start-search="canStartSearch"
:panel-mode="panelMode"
:error-message="errorMessage"
:race-availability-message="raceAvailabilityMessage"
:consent-accepted="consentAccepted"
:consent-checked="consentChecked"
:assign-file-input="assignFileInput"
:t="t"
@toggle-consent="setConsentChecked"
@accept-consent="acceptDisclaimer"
@open-file-picker="openFilePicker"
@file-change="onFileChange"
@drag-enter="onDragEnter"
@ -76,21 +71,11 @@ function assignFileInput(element) {
@drag-leave="onDragLeave"
@drop="onDrop"
@clear-file="clearSelectedFile"
@submit-search="submitSearch"
@retry="resetForRetry"
@return-to-legacy="returnToLegacy"
/>
</div>
</div>
<FaceAiFeedbackPanel
:status-label="statusLabel"
:is-working="isWorking"
:busy-label="busyLabel"
:active-search="activeSearch"
:redirect-url="redirectUrl"
:error-message="errorMessage"
:t="t"
/>
</div>
</main>
</template>