feat(native): auto-detect Mac activation via Supabase Realtime
Replaces the manual "I've installed it" button in AddMacSheet with an auto-advancing waiting-pill. As soon as the backend flips status from pending → active (triggered by the DoH handshake from the AdGuard watcher), the sheet jumps to the success step automatically. - useProtectedDevicesRealtime hook subscribes to rebreak.protected_devices UPDATE events for the current user, with auto-reconnect on CHANNEL_ERROR - AddMacSheet listens only while in step 2 (download/install) - devices.tsx keeps a list-level subscription so the table refreshes even if the user dismissed the sheet before activation - i18n: waiting_install / waiting_hint / activated_toast (DE + EN)
This commit is contained in:
parent
0e4c3787c2
commit
42a8223bfc
@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useColors } from '../lib/theme';
|
||||
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
||||
import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protectedDevices';
|
||||
import { useProtectedDevicesRealtime } from '../hooks/useProtectedDevicesRealtime';
|
||||
import { useUserPlan } from '../hooks/useUserPlan';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
||||
@ -422,6 +423,8 @@ export default function DevicesScreen() {
|
||||
loadProtected();
|
||||
}, []);
|
||||
|
||||
useProtectedDevicesRealtime();
|
||||
|
||||
const MAX_PROTECTED_DEVICES = 2;
|
||||
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
|
||||
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES;
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
@ -16,6 +16,7 @@ import { useColors } from '../../lib/theme';
|
||||
import { FormSheet } from '../FormSheet';
|
||||
import { RiveAvatar } from '../RiveAvatar';
|
||||
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
|
||||
import { useProtectedDevicesRealtime } from '../../hooks/useProtectedDevicesRealtime';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
|
||||
@ -45,20 +46,28 @@ export function AddMacSheet({
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore();
|
||||
const { enroll, enrolling } = useProtectedDevicesStore();
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [label, setLabel] = useState('MacBook Pro');
|
||||
const [labelError, setLabelError] = useState('');
|
||||
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const handleActivated = useCallback(() => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
setStep(3);
|
||||
}, []);
|
||||
|
||||
useProtectedDevicesRealtime(
|
||||
step === 2 ? handleActivated : undefined,
|
||||
step === 2,
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setStep(1);
|
||||
setLabel('MacBook Pro');
|
||||
setLabelError('');
|
||||
setEnrollResult(null);
|
||||
setConfirming(false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@ -86,20 +95,6 @@ export function AddMacSheet({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmInstalled() {
|
||||
if (!enrollResult) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
await confirmInstalled(enrollResult.deviceId);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||
setStep(3);
|
||||
} catch {
|
||||
Alert.alert(t('common.error'), t('common.unknown_error'));
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!enrollResult?.downloadUrl) return;
|
||||
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
|
||||
@ -141,9 +136,7 @@ export function AddMacSheet({
|
||||
{step === 2 && (
|
||||
<Step2OnboardingContent
|
||||
onDownload={handleDownload}
|
||||
onConfirmInstalled={handleConfirmInstalled}
|
||||
onNeedHelp={handleNeedHelp}
|
||||
confirming={confirming}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
@ -230,16 +223,12 @@ function Step1LabelContent({
|
||||
|
||||
function Step2OnboardingContent({
|
||||
onDownload,
|
||||
onConfirmInstalled,
|
||||
onNeedHelp,
|
||||
confirming,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
onDownload: () => void;
|
||||
onConfirmInstalled: () => void;
|
||||
onNeedHelp: () => void;
|
||||
confirming: boolean;
|
||||
colors: ReturnType<typeof useColors>;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
@ -338,28 +327,36 @@ function Step2OnboardingContent({
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Confirm installed */}
|
||||
<TouchableOpacity
|
||||
onPress={onConfirmInstalled}
|
||||
disabled={confirming}
|
||||
activeOpacity={0.7}
|
||||
{/* Pending auto-detect pill */}
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.brandOrange,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
opacity: confirming ? 0.7 : 1,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{confirming ? (
|
||||
<ActivityIndicator color={colors.brandOrange} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{t('devices.confirm_installed')}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<ActivityIndicator size="small" color={colors.brandOrange} />
|
||||
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold', flex: 1 }}>
|
||||
{t('devices.waiting_install')}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
lineHeight: 17,
|
||||
marginLeft: 30,
|
||||
}}
|
||||
>
|
||||
{t('devices.waiting_hint')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Need help */}
|
||||
<TouchableOpacity
|
||||
|
||||
72
apps/rebreak-native/hooks/useProtectedDevicesRealtime.ts
Normal file
72
apps/rebreak-native/hooks/useProtectedDevicesRealtime.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||
import { useProtectedDevicesStore } from "../stores/protectedDevices";
|
||||
|
||||
type OnActivated = (deviceId: string) => void;
|
||||
|
||||
export function useProtectedDevicesRealtime(
|
||||
onActivated?: OnActivated,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
const onActivatedRef = useRef<OnActivated | undefined>(onActivated);
|
||||
onActivatedRef.current = onActivated;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let channel: RealtimeChannel | null = null;
|
||||
let cancelled = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function subscribe() {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const session = data.session;
|
||||
if (!session?.access_token || cancelled) return;
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
channel = supabase
|
||||
.channel(`protected:${userId}:${Date.now()}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "UPDATE",
|
||||
schema: "rebreak",
|
||||
table: "protected_devices",
|
||||
filter: `user_id=eq.${userId}`,
|
||||
},
|
||||
(payload: any) => {
|
||||
const updated = payload.new;
|
||||
useProtectedDevicesStore.getState().load();
|
||||
if (updated?.status === "active" && payload.old?.status === "pending") {
|
||||
onActivatedRef.current?.(updated.id);
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe((status) => {
|
||||
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
|
||||
cleanup();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!cancelled) subscribe();
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
cleanup();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@ -878,9 +878,12 @@
|
||||
"step_3_title": "Standard-Account nutzen",
|
||||
"step_3_body": "Wichtig: arbeite auf dem Mac mit einem Standard-Account, nicht Admin. Wenn du ein Notfall-Bedürfnis hast, das Profile zu entfernen, brauchst du das Admin-Passwort — das idealerweise jemand anderes hat (Partnerin, Freund, jemand dem du vertraust). Das ist die eigentliche Schutzschicht.",
|
||||
"step_4_title": "Fertig",
|
||||
"step_4_body": "Sobald du installiert hast, klick \"Ich hab's installiert\" — dann zähl ich den Mac als geschütztes Gerät.",
|
||||
"step_4_body": "Sobald du auf dem Mac im Browser oder in einer App eine Webseite öffnest, erkennen wir das automatisch und aktivieren den Schutz.",
|
||||
"download_button": "Profile auf Mac downloaden",
|
||||
"confirm_installed": "Ich hab's installiert ✓",
|
||||
"waiting_install": "Warte auf Profile-Installation auf deinem Mac…",
|
||||
"waiting_hint": "Sobald du im Browser oder einer App eine Webseite öffnest, aktivieren wir automatisch dein Gerät.",
|
||||
"activated_toast": "Mac verbunden!",
|
||||
"need_help": "Brauche Hilfe",
|
||||
"success_title": "Mac geschützt!",
|
||||
"success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.",
|
||||
|
||||
@ -878,9 +878,12 @@
|
||||
"step_3_title": "Use a standard account",
|
||||
"step_3_body": "Important: use a standard account on the Mac, not admin. If you ever have an urgent need to remove the profile, you'll need the admin password — ideally held by someone you trust (partner, friend). That's the real protection layer.",
|
||||
"step_4_title": "Done",
|
||||
"step_4_body": "Once you've installed it, tap \"I've installed it\" — then I'll count the Mac as a protected device.",
|
||||
"step_4_body": "Once installed, just open any website in a browser or app on your Mac — we'll detect it automatically and activate protection.",
|
||||
"download_button": "Download profile to Mac",
|
||||
"confirm_installed": "I've installed it ✓",
|
||||
"waiting_install": "Waiting for profile installation on your Mac…",
|
||||
"waiting_hint": "As soon as you open a website in a browser or app, we'll automatically activate your device.",
|
||||
"activated_toast": "Mac connected!",
|
||||
"need_help": "I need help",
|
||||
"success_title": "Mac protected!",
|
||||
"success_body": "You can add more devices whenever you like.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user