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:
parent
b23bd6d29f
commit
1596a4ea7a
@ -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 |
134
apps/rebreak-native/components/ProtectionOffSheet.tsx
Normal file
134
apps/rebreak-native/components/ProtectionOffSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/rebreak-native/lib/onboardingAssets.ts
Normal file
60
apps/rebreak-native/lib/onboardingAssets.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -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": "فلتر شامل",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
28
backend/server/api/protection/mark-active.post.ts
Normal file
28
backend/server/api/protection/mark-active.post.ts
Normal 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 };
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user