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:
parent
59766f8530
commit
ab4b9c48e5
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { useRouter } from 'expo-router';
|
||||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -85,6 +85,11 @@ export default function BlockerScreen() {
|
|||||||
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
|
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
|
||||||
const [protectionOffOpen, setProtectionOffOpen] = 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 urlFilterActive = state?.layers.urlFilter === true;
|
||||||
const familyControlsActive = state?.layers.familyControls === true;
|
const familyControlsActive = state?.layers.familyControls === true;
|
||||||
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
||||||
@ -219,6 +224,28 @@ export default function BlockerScreen() {
|
|||||||
return { enabled: false };
|
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 ────────────────────────────────────────
|
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
|
||||||
|
|
||||||
function openDetails() {
|
function openDetails() {
|
||||||
@ -339,6 +366,20 @@ export default function BlockerScreen() {
|
|||||||
lockedHint={t('blocker.layers_app_lock_locked_hint')}
|
lockedHint={t('blocker.layers_app_lock_locked_hint')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -350,6 +391,7 @@ export default function BlockerScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
|
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
|
||||||
<MyFiltersList
|
<MyFiltersList
|
||||||
domains={domains}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -406,6 +406,26 @@ export const protection = {
|
|||||||
return { cooldownEndsAt: res.cooldownEndsAt };
|
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. */
|
/** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */
|
||||||
async cancelDeactivation(): Promise<{ cancelled: boolean }> {
|
async cancelDeactivation(): Promise<{ cancelled: boolean }> {
|
||||||
const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", {
|
const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", {
|
||||||
|
|||||||
@ -311,6 +311,15 @@
|
|||||||
"layers_app_lock_subtitle_inactive": "يمنع إيقاف rebreak أو الفلتر في لحظة الاندفاع",
|
"layers_app_lock_subtitle_inactive": "يمنع إيقاف rebreak أو الفلتر في لحظة الاندفاع",
|
||||||
"layers_app_lock_warning": "بمجرد التفعيل لا يمكنك إيقاف الحماية إلا عبر تهدئة 24 ساعة. هذا مقصود.",
|
"layers_app_lock_warning": "بمجرد التفعيل لا يمكنك إيقاف الحماية إلا عبر تهدئة 24 ساعة. هذا مقصود.",
|
||||||
"layers_app_lock_locked_hint": "مقفل بواسطة النظام. الإيقاف فقط عبر إعدادات iOS → مدة استخدام الجهاز → الإدارة بواسطة ReBreak.",
|
"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_active": "إمكانية الوصول نشطة — حماية التطبيق مفعّلة",
|
||||||
"layers_a11y_subtitle_inactive": "إمكانية الوصول غير مفعّلة — قم بالإعداد الآن",
|
"layers_a11y_subtitle_inactive": "إمكانية الوصول غير مفعّلة — قم بالإعداد الآن",
|
||||||
"kpi_global_label": "النطاقات المحجوبة عالمياً",
|
"kpi_global_label": "النطاقات المحجوبة عالمياً",
|
||||||
|
|||||||
@ -328,6 +328,15 @@
|
|||||||
"layers_app_lock_subtitle_inactive": "Verhindert, dass du ReBreak oder den Filter im Impuls abschaltest",
|
"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_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.",
|
"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_active": "Eingabehilfe aktiv — App-Schutz armiert",
|
||||||
"layers_a11y_subtitle_inactive": "Eingabehilfe nicht aktiviert — jetzt einrichten",
|
"layers_a11y_subtitle_inactive": "Eingabehilfe nicht aktiviert — jetzt einrichten",
|
||||||
"kpi_global_label": "Geblockte Domains weltweit",
|
"kpi_global_label": "Geblockte Domains weltweit",
|
||||||
|
|||||||
@ -328,6 +328,15 @@
|
|||||||
"layers_app_lock_subtitle_inactive": "Stops you from switching off ReBreak or the filter on impulse",
|
"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_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.",
|
"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_active": "Accessibility active — app protection armed",
|
||||||
"layers_a11y_subtitle_inactive": "Accessibility not enabled — set it up now",
|
"layers_a11y_subtitle_inactive": "Accessibility not enabled — set it up now",
|
||||||
"kpi_global_label": "Domains blocked worldwide",
|
"kpi_global_label": "Domains blocked worldwide",
|
||||||
|
|||||||
@ -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_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_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.",
|
"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_label": "Domaines bloqués dans le monde",
|
||||||
"kpi_global_subtitle": "Entrées actives dans la liste de blocage globale",
|
"kpi_global_subtitle": "Entrées actives dans la liste de blocage globale",
|
||||||
"delta_week": "cette semaine",
|
"delta_week": "cette semaine",
|
||||||
|
|||||||
1
backend/prisma/migrations/add_screentime_passcode.sql
Normal file
1
backend/prisma/migrations/add_screentime_passcode.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS screentime_passcode TEXT;
|
||||||
@ -102,6 +102,13 @@ model Profile {
|
|||||||
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
|
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
|
||||||
globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until")
|
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) ────────────────────
|
// ─── MDM-Managed Flag (Build 19, Migration 20260526) ────────────────────
|
||||||
// mdmManaged: true wenn User's Device via MDM verwaltet wird (NEFilter-
|
// mdmManaged: true wenn User's Device via MDM verwaltet wird (NEFilter-
|
||||||
// Profil sideloaded + non-removable). Wird vom App-Code nach nativem
|
// Profil sideloaded + non-removable). Wird vom App-Code nach nativem
|
||||||
|
|||||||
38
backend/server/api/protection/screentime-passcode.get.ts
Normal file
38
backend/server/api/protection/screentime-passcode.get.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
});
|
||||||
29
backend/server/api/protection/screentime-passcode.post.ts
Normal file
29
backend/server/api/protection/screentime-passcode.post.ts
Normal 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 };
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user