From ab4b9c48e5c2bfaa6da8ca3db1681225896818b9 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 04:19:43 +0200 Subject: [PATCH] feat(ios): Screen Time Passcode als Layer 3 (setup flow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/rebreak-native/app/(app)/blocker.tsx | 142 +++++++++++++++++- apps/rebreak-native/lib/protection.ts | 20 +++ apps/rebreak-native/locales/ar.json | 9 ++ apps/rebreak-native/locales/de.json | 9 ++ apps/rebreak-native/locales/en.json | 9 ++ apps/rebreak-native/locales/fr.json | 9 ++ .../migrations/add_screentime_passcode.sql | 1 + backend/prisma/schema.prisma | 7 + .../api/protection/screentime-passcode.get.ts | 38 +++++ .../protection/screentime-passcode.post.ts | 29 ++++ 10 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/migrations/add_screentime_passcode.sql create mode 100644 backend/server/api/protection/screentime-passcode.get.ts create mode 100644 backend/server/api/protection/screentime-passcode.post.ts diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx index 4bacd3f..451d9f2 100644 --- a/apps/rebreak-native/app/(app)/blocker.tsx +++ b/apps/rebreak-native/app/(app)/blocker.tsx @@ -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(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 && ( + Linking.openURL('App-Prefs:SCREEN_TIME').catch(() => Linking.openSettings())} + onConfirm={handleScreentimeConfirm} + colors={colors} + t={t} + /> + )} )} @@ -350,6 +391,7 @@ export default function BlockerScreen() { /> )} + {/* Sektion 1: Meine Filter (unified web + mail_domain) */} void; + onOpenSettings: () => void; + onConfirm: () => void; + colors: ReturnType; + t: ReturnType['t']; +}) { + if (confirmed) { + return ( + + + 🔐 + + {t('blocker.screentime_confirmed_title')} + + + + {t('blocker.screentime_confirmed_desc')} + + + ); + } + + return ( + + + 🔒 + + {t('blocker.screentime_title')} + + + + {t('blocker.screentime_desc')} + + + {!code ? ( + + + {t('blocker.screentime_generate_cta')} + + + ) : ( + + + + {t('blocker.screentime_code_label')} + + + {code} + + + {t('blocker.screentime_code_hint')} + + + + + {t('blocker.screentime_open_settings_cta')} + + + + {saving + ? + : {t('blocker.screentime_confirm_cta')} + } + + + )} + + ); +} + diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts index eb9d2d0..1d62a4f 100644 --- a/apps/rebreak-native/lib/protection.ts +++ b/apps/rebreak-native/lib/protection.ts @@ -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 { + 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 { + 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", { diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index b726ab8..26e7fd1 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -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": "النطاقات المحجوبة عالمياً", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 1d70b54..5bf8d7f 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 0c0ad83..9dbafdc 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 7dacc28..0cc2082 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -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", diff --git a/backend/prisma/migrations/add_screentime_passcode.sql b/backend/prisma/migrations/add_screentime_passcode.sql new file mode 100644 index 0000000..f4c0de0 --- /dev/null +++ b/backend/prisma/migrations/add_screentime_passcode.sql @@ -0,0 +1 @@ +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS screentime_passcode TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 357637a..e2b56c9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/server/api/protection/screentime-passcode.get.ts b/backend/server/api/protection/screentime-passcode.get.ts new file mode 100644 index 0000000..c895038 --- /dev/null +++ b/backend/server/api/protection/screentime-passcode.get.ts @@ -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 }, + }; +}); diff --git a/backend/server/api/protection/screentime-passcode.post.ts b/backend/server/api/protection/screentime-passcode.post.ts new file mode 100644 index 0000000..705ee40 --- /dev/null +++ b/backend/server/api/protection/screentime-passcode.post.ts @@ -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 }; +});