diff --git a/echo/directus/sync/collections/permissions.json b/echo/directus/sync/collections/permissions.json index 092af417..33dee44d 100644 --- a/echo/directus/sync/collections/permissions.json +++ b/echo/directus/sync/collections/permissions.json @@ -1002,7 +1002,9 @@ "fields": [ "disable_create_project", "projects", - "whitelabel_logo" + "whitelabel_logo", + "legal_basis", + "privacy_policy_url" ], "policy": "37a60e48-dd00-4867-af07-1fb22ac89078", "_syncId": "25411e4e-fb6c-41e7-be7b-300ca3b503ef" diff --git a/echo/directus/sync/snapshot/fields/directus_users/legal_basis.json b/echo/directus/sync/snapshot/fields/directus_users/legal_basis.json new file mode 100644 index 00000000..a1ad5cea --- /dev/null +++ b/echo/directus/sync/snapshot/fields/directus_users/legal_basis.json @@ -0,0 +1,74 @@ +{ + "collection": "directus_users", + "field": "legal_basis", + "type": "string", + "meta": { + "collection": "directus_users", + "conditions": null, + "display": "labels", + "display_options": { + "choices": [ + { + "text": "Client Managed", + "value": "client-managed" + }, + { + "text": "Consent", + "value": "consent" + }, + { + "text": "dembrane events ", + "value": "dembrane-events" + } + ] + }, + "field": "legal_basis", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "GDPR legal basis for processing participant data. Affects consent flows in the participant portal.", + "options": { + "choices": [ + { + "text": "Client Managed", + "value": "client-managed" + }, + { + "text": "Consent", + "value": "consent" + }, + { + "text": "dembrane events ", + "value": "dembrane-events" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "legal_basis", + "table": "directus_users", + "data_type": "character varying", + "default_value": "client-managed", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/directus/sync/snapshot/fields/directus_users/privacy_policy_url.json b/echo/directus/sync/snapshot/fields/directus_users/privacy_policy_url.json new file mode 100644 index 00000000..1539003b --- /dev/null +++ b/echo/directus/sync/snapshot/fields/directus_users/privacy_policy_url.json @@ -0,0 +1,46 @@ +{ + "collection": "directus_users", + "field": "privacy_policy_url", + "type": "string", + "meta": { + "collection": "directus_users", + "conditions": null, + "display": null, + "display_options": null, + "field": "privacy_policy_url", + "group": null, + "hidden": false, + "interface": "input", + "note": "URL to the organiser's privacy policy. Shown to participants when Legal Basis is 'Consent'.", + "options": { + "placeholder": "https://example.com/privacy-policy" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "privacy_policy_url", + "table": "directus_users", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/echo/frontend/src/components/auth/hooks/index.ts b/echo/frontend/src/components/auth/hooks/index.ts index 38471735..c15e19c4 100644 --- a/echo/frontend/src/components/auth/hooks/index.ts +++ b/echo/frontend/src/components/auth/hooks/index.ts @@ -32,6 +32,8 @@ export const useCurrentUser = ({ "disable_create_project", "tfa_secret", "whitelabel_logo", + "legal_basis", + "privacy_policy_url", ], }), ); diff --git a/echo/frontend/src/components/layout/ParticipantHeader.tsx b/echo/frontend/src/components/layout/ParticipantHeader.tsx index 345b30aa..a13dacc2 100644 --- a/echo/frontend/src/components/layout/ParticipantHeader.tsx +++ b/echo/frontend/src/components/layout/ParticipantHeader.tsx @@ -11,8 +11,8 @@ import { DIRECTUS_PUBLIC_URL } from "@/config"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { useWhitelabelLogo } from "@/hooks/useWhitelabelLogo"; import { testId } from "@/lib/testUtils"; -import { useParticipantProjectById } from "../participant/hooks"; import { Logo } from "../common/Logo"; +import { useParticipantProjectById } from "../participant/hooks"; import { ParticipantSettingsModal } from "../participant/ParticipantSettingsModal"; export const ParticipantHeader = () => { @@ -28,7 +28,7 @@ export const ParticipantHeader = () => { const projectQuery = useParticipantProjectById(projectId ?? ""); useEffect(() => { - const logoFileId = (projectQuery.data as any)?.whitelabel_logo_url; + const logoFileId = projectQuery.data?.whitelabel_logo_url; if (logoFileId) { setLogoUrl(`${DIRECTUS_PUBLIC_URL}/assets/${logoFileId}`); } else { diff --git a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx index 5575120d..ce4e91c5 100644 --- a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx +++ b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx @@ -42,7 +42,11 @@ export interface LanguageCards { [language: string]: Section[]; } -const ParticipantOnboardingCards = ({ project }: { project: Project }) => { +const ParticipantOnboardingCards = ({ + project, +}: { + project: ParticipantProject; +}) => { const [searchParams] = useSearchParams(); const skipOnboarding = searchParams.get("skipOnboarding"); @@ -74,10 +78,12 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { const { getSystemCards } = useOnboardingCards(); const tutorialSlug = project.default_conversation_tutorial_slug ?? "none"; + const legalBasis = project.legal_basis ?? "client-managed"; + const privacyPolicyUrl = project.privacy_policy_url; const cards: LanguageCards = { "de-DE": [ - ...getSystemCards("de-DE", tutorialSlug), + ...getSystemCards("de-DE", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Mikrofon-Check", slides: [ @@ -100,7 +106,7 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { }, ], "en-US": [ - ...getSystemCards("en-US", tutorialSlug), + ...getSystemCards("en-US", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Microphone Check", slides: [ @@ -123,7 +129,7 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { }, ], "es-ES": [ - ...getSystemCards("es-ES", tutorialSlug), + ...getSystemCards("es-ES", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Verificación del Micrófono", slides: [ @@ -146,7 +152,7 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { }, ], "fr-FR": [ - ...getSystemCards("fr-FR", tutorialSlug), + ...getSystemCards("fr-FR", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Vérification du Microphone", slides: [ @@ -169,7 +175,7 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { }, ], "it-IT": [ - ...getSystemCards("it-IT", tutorialSlug), + ...getSystemCards("it-IT", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Controllo Microfono", slides: [ @@ -192,7 +198,7 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { }, ], "nl-NL": [ - ...getSystemCards("nl-NL", tutorialSlug), + ...getSystemCards("nl-NL", tutorialSlug, legalBasis, privacyPolicyUrl), { section: "Microfoon Controle", slides: [ @@ -330,7 +336,11 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { )} {currentCard.extraHelp && ( - + {currentCard.extraHelp} )} @@ -371,7 +381,8 @@ const ParticipantOnboardingCards = ({ project }: { project: Project }) => { label={currentCard.checkbox.label} classNames={{ body: "items-center", - label: "text-lg text-gray-700", + label: + "text-lg leading-snug pl-4 text-gray-700 text-left pt-0.5", root: "items-start", }} {...testId("portal-onboarding-checkbox")} diff --git a/echo/frontend/src/components/participant/hooks/useOnboardingCards.ts b/echo/frontend/src/components/participant/hooks/useOnboardingCards.ts index d85025e9..9b177579 100644 --- a/echo/frontend/src/components/participant/hooks/useOnboardingCards.ts +++ b/echo/frontend/src/components/participant/hooks/useOnboardingCards.ts @@ -1,37 +1,43 @@ import type { LanguageCards } from "../ParticipantOnboardingCards"; +type LegalBasis = ParticipantProject["legal_basis"]; + export const useOnboardingCards = () => { const getSystemCards = ( lang: string, tutorialSlug?: string, + legalBasis?: LegalBasis, + privacyPolicyUrl?: string | null, ): LanguageCards[string] => { + const basis = legalBasis ?? "client-managed"; + // Normalize and fallback invalid values to "none" const normalizedSlug = tutorialSlug?.toLowerCase(); - const validSlugs = ["skip-consent", "none", "basic", "advanced"]; + const validSlugs = ["none", "basic", "advanced"]; const finalSlug = validSlugs.includes(normalizedSlug || "") ? normalizedSlug : "none"; - if (finalSlug === "skip-consent") { - return []; - } - // none: Only privacy statement if (finalSlug === "none") { - const privacyCard = getPrivacyCard(lang); + const privacyCard = getPrivacyCard(lang, basis, privacyPolicyUrl); return privacyCard ? [privacyCard] : []; } // basic: Tutorial slides + privacy statement if (finalSlug === "basic") { const tutorialCards = getBasicTutorialCards(lang); - const privacyCard = getPrivacyCard(lang); + const privacyCard = getPrivacyCard(lang, basis, privacyPolicyUrl); return [...tutorialCards, ...(privacyCard ? [privacyCard] : [])]; } // advanced: Full tutorial + privacy statement + best practices if (finalSlug === "advanced") { - const tutorialCards = getAdvancedTutorialCards(lang); + const tutorialCards = getAdvancedTutorialCards( + lang, + basis, + privacyPolicyUrl, + ); return tutorialCards; } @@ -426,7 +432,11 @@ export const useOnboardingCards = () => { return tutorialCards[lang] || tutorialCards["en-US"] || []; }; - const getAdvancedTutorialCards = (lang: string): LanguageCards[string] => { + const getAdvancedTutorialCards = ( + lang: string, + legalBasis: LegalBasis = "client-managed", + privacyPolicyUrl?: string | null, + ): LanguageCards[string] => { const tutorialCards: Record = { "de-DE": [ { @@ -489,7 +499,8 @@ export const useOnboardingCards = () => { "Vermeiden Sie die Weitergabe von Details, die Sie dem Gastgeber nicht mitteilen möchten. Seien Sie achtsam und nehmen Sie andere nicht ohne deren Zustimmung auf.", title: "Datenschutz ist wichtig", }, - ...(getPrivacyCard("de-DE")?.slides || []), + ...(getPrivacyCard("de-DE", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -582,7 +593,8 @@ export const useOnboardingCards = () => { "Avoid sharing details you don't want the host to know. Be mindful and don't record others without their consent.", title: "Privacy Matters", }, - ...(getPrivacyCard("en-US")?.slides || []), + ...(getPrivacyCard("en-US", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -674,7 +686,8 @@ export const useOnboardingCards = () => { "Evita compartir detalles que no quieras que el anfitrión conozca. Sé consciente y no grabes a otros sin su consentimiento.", title: "La Privacidad Importa", }, - ...(getPrivacyCard("es-ES")?.slides || []), + ...(getPrivacyCard("es-ES", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -768,7 +781,8 @@ export const useOnboardingCards = () => { "Évitez de partager des détails que vous ne voulez pas que l'hôte connaisse. Soyez attentif et n'enregistrez pas les autres sans leur consentement.", title: "La Confidentialité Compte", }, - ...(getPrivacyCard("fr-FR")?.slides || []), + ...(getPrivacyCard("fr-FR", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -861,7 +875,8 @@ export const useOnboardingCards = () => { "Evita di condividere dettagli che non vuoi rendere noti all'host. Chiedi sempre il consenso prima di registrare altre persone.", title: "La privacy conta", }, - ...(getPrivacyCard("it-IT")?.slides || []), + ...(getPrivacyCard("it-IT", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -954,7 +969,8 @@ export const useOnboardingCards = () => { "Vermijd het delen van details die je niet met de organisator wilt delen. Wees voorzichtig en neem anderen niet op zonder hun toestemming.", title: "Privacy is belangrijk", }, - ...(getPrivacyCard("nl-NL")?.slides || []), + ...(getPrivacyCard("nl-NL", legalBasis, privacyPolicyUrl)?.slides || + []), ], }, { @@ -995,26 +1011,135 @@ export const useOnboardingCards = () => { const getPrivacyCard = ( lang: string, + legalBasis: LegalBasis = "client-managed", + privacyPolicyUrl?: string | null, + ): LanguageCards[string][number] | null => { + if (legalBasis === "client-managed") { + return getClientManagedPrivacyCard(lang); + } + if (legalBasis === "dembrane-events") { + return getDembraneEventsPrivacyCard(lang); + } + return getConsentPrivacyCard(lang, privacyPolicyUrl); + }; + + const getClientManagedPrivacyCard = ( + lang: string, + ): LanguageCards[string][number] | null => { + const cards: Record = { + "de-DE": { + section: "Privatsphäre", + slides: [ + { + content: + "Der Organisator ist verantwortlich dafür, wie Ihre Daten in dieser Sitzung verwendet werden. dembrane verarbeitet Ihr Gespräch in seinem Auftrag.", + cta: "Ich verstehe.", + extraHelp: + "Aufnahmen werden transkribiert und für Erkenntnisse analysiert. Ihre Daten werden auf gesicherten Servern in Europa gespeichert, nicht zum Trainieren von KI-Modellen verwendet und innerhalb von 30 Tagen nach Projektende gelöscht.\nFragen zu Ihrer Privatsphäre? Wenden Sie sich direkt an den Organisator.", + title: "Verantwortlicher, Nutzung und Sicherheit.", + }, + ], + }, + "en-US": { + section: "Privacy", + slides: [ + { + content: + "The organiser is responsible for how your data is used in this session. dembrane processes your conversation on their behalf.", + cta: "I understand", + extraHelp: + "Recordings are transcribed and analysed for insights. Your data is stored on secured servers in Europe, is not used to train AI models, and is deleted within 30 days after the project has ended.\nQuestions about your privacy? Contact the organiser directly.", + title: "Data controller, usage and security.", + }, + ], + }, + "es-ES": { + section: "Privacidad", + slides: [ + { + content: + "El organizador es responsable de cómo se utilizan sus datos en esta sesión. dembrane procesa su conversación en su nombre.", + cta: "Entiendo", + extraHelp: + "Las grabaciones se transcriben y analizan para obtener información. Sus datos se almacenan en servidores seguros en Europa, no se utilizan para entrenar modelos de IA y se eliminan dentro de los 30 días posteriores a la finalización del proyecto.\n¿Preguntas sobre su privacidad? Contacte directamente al organizador.", + title: "Responsable del tratamiento, uso y seguridad.", + }, + ], + }, + "fr-FR": { + section: "Confidentialité", + slides: [ + { + content: + "L'organisateur est responsable de la manière dont vos données sont utilisées dans cette session. dembrane traite votre conversation en son nom.", + cta: "Je comprends", + extraHelp: + "Les enregistrements sont transcrits et analysés pour en tirer des enseignements. Vos données sont stockées sur des serveurs sécurisés en Europe, ne sont pas utilisées pour entraîner des modèles d'IA et sont supprimées dans les 30 jours suivant la fin du projet.\nDes questions sur votre vie privée ? Contactez directement l'organisateur.", + title: "Responsable du traitement, utilisation et sécurité.", + }, + ], + }, + "it-IT": { + section: "Privacy", + slides: [ + { + content: + "L'organizzatore è responsabile di come vengono utilizzati i tuoi dati in questa sessione. dembrane elabora la tua conversazione per suo conto.", + cta: "Ho capito", + extraHelp: + "Le registrazioni vengono trascritte e analizzate per ottenere insight. I tuoi dati sono archiviati su server sicuri in Europa, non vengono utilizzati per addestrare modelli di IA e vengono eliminati entro 30 giorni dalla fine del progetto.\nDomande sulla tua privacy? Contatta direttamente l'organizzatore.", + title: "Titolare del trattamento, utilizzo e sicurezza.", + }, + ], + }, + "nl-NL": { + section: "Privacy", + slides: [ + { + content: + "De organisator is verantwoordelijk voor hoe je gegevens worden gebruikt in deze sessie. dembrane verwerkt je gesprek namens hen.", + cta: "Ik begrijp het", + extraHelp: + "Opnames worden getranscribeerd en geanalyseerd voor inzichten. Je gegevens worden opgeslagen op beveiligde servers in Europa, worden niet gebruikt om AI-modellen te trainen en worden binnen 30 dagen na het einde van het project verwijderd.\nVragen over je privacy? Neem direct contact op met de organisator.", + title: "Verwerkingsverantwoordelijke, gebruik en beveiliging.", + }, + ], + }, + }; + + return cards[lang] || cards["en-US"] || null; + }; + + const getConsentPrivacyCard = ( + lang: string, + privacyPolicyUrl?: string | null, ): LanguageCards[string][number] | null => { - const privacyCards: Record = { + const policyUrl = privacyPolicyUrl || undefined; + + const cards: Record = { "de-DE": { section: "Privatsphäre", slides: [ { checkbox: { - label: "Ich stimme der Datenschutzrichtlinie zu", + label: + "Ich stimme zu, dass mein Gespräch aufgezeichnet und verarbeitet wird.", required: true, }, content: - "Ihre Daten werden sicher gespeichert, analysiert und niemals mit Dritten geteilt.", + "Der Organisator ist verantwortlich dafür, wie Ihre Daten in dieser Sitzung verwendet werden. dembrane verarbeitet Ihr Gespräch in seinem Auftrag.", cta: "Ich verstehe.", extraHelp: - "Aufnahmen werden transkribiert und aufschlussreich analysiert, anschließend nach 30 Tagen gelöscht. Für spezifische Details wenden Sie sich bitte an den Host, der Ihnen den QR-Code zur Verfügung gestellt hat.", - link: { - label: "Die vollständige Datenschutzrichtlinie lesen", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", - }, - title: "Datenverwendung und Sicherheit.", + "Aufnahmen werden transkribiert und für Erkenntnisse analysiert. Ihre Daten werden auf gesicherten Servern in Europa gespeichert, nicht zum Trainieren von KI-Modellen verwendet und innerhalb von 30 Tagen nach Projektende gelöscht.\nFragen zu Ihrer Privatsphäre? Wenden Sie sich direkt an den Organisator.", + ...(policyUrl + ? { + link: { + label: "Datenschutzrichtlinie des Organisators lesen", + url: policyUrl, + }, + } + : {}), + title: "Verantwortlicher, Nutzung und Sicherheit.", }, ], }, @@ -1023,19 +1148,24 @@ export const useOnboardingCards = () => { slides: [ { checkbox: { - label: "I agree to the privacy policy", + label: + "I consent to my conversation being recorded and processed.", required: true, }, content: - "Your data is securely stored, analyzed, and never shared with third parties.", + "The organiser is responsible for how your data is used in this session. dembrane processes your conversation on their behalf.", cta: "I understand", extraHelp: - "Recordings are transcribed and analyzed for insights, then deleted after 30 days. For specific details, consult the host who provided your QR code.", - link: { - label: "Read the full privacy policy", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", - }, - title: "Data usage and security.", + "Recordings are transcribed and analysed for insights. Your data is stored on secured servers in Europe, is not used to train AI models, and is deleted within 30 days after the project has ended.\nQuestions about your privacy? Contact the organiser directly.", + ...(policyUrl + ? { + link: { + label: "Read the organiser's privacy policy", + url: policyUrl, + }, + } + : {}), + title: "Data controller, usage and security.", }, ], }, @@ -1044,19 +1174,24 @@ export const useOnboardingCards = () => { slides: [ { checkbox: { - label: "Acepto la política de privacidad", + label: + "Doy mi consentimiento para que mi conversación sea grabada y procesada.", required: true, }, content: - "Sus datos se almacenan de forma segura, se analizan y nunca se comparten con terceros.", + "El organizador es responsable de cómo se utilizan sus datos en esta sesión. dembrane procesa su conversación en su nombre.", cta: "Entiendo", extraHelp: - "Las grabaciones se transcriben y analizan para obtener información, luego se eliminan después de 30 días. Para detalles específicos, consulte al anfitrión que le proporcionó su código QR.", - link: { - label: "Lea la política de privacidad completa", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", - }, - title: "Uso de datos y seguridad.", + "Las grabaciones se transcriben y analizan para obtener información. Sus datos se almacenan en servidores seguros en Europa, no se utilizan para entrenar modelos de IA y se eliminan dentro de los 30 días posteriores a la finalización del proyecto.\n¿Preguntas sobre su privacidad? Contacte directamente al organizador.", + ...(policyUrl + ? { + link: { + label: "Lea la política de privacidad del organizador", + url: policyUrl, + }, + } + : {}), + title: "Responsable del tratamiento, uso y seguridad.", }, ], }, @@ -1065,19 +1200,25 @@ export const useOnboardingCards = () => { slides: [ { checkbox: { - label: "J'accepte la politique de confidentialité", + label: + "Je consens à ce que ma conversation soit enregistrée et traitée.", required: true, }, content: - "Vos données sont stockées en toute sécurité, analysées et jamais partagées avec des tiers.", + "L'organisateur est responsable de la manière dont vos données sont utilisées dans cette session. dembrane traite votre conversation en son nom.", cta: "Je comprends", extraHelp: - "Les enregistrements sont transcrits et analysés pour obtenir des informations, puis supprimés après 30 jours. Pour des détails spécifiques, consultez l'hôte qui vous a fourni votre code QR.", - link: { - label: "Lire la politique de confidentialité complète", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", - }, - title: "Utilisation des données et sécurité.", + "Les enregistrements sont transcrits et analysés pour en tirer des enseignements. Vos données sont stockées sur des serveurs sécurisés en Europe, ne sont pas utilisées pour entraîner des modèles d'IA et sont supprimées dans les 30 jours suivant la fin du projet.\nDes questions sur votre vie privée ? Contactez directement l'organisateur.", + ...(policyUrl + ? { + link: { + label: + "Lire la politique de confidentialité de l'organisateur", + url: policyUrl, + }, + } + : {}), + title: "Responsable du traitement, utilisation et sécurité.", }, ], }, @@ -1086,19 +1227,25 @@ export const useOnboardingCards = () => { slides: [ { checkbox: { - label: "Accetto l'informativa sulla privacy", + label: + "Acconsento alla registrazione e al trattamento della mia conversazione.", required: true, }, content: - "I tuoi dati sono archiviati in modo sicuro, analizzati e mai condivisi con terze parti.", + "L'organizzatore è responsabile di come vengono utilizzati i tuoi dati in questa sessione. dembrane elabora la tua conversazione per suo conto.", cta: "Ho capito", extraHelp: - "Le registrazioni vengono trascritte e analizzate per ottenere insight, poi eliminate dopo 30 giorni. Per dettagli specifici, contatta l'host che ti ha fornito il QR code.", - link: { - label: "Leggi l'informativa completa sulla privacy", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", - }, - title: "Uso dei dati e sicurezza.", + "Le registrazioni vengono trascritte e analizzate per ottenere insight. I tuoi dati sono archiviati su server sicuri in Europa, non vengono utilizzati per addestrare modelli di IA e vengono eliminati entro 30 giorni dalla fine del progetto.\nDomande sulla tua privacy? Contatta direttamente l'organizzatore.", + ...(policyUrl + ? { + link: { + label: + "Leggi l'informativa sulla privacy dell'organizzatore", + url: policyUrl, + }, + } + : {}), + title: "Titolare del trattamento, utilizzo e sicurezza.", }, ], }, @@ -1107,25 +1254,144 @@ export const useOnboardingCards = () => { slides: [ { checkbox: { - label: "Ik ga akkoord met het privacybeleid", + label: + "Ik stem in met het opnemen en verwerken van mijn gesprek.", required: true, }, content: - "Je gegevens worden veilig opgeslagen, geanalyseerd en nooit gedeeld met derden.", + "De organisator is verantwoordelijk voor hoe je gegevens worden gebruikt in deze sessie. dembrane verwerkt je gesprek namens hen.", + cta: "Ik begrijp het", + extraHelp: + "Opnames worden getranscribeerd en geanalyseerd voor inzichten. Je gegevens worden opgeslagen op beveiligde servers in Europa, worden niet gebruikt om AI-modellen te trainen en worden binnen 30 dagen na het einde van het project verwijderd.\nVragen over je privacy? Neem direct contact op met de organisator.", + ...(policyUrl + ? { + link: { + label: "Lees het privacybeleid van de organisator", + url: policyUrl, + }, + } + : {}), + title: "Verwerkingsverantwoordelijke, gebruik en beveiliging.", + }, + ], + }, + }; + + return cards[lang] || cards["en-US"] || null; + }; + + const getDembraneEventsPrivacyCard = ( + lang: string, + ): LanguageCards[string][number] | null => { + const dembranePrivacyUrl = + "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115"; + + const cards: Record = { + "de-DE": { + section: "Privatsphäre", + slides: [ + { + content: + "dembrane zeichnet dieses Gespräch auf und analysiert es auf Grundlage unseres berechtigten Interesses: Diskussionen genau festzuhalten, zuverlässige Erkenntnisse zu liefern und unsere Plattform weiterzuentwickeln.", + cta: "Ich verstehe.", + extraHelp: + "Aufnahmen und Transkripte werden innerhalb von 30 Tagen nach Schließung der Sitzung gelöscht. Daten werden auf gesicherten Servern in Europa gespeichert und nicht zum Trainieren von KI-Modellen verwendet.\nFragen oder Einwände? Kontaktieren Sie uns unter info@dembrane.com oder lesen Sie unsere Datenschutzrichtlinie.", + link: { + label: "Vollständige Datenschutzrichtlinie lesen", + url: dembranePrivacyUrl, + }, + title: "Datenverwendung und Sicherheit", + }, + ], + }, + "en-US": { + section: "Privacy", + slides: [ + { + content: + "dembrane records and analyses this conversation based on our legitimate interest: to capture discussions accurately, deliver reliable insights, and develop our platform.", + cta: "I understand", + extraHelp: + "Recordings and transcripts are deleted within 30 days of the session closing. Data is stored on secured servers in Europe and is not used to train AI models.\nQuestions or want to object? Contact us at info@dembrane.com or see our privacy policy.", + link: { + label: "Read the full privacy policy", + url: dembranePrivacyUrl, + }, + title: "Data usage and security", + }, + ], + }, + "es-ES": { + section: "Privacidad", + slides: [ + { + content: + "dembrane graba y analiza esta conversación basándose en nuestro interés legítimo: capturar las discusiones con precisión, ofrecer información fiable y desarrollar nuestra plataforma.", + cta: "Entiendo", + extraHelp: + "Las grabaciones y transcripciones se eliminan dentro de los 30 días posteriores al cierre de la sesión. Los datos se almacenan en servidores seguros en Europa y no se utilizan para entrenar modelos de IA.\n¿Preguntas o desea objetar? Contáctenos en info@dembrane.com o consulte nuestra política de privacidad.", + link: { + label: "Lea la política de privacidad completa", + url: dembranePrivacyUrl, + }, + title: "Uso de datos y seguridad", + }, + ], + }, + "fr-FR": { + section: "Confidentialité", + slides: [ + { + content: + "dembrane enregistre et analyse cette conversation sur la base de notre intérêt légitime : capturer les discussions avec précision, fournir des informations fiables et développer notre plateforme.", + cta: "Je comprends", + extraHelp: + "Les enregistrements et les transcriptions sont supprimés dans les 30 jours suivant la clôture de la session. Les données sont stockées sur des serveurs sécurisés en Europe et ne sont pas utilisées pour entraîner des modèles d'IA.\nDes questions ou souhaitez-vous vous opposer ? Contactez-nous à info@dembrane.com ou consultez notre politique de confidentialité.", + link: { + label: "Lire la politique de confidentialité complète", + url: dembranePrivacyUrl, + }, + title: "Utilisation des données et sécurité", + }, + ], + }, + "it-IT": { + section: "Privacy", + slides: [ + { + content: + "dembrane registra e analizza questa conversazione sulla base del nostro legittimo interesse: acquisire le discussioni in modo accurato, fornire informazioni affidabili e sviluppare la nostra piattaforma.", + cta: "Ho capito", + extraHelp: + "Le registrazioni e le trascrizioni vengono eliminate entro 30 giorni dalla chiusura della sessione. I dati sono archiviati su server sicuri in Europa e non vengono utilizzati per addestrare modelli di IA.\nDomande o vuoi opporti? Contattaci a info@dembrane.com o consulta la nostra informativa sulla privacy.", + link: { + label: "Leggi l'informativa sulla privacy completa", + url: dembranePrivacyUrl, + }, + title: "Uso dei dati e sicurezza", + }, + ], + }, + "nl-NL": { + section: "Privacy", + slides: [ + { + content: + "dembrane neemt dit gesprek op en analyseert het op basis van ons gerechtvaardigd belang: om discussies nauwkeurig vast te leggen, betrouwbare inzichten te leveren en ons platform te ontwikkelen.", cta: "Ik begrijp het", extraHelp: - "Opnames worden getranscribeerd en geanalyseerd voor inzichten, en na 30 dagen verwijderd. Voor specifieke details, raadpleeg de organisator die je de QR-code heeft gegeven.", + "Opnames en transcripties worden binnen 30 dagen na het sluiten van de sessie verwijderd. Gegevens worden opgeslagen op beveiligde servers in Europa en worden niet gebruikt om AI-modellen te trainen.\nVragen of bezwaar? Neem contact met ons op via info@dembrane.com of bekijk ons privacybeleid.", link: { - label: "Lees het privacybeleid", - url: "https://dembrane.notion.site/Privacy-Statement-Dembrane-1439cd84270580748046cc589861d115", + label: "Lees het volledige privacybeleid", + url: dembranePrivacyUrl, }, - title: "Gegevensgebruik en beveiliging.", + title: "Gegevensgebruik en beveiliging", }, ], }, }; - return privacyCards[lang] || null; + return cards[lang] || cards["en-US"] || null; }; return { getSystemCards }; diff --git a/echo/frontend/src/components/project/ProjectPortalEditor.tsx b/echo/frontend/src/components/project/ProjectPortalEditor.tsx index 1d521f54..2267a19a 100644 --- a/echo/frontend/src/components/project/ProjectPortalEditor.tsx +++ b/echo/frontend/src/components/project/ProjectPortalEditor.tsx @@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ActionIcon, + Anchor, Badge, Box, Button, @@ -20,13 +21,23 @@ import { Title, } from "@mantine/core"; import { DetectiveIcon } from "@phosphor-icons/react"; -import { IconEye, IconEyeOff, IconInfoCircle, IconRefresh, IconRosetteDiscountCheck, IconX } from "@tabler/icons-react"; +import { + IconExternalLink, + IconEye, + IconEyeOff, + IconInfoCircle, + IconRefresh, + IconRosetteDiscountCheck, + IconScale, + IconX, +} from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { Resizable } from "re-resizable"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Controller, useForm, useWatch } from "react-hook-form"; import { z } from "zod"; import { useAutoSave } from "@/hooks/useAutoSave"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { useLanguage } from "@/hooks/useLanguage"; import type { VerificationTopicsResponse } from "@/lib/api"; import { testId } from "@/lib/testUtils"; @@ -203,6 +214,7 @@ const ProjectPortalEditorComponent: React.FC = ({ isVerificationTopicsLoading = false, }) => { const queryClient = useQueryClient(); + const settingsNavigate = useI18nNavigate(); const [showPreview, setShowPreview] = useState(false); const link = useProjectSharingLink(project); const [previewKey, setPreviewKey] = useState(0); @@ -246,7 +258,7 @@ const ProjectPortalEditorComponent: React.FC = ({ const defaultValues = useMemo(() => { const rawTutorialSlug = project.default_conversation_tutorial_slug?.toLowerCase(); - const validSlugs = ["skip-consent", "none", "basic", "advanced"]; + const validSlugs = ["none", "basic", "advanced"]; const normalizedTutorialSlug = validSlugs.includes(rawTutorialSlug || "") ? rawTutorialSlug : "none"; @@ -571,6 +583,33 @@ const ProjectPortalEditorComponent: React.FC = ({ /> )} /> + + + + Legal Basis + + + + + + Determines under which GDPR legal basis personal data + is processed. This setting applies to all your + projects and can be changed in your account settings. + + + + settingsNavigate("/settings#legal-basis") + } + style={{ cursor: "pointer" }} + > + + Go to Settings + + + + = ({ } data={[ - { - label: t`Skip data privacy slide (Host manages legal base)`, - value: "skip-consent", - }, { label: t`Default - No tutorial (Only privacy statements)`, value: "none", @@ -1220,10 +1255,7 @@ const ProjectPortalEditorComponent: React.FC = ({ <Trans>Auto-generate Titles</Trans> - + Beta diff --git a/echo/frontend/src/components/settings/LegalBasisSettingsCard.tsx b/echo/frontend/src/components/settings/LegalBasisSettingsCard.tsx new file mode 100644 index 00000000..4e7dcea6 --- /dev/null +++ b/echo/frontend/src/components/settings/LegalBasisSettingsCard.tsx @@ -0,0 +1,142 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + Alert, + Button, + Card, + Group, + Radio, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { IconAlertTriangle, IconScale } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useCurrentUser } from "@/components/auth/hooks"; +import { API_BASE_URL } from "@/config"; +import { toast } from "../common/Toaster"; + +type LegalBasisValue = CustomDirectusUser["legal_basis"]; + +export const LegalBasisSettingsCard = () => { + const { data: user } = useCurrentUser(); + const queryClient = useQueryClient(); + + const currentLegalBasis = + (user?.legal_basis as LegalBasisValue | null) ?? "client-managed"; + const currentPrivacyUrl = (user?.privacy_policy_url as string | null) ?? ""; + const userEmail = user?.email ?? ""; + const isDembraneUser = userEmail.endsWith("@dembrane.com"); + + const [legalBasis, setLegalBasis] = + useState(currentLegalBasis); + const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(currentPrivacyUrl); + + useEffect(() => { + setLegalBasis(currentLegalBasis); + setPrivacyPolicyUrl(currentPrivacyUrl); + }, [currentLegalBasis, currentPrivacyUrl]); + + const hasChanges = + legalBasis !== currentLegalBasis || + (legalBasis === "consent" && privacyPolicyUrl !== currentPrivacyUrl); + + const mutation = useMutation({ + mutationFn: async () => { + const response = await fetch( + `${API_BASE_URL}/user-settings/legal-basis`, + { + body: JSON.stringify({ + legal_basis: legalBasis, + privacy_policy_url: + legalBasis === "consent" ? privacyPolicyUrl || null : null, + }), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || "Failed to update legal basis"); + } + }, + onError: (error: Error) => { + toast.error(error.message || t`Failed to update legal basis`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users", "me"] }); + toast.success(t`Legal basis updated`); + }, + }); + + return ( + + + + + + <Trans>Legal Basis</Trans> + + + + + + Determines under which GDPR legal basis personal data is processed. + This affects consent flows, data subject rights, and retention + obligations. + + + + } + > + + + Only change this setting in consultation with the responsible + person(s) for data protection within your organisation. + + + + + setLegalBasis(value as LegalBasisValue)} + > + + + + {isDembraneUser && ( + + )} + + + + {legalBasis === "consent" && ( + setPrivacyPolicyUrl(e.currentTarget.value)} + /> + )} + + + + + + + ); +}; diff --git a/echo/frontend/src/lib/api.ts b/echo/frontend/src/lib/api.ts index 8021f6d0..eee2ea30 100644 --- a/echo/frontend/src/lib/api.ts +++ b/echo/frontend/src/lib/api.ts @@ -32,7 +32,9 @@ interface CustomAxiosRequestConfig extends AxiosRequestConfig { } export const getParticipantProjectById = async (projectId: string) => { - return apiNoAuth.get(`/participant/projects/${projectId}`); + return apiNoAuth.get( + `/participant/projects/${projectId}`, + ); }; export const getParticipantConversationById = async ( diff --git a/echo/frontend/src/lib/typesDirectus.d.ts b/echo/frontend/src/lib/typesDirectus.d.ts index 88b24690..32f4650e 100644 --- a/echo/frontend/src/lib/typesDirectus.d.ts +++ b/echo/frontend/src/lib/typesDirectus.d.ts @@ -286,6 +286,12 @@ interface Project { conversations_count?: number | null; } +interface ParticipantProject extends Project { + whitelabel_logo_url: string | null; + legal_basis: "client-managed" | "consent" | "dembrane-events" | null; + privacy_policy_url: string | null; +} + interface ProjectAnalysisRun { created_at: string | null; id: string; @@ -459,6 +465,8 @@ interface View { interface CustomDirectusUser { disable_create_project: boolean | null; whitelabel_logo: string | null; + legal_basis: "client-managed" | "consent" | "dembrane-events" | null; + privacy_policy_url: string | null; projects: string[] | Project[]; } diff --git a/echo/frontend/src/routes/participant/ParticipantStart.tsx b/echo/frontend/src/routes/participant/ParticipantStart.tsx index 44d16e8c..6842d9d6 100644 --- a/echo/frontend/src/routes/participant/ParticipantStart.tsx +++ b/echo/frontend/src/routes/participant/ParticipantStart.tsx @@ -50,7 +50,7 @@ export const ParticipantStartRoute = () => { ) : ( - + )} ); diff --git a/echo/frontend/src/routes/settings/UserSettingsRoute.tsx b/echo/frontend/src/routes/settings/UserSettingsRoute.tsx index c42e8956..0e9e3034 100644 --- a/echo/frontend/src/routes/settings/UserSettingsRoute.tsx +++ b/echo/frontend/src/routes/settings/UserSettingsRoute.tsx @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ActionIcon, + Box, Container, Divider, Group, @@ -10,20 +11,34 @@ import { } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { IconArrowLeft } from "@tabler/icons-react"; +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router"; import { useCurrentUser } from "@/components/auth/hooks"; import { AuditLogsCard } from "@/components/settings/AuditLogsCard"; import { FontSettingsCard } from "@/components/settings/FontSettingsCard"; import { FontSizeSettingsCard } from "@/components/settings/FontSizeSettingsCard"; -import { WhitelabelLogoCard } from "@/components/settings/WhitelabelLogoCard"; +import { LegalBasisSettingsCard } from "@/components/settings/LegalBasisSettingsCard"; import { TwoFactorSettingsCard } from "@/components/settings/TwoFactorSettingsCard"; +import { WhitelabelLogoCard } from "@/components/settings/WhitelabelLogoCard"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; export const UserSettingsRoute = () => { useDocumentTitle(t`Settings | Dembrane`); const { data: user, isLoading } = useCurrentUser(); const navigate = useI18nNavigate(); + const location = useLocation(); const isTwoFactorEnabled = Boolean(user?.tfa_secret); + const legalBasisRef = useRef(null); + + useEffect(() => { + if (location.hash === "#legal-basis") { + legalBasisRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [location.hash]); return ( @@ -50,6 +65,10 @@ export const UserSettingsRoute = () => { + + + + 0: - logo_file_id = user_data[0].get("whitelabel_logo") + owner = user_data[0] + logo_file_id = owner.get("whitelabel_logo") if logo_file_id: project["whitelabel_logo_url"] = logo_file_id + project["legal_basis"] = owner.get("legal_basis") or "client-managed" + project["privacy_policy_url"] = owner.get("privacy_policy_url") except Exception as e: - logger.warning(f"Failed to resolve whitelabel logo for project {project_id}: {e}") + logger.warning(f"Failed to resolve owner settings for project {project_id}: {e}") return project diff --git a/echo/server/dembrane/api/user_settings.py b/echo/server/dembrane/api/user_settings.py index 95e93f31..e12a31e7 100644 --- a/echo/server/dembrane/api/user_settings.py +++ b/echo/server/dembrane/api/user_settings.py @@ -1,9 +1,12 @@ +from typing import Literal, Optional from logging import getLogger import requests from fastapi import APIRouter, UploadFile, HTTPException +from pydantic import BaseModel from dembrane.directus import directus +from dembrane.async_helpers import run_in_thread_pool from dembrane.api.dependency_auth import DependencyDirectusSession logger = getLogger("api.user_settings") @@ -11,6 +14,11 @@ UserSettingsRouter = APIRouter() +class LegalBasisUpdateSchema(BaseModel): + legal_basis: Literal["client-managed", "consent", "dembrane-events"] + privacy_policy_url: Optional[str] = None + + def _get_or_create_custom_logos_folder_id() -> str | None: """Look up the custom_logos folder ID using admin client, creating it if it doesn't exist.""" try: @@ -50,7 +58,9 @@ async def upload_whitelabel_logo( data = {"folder": folder_id} try: - response = requests.post(url, headers=headers, files=files, data=data, verify=directus.verify) + response = requests.post( + url, headers=headers, files=files, data=data, verify=directus.verify + ) if response.status_code != 200: logger.error(f"Failed to upload file: {response.status_code} {response.text}") raise HTTPException(status_code=500, detail="Failed to upload file") @@ -84,3 +94,47 @@ async def remove_whitelabel_logo( raise HTTPException(status_code=500, detail="Failed to remove logo") from e return {"status": "ok"} + + +@UserSettingsRouter.patch("/legal-basis") +async def update_legal_basis( + body: LegalBasisUpdateSchema, + auth: DependencyDirectusSession, +) -> dict: + """Update the user's legal basis setting.""" + if body.legal_basis == "dembrane-events": + try: + user_data = await run_in_thread_pool( + directus.get_users, + { + "query": { + "filter": {"id": {"_eq": auth.user_id}}, + "fields": ["email"], + } + }, + ) + email = user_data[0].get("email", "") if user_data else "" + if not email or not email.lower().endswith("@dembrane.com"): + raise HTTPException( + status_code=403, + detail="dembrane-events is only available for dembrane accounts", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to verify user email: {e}") + raise HTTPException(status_code=500, detail="Failed to verify user") from e + + update_data: dict = {"legal_basis": body.legal_basis} + if body.legal_basis == "consent": + update_data["privacy_policy_url"] = body.privacy_policy_url + else: + update_data["privacy_policy_url"] = None + + try: + await run_in_thread_pool(directus.update_user, auth.user_id, update_data) + except Exception as e: + logger.error(f"Failed to update legal basis: {e}") + raise HTTPException(status_code=500, detail="Failed to update legal basis") from e + + return {"status": "ok"}