feat(protection,onboarding): anti-auto-reactivation + protection pre-explainer + custom sheets

## Backend: Anti-Auto-Reactivation nach Cooldown

Bug: nach Cooldown-Ablauf wurde der URL-Filter automatisch wieder
reaktiviert (enforceProtection-Loop fängt 'recoveringFromBypass'-Phase ab).
Damit war der Cooldown-Schritt entwertet — User konnte nicht wirklich
abschalten, weil die App den Schutz sofort wieder hochfuhr.

Fix: Profile.protectionDisabledAt (DateTime nullable). Wird in
/api/cooldown/status auf cooldown-auto-resolve gesetzt. /api/protection/state
gibt dann protectionShouldBeActive=false zurück → Frontend macht KEINE
Auto-Reactivation. User muss explizit re-aktivieren (CTA in der App).

- Migration 20260517_protection_disabled_at
- Schema: Profile.protectionDisabledAt
- /api/cooldown/status: setzt das Feld auf expired+resolve
- /api/protection/state: includes profile.protectionDisabledAt in shouldBeActive-Berechnung
- /api/protection/mark-active (POST, NEU): clears das Feld, vom Frontend
  auto-aufgerufen nach erfolgreichem activateUrlFilter

Bypass-Recovery durch externe iOS-Settings-Disable (nicht cooldown-bezogen)
funktioniert weiter — protectionDisabledAt ist dann null, alte Logik greift.

## Frontend: ProtectionOffSheet (Custom-Sheet statt Alert.alert)

Bisheriges native Alert mit OK+Reactivate-Buttons hat keine visuelle
Hierarchy (iOS macht beide gleich). Ersetzt mit FormSheet:
 - Großer blauer Primary "Schutz wieder einschalten"
 - Ghost-Link "Später"
 - Swipe-down / Backdrop-Tap zum Schließen

## Frontend: ProtectionSlide mit Pre-Explainer (Screenshot + Pulse-Marker)

User-Request: vor dem iOS-Permission-Dialog ein Erklärungs-Screen zeigen
damit der User weiß wo er tappen muss (Apple's "Don't Allow" ist groß+
blau = Trap, "Allow" ist der unscheinbare Button unten).

- components/onboarding/ScreenshotPointer.tsx — Reanimated pulsing red
  ring, positionierbar via {xPercent, yPercent}
- lib/onboardingAssets.ts — locale-aware require()-Map für Screenshot-
  Assets mit de-Fallback
- assets/onboarding/de/ — 4 iOS-Screenshots vom User (url_filter +
  screen_time permission dialogs + 2 confirm screens)
- ProtectionSlide refactored: internal phase state preexplain_url →
  preexplain_lock → done. Jede Phase zeigt Screenshot + Pulse-Marker auf
  korrekten Button + Lyra-Bubble + activate-CTA.

## Locale-Keys

- onboarding.lyra.protection_url.body, onboarding.lyra.protection_lock.body
- onboarding.protection.url_title, .lock_title, .tap_marker_hint
- onboarding.protection.applock_failed_*, applock_skip
- blocker.protection_off_later, reactivate_btn (refined)

## Bugfix: de.json JSON-syntax

Smart-quote-typo: schließendes "" nach „Erlauben" und „Fortfahren" war
ein plain ASCII " (U+0022) statt U+201D, was den JSON-String früh
terminiert hat. Metro+Hermes warfen "unrecognized Unicode —".
Fix: escapte \" verwendet — JSON-safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-17 19:05:37 +02:00
parent b23bd6d29f
commit 1596a4ea7a
19 changed files with 604 additions and 131 deletions

View File

@ -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;
}}
/>
<ProtectionOffSheet
visible={protectionOffOpen}
onClose={() => setProtectionOffOpen(false)}
onReactivate={() => handleActivateUrlFilter()}
/>
</>
) : null}
</View>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -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> | 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 (
<FormSheet
visible={visible}
onClose={onClose}
title={t('blocker.protection_off_title')}
initialHeightPct={0.5}
minHeightPct={0.3}
>
<View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16 }}>
{/* Hero-Icon */}
<View style={{ alignItems: 'center', marginTop: 8, marginBottom: 16 }}>
<View
style={{
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: 'rgba(245,158,11,0.12)',
borderWidth: 1,
borderColor: 'rgba(245,158,11,0.30)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-outline" size={32} color={colors.warning} />
</View>
</View>
{/* Body-Text */}
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 14,
lineHeight: 21,
color: colors.text,
textAlign: 'center',
marginBottom: 22,
}}
>
{t('blocker.protection_off_message')}
</Text>
{/* Primary CTA — blau, prominent */}
<TouchableOpacity
onPress={handleReactivate}
disabled={busy}
activeOpacity={0.85}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
marginBottom: 10,
opacity: busy ? 0.6 : 1,
}}
>
<Text
style={{
fontFamily: 'Nunito_700Bold',
fontSize: 16,
color: '#ffffff',
letterSpacing: 0.2,
}}
>
{busy ? t('common.loading') : t('blocker.reactivate_btn')}
</Text>
</TouchableOpacity>
{/* Secondary CTA — Ghost-Link, klein */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.6}
style={{ paddingVertical: 12, alignItems: 'center' }}
>
<Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 14,
color: colors.textMuted,
}}
>
{t('blocker.protection_off_later')}
</Text>
</TouchableOpacity>
</View>
</FormSheet>
);
}

View File

@ -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.
*
* <View style={{ aspectRatio: 9/16, ... }}>
* <Image source={...} />
* <ScreenshotPointer xPercent={50} yPercent={87} />
* </View>
*
* `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 (
<View
pointerEvents="none"
style={{
position: 'absolute',
top: `${yPercent}%`,
left: `${xPercent}%`,
// Marker center'n auf der Position
transform: [{ translateX: -28 }, { translateY: -28 }],
width: 56,
height: 56,
}}
>
{/* Outer pulse-ring */}
<Animated.View
style={{
position: 'absolute',
width: 56,
height: 56,
borderRadius: 28,
borderWidth: 3,
borderColor: color,
opacity,
transform: [{ scale }],
}}
/>
{/* Inner solid ring */}
<View
style={{
position: 'absolute',
width: 56,
height: 56,
borderRadius: 28,
borderWidth: 3,
borderColor: color,
backgroundColor: 'transparent',
}}
/>
</View>
);
}

View File

@ -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<Phase>('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' ? (
<PreExplainer
key="url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateUrlFilter}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) setPhase('preexplain_lock');
return res;
}}
/>
</PreExplainer>
) : phase === 'preexplain_lock' ? (
<PreExplainer
key="lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
/>
) : 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 (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_primary')}
onPrimary={activate}
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 22,
fontFamily: 'Nunito_700Bold',
fontSize: 14,
letterSpacing: 0.5,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
{/* Screenshot mit Pulse-Pointer auf den korrekten Button */}
<View
style={{
marginTop: 24,
padding: 16,
borderRadius: 14,
marginTop: 12,
alignSelf: 'center',
width: '92%',
aspectRatio: 0.9, // Screenshots sind etwa quadratisch / leicht hochkant
borderRadius: 18,
overflow: 'hidden',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
gap: 12,
}}
>
<ProtectionRow
icon="globe-outline"
title={t('onboarding.protection.feat_blocklist_title')}
desc={t('onboarding.protection.feat_blocklist_desc')}
colors={colors}
/>
<ProtectionRow
icon={Platform.OS === 'ios' ? 'shield-checkmark-outline' : 'lock-closed-outline'}
title={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_title'
: 'onboarding.protection.feat_android_title',
)}
desc={t(
Platform.OS === 'ios'
? 'onboarding.protection.feat_ios_desc'
: 'onboarding.protection.feat_android_desc',
)}
colors={colors}
/>
<ProtectionRow
icon="time-outline"
title={t('onboarding.protection.feat_cooldown_title')}
desc={t('onboarding.protection.feat_cooldown_desc')}
colors={colors}
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
<ScreenshotPointer xPercent={pointer.xPercent} yPercent={pointer.yPercent} />
</View>
<Text
style={{
marginTop: 16,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 18,
fontSize: 13,
lineHeight: 19,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t('onboarding.protection.permission_note')}
{t('onboarding.protection.tap_marker_hint')}
</Text>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => 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}
</OnboardingShell>
);
}
function ProtectionRow({
icon,
title,
desc,
colors,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
desc: string;
colors: import('../../../lib/theme').ColorScheme;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: 'rgba(0,122,255,0.12)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color={colors.brandOrange} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 14, color: colors.text }}>
{title}
</Text>
<Text
style={{
marginTop: 2,
fontFamily: 'Nunito_400Regular',
fontSize: 13,
lineHeight: 18,
color: colors.textMuted,
}}
>
{desc}
</Text>
</View>
</View>
);
}

View File

@ -0,0 +1,60 @@
/**
* iOS Permission-Dialog-Screenshots für den Onboarding-Pre-Explainer.
*
* Pro Sprache liegen die Screenshots in `assets/onboarding/<lang>/`. 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<Dialog, Partial<Record<Lang, number>>> = {
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 };
}
}

View File

@ -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;
},
/**

View File

@ -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": "فلتر شامل",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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

View File

@ -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,

View File

@ -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 };
});

View File

@ -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,