diff --git a/apps/rebreak-native/app/(auth)/signin.tsx b/apps/rebreak-native/app/(auth)/signin.tsx index d8b7d32..254f729 100644 --- a/apps/rebreak-native/app/(auth)/signin.tsx +++ b/apps/rebreak-native/app/(auth)/signin.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { View, Text, @@ -10,7 +10,8 @@ import { useRouter } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Path } from 'react-native-svg'; import { useTranslation } from 'react-i18next'; -import { useAuthStore } from '../../stores/auth'; +import { Ionicons } from '@expo/vector-icons'; +import { useAuthStore, type DeviceLockedError } from '../../stores/auth'; import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen'; function GoogleIcon() { @@ -34,6 +35,152 @@ function AppleIcon() { type OAuthProvider = 'google' | 'apple' | null; +function formatRemainingTime(isoTarget: string): string { + const ms = new Date(isoTarget).getTime() - Date.now(); + if (ms <= 0) return '0min'; + const totalMin = Math.floor(ms / 60_000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + if (h > 0) return `${h}h ${m}min`; + return `${m}min`; +} + +function DeviceLockedPanel({ + locked, + onBack, +}: { + locked: DeviceLockedError; + onBack: () => void; +}) { + const { t } = useTranslation(); + const [remaining, setRemaining] = useState( + locked.lockedUntil ? formatRemainingTime(locked.lockedUntil) : null + ); + + useEffect(() => { + if (!locked.lockedUntil) return; + const id = setInterval(() => { + setRemaining(formatRemainingTime(locked.lockedUntil!)); + }, 30_000); + return () => clearInterval(id); + }, [locked.lockedUntil]); + + return ( + + + + + + + {t('auth.device_locked_headline')} + + + + {t('auth.device_locked_body')} + + + {remaining ? ( + + + {t('auth.device_locked_countdown', { remaining })} + + + ) : null} + + + + {t('auth.device_locked_email_hint')} + + + + {locked.releaseRequestable ? ( + + + {t('auth.device_locked_use_original')} + + + ) : null} + + + + {t('auth.device_locked_back')} + + + + ); +} + const INPUT_STYLE = { fontSize: 16, lineHeight: 22, @@ -53,6 +200,7 @@ export default function SignInScreen() { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [oauthLoading, setOauthLoading] = useState(null); + const [deviceLocked, setDeviceLocked] = useState(null); const onSubmit = async () => { if (!email.trim() || !password) return; @@ -60,6 +208,10 @@ export default function SignInScreen() { setSubmitting(true); const res = await signInWithPassword(email.trim(), password); setSubmitting(false); + if (res.deviceLocked) { + setDeviceLocked(res.deviceLocked); + return; + } if (res.error) { setError(res.error); return; @@ -81,6 +233,14 @@ export default function SignInScreen() { const isLoading = submitting || oauthLoading !== null; + if (deviceLocked) { + return ( + + setDeviceLocked(null)} /> + + ); + } + return ( Promise; - signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>; + signInWithPassword: (email: string, password: string) => Promise; signUp: ( email: string, password: string, @@ -47,6 +56,41 @@ export const useAuthStore = create((set) => ({ const { data, error } = await supabase.auth.signInWithPassword({ email, password }); if (error) return { error: error.message }; set({ session: data.session, user: data.user }); + + // After Supabase auth succeeds, check device binding via the register endpoint. + // If the device is already bound to a different account the backend returns 409 + // DEVICE_LOCKED. We sign back out of Supabase in that case — the user cannot + // proceed on this device until the lock is released. + // TODO(backend): move this check into POST /api/devices/register so it fires + // automatically with x-device-id. Currently requires explicit call here. + try { + await apiFetch('/api/devices/check-lock', { method: 'POST' }); + } catch (e: any) { + const msg: string = e?.message ?? ''; + const status = parseInt(msg.match(/^API (\d+)/)?.[1] ?? '0', 10); + if (status === 409) { + try { + const raw = msg.replace(/^API \d+: /, ''); + const parsed = JSON.parse(raw); + const lockData = parsed?.data ?? parsed; + if (lockData?.error === 'DEVICE_LOCKED') { + await supabase.auth.signOut(); + set({ session: null, user: null }); + return { + deviceLocked: { + type: 'DEVICE_LOCKED', + lockedUntil: lockData.lockedUntil ?? null, + releaseRequestable: lockData.releaseRequestable ?? true, + }, + }; + } + } catch { + // JSON parse failed — not a DEVICE_LOCKED response, ignore + } + } + // Non-409 errors from the check are non-fatal (device check is best-effort) + } + return {}; },