feat(devices): Settings → Geräte UI + AddMacSheet 3-step Lyra flow
Frontend: - New devices.tsx: section 'this device' + 'protected devices' + Legend CTA - AddMacSheet: label → Lyra-onboarding (4 steps) → success - protectedDevices store (Zustand): load, enroll, confirmInstalled, remove - Locale strings DE + EN (devices namespace, 36 keys each) - Path-fix: /api/devices/protected (was /api/devices) + /api/devices/:id/revoke Free/Pro see upgrade-CTA, Legend see add-Mac. Windows button shown disabled (soon). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
677b67902b
commit
6700391eed
@ -7,22 +7,32 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { MenuView } from '@react-native-menu/menu';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
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 { AppHeader } from '../components/AppHeader';
|
||||||
|
import { AddMacSheet } from '../components/devices/AddMacSheet';
|
||||||
|
|
||||||
function platformIcon(
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
platform: string
|
|
||||||
): React.ComponentProps<typeof Ionicons>['name'] {
|
function mobileIcon(platform: string): React.ComponentProps<typeof Ionicons>['name'] {
|
||||||
if (platform === 'ios') return 'logo-apple';
|
if (platform === 'ios') return 'logo-apple';
|
||||||
if (platform === 'android') return 'logo-android';
|
if (platform === 'android') return 'logo-android';
|
||||||
return 'phone-portrait-outline';
|
return 'phone-portrait-outline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function protectedDeviceIcon(platform: string): React.ComponentProps<typeof Ionicons>['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 {
|
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
|
||||||
const ms = Date.now() - new Date(iso).getTime();
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
const min = Math.floor(ms / 60_000);
|
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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: config.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 10, color: config.fg, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{config.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mobile Device Row (existing) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MobileDeviceRow({
|
||||||
device,
|
device,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
@ -92,11 +148,7 @@ function DeviceRow({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name={mobileIcon(device.platform)} size={20} color={colors.text} />
|
||||||
name={platformIcon(device.platform)}
|
|
||||||
size={20}
|
|
||||||
color={colors.text}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
@ -134,9 +186,7 @@ function DeviceRow({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{device.model &&
|
{device.model && device.name && !device.name.includes(device.model) ? (
|
||||||
device.name &&
|
|
||||||
!device.name.includes(device.model) ? (
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@ -149,22 +199,11 @@ function DeviceRow({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 4 }}>
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
|
||||||
fontSize: 11,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{formatLastSeen(device.lastSeenAt, t)}
|
{formatLastSeen(device.lastSeenAt, t)}
|
||||||
</Text>
|
</Text>
|
||||||
@ -172,11 +211,7 @@ function DeviceRow({
|
|||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
|
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
|
||||||
fontSize: 11,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
@ -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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={protectedDeviceIcon(device.platform)} size={20} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.label}
|
||||||
|
</Text>
|
||||||
|
<StatusBadge status={device.status} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||||
|
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<MenuView
|
||||||
|
title={device.label}
|
||||||
|
actions={menuActions}
|
||||||
|
onPressAction={({ nativeEvent: { event } }) => handleMenuSelect(event)}
|
||||||
|
shouldOpenOnLongPress={false}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={18} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</MenuView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section Card wrapper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionCard({ children }: { children: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ title }: { title: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DevicesScreen() {
|
export default function DevicesScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { devices, maxDevices, plan, loading, loadDevices, removeDevice } =
|
const { plan } = useUserPlan();
|
||||||
useDevicesStore();
|
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(() => {
|
useEffect(() => {
|
||||||
loadDevices();
|
loadDevices();
|
||||||
|
loadProtected();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const atLimit = devices.length >= maxDevices;
|
const currentDevice = mobileDevices.find((d) => d.isCurrent);
|
||||||
const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices));
|
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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||||
<AppHeader showBack title={t('settings.devices_page_title')} />
|
<AppHeader showBack title={t('settings.devices')} />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
@ -221,162 +428,190 @@ export default function DevicesScreen() {
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 16,
|
paddingTop: 16,
|
||||||
paddingBottom: insets.bottom + 40,
|
paddingBottom: insets.bottom + 40,
|
||||||
|
gap: 24,
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Slot counter card */}
|
{/* Subtitle */}
|
||||||
<View
|
<Text
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: colors.surface,
|
fontSize: 13,
|
||||||
borderRadius: 14,
|
color: colors.textMuted,
|
||||||
padding: 16,
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginBottom: 16,
|
lineHeight: 18,
|
||||||
shadowColor: '#000',
|
marginBottom: -12,
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.04,
|
|
||||||
shadowRadius: 3,
|
|
||||||
elevation: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{subtitle}
|
||||||
style={{
|
</Text>
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('settings.devices_slots')}
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 3,
|
|
||||||
borderRadius: 20,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: atLimit
|
|
||||||
? 'rgba(239,68,68,0.3)'
|
|
||||||
: 'rgba(0,0,0,0.08)',
|
|
||||||
backgroundColor: atLimit
|
|
||||||
? 'rgba(239,68,68,0.08)'
|
|
||||||
: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
|
||||||
color: atLimit ? colors.error : colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{devices.length} / {maxDevices}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
{/* Section 1: Dieses Gerät */}
|
||||||
style={{
|
<View>
|
||||||
fontSize: 12,
|
<SectionLabel title={t('devices.section_title_this')} />
|
||||||
color: colors.textMuted,
|
<SectionCard>
|
||||||
fontFamily: 'Nunito_400Regular',
|
{mobileLoading && !currentDevice ? (
|
||||||
marginBottom: 10,
|
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||||
}}
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
>
|
|
||||||
{t('settings.devices_slots_desc', { plan: plan.toUpperCase() })}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 5,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 5,
|
|
||||||
borderRadius: 4,
|
|
||||||
width: `${fillRatio * 100}%`,
|
|
||||||
backgroundColor: atLimit
|
|
||||||
? colors.error
|
|
||||||
: fillRatio >= 0.8
|
|
||||||
? '#f59e0b'
|
|
||||||
: colors.brandOrange,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Device list card */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderRadius: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.04,
|
|
||||||
shadowRadius: 3,
|
|
||||||
elevation: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingVertical: 32,
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator color={colors.brandOrange} />
|
|
||||||
</View>
|
|
||||||
) : devices.length === 0 ? (
|
|
||||||
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('settings.devices_empty')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
devices.map((device, i) => (
|
|
||||||
<View
|
|
||||||
key={device.id}
|
|
||||||
style={{
|
|
||||||
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
|
|
||||||
borderBottomColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeviceRow device={device} onRemove={removeDevice} />
|
|
||||||
</View>
|
</View>
|
||||||
))
|
) : currentDevice ? (
|
||||||
)}
|
<MobileDeviceRow device={currentDevice} onRemove={removeMobileDevice} />
|
||||||
|
) : (
|
||||||
|
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Section 2: Weitere geschützte Geräte */}
|
||||||
|
<View>
|
||||||
|
<SectionLabel title={t('devices.section_title_others')} />
|
||||||
|
<SectionCard>
|
||||||
|
{protectedLoading ? (
|
||||||
|
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
) : protectedDevices.length === 0 ? (
|
||||||
|
<View style={{ paddingVertical: 24, paddingHorizontal: 16, alignItems: 'center', gap: 8 }}>
|
||||||
|
<Ionicons name="laptop-outline" size={32} color={colors.border} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLegend
|
||||||
|
? t('devices.add_mac')
|
||||||
|
: t('devices.subtitle_free')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
protectedDevices.map((device, i) => (
|
||||||
|
<View
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* CTA or Upgrade */}
|
||||||
|
{isLegend ? (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setAddMacVisible(true)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={20} color="#fff" />
|
||||||
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('devices.add_mac')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
disabled
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
opacity: 0.4,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="desktop-outline" size={18} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}
|
||||||
|
>
|
||||||
|
{t('devices.add_windows')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Ionicons name="shield-checkmark-outline" size={22} color={colors.brandOrange} />
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}
|
||||||
|
>
|
||||||
|
{t('devices.subtitle_legend')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 15, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('devices.upgrade_cta')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
marginTop: 12,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
lineHeight: 16,
|
lineHeight: 16,
|
||||||
|
marginHorizontal: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('settings.devices_hint')}
|
{t('settings.devices_hint')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<AddMacSheet
|
||||||
|
visible={addMacVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setAddMacVisible(false);
|
||||||
|
loadProtected();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
457
apps/rebreak-native/components/devices/AddMacSheet.tsx
Normal file
457
apps/rebreak-native/components/devices/AddMacSheet.tsx
Normal file
@ -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<typeof Ionicons>['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<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);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<KeyboardAwareSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={handleClose}
|
||||||
|
collapsedHeight={collapsedHeight}
|
||||||
|
pushChildrenToBottom={false}
|
||||||
|
header={
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step === 1
|
||||||
|
? t('devices.label_question')
|
||||||
|
: step === 2
|
||||||
|
? t('devices.download_button')
|
||||||
|
: t('devices.success_title')}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleClose}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step === 1 && <Step1LabelContent
|
||||||
|
label={label}
|
||||||
|
setLabel={setLabel}
|
||||||
|
labelError={labelError}
|
||||||
|
onPrepare={handlePrepare}
|
||||||
|
enrolling={enrolling}
|
||||||
|
colors={colors}
|
||||||
|
t={t}
|
||||||
|
/>}
|
||||||
|
{step === 2 && <Step2OnboardingContent
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onConfirmInstalled={handleConfirmInstalled}
|
||||||
|
onNeedHelp={handleNeedHelp}
|
||||||
|
confirming={confirming}
|
||||||
|
colors={colors}
|
||||||
|
t={t}
|
||||||
|
/>}
|
||||||
|
{step === 3 && <Step3SuccessContent
|
||||||
|
onClose={handleClose}
|
||||||
|
colors={colors}
|
||||||
|
t={t}
|
||||||
|
/>}
|
||||||
|
</KeyboardAwareSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step1LabelContent({
|
||||||
|
label,
|
||||||
|
setLabel,
|
||||||
|
labelError,
|
||||||
|
onPrepare,
|
||||||
|
enrolling,
|
||||||
|
colors,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
setLabel: (v: string) => void;
|
||||||
|
labelError: string;
|
||||||
|
onPrepare: () => void;
|
||||||
|
enrolling: boolean;
|
||||||
|
colors: ReturnType<typeof useColors>;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 16, gap: 16 }}>
|
||||||
|
<TextInput
|
||||||
|
value={label}
|
||||||
|
onChangeText={setLabel}
|
||||||
|
placeholder={t('devices.label_placeholder')}
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
maxLength={32}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={onPrepare}
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: labelError ? colors.error : colors.border,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{labelError ? (
|
||||||
|
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
{labelError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onPrepare}
|
||||||
|
disabled={enrolling}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed || enrolling ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{enrolling ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('devices.prepare_profile')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2OnboardingContent({
|
||||||
|
onDownload,
|
||||||
|
onConfirmInstalled,
|
||||||
|
onNeedHelp,
|
||||||
|
confirming,
|
||||||
|
colors,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
onDownload: () => void;
|
||||||
|
onConfirmInstalled: () => void;
|
||||||
|
onNeedHelp: () => void;
|
||||||
|
confirming: boolean;
|
||||||
|
colors: ReturnType<typeof useColors>;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}>
|
||||||
|
{/* Lyra intro card */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiveAvatar emotion="empathy" size="sm" />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 19,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('devices.lyra_intro')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 4-step list */}
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{STEPS.map((item, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(0,122,255,0.1)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={item.icon} size={16} color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
|
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t(item.titleKey)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(item.bodyKey)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onDownload}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="download-outline" size={18} color="#fff" />
|
||||||
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('devices.download_button')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Confirm installed */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onConfirmInstalled}
|
||||||
|
disabled={confirming}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed || confirming ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{confirming ? (
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{t('devices.confirm_installed')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Need help */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onNeedHelp}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, alignItems: 'center' })}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('devices.need_help')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3SuccessContent({
|
||||||
|
onClose,
|
||||||
|
colors,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
colors: ReturnType<typeof useColors>;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiveAvatar emotion="happy" size="md" />
|
||||||
|
|
||||||
|
<View style={{ alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('devices.success_title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('devices.success_body')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('common.ok')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -716,6 +716,38 @@
|
|||||||
"picker_job_tenure": "Im aktuellen Job seit",
|
"picker_job_tenure": "Im aktuellen Job seit",
|
||||||
"picker_bundesland": "Bundesland"
|
"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": {
|
"gameOver": {
|
||||||
"title": "Spiel beendet",
|
"title": "Spiel beendet",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
|||||||
@ -716,6 +716,38 @@
|
|||||||
"picker_job_tenure": "Time in current job",
|
"picker_job_tenure": "Time in current job",
|
||||||
"picker_bundesland": "State"
|
"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": {
|
"gameOver": {
|
||||||
"title": "Game over",
|
"title": "Game over",
|
||||||
"score": "Score",
|
"score": "Score",
|
||||||
|
|||||||
78
apps/rebreak-native/stores/protectedDevices.ts
Normal file
78
apps/rebreak-native/stores/protectedDevices.ts
Normal file
@ -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<void>;
|
||||||
|
enroll: (label: string) => Promise<EnrollResult>;
|
||||||
|
confirmInstalled: (id: string) => Promise<void>;
|
||||||
|
remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get) => ({
|
||||||
|
devices: [],
|
||||||
|
loading: false,
|
||||||
|
enrolling: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const devices = await apiFetch<ProtectedDevice[]>('/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<EnrollResult>('/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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user