feat(ios): Screen Time Passcode als Layer 3 (setup flow)

User generiert 4-stelligen Code in der App, setzt ihn manuell als
Screen Time Passcode → ReBreak speichert ihn auf dem Backend.
Damit kann niemand Screen Time deaktivieren → deny-removal bleibt
aktiv → App nicht deinstallierbar ohne den Passcode.

Backend:
- Profile.screentimePasscode Feld (Migration add_screentime_passcode)
- POST /api/protection/screentime-passcode — Code speichern
- GET /api/protection/screentime-passcode — Code abrufen (nach Cooldown)

iOS UI (blocker.tsx):
- ScreentimePasscodeCard erscheint wenn Layer 1 + 2 aktiv (iOS only)
- Code-Generierung → Einmal-Anzeige → Deep-Link zu Settings → Screen Time
- Bestätigung speichert Code auf Backend, Card zeigt Confirmed-State

Locales: DE/EN/FR/AR screentime_* Keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 04:19:43 +02:00
parent 59766f8530
commit ab4b9c48e5
10 changed files with 272 additions and 1 deletions

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, Platform, ScrollView, View, Alert, ActivityIndicator } from 'react-native';
import { AppState, Linking, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
@ -85,6 +85,11 @@ export default function BlockerScreen() {
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
const [protectionOffOpen, setProtectionOffOpen] = useState(false);
// Screen Time Passcode (iOS Layer 3)
const [screentimeCode, setScreentimeCode] = useState<string | null>(null);
const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
const [screentimeSaving, setScreentimeSaving] = useState(false);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
@ -219,6 +224,28 @@ export default function BlockerScreen() {
return { enabled: false };
}
// ─── Screen Time Passcode (iOS Layer 3) ─────────────────────────────
function handleGenerateScreentimeCode() {
const code = Math.floor(1000 + Math.random() * 9000).toString();
setScreentimeCode(code);
setScreentimeConfirmed(false);
}
async function handleScreentimeConfirm() {
if (!screentimeCode) return;
setScreentimeSaving(true);
try {
await protection.saveScreenTimePasscode(screentimeCode);
setScreentimeConfirmed(true);
setScreentimeCode(null);
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('common.unknown_error'));
} finally {
setScreentimeSaving(false);
}
}
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
function openDetails() {
@ -339,6 +366,20 @@ export default function BlockerScreen() {
lockedHint={t('blocker.layers_app_lock_locked_hint')}
/>
) : null}
{/* iOS Layer 3 — Screen Time Passcode */}
{Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && appDeletionLockActive && (
<ScreentimePasscodeCard
code={screentimeCode}
confirmed={screentimeConfirmed}
saving={screentimeSaving}
onGenerate={handleGenerateScreentimeCode}
onOpenSettings={() => Linking.openURL('App-Prefs:SCREEN_TIME').catch(() => Linking.openSettings())}
onConfirm={handleScreentimeConfirm}
colors={colors}
t={t}
/>
)}
</View>
)}
@ -350,6 +391,7 @@ export default function BlockerScreen() {
/>
)}
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
<MyFiltersList
domains={domains}
@ -467,3 +509,101 @@ export default function BlockerScreen() {
);
}
// ─── Screen Time Passcode Card (iOS Layer 3) ──────────────────────────────────
function ScreentimePasscodeCard({
code,
confirmed,
saving,
onGenerate,
onOpenSettings,
onConfirm,
colors,
t,
}: {
code: string | null;
confirmed: boolean;
saving: boolean;
onGenerate: () => void;
onOpenSettings: () => void;
onConfirm: () => void;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
if (confirmed) {
return (
<View style={{ backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: '#16a34a40', padding: 14, gap: 6 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 16 }}>🔐</Text>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text, flex: 1 }}>
{t('blocker.screentime_confirmed_title')}
</Text>
</View>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.screentime_confirmed_desc')}
</Text>
</View>
);
}
return (
<View style={{ backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: colors.border, padding: 14, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 16 }}>🔒</Text>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text, flex: 1 }}>
{t('blocker.screentime_title')}
</Text>
</View>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.screentime_desc')}
</Text>
{!code ? (
<TouchableOpacity
onPress={onGenerate}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.screentime_generate_cta')}
</Text>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.screentime_code_label')}
</Text>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_700Bold', color: colors.brandOrange, letterSpacing: 12 }}>
{code}
</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, textAlign: 'center' }}>
{t('blocker.screentime_code_hint')}
</Text>
</View>
<TouchableOpacity
onPress={onOpenSettings}
activeOpacity={0.8}
style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{t('blocker.screentime_open_settings_cta')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onConfirm}
disabled={saving}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{saving
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.screentime_confirm_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</View>
);
}

View File

@ -406,6 +406,26 @@ export const protection = {
return { cooldownEndsAt: res.cooldownEndsAt };
},
// ─── Screen Time Passcode (iOS Layer 3) ────────────────────────────────
/** Speichert den generierten 4-stelligen Screen-Time-Passcode auf dem Backend.
* Wird im Onboarding aufgerufen nachdem User bestätigt hat den Code gesetzt zu haben. */
async saveScreenTimePasscode(passcode: string): Promise<void> {
await apiFetch("/api/protection/screentime-passcode", {
method: "POST",
body: { passcode },
});
},
/** Ruft den Screen-Time-Passcode ab nur wenn kein aktiver Cooldown läuft.
* Gibt null zurück wenn Code fehlt oder Cooldown noch aktiv ist. */
async getScreenTimePasscode(): Promise<string | null> {
const res = await apiFetch<{ passcode: string | null }>(
"/api/protection/screentime-passcode",
);
return res.passcode;
},
/** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */
async cancelDeactivation(): Promise<{ cancelled: boolean }> {
const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", {

View File

@ -311,6 +311,15 @@
"layers_app_lock_subtitle_inactive": "يمنع إيقاف rebreak أو الفلتر في لحظة الاندفاع",
"layers_app_lock_warning": "بمجرد التفعيل لا يمكنك إيقاف الحماية إلا عبر تهدئة 24 ساعة. هذا مقصود.",
"layers_app_lock_locked_hint": "مقفل بواسطة النظام. الإيقاف فقط عبر إعدادات iOS → مدة استخدام الجهاز → الإدارة بواسطة ReBreak.",
"screentime_title": "قفل وقت الشاشة (Layer 3)",
"screentime_desc": "عيّن رمزًا لا يعرفه إلا ReBreak — حتى لا يتمكن أحد من تعطيل وقت الشاشة لتجاوز الحماية.",
"screentime_generate_cta": "توليد رمز",
"screentime_code_label": "رمزك",
"screentime_code_hint": "اذهب إلى الإعدادات → وقت الشاشة → استخدام رمز وقت الشاشة وأدخل هذا الرمز.",
"screentime_open_settings_cta": "فتح الإعدادات → وقت الشاشة",
"screentime_confirm_cta": "لقد عيّنت الرمز",
"screentime_confirmed_title": "وقت الشاشة مقفل ✓",
"screentime_confirmed_desc": "حمايتك الآن ثلاثية الطبقات. لا يمكن حذف ReBreak بعد الآن دون المرور بفترة التهدئة.",
"layers_a11y_subtitle_active": "إمكانية الوصول نشطة — حماية التطبيق مفعّلة",
"layers_a11y_subtitle_inactive": "إمكانية الوصول غير مفعّلة — قم بالإعداد الآن",
"kpi_global_label": "النطاقات المحجوبة عالمياً",

View File

@ -328,6 +328,15 @@
"layers_app_lock_subtitle_inactive": "Verhindert, dass du ReBreak oder den Filter im Impuls abschaltest",
"layers_app_lock_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
"layers_app_lock_locked_hint": "System-gesperrt. Deaktivierung nur in iOS-Einstellungen → Bildschirmzeit → Verwaltung durch ReBreak.",
"screentime_title": "Bildschirmzeit sperren (Layer 3)",
"screentime_desc": "Setze einen Code den nur ReBreak kennt — damit kann niemand Bildschirmzeit ausschalten um Deinstallation zu ermöglichen.",
"screentime_generate_cta": "Code generieren",
"screentime_code_label": "Dein Code",
"screentime_code_hint": "Geh zu Einstellungen → Bildschirmzeit → Code festlegen und gib diesen Code ein.",
"screentime_open_settings_cta": "Einstellungen → Bildschirmzeit öffnen",
"screentime_confirm_cta": "Ich habe den Code gesetzt",
"screentime_confirmed_title": "Bildschirmzeit gesperrt ✓",
"screentime_confirmed_desc": "Dein Schutz ist jetzt dreifach gesichert. ReBreak kann nicht mehr ohne Weiteres deinstalliert werden.",
"layers_a11y_subtitle_active": "Eingabehilfe aktiv — App-Schutz armiert",
"layers_a11y_subtitle_inactive": "Eingabehilfe nicht aktiviert — jetzt einrichten",
"kpi_global_label": "Geblockte Domains weltweit",

View File

@ -328,6 +328,15 @@
"layers_app_lock_subtitle_inactive": "Stops you from switching off ReBreak or the filter on impulse",
"layers_app_lock_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
"layers_app_lock_locked_hint": "System-locked. Only disable via iOS Settings → Screen Time → Management by ReBreak.",
"screentime_title": "Lock Screen Time (Layer 3)",
"screentime_desc": "Set a code only ReBreak knows — so nobody can disable Screen Time to bypass the uninstall protection.",
"screentime_generate_cta": "Generate code",
"screentime_code_label": "Your code",
"screentime_code_hint": "Go to Settings → Screen Time → Use Screen Time Passcode and enter this code.",
"screentime_open_settings_cta": "Open Settings → Screen Time",
"screentime_confirm_cta": "I've set the code",
"screentime_confirmed_title": "Screen Time locked ✓",
"screentime_confirmed_desc": "Your protection is now triple-layered. ReBreak can no longer be uninstalled without going through the cooldown.",
"layers_a11y_subtitle_active": "Accessibility active — app protection armed",
"layers_a11y_subtitle_inactive": "Accessibility not enabled — set it up now",
"kpi_global_label": "Domains blocked worldwide",

View File

@ -311,6 +311,15 @@
"layers_app_lock_subtitle_inactive": "Vous empêche de désactiver ReBreak ou le filtre sous l'impulsion",
"layers_app_lock_warning": "Une fois actif, vous ne pouvez désactiver la protection que via une pause de sécurité de 24 heures. C'est voulu.",
"layers_app_lock_locked_hint": "Verrouillé par le système. Désactivation uniquement via Réglages iOS → Temps d'écran → Gestion par ReBreak.",
"screentime_title": "Verrouiller le temps d'écran (Layer 3)",
"screentime_desc": "Définis un code que seul ReBreak connaît — personne ne pourra désactiver le temps d'écran pour contourner la protection.",
"screentime_generate_cta": "Générer un code",
"screentime_code_label": "Ton code",
"screentime_code_hint": "Va dans Réglages → Temps d'écran → Code temps d'écran et entre ce code.",
"screentime_open_settings_cta": "Ouvrir Réglages → Temps d'écran",
"screentime_confirm_cta": "J'ai défini le code",
"screentime_confirmed_title": "Temps d'écran verrouillé ✓",
"screentime_confirmed_desc": "Ta protection est maintenant triple. ReBreak ne peut plus être désinstallé sans passer par la période de refroidissement.",
"kpi_global_label": "Domaines bloqués dans le monde",
"kpi_global_subtitle": "Entrées actives dans la liste de blocage globale",
"delta_week": "cette semaine",

View File

@ -0,0 +1 @@
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS screentime_passcode TEXT;

View File

@ -102,6 +102,13 @@ model Profile {
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until")
// ─── Screen Time Passcode (iOS Layer 3, Migration 20260601) ─────────────
// Vom App-generierten 4-stelligen Code beim Onboarding. User setzt ihn
// manuell als Screen Time Passcode, wir speichern ihn damit er nach dem
// Cooldown abrufbar ist. Kein Hash — muss plain abrufbar sein.
// Abruf NUR via GET /api/protection/screentime-passcode + canDisableProtection=true.
screentimePasscode String? @map("screentime_passcode")
// ─── MDM-Managed Flag (Build 19, Migration 20260526) ────────────────────
// mdmManaged: true wenn User's Device via MDM verwaltet wird (NEFilter-
// Profil sideloaded + non-removable). Wird vom App-Code nach nativem

View File

@ -0,0 +1,38 @@
import { requireUser } from "../../utils/auth";
import { usePrisma } from "../../utils/prisma";
import { getActiveCooldown, resolveCooldown } from "../../db/cooldown";
/**
* GET /api/protection/screentime-passcode
*
* Gibt den Screen-Time-Passcode zurück aber NUR wenn kein aktiver
* Cooldown läuft (canDisableProtection = true). Das stellt sicher dass
* der Code erst nach der 24h-Wartezeit abrufbar ist.
*
* Gibt { passcode: null } zurück wenn noch kein Code gesetzt wurde
* oder Cooldown noch läuft (kein Error, kein 403 Client unterscheidet
* anhand passcode=null ob Code fehlt oder gesperrt ist).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const db = usePrisma();
const cooldown = await getActiveCooldown(user.id);
if (cooldown) {
const expired = new Date() >= cooldown.cooldownEndsAt;
if (!expired) {
return { success: true, data: { passcode: null, reason: "cooldown_active" } };
}
await resolveCooldown(cooldown.id);
}
const profile = await db.profile.findUnique({
where: { id: user.id },
select: { screentimePasscode: true },
});
return {
success: true,
data: { passcode: profile?.screentimePasscode ?? null },
};
});

View File

@ -0,0 +1,29 @@
import { requireUser } from "../../utils/auth";
import { usePrisma } from "../../utils/prisma";
/**
* POST /api/protection/screentime-passcode
*
* Speichert den vom Client generierten Screen-Time-Passcode.
* User setzt diesen Code manuell in iOS Settings Screen Time.
* Nach Cooldown abrufbar via GET /api/protection/screentime-passcode.
*
* Body: { passcode: string } 4-stelliger numerischer Code
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const passcode = body?.passcode;
if (typeof passcode !== "string" || !/^\d{4}$/.test(passcode)) {
throw createError({ statusCode: 400, message: "passcode must be a 4-digit string" });
}
const db = usePrisma();
await db.profile.update({
where: { id: user.id },
data: { screentimePasscode: passcode },
});
return { success: true };
});