diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 1578c10..0c64a94 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -13,6 +13,7 @@ import { AddDomainSheet } from '../../components/blocker/AddDomainSheet'; import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet'; import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet'; import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet'; +import { ProtectionOffSheet } from '../../components/ProtectionOffSheet'; import { useProtectionState } from '../../hooks/useProtectionState'; import { useCustomDomains } from '../../hooks/useCustomDomains'; import { useBlocklistSync } from '../../hooks/useBlocklistSync'; @@ -68,6 +69,7 @@ export default function BlockerScreen() { const [detailsOpen, setDetailsOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); + const [protectionOffOpen, setProtectionOffOpen] = useState(false); const urlFilterActive = state?.layers.urlFilter === true; const familyControlsActive = state?.layers.familyControls === true; @@ -204,15 +206,8 @@ export default function BlockerScreen() { } if (bypassAlertShownRef.current) return; bypassAlertShownRef.current = true; - Alert.alert( - t('blocker.protection_off_title'), - t('blocker.protection_off_message'), - [ - { text: t('common.ok'), style: 'cancel' }, - { text: t('blocker.reactivate_btn'), onPress: () => { void handleActivateUrlFilter(); } }, - ], - ); - }, [state?.phase, t]); + setProtectionOffOpen(true); + }, [state?.phase]); // ─── Render ────────────────────────────────────────────────────────── @@ -422,6 +417,12 @@ export default function BlockerScreen() { return res; }} /> + + setProtectionOffOpen(false)} + onReactivate={() => handleActivateUrlFilter()} + /> ) : null} diff --git a/apps/rebreak-native/assets/onboarding/de/screen_time_confirm.jpeg b/apps/rebreak-native/assets/onboarding/de/screen_time_confirm.jpeg new file mode 100644 index 0000000..cb33fcd Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/screen_time_confirm.jpeg differ diff --git a/apps/rebreak-native/assets/onboarding/de/screen_time_confirm_1.jpeg b/apps/rebreak-native/assets/onboarding/de/screen_time_confirm_1.jpeg new file mode 100644 index 0000000..7ca14ac Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/screen_time_confirm_1.jpeg differ diff --git a/apps/rebreak-native/assets/onboarding/de/screen_time_permission.jpeg b/apps/rebreak-native/assets/onboarding/de/screen_time_permission.jpeg new file mode 100644 index 0000000..532fe73 Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/screen_time_permission.jpeg differ diff --git a/apps/rebreak-native/assets/onboarding/de/url_filter_permission.jpeg b/apps/rebreak-native/assets/onboarding/de/url_filter_permission.jpeg new file mode 100644 index 0000000..179d61a Binary files /dev/null and b/apps/rebreak-native/assets/onboarding/de/url_filter_permission.jpeg differ diff --git a/apps/rebreak-native/components/ProtectionOffSheet.tsx b/apps/rebreak-native/components/ProtectionOffSheet.tsx new file mode 100644 index 0000000..acc258e --- /dev/null +++ b/apps/rebreak-native/components/ProtectionOffSheet.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../lib/theme'; +import { FormSheet } from './FormSheet'; + +/** + * Erinnerungs-Sheet wenn der Schutz auf dem Device aus ist (z.B. User hat + * in iOS-Settings das Filter-Profil manuell entfernt) und das Backend ihn + * trotzdem als aktiv erwartet (= bypassed/recovering). + * + * Ersetzt das native Alert.alert weil: + * - iOS Alert kann den Reactivate-Button nicht visuell höher gewichten + * (OK + Reactivate sehen gleich aus, oder OK ist sogar fetter wenn als + * cancel-style) + * - Wir wollen einen klaren blauen Primary "Schutz einschalten" + dezenten + * ghost-Link "Später" — kein "Two-Equal-Buttons"-Look + * + * Schließbar via swipe-down / Backdrop-Tap / "Später"-Link. + */ +export function ProtectionOffSheet({ + visible, + onClose, + onReactivate, +}: { + visible: boolean; + onClose: () => void; + /** Wird gerufen wenn User "Schutz wieder einschalten" tappt — soll + * handleActivateUrlFilter o.ä. callen. */ + onReactivate: () => Promise | void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const [busy, setBusy] = useState(false); + + async function handleReactivate() { + if (busy) return; + setBusy(true); + try { + await onReactivate(); + onClose(); + } finally { + setBusy(false); + } + } + + return ( + + + {/* Hero-Icon */} + + + + + + + {/* Body-Text */} + + {t('blocker.protection_off_message')} + + + {/* Primary CTA — blau, prominent */} + + + {busy ? t('common.loading') : t('blocker.reactivate_btn')} + + + + {/* Secondary CTA — Ghost-Link, klein */} + + + {t('blocker.protection_off_later')} + + + + + ); +} diff --git a/apps/rebreak-native/components/onboarding/ScreenshotPointer.tsx b/apps/rebreak-native/components/onboarding/ScreenshotPointer.tsx new file mode 100644 index 0000000..677754c --- /dev/null +++ b/apps/rebreak-native/components/onboarding/ScreenshotPointer.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react'; +import { Animated, Easing, View } from 'react-native'; + +/** + * Animierter Pulse-Marker für Screenshot-Overlays im Onboarding-Pre-Explainer. + * + * Positioniert via Prozent-Koordinaten relativ zum Container — so unabhängig + * von Screen-Größe + iPad-Skalierung. Pulsiert (Outer-Ring) + arrow pointer + * darunter zeigt klar wo der User tappen muss. + * + * + * + * + * + * + * `xPercent=50, yPercent=87` ist die typische Position für "Erlauben"/ + * "Fortfahren" am Bottom des iOS-Permission-Dialogs. + */ +export function ScreenshotPointer({ + xPercent, + yPercent, + color = '#dc2626', +}: { + /** Horizontale Position als 0..100 vom Container-Width */ + xPercent: number; + /** Vertikale Position als 0..100 vom Container-Height */ + yPercent: number; + /** Pulse-Color. Default rot — fällt auf, signalisiert "wichtig". */ + color?: string; +}) { + const pulse = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { + toValue: 1, + duration: 1100, + useNativeDriver: true, + easing: Easing.out(Easing.cubic), + }), + Animated.timing(pulse, { + toValue: 0, + duration: 1100, + useNativeDriver: true, + easing: Easing.in(Easing.cubic), + }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [pulse]); + + const scale = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] }); + const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.95, 0.2] }); + + return ( + + {/* Outer pulse-ring */} + + {/* Inner solid ring */} + + + ); +} diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx index 7a6e637..08da415 100644 --- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx +++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx @@ -1,32 +1,60 @@ import { useState } from 'react'; -import { Alert, Platform, Text, View } from 'react-native'; +import { Alert, Image, Platform, Text, View } from 'react-native'; import { useTranslation } from 'react-i18next'; -import { Ionicons } from '@expo/vector-icons'; import { useColors } from '../../../lib/theme'; import { apiFetch } from '../../../lib/api'; import { invalidateMe } from '../../../hooks/useMe'; import { protection } from '../../../lib/protection'; +import { getPermissionScreenshot, getPointerPosition } from '../../../lib/onboardingAssets'; import { OnboardingShell } from '../OnboardingShell'; import { LyraBubble } from '../LyraBubble'; import { CTABar } from '../CTABar'; +import { ScreenshotPointer } from '../ScreenshotPointer'; import { PermissionDeniedSheet } from '../../PermissionDeniedSheet'; +import i18n from '../../../lib/i18n'; + +/** + * Onboarding Step Protection — 2 Phasen, beide mit Pre-Explainer-Modal das + * den iOS-Permission-Dialog vorzeigt + Pulse-Marker auf den "Erlauben"-Button. + * + * ┌──────────────────────────────────────────────────────────────┐ + * │ Phase A: preexplain_url │ + * │ Lyra: "Gleich kommt iOS-Dialog. Tippe ERLAUBEN." │ + * │ Screenshot vom NEFilter-Dialog + roter Pulse auf "Erlauben" │ + * │ CTA "Aktivieren" → triggers protection.activateUrlFilter() │ + * │ │ + * │ Phase B: preexplain_lock │ + * │ Lyra: "Jetzt App-Schutz. Tippe FORTFAHREN." │ + * │ Screenshot vom Screen-Time-Dialog + roter Pulse │ + * │ CTA "Aktivieren" → triggers protection.activateFamilyControls │ + * │ │ + * │ Phase C: done → onDone() │ + * └──────────────────────────────────────────────────────────────┘ + * + * Wenn URL-Filter fehlschlägt mit code 5 → PermissionDeniedSheet öffnet sich + * (Retry-Pfad via resetUrlFilter()). Family-Controls hat keinen analogen + * Recovery-Sheet — User muss in Settings → Bildschirmzeit den App-Zugriff + * gewähren. + */ + +type Phase = 'preexplain_url' | 'preexplain_lock' | 'done'; export function ProtectionSlide({ onDone, current, total, }: { - /** Wird gerufen wenn URL-Filter erfolgreich aktiviert wurde. */ onDone: () => void; current: number; total: number; }) { const { t } = useTranslation(); const colors = useColors(); + const [phase, setPhase] = useState('preexplain_url'); const [activating, setActivating] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); - async function activate() { + async function activateUrlFilter() { if (activating) return; setActivating(true); try { @@ -46,150 +74,181 @@ export function ProtectionSlide({ ); return; } - // Schutz live → step='done' - await apiFetch('/api/profile/me/onboarding-step', { - method: 'PATCH', - body: { step: 'done' }, - }).catch(() => {}); - invalidateMe(); - onDone(); - } catch (e: unknown) { - Alert.alert( - t('common.error'), - e instanceof Error ? e.message : t('common.unknown_error'), - ); + // Filter live → weiter zur Phase B (App-Lock) + setPhase('preexplain_lock'); } finally { setActivating(false); } } + async function activateAppLock() { + if (activating) return; + setActivating(true); + try { + const res = await protection.activateFamilyControls(); + if (!res.enabled) { + // Family Controls fehlgeschlagen → User Info aber Tour-Done (URL-Filter + // läuft schon, das ist der Hauptschutz; App-Lock ist optional) + Alert.alert( + t('onboarding.protection.applock_failed_title'), + res.error ?? t('onboarding.protection.applock_failed_msg'), + [ + { + text: t('onboarding.protection.applock_skip'), + style: 'cancel', + onPress: () => finishProtectionStep(), + }, + { text: t('common.retry'), onPress: activateAppLock }, + ], + ); + return; + } + finishProtectionStep(); + } finally { + setActivating(false); + } + } + + async function finishProtectionStep() { + await apiFetch('/api/profile/me/onboarding-step', { + method: 'PATCH', + body: { step: 'done' }, + }).catch(() => {}); + invalidateMe(); + setPhase('done'); + onDone(); + } + + return phase === 'preexplain_url' ? ( + + setPermissionDeniedOpen(false)} + onRetry={async () => { + const res = await protection.resetUrlFilter(); + if (res.enabled) setPhase('preexplain_lock'); + return res; + }} + /> + + ) : phase === 'preexplain_lock' ? ( + + ) : null; +} + +// ─── PreExplainer (shared) ─────────────────────────────────────────────────── + +function PreExplainer({ + dialog, + lyraBodyKey, + titleKey, + ctaKey, + activating, + onActivate, + current, + total, + children, +}: { + dialog: 'url_filter' | 'screen_time'; + lyraBodyKey: string; + titleKey: string; + ctaKey: string; + activating: boolean; + onActivate: () => void; + current: number; + total: number; + children?: React.ReactNode; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const lang = i18n.language || 'de'; + const screenshot = getPermissionScreenshot(dialog, lang); + const pointer = getPointerPosition(dialog); + return ( } > - + + + {t(titleKey)} + + + {/* Screenshot mit Pulse-Pointer auf den korrekten Button */} - - - + - {t('onboarding.protection.permission_note')} + {t('onboarding.protection.tap_marker_hint')} - - setPermissionDeniedOpen(false)} - onRetry={async () => { - const res = await protection.resetUrlFilter(); - if (res.enabled) { - await apiFetch('/api/profile/me/onboarding-step', { - method: 'PATCH', - body: { step: 'done' }, - }).catch(() => {}); - invalidateMe(); - onDone(); - } - return res; - }} - /> + {children} ); } - -function ProtectionRow({ - icon, - title, - desc, - colors, -}: { - icon: keyof typeof Ionicons.glyphMap; - title: string; - desc: string; - colors: import('../../../lib/theme').ColorScheme; -}) { - return ( - - - - - - - {title} - - - {desc} - - - - ); -} diff --git a/apps/rebreak-native/lib/onboardingAssets.ts b/apps/rebreak-native/lib/onboardingAssets.ts new file mode 100644 index 0000000..44884ed --- /dev/null +++ b/apps/rebreak-native/lib/onboardingAssets.ts @@ -0,0 +1,60 @@ +/** + * iOS Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer. + * + * Pro Sprache liegen die Screenshots in `assets/onboarding//`. Falls + * eine Sprache nicht (oder noch nicht) verfügbar ist, fällt der Resolver auf + * `de` zurück. + * + * Diese Maps explizit mit `require(...)` deklarieren — RN/Metro kann keine + * dynamischen Pfade auflösen. + */ + +type Dialog = 'url_filter' | 'screen_time'; +type Lang = 'de' | 'en' | 'fr' | 'ar'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const URL_FILTER_DE = require('../assets/onboarding/de/url_filter_permission.jpeg'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const SCREEN_TIME_DE = require('../assets/onboarding/de/screen_time_permission.jpeg'); + +// TODO: en / fr / ar Screenshots ergänzen sobald User uploaded: +// require('../assets/onboarding/en/url_filter_permission.jpeg'), etc. +const SCREENSHOTS: Record>> = { + url_filter: { + de: URL_FILTER_DE, + }, + screen_time: { + de: SCREEN_TIME_DE, + }, +}; + +/** + * Resolves the right screenshot for the current language, with de-fallback. + * Returns the result of `require(...)` (an opaque module-handle for Metro). + */ +export function getPermissionScreenshot(dialog: Dialog, lang: string): number { + const normalized = ( + lang === 'de' || lang === 'en' || lang === 'fr' || lang === 'ar' ? lang : 'de' + ) as Lang; + const map = SCREENSHOTS[dialog]; + return map[normalized] ?? map.de!; +} + +/** + * Wo der Pointer-Marker auf dem Screenshot positioniert werden soll. + * Werte sind Prozent (0..100) relativ zum Container. + * + * Beide iOS-Dialoge haben den korrekten Button am Bottom (~85-88% Y, 50% X). + * Falls Apple das Layout pro Locale anders rendert — pro-Lang-Overrides + * hier ergänzen. + */ +export function getPointerPosition(dialog: Dialog): { xPercent: number; yPercent: number } { + switch (dialog) { + case 'url_filter': + // Button "Erlauben" am unteren Rand des Modals + return { xPercent: 50, yPercent: 86 }; + case 'screen_time': + // Button "Fortfahren" am unteren Rand + return { xPercent: 50, yPercent: 86 }; + } +} diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index d820fd1..d750270 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -105,14 +105,23 @@ export const protection = { }, async activateUrlFilter(): Promise<{ enabled: boolean; error?: string }> { + let res: { enabled: boolean; error?: string }; if (Platform.OS === "android") { // Android Layer-1 = VpnService (DNS-Filter). iOS-API erwartet hier // {enabled, error?}, also Native-`activate()`-Result re-shapen. - const res = await RebreakProtection.activate(); - const enabled = !res.missingLayers.includes("vpn"); - return enabled ? { enabled: true } : { enabled: false, error: res.errors?.[0] }; + const r = await RebreakProtection.activate(); + const enabled = !r.missingLayers.includes("vpn"); + res = enabled ? { enabled: true } : { enabled: false, error: r.errors?.[0] }; + } else { + res = await RebreakProtection.activateUrlFilter(); } - return RebreakProtection.activateUrlFilter(); + // Bei erfolgreicher Reaktivierung: Backend-Flag clearen (sonst bleibt + // protectionShouldBeActive=false und Bypass-Detection feuert nicht mehr). + // Best-effort — wenn das Backend nicht erreichbar ist, lokal nicht blocken. + if (res.enabled) { + apiFetch("/api/protection/mark-active", { method: "POST" }).catch(() => {}); + } + return res; }, /** diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 041554c..77206dd 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -271,7 +271,8 @@ }, "protection_off_title": "الحماية معطّلة", "protection_off_message": "الفلتر لا يعمل حالياً مع أنه يجب أن يكون نشطاً. هل تريد إعادة تشغيله؟", - "reactivate_btn": "إعادة التشغيل", + "reactivate_btn": "إعادة تشغيل الحماية", + "protection_off_later": "لاحقاً", "activate_app_lock_failed_title": "تعذّر تفعيل قفل التطبيق", "activate_app_lock_failed_msg": "تم رفض الإذن اللازم. يمكنك المحاولة مجدداً.", "sync_list_failed_title": "تعذّر تحميل قائمة الفلتر", @@ -366,6 +367,8 @@ "plan": { "body": "حماية جهازك تكلف بعض الشيء — لكن 14 يوماً مجاناً. أي خطة تناسبك؟" }, "payment": { "body": "خطوة قصيرة: أكّد تجربتك. يمكنك الإلغاء في أي وقت — Apple يتولى ذلك لك." }, "protection": { "body": "الآن الجزء الأهم — الحماية على جهازك. مستعد؟" }, + "protection_url": { "body": "ستظهر نافذة iOS. اضغط «السماح» — الزر السفلي (وليس الأزرق الكبير في الأعلى — هذا فخ)." }, + "protection_lock": { "body": "الآن قفل التطبيق. iOS يطلب الوصول إلى مدة استخدام الجهاز — اضغط «متابعة»، مرة أخرى الزر السفلي." }, "done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." } }, "welcome": { @@ -438,6 +441,12 @@ }, "protection": { "cta_primary": "فعّل الحماية", + "url_title": "الخطوة 1 من 2 — فلتر المحتوى", + "lock_title": "الخطوة 2 من 2 — قفل التطبيق", + "tap_marker_hint": "العلامة الحمراء تشير إلى الزر الصحيح. Apple يضع الأزرق الكبير في الأعلى («عدم السماح») — لا تقع في الفخ.", + "applock_failed_title": "فشل قفل التطبيق", + "applock_failed_msg": "يمكنك المحاولة مرة أخرى أو تخطي هذه الخطوة — فلتر URL يعمل بالفعل.", + "applock_skip": "تخطّي", "error_title": "تعذّر تفعيل الحماية", "error_unknown": "خطأ غير معروف. حاول مرة أخرى.", "feat_blocklist_title": "فلتر شامل", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 69fa91e..bafdcd5 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -271,7 +271,8 @@ }, "protection_off_title": "Schutz ist aus", "protection_off_message": "Der Filter läuft gerade nicht, sollte aber an sein. Willst du ihn wieder einschalten?", - "reactivate_btn": "Wieder einschalten", + "reactivate_btn": "Schutz wieder einschalten", + "protection_off_later": "Später", "activate_app_lock_failed_title": "App-Lock konnte nicht aktiviert werden", "activate_app_lock_failed_msg": "Die nötige Berechtigung wurde verweigert. Du kannst es nochmal versuchen.", "sync_list_failed_title": "Filter-Liste konnte nicht geladen werden", @@ -366,6 +367,8 @@ "plan": { "body": "Schutz auf deinem Gerät kostet etwas — aber 14 Tage gratis. Welcher Plan passt zu dir?" }, "payment": { "body": "Kurzer Schritt: bestätige deinen Trial. Du kannst jederzeit kündigen — Apple regelt das für dich." }, "protection": { "body": "Jetzt der wichtigste Teil — der Schutz auf deinem Gerät. Bereit?" }, + "protection_url": { "body": "Gleich kommt ein iOS-Dialog. Tippe \"Erlauben\" — den unteren Button (nicht den großen blauen oben — der ist die Falle)." }, + "protection_lock": { "body": "Jetzt der App-Schutz. iOS fragt nach Bildschirmzeit-Zugriff — tippe \"Fortfahren\", wieder den unteren Button." }, "done": { "body": "Geschafft. Tag 1 deiner neuen Streak — und du gehst nicht allein." } }, "welcome": { @@ -438,6 +441,12 @@ }, "protection": { "cta_primary": "Schutz aktivieren", + "url_title": "Schritt 1 von 2 — Inhaltsfilter", + "lock_title": "Schritt 2 von 2 — App-Schutz", + "tap_marker_hint": "Der rote Marker zeigt den richtigen Button. Apple platziert den großen blauen oben (\"Nicht erlauben\") — bitte nicht reinfallen.", + "applock_failed_title": "App-Schutz fehlgeschlagen", + "applock_failed_msg": "Du kannst es nochmal versuchen oder den Schritt überspringen — der URL-Filter läuft schon.", + "applock_skip": "Überspringen", "error_title": "Schutz konnte nicht aktiviert werden", "error_unknown": "Unbekannter Fehler. Bitte nochmal versuchen.", "feat_blocklist_title": "Globaler Filter", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 844acf4..71ff9b3 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -271,7 +271,8 @@ "activate_settings_btn": "Settings", "protection_off_title": "Protection is off", "protection_off_message": "The filter isn't running but should be. Want to turn it back on?", - "reactivate_btn": "Turn back on", + "reactivate_btn": "Turn protection back on", + "protection_off_later": "Later", "activate_app_lock_failed_title": "Could not activate App Lock", "activate_app_lock_failed_msg": "The required permission was denied. You can try again.", "sync_list_failed_title": "Filter list could not be loaded", @@ -366,6 +367,8 @@ "plan": { "body": "Protecting your device costs a bit to run — but 14 days free. Which plan fits you?" }, "payment": { "body": "Quick step: confirm your trial. You can cancel anytime — Apple handles that for you." }, "protection": { "body": "Now the important part — the protection on your device. Ready?" }, + "protection_url": { "body": "An iOS dialog will appear. Tap \"Allow\" — the bottom button (not the big blue one on top — that's the trap)." }, + "protection_lock": { "body": "Now the app lock. iOS asks for Screen Time access — tap \"Continue\", again the bottom button." }, "done": { "body": "Done. Day 1 of your new streak — and you're not walking alone." } }, "welcome": { @@ -438,6 +441,12 @@ }, "protection": { "cta_primary": "Activate protection", + "url_title": "Step 1 of 2 — Content filter", + "lock_title": "Step 2 of 2 — App lock", + "tap_marker_hint": "The red marker shows the correct button. Apple puts the big blue one on top (\"Don't Allow\") — please don't fall for it.", + "applock_failed_title": "App lock failed", + "applock_failed_msg": "You can try again or skip this step — the URL filter is already running.", + "applock_skip": "Skip", "error_title": "Protection couldn't be activated", "error_unknown": "Unknown error. Please try again.", "feat_blocklist_title": "Global filter", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 89ce79c..6ab91ec 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -271,7 +271,8 @@ }, "protection_off_title": "La protection est désactivée", "protection_off_message": "Le filtre ne fonctionne pas alors qu'il devrait être actif. Voulez-vous le réactiver ?", - "reactivate_btn": "Réactiver", + "reactivate_btn": "Réactiver la protection", + "protection_off_later": "Plus tard", "activate_app_lock_failed_title": "Impossible d'activer le verrouillage", "activate_app_lock_failed_msg": "L'autorisation requise a été refusée. Vous pouvez réessayer.", "sync_list_failed_title": "Impossible de charger la liste de filtrage", @@ -364,6 +365,8 @@ "plan": { "body": "Protéger ton appareil coûte un peu à faire tourner — mais 14 jours gratuits. Quel plan te convient ?" }, "payment": { "body": "Étape rapide : confirme ton essai. Tu peux annuler à tout moment — Apple s'en occupe pour toi." }, "protection": { "body": "Maintenant la partie importante — la protection sur ton appareil. Prêt·e ?" }, + "protection_url": { "body": "Une fenêtre iOS va apparaître. Touche « Autoriser » — le bouton du bas (pas le grand bleu en haut — c'est le piège)." }, + "protection_lock": { "body": "Maintenant le verrou d'app. iOS demande l'accès à Temps d'écran — touche « Continuer », encore le bouton du bas." }, "done": { "body": "Voilà. Jour 1 de ta nouvelle série — et tu n'es pas seul·e." } }, "welcome": { @@ -436,6 +439,12 @@ }, "protection": { "cta_primary": "Activer la protection", + "url_title": "Étape 1 sur 2 — Filtre de contenu", + "lock_title": "Étape 2 sur 2 — Verrou d'app", + "tap_marker_hint": "Le marqueur rouge indique le bon bouton. Apple place le grand bleu en haut (« Refuser ») — n'y tombe pas.", + "applock_failed_title": "Échec du verrou d'app", + "applock_failed_msg": "Tu peux réessayer ou ignorer cette étape — le filtre URL est déjà actif.", + "applock_skip": "Ignorer", "error_title": "Impossible d'activer la protection", "error_unknown": "Erreur inconnue. Réessaie.", "feat_blocklist_title": "Filtre global", diff --git a/backend/prisma/migrations/20260517_protection_disabled_at/migration.sql b/backend/prisma/migrations/20260517_protection_disabled_at/migration.sql new file mode 100644 index 0000000..85aa6d8 --- /dev/null +++ b/backend/prisma/migrations/20260517_protection_disabled_at/migration.sql @@ -0,0 +1,20 @@ +-- Profile.protection_disabled_at — Anti-Auto-Reactivation Guard nach Cooldown. +-- +-- Wenn `!= NULL`, gibt /api/protection/state `protectionShouldBeActive: false` +-- zurück. Damit greift die Auto-Reactivation-Logik im Frontend (enforceProtection +-- + onBypassNotificationTap) nicht — der User muss explizit re-aktivieren +-- (Sheet/CTA "Schutz wieder einschalten" → POST /api/protection/mark-active). +-- +-- Wird gesetzt: +-- - in /api/cooldown/status GET wenn ein expired-Cooldown auto-resolved wird +-- (User hat den 24h-Cooldown durchgehalten + jetzt explizit abgeschaltet) +-- +-- Wird zurückgesetzt: +-- - in /api/protection/mark-active POST (User hat in der App auf "Reaktivieren" +-- getippt + Native-Activation war erfolgreich) +-- +-- Backfill: alle existierenden Profile bekommen NULL (= "in normalem aktiv-State") +-- — also keine Verhaltensänderung für laufende Sessions. + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "protection_disabled_at" TIMESTAMPTZ NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d8cbe75..80694a1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -58,6 +58,15 @@ model Profile { // wiederherzustellen — auch nach App-Reinstall (DB-State statt AsyncStorage). onboardingStep String @default("welcome") @map("onboarding_step") + // ─── Protection-Disable-State (post-cooldown anti-auto-reactivation) ─── + // Wird gesetzt wenn der User per Cooldown-Resolve den Schutz explizit + // abschaltet. Solange `!= null`, gibt protection/state.protectionShouldBeActive + // false zurück → Frontend macht KEINE Auto-Reactivation. User muss explizit + // im UI re-aktivieren (POST /api/protection/mark-active setzt das Feld zurück). + // Anti-Pattern: ohne dieses Feld würde der Schutz nach Cooldown sofort wieder + // anspringen, was den Sinn des Cooldowns aushebelt. + protectionDisabledAt DateTime? @map("protection_disabled_at") + // ─── DiGA Rezept-Code-Audit ───────────────────────────────────────────── // Wenn ein User per Krankenkassen-Rezept reinkommt (DiGA-Pfad), wird der // Einlöse-Zeitpunkt hier persistiert. Reverse-Lookup auf den Code selbst diff --git a/backend/server/api/cooldown/status.get.ts b/backend/server/api/cooldown/status.get.ts index 0e29cbd..eedbe06 100644 --- a/backend/server/api/cooldown/status.get.ts +++ b/backend/server/api/cooldown/status.get.ts @@ -1,6 +1,7 @@ import { requireUser } from "../../utils/auth"; import { getActiveCooldown, resolveCooldown } from "../../db/cooldown"; import { signCooldownToken } from "../../utils/cooldownToken"; +import { usePrisma } from "../../utils/prisma"; /** GET /api/cooldown/status — Current cooldown state for the authenticated user. */ export default defineEventHandler(async (event) => { @@ -29,6 +30,17 @@ export default defineEventHandler(async (event) => { // Auto-resolve so we don't re-check next time. await resolveCooldown(cooldown.id); + // Anti-Auto-Reactivation: User hat den 24h-Cooldown durchgehalten + jetzt + // wird der Schutz abgeschaltet. Markiere Profile damit /api/protection/state + // protectionShouldBeActive=false zurückgibt → Frontend macht keine Auto- + // Reactivation (Sucht-Recovery-Pattern: einfach an, schwer aus, sehr schwer + // wieder zurück an Auto-Magic). User muss explizit reaktivieren. + const db = usePrisma(); + await db.profile.update({ + where: { id: user.id }, + data: { protectionDisabledAt: new Date() }, + }).catch(() => {}); + const token = await signCooldownToken( user.id, cooldown.tokenJti, diff --git a/backend/server/api/protection/mark-active.post.ts b/backend/server/api/protection/mark-active.post.ts new file mode 100644 index 0000000..a7bd10f --- /dev/null +++ b/backend/server/api/protection/mark-active.post.ts @@ -0,0 +1,28 @@ +import { requireUser } from "../../utils/auth"; +import { usePrisma } from "../../utils/prisma"; + +/** + * POST /api/protection/mark-active + * + * Räumt den `protectionDisabledAt`-Anti-Auto-Reactivation-Guard ab. + * Vom Frontend zu callen NACHDEM eine User-initiierte Reaktivierung des + * Schutzes erfolgreich war (native NEFilter/VPN ist wieder live). + * + * Side-effects: + * - profile.protection_disabled_at = NULL + * - Damit wird /api/protection/state.protectionShouldBeActive wieder true + * → Frontend-enforceProtection kann wieder Bypass-Detection machen + * + * Idempotent. Wenn schon NULL → kein-op. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const db = usePrisma(); + + await db.profile.update({ + where: { id: user.id }, + data: { protectionDisabledAt: null }, + }); + + return { success: true }; +}); diff --git a/backend/server/api/protection/state.get.ts b/backend/server/api/protection/state.get.ts index df947bb..d339de4 100644 --- a/backend/server/api/protection/state.get.ts +++ b/backend/server/api/protection/state.get.ts @@ -36,10 +36,18 @@ export default defineEventHandler(async (event) => { const plan = (profile?.plan ?? "free") as "free" | "pro" | "legend"; + // protectionShouldBeActive = "der Schutz sollte gerade auf dem Device laufen" + // - false wenn Cooldown aktiv ist (User darf grad nicht zurück-reaktivieren) + // - false wenn User per Cooldown-Resolve explizit abgeschaltet hat + // (protectionDisabledAt gesetzt) → Frontend macht KEINE Auto-Reactivation, + // User muss explizit re-aktivieren via /api/protection/mark-active. + // - true sonst (Normal-Zustand: Schutz sollte laufen) + const protectionShouldBeActive = !active && profile?.protectionDisabledAt === null; + return { success: true, data: { - protectionShouldBeActive: !active, + protectionShouldBeActive, cooldown: { active, remainingSeconds,