From d857d2a7aaad216fff9833be3d2781f57616920e Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 8 May 2026 21:27:33 +0200 Subject: [PATCH] feat(devices): global Device-Limit-Reached handler + recovery sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend wirft 403 device_limit_reached für ALLE auth'd endpoints sobald User über plan-limit ist. Bisheriges Frontend hat silent gefailt → Profile/Notifications/etc zeigten nichts mehr, User war verwirrt. Now: - lib/api.ts: 403 device_limit_reached intercepten, parse error.data.devices, trigger useDeviceLimitStore.show() - stores/deviceLimit.ts: Zustand store (visible, devices, max, plan, show/hide) - components/DeviceLimitReachedSheet.tsx: TrueSheet (UISheetPresentationController) Auto-präsentiert wenn store visible, zeigt device-list mit trash-button per Eintrag, DELETE /api/devices/:id mit skipDeviceHeader: true (sonst circular 403) - app/_layout.tsx: als globaler overlay vor - i18n: device_limit_* keys DE+EN UX: User sieht jetzt sofort native bottom-sheet mit erklärung + actionable device-list statt silent fail. Auto-close wenn devices.length < max nach delete. TS-fix: detents={['auto', 1] satisfies SheetDetent[]}, onDidDismiss statt onDismiss (prop heißt anders in TrueSheet API). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/_layout.tsx | 2 + .../components/DeviceLimitReachedSheet.tsx | 236 ++++++++++++++++++ apps/rebreak-native/lib/api.ts | 15 ++ apps/rebreak-native/locales/de.json | 6 + apps/rebreak-native/locales/en.json | 6 + apps/rebreak-native/stores/deviceLimit.ts | 33 +++ 6 files changed, 298 insertions(+) create mode 100644 apps/rebreak-native/components/DeviceLimitReachedSheet.tsx create mode 100644 apps/rebreak-native/stores/deviceLimit.ts diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 26c7968..54f904d 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -18,6 +18,7 @@ import { useAuthStore } from '../stores/auth'; import { useThemeStore } from '../stores/theme'; import { useLanguageStore } from '../stores/language'; import { BrandSplash } from '../components/BrandSplash'; +import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; import '../lib/i18n'; // i18next-Init via Side-Effect import '../global.css'; @@ -71,6 +72,7 @@ function RootLayoutInner() { return ( <> + ['name'] { + if (platform === 'ios') return 'logo-apple'; + if (platform === 'android') return 'logo-android'; + return 'phone-portrait-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); + if (min < 1) return t('settings.devices_just_now'); + if (min < 60) return t('settings.devices_mins_ago', { count: min }); + const hr = Math.floor(min / 60); + if (hr < 24) return t('settings.devices_hours_ago', { count: hr }); + const day = Math.floor(hr / 24); + if (day < 30) return t('settings.devices_days_ago', { count: day }); + return new Date(iso).toLocaleDateString( + Platform.OS === 'ios' ? undefined : 'de-DE', + { day: '2-digit', month: 'short', year: 'numeric' } + ); +} + +function DeviceLimitRow({ + device, + removing, + onRemove, +}: { + device: DeviceLimitDevice; + removing: boolean; + onRemove: (id: string) => void; +}) { + const { t } = useTranslation(); + + return ( + + + + + + + + {device.name ?? device.model ?? device.platform} + + {device.model && device.name && !device.name.includes(device.model) ? ( + + {device.model} + + ) : null} + + + + {formatLastSeen(device.lastSeenAt, t)} + + + + + {removing ? ( + + ) : ( + onRemove(device.id)} + hitSlop={8} + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} + > + + + )} + + ); +} + +export function DeviceLimitReachedSheet() { + const { t } = useTranslation(); + const sheetRef = useRef(null); + const { visible, devices, max, plan, hide, removeDevice } = useDeviceLimitStore(); + const [removingId, setRemovingId] = useState(null); + + useEffect(() => { + if (visible) { + sheetRef.current?.present(); + } + }, [visible]); + + async function handleRemove(id: string) { + setRemovingId(id); + try { + await apiFetch(`/api/devices/${id}`, { + method: 'DELETE', + skipDeviceHeader: true, + }); + removeDevice(id); + + const remaining = useDeviceLimitStore.getState().devices; + if (remaining.length < max) { + sheetRef.current?.dismiss(); + hide(); + } + } finally { + setRemovingId(null); + } + } + + return ( + + + + + + + + {t('device_limit.title')} + + + {t('device_limit.subtitle', { max, plan: plan.toUpperCase() })} + + + + {devices.map((device, i) => ( + + + + ))} + + + + {t('device_limit.hint')} + + + + ); +} diff --git a/apps/rebreak-native/lib/api.ts b/apps/rebreak-native/lib/api.ts index 0c6d98a..8d73b0d 100644 --- a/apps/rebreak-native/lib/api.ts +++ b/apps/rebreak-native/lib/api.ts @@ -1,6 +1,7 @@ import Constants from 'expo-constants'; import { supabase } from './supabase'; import { getDeviceId, getPlatformName } from './deviceId'; +import { useDeviceLimitStore } from '../stores/deviceLimit'; const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; @@ -49,6 +50,20 @@ export async function apiFetch( if (!res.ok) { const text = await res.text(); + + if (res.status === 403) { + try { + const parsed = JSON.parse(text); + if ( + parsed?.statusMessage === 'device_limit_reached' || + parsed?.data?.error === 'device_limit_reached' + ) { + const { devices, max, plan } = parsed.data; + useDeviceLimitStore.getState().show(devices ?? [], max ?? 0, plan ?? 'free'); + } + } catch {} + } + throw new Error(`API ${res.status}: ${text}`); } diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 6e2de2e..14b44b5 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -483,6 +483,12 @@ "devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.", "devices_remove_confirm": "Entfernen" }, + "device_limit": { + "title": "Geräte-Limit erreicht", + "subtitle": "{{max}} von {{max}} Geräten belegt ({{plan}}) — entferne ein Gerät um weiterzumachen", + "hint": "Entfernte Geräte können sich beim nächsten Login wieder registrieren.", + "remove_cta": "Gerät entfernen" + }, "urge": { "title": "SOS — Atemübung", "step_dashboard": "Start", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 65fa117..44d8236 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -483,6 +483,12 @@ "devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.", "devices_remove_confirm": "Remove" }, + "device_limit": { + "title": "Device limit reached", + "subtitle": "{{max}} of {{max}} device slots used ({{plan}}) — remove a device to continue", + "hint": "Removed devices can re-register on next sign-in.", + "remove_cta": "Remove device" + }, "urge": { "title": "SOS — Breathing exercise", "step_dashboard": "Start", diff --git a/apps/rebreak-native/stores/deviceLimit.ts b/apps/rebreak-native/stores/deviceLimit.ts new file mode 100644 index 0000000..f1e501b --- /dev/null +++ b/apps/rebreak-native/stores/deviceLimit.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand'; + +export type DeviceLimitDevice = { + id: string; + deviceId: string; + platform: string; + model: string | null; + name: string | null; + lastSeenAt: string; + createdAt: string; +}; + +type DeviceLimitState = { + visible: boolean; + devices: DeviceLimitDevice[]; + max: number; + plan: string; + show: (devices: DeviceLimitDevice[], max: number, plan: string) => void; + hide: () => void; + removeDevice: (id: string) => void; +}; + +export const useDeviceLimitStore = create((set) => ({ + visible: false, + devices: [], + max: 0, + plan: 'free', + + show: (devices, max, plan) => set({ visible: true, devices, max, plan }), + hide: () => set({ visible: false }), + removeDevice: (id) => + set((s) => ({ devices: s.devices.filter((d) => d.id !== id) })), +}));