diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index 13b65c9..4057831 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -7,22 +7,32 @@ import { Text, View, } from 'react-native'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; +import { MenuView } from '@react-native-menu/menu'; 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 { useUserPlan } from '../hooks/useUserPlan'; import { AppHeader } from '../components/AppHeader'; +import { AddMacSheet } from '../components/devices/AddMacSheet'; -function platformIcon( - platform: string -): React.ComponentProps['name'] { +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function mobileIcon(platform: string): React.ComponentProps['name'] { if (platform === 'ios') return 'logo-apple'; if (platform === 'android') return 'logo-android'; return 'phone-portrait-outline'; } +function protectedDeviceIcon(platform: string): React.ComponentProps['name'] { + if (platform === 'mac') return 'laptop-outline'; + if (platform === 'windows') return 'desktop-outline'; + return 'globe-outline'; +} + function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string { const ms = Date.now() - new Date(iso).getTime(); const min = Math.floor(ms / 60_000); @@ -47,7 +57,53 @@ function formatSince(iso: string): string { }); } -function DeviceRow({ +// ─── Status Badge ───────────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: ProtectedDevice['status'] }) { + const { t } = useTranslation(); + const colors = useColors(); + + const config = { + pending: { + label: t('devices.status_pending'), + bg: 'rgba(245,158,11,0.12)', + fg: '#f59e0b', + }, + active: { + label: t('devices.status_active'), + bg: colors.success + '1a', + fg: colors.success, + }, + revoked: { + label: t('devices.status_revoked'), + bg: 'rgba(0,0,0,0.06)', + fg: colors.textMuted, + }, + }[status] ?? { + label: status, + bg: 'rgba(0,0,0,0.06)', + fg: colors.textMuted, + }; + + return ( + + + {config.label} + + + ); +} + +// ─── Mobile Device Row (existing) ──────────────────────────────────────────── + +function MobileDeviceRow({ device, onRemove, }: { @@ -92,11 +148,7 @@ function DeviceRow({ justifyContent: 'center', }} > - + @@ -134,9 +186,7 @@ function DeviceRow({ ) : null} - {device.model && - device.name && - !device.name.includes(device.model) ? ( + {device.model && device.name && !device.name.includes(device.model) ? ( ) : null} - + {formatLastSeen(device.lastSeenAt, t)} @@ -172,11 +211,7 @@ function DeviceRow({ {t('settings.devices_since')} {formatSince(device.createdAt)} @@ -197,23 +232,195 @@ function DeviceRow({ ); } +// ─── Protected Device Row ──────────────────────────────────────────────────── + +function ProtectedDeviceRow({ + device, + onRemove, +}: { + device: ProtectedDevice; + onRemove: (id: string) => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + + const menuActions = device.status === 'pending' + ? [ + { id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } }, + ] + : [ + { id: 'remove', title: t('settings.devices_remove_confirm'), attributes: { destructive: true } }, + ]; + + function handleMenuSelect(id: string) { + if (id === 'remove') { + Alert.alert( + t('devices.remove_warning_title'), + t('devices.remove_warning_body'), + [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('settings.devices_remove_confirm'), + style: 'destructive', + onPress: () => onRemove(device.id), + }, + ] + ); + } + } + + return ( + + + + + + + + + {device.label} + + + + + + + + {t('settings.devices_since')} {formatSince(device.createdAt)} + + + + + handleMenuSelect(event)} + shouldOpenOnLongPress={false} + > + ({ opacity: pressed ? 0.5 : 1 })} + > + + + + + ); +} + +// ─── Section Card wrapper ───────────────────────────────────────────────────── + +function SectionCard({ children }: { children: React.ReactNode }) { + const colors = useColors(); + return ( + + {children} + + ); +} + +function SectionLabel({ title }: { title: string }) { + const colors = useColors(); + return ( + + {title} + + ); +} + +// ─── Screen ────────────────────────────────────────────────────────────────── + export default function DevicesScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); const colors = useColors(); - const { devices, maxDevices, plan, loading, loadDevices, removeDevice } = - useDevicesStore(); + const { plan } = useUserPlan(); + const isLegend = plan === 'legend'; + + const { + devices: mobileDevices, + loading: mobileLoading, + loadDevices, + removeDevice: removeMobileDevice, + } = useDevicesStore(); + + const { + devices: protectedDevices, + loading: protectedLoading, + load: loadProtected, + remove: removeProtected, + } = useProtectedDevicesStore(); + + const [addMacVisible, setAddMacVisible] = useState(false); useEffect(() => { loadDevices(); + loadProtected(); }, []); - const atLimit = devices.length >= maxDevices; - const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices)); + const currentDevice = mobileDevices.find((d) => d.isCurrent); + const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free'); + + async function handleRemoveProtected(id: string) { + try { + const { manualRemovalRequired } = await removeProtected(id); + if (manualRemovalRequired) { + Alert.alert(t('devices.remove_warning_title'), t('devices.remove_warning_body')); + } + } catch { + Alert.alert(t('common.error'), t('common.unknown_error')); + } + } return ( - + - {/* Slot counter card */} - - - - {t('settings.devices_slots')} - - - - {devices.length} / {maxDevices} - - - + {subtitle} + - - {t('settings.devices_slots_desc', { plan: plan.toUpperCase() })} - - - - = 0.8 - ? '#f59e0b' - : colors.brandOrange, - }} - /> - - - - {/* Device list card */} - - {loading ? ( - - - - ) : devices.length === 0 ? ( - - - {t('settings.devices_empty')} - - - ) : ( - devices.map((device, i) => ( - - + {/* Section 1: Dieses Gerät */} + + + + {mobileLoading && !currentDevice ? ( + + - )) - )} + ) : currentDevice ? ( + + ) : ( + + + {t('settings.devices_empty')} + + + )} + + {/* Section 2: Weitere geschützte Geräte */} + + + + {protectedLoading ? ( + + + + ) : protectedDevices.length === 0 ? ( + + + + {isLegend + ? t('devices.add_mac') + : t('devices.subtitle_free')} + + + ) : ( + protectedDevices.map((device, i) => ( + + + + )) + )} + + + + {/* CTA or Upgrade */} + {isLegend ? ( + + setAddMacVisible(true)} + style={({ pressed }) => ({ + backgroundColor: colors.brandOrange, + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: 8, + opacity: pressed ? 0.7 : 1, + })} + > + + + {t('devices.add_mac')} + + + + + + + {t('devices.add_windows')} + + + + ) : ( + + + + + {t('devices.subtitle_legend')} + + + ({ + backgroundColor: colors.brandOrange, + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + opacity: pressed ? 0.7 : 1, + })} + > + + {t('devices.upgrade_cta')} + + + + )} + {t('settings.devices_hint')} + + { + setAddMacVisible(false); + loadProtected(); + }} + /> ); } diff --git a/apps/rebreak-native/components/devices/AddMacSheet.tsx b/apps/rebreak-native/components/devices/AddMacSheet.tsx new file mode 100644 index 0000000..db63eaa --- /dev/null +++ b/apps/rebreak-native/components/devices/AddMacSheet.tsx @@ -0,0 +1,457 @@ +import { + ActivityIndicator, + Alert, + Linking, + Pressable, + Text, + TextInput, + View, +} from 'react-native'; +import { useRef, useState } from 'react'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import * as Haptics from 'expo-haptics'; +import { useColors } from '../../lib/theme'; +import { KeyboardAwareSheet } from '../KeyboardAwareSheet'; +import { RiveAvatar } from '../RiveAvatar'; +import { useProtectedDevicesStore } from '../../stores/protectedDevices'; +import { useRouter } from 'expo-router'; + +// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone + +type Step = 1 | 2 | 3; + +interface StepItem { + titleKey: string; + bodyKey: string; + icon: React.ComponentProps['name']; +} + +const STEPS: StepItem[] = [ + { titleKey: 'devices.step_1_title', bodyKey: 'devices.step_1_body', icon: 'download-outline' }, + { titleKey: 'devices.step_2_title', bodyKey: 'devices.step_2_body', icon: 'settings-outline' }, + { titleKey: 'devices.step_3_title', bodyKey: 'devices.step_3_body', icon: 'person-outline' }, + { titleKey: 'devices.step_4_title', bodyKey: 'devices.step_4_body', icon: 'checkmark-circle-outline' }, +]; + +export function AddMacSheet({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const colors = useColors(); + const router = useRouter(); + const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore(); + + const [step, setStep] = useState(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); + + function reset() { + setStep(1); + setLabel('MacBook Pro'); + setLabelError(''); + setEnrollResult(null); + setConfirming(false); + } + + function handleClose() { + reset(); + onClose(); + } + + async function handlePrepare() { + const trimmed = label.trim(); + if (!trimmed) { + setLabelError(t('devices.label_question')); + return; + } + if (trimmed.length > 32) { + setLabelError(t('devices.label_question')); + return; + } + setLabelError(''); + try { + const result = await enroll(trimmed); + setEnrollResult(result); + setStep(2); + } catch { + Alert.alert(t('common.error'), t('common.unknown_error')); + } + } + + 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(() => {}); + } + + function handleNeedHelp() { + handleClose(); + router.push('/coach'); + } + + const collapsedHeight = step === 1 ? 300 : step === 2 ? 620 : 380; + + return ( + + + {step === 1 + ? t('devices.label_question') + : step === 2 + ? t('devices.download_button') + : t('devices.success_title')} + + ({ opacity: pressed ? 0.5 : 1 })} + > + + + + } + > + {step === 1 && } + {step === 2 && } + {step === 3 && } + + ); +} + +function Step1LabelContent({ + label, + setLabel, + labelError, + onPrepare, + enrolling, + colors, + t, +}: { + label: string; + setLabel: (v: string) => void; + labelError: string; + onPrepare: () => void; + enrolling: boolean; + colors: ReturnType; + t: (k: string) => string; +}) { + return ( + + + {labelError ? ( + + {labelError} + + ) : null} + + ({ + backgroundColor: colors.brandOrange, + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + opacity: pressed || enrolling ? 0.7 : 1, + })} + > + {enrolling ? ( + + ) : ( + + {t('devices.prepare_profile')} + + )} + + + ); +} + +function Step2OnboardingContent({ + onDownload, + onConfirmInstalled, + onNeedHelp, + confirming, + colors, + t, +}: { + onDownload: () => void; + onConfirmInstalled: () => void; + onNeedHelp: () => void; + confirming: boolean; + colors: ReturnType; + t: (k: string) => string; +}) { + return ( + + {/* Lyra intro card */} + + + + {t('devices.lyra_intro')} + + + + {/* 4-step list */} + + {STEPS.map((item, idx) => ( + + + + + + + {t(item.titleKey)} + + + {t(item.bodyKey)} + + + + ))} + + + {/* Download button */} + ({ + backgroundColor: colors.brandOrange, + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: 8, + opacity: pressed ? 0.7 : 1, + })} + > + + + {t('devices.download_button')} + + + + {/* Confirm installed */} + ({ + borderWidth: 1.5, + borderColor: colors.brandOrange, + borderRadius: 14, + paddingVertical: 14, + alignItems: 'center', + opacity: pressed || confirming ? 0.7 : 1, + })} + > + {confirming ? ( + + ) : ( + + {t('devices.confirm_installed')} + + )} + + + {/* Need help */} + ({ opacity: pressed ? 0.5 : 1, alignItems: 'center' })} + > + + {t('devices.need_help')} + + + + ); +} + +function Step3SuccessContent({ + onClose, + colors, + t, +}: { + onClose: () => void; + colors: ReturnType; + t: (k: string) => string; +}) { + return ( + + + + + + {t('devices.success_title')} + + + {t('devices.success_body')} + + + + ({ + backgroundColor: colors.brandOrange, + borderRadius: 14, + paddingVertical: 16, + paddingHorizontal: 40, + alignItems: 'center', + opacity: pressed ? 0.7 : 1, + alignSelf: 'stretch', + })} + > + + {t('common.ok')} + + + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index c68c6bd..60320d4 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -716,6 +716,38 @@ "picker_job_tenure": "Im aktuellen Job seit", "picker_bundesland": "Bundesland" }, + "devices": { + "section_title_this": "Dieses Gerät", + "section_title_others": "Weitere geschützte Geräte", + "subtitle_legend": "Schutz auf bis zu 3 Geräten — egal welches du benutzt.", + "subtitle_free": "Aktuelles Gerät geschützt.", + "add_mac": "Mac hinzufügen", + "add_windows": "Windows hinzufügen (bald)", + "upgrade_cta": "Auf Legend upgraden", + "status_pending": "Bereit zum Installieren", + "status_active": "Aktiv", + "status_revoked": "Entfernt", + "label_placeholder": "z.B. MacBook Pro", + "label_default": "MacBook Pro", + "label_question": "Wie soll der Mac heißen?", + "prepare_profile": "Profile vorbereiten", + "lyra_intro": "Drei Schritte. Ich begleite dich durch jeden — wenn was schiefgeht, klick auf Hilfe.", + "step_1_title": "Profile downloaden", + "step_1_body": "Klick den Button unten. Wenn du am Mac bist, öffnet das die Profile-Datei direkt. Bist du am Phone? Dann kommt ein QR-Code — den du am Mac einscannst um die Datei zu kriegen.", + "step_2_title": "Profile installieren", + "step_2_body": "Auf dem Mac: Doppelklick auf die heruntergeladene Datei → Systemeinstellungen öffnet sich → \"Profil installieren\" → Mac-Passwort eingeben → fertig.", + "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.", + "download_button": "Profile auf Mac downloaden", + "confirm_installed": "Ich hab's installiert ✓", + "need_help": "Brauche Hilfe", + "success_title": "Mac geschützt!", + "success_body": "Du kannst weitere Geräte hinzufügen wenn du willst.", + "remove_warning_title": "Profile manuell entfernen", + "remove_warning_body": "Wir können das Profile nicht aus der Ferne löschen. Auf dem Mac: Systemeinstellungen → Profile → ReBreak → Entfernen (Admin-Passwort nötig)." + }, "gameOver": { "title": "Spiel beendet", "score": "Score", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index ce94eb9..f5e23b7 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -716,6 +716,38 @@ "picker_job_tenure": "Time in current job", "picker_bundesland": "State" }, + "devices": { + "section_title_this": "This device", + "section_title_others": "Other protected devices", + "subtitle_legend": "Protection across up to 3 devices — whichever one you use.", + "subtitle_free": "Current device protected.", + "add_mac": "Add Mac", + "add_windows": "Add Windows (coming soon)", + "upgrade_cta": "Upgrade to Legend", + "status_pending": "Ready to install", + "status_active": "Active", + "status_revoked": "Removed", + "label_placeholder": "e.g. MacBook Pro", + "label_default": "MacBook Pro", + "label_question": "What should this Mac be called?", + "prepare_profile": "Prepare profile", + "lyra_intro": "Three steps. I'll walk you through each one — if something goes wrong, tap Help.", + "step_1_title": "Download the profile", + "step_1_body": "Tap the button below. If you're on your Mac, it opens the profile file directly. On your phone? You'll get a QR code — scan it on your Mac to get the file.", + "step_2_title": "Install the profile", + "step_2_body": "On the Mac: double-click the downloaded file → System Settings opens → \"Install Profile\" → enter your Mac password → done.", + "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.", + "download_button": "Download profile to Mac", + "confirm_installed": "I've installed it ✓", + "need_help": "I need help", + "success_title": "Mac protected!", + "success_body": "You can add more devices whenever you like.", + "remove_warning_title": "Remove profile manually", + "remove_warning_body": "We can't delete the profile remotely. On the Mac: System Settings → Profiles → ReBreak → Remove (admin password required)." + }, "gameOver": { "title": "Game over", "score": "Score", diff --git a/apps/rebreak-native/stores/protectedDevices.ts b/apps/rebreak-native/stores/protectedDevices.ts new file mode 100644 index 0000000..59160b1 --- /dev/null +++ b/apps/rebreak-native/stores/protectedDevices.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { apiFetch } from '../lib/api'; + +export type ProtectedDeviceStatus = 'pending' | 'active' | 'revoked'; + +export interface ProtectedDevice { + id: string; + platform: 'mac' | 'windows' | string; + label: string; + status: ProtectedDeviceStatus; + installedAt: string | null; + createdAt: string; +} + +export interface EnrollResult { + deviceId: string; + downloadUrl: string; +} + +type ProtectedDevicesState = { + devices: ProtectedDevice[]; + loading: boolean; + enrolling: boolean; + + load: () => Promise; + enroll: (label: string) => Promise; + confirmInstalled: (id: string) => Promise; + remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>; +}; + +export const useProtectedDevicesStore = create((set, get) => ({ + devices: [], + loading: false, + enrolling: false, + + load: async () => { + set({ loading: true }); + try { + const devices = await apiFetch('/api/devices/protected'); + set({ devices }); + } catch { + // endpoint might not be ready yet — keep empty state, screen handles it + } finally { + set({ loading: false }); + } + }, + + enroll: async (label: string) => { + set({ enrolling: true }); + try { + const result = await apiFetch('/api/devices/enroll', { + method: 'POST', + body: { platform: 'mac', label }, + }); + await get().load(); + return result; + } finally { + set({ enrolling: false }); + } + }, + + confirmInstalled: async (id: string) => { + await apiFetch(`/api/devices/${id}/confirm-installed`, { method: 'POST' }); + set((s) => ({ + devices: s.devices.map((d) => + d.id === id ? { ...d, status: 'active' as const, installedAt: new Date().toISOString() } : d + ), + })); + }, + + remove: async (id: string) => { + const res = await apiFetch<{ manualRemovalRequired: boolean }>(`/api/devices/${id}/revoke`, { + method: 'DELETE', + }); + set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })); + return res; + }, +}));