feat(native): DEVICE_LOCKED sign-in handling + DeviceLockedPanel UI

After Supabase auth succeeds the store calls POST /api/devices/check-lock
(x-device-id auto-attached via apiFetch). A 409 DEVICE_LOCKED response
triggers a Supabase sign-out and returns { deviceLocked } instead of
proceeding. The signin screen swaps to DeviceLockedPanel which shows:
- lock icon + headline + explanatory body
- amber countdown badge if a release is already in progress
- grey hint pointing to the email notification
- primary CTA to go back and sign in with the original account

Backend TODO: POST /api/devices/check-lock endpoint — same device-lock
query as login.post.ts but callable with a valid Supabase session token
(for email-login flow that bypasses /api/auth/login).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-16 00:37:22 +02:00
parent edf047eacf
commit 0d073b398f
2 changed files with 207 additions and 3 deletions

View File

@ -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<string | null>(
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 (
<View style={{ paddingHorizontal: 24, paddingVertical: 32, gap: 20 }}>
<View
style={{
width: 48,
height: 48,
borderRadius: 16,
backgroundColor: 'rgba(220,38,38,0.08)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="lock-closed" size={22} color="#dc2626" />
</View>
<Text
style={{
fontSize: 22,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
lineHeight: 28,
}}
>
{t('auth.device_locked_headline')}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#525252',
lineHeight: 22,
}}
>
{t('auth.device_locked_body')}
</Text>
{remaining ? (
<View
style={{
backgroundColor: 'rgba(245,158,11,0.1)',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#b45309',
}}
>
{t('auth.device_locked_countdown', { remaining })}
</Text>
</View>
) : null}
<View
style={{
backgroundColor: '#f5f5f5',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#737373',
lineHeight: 18,
}}
>
{t('auth.device_locked_email_hint')}
</Text>
</View>
{locked.releaseRequestable ? (
<TouchableOpacity
onPress={onBack}
activeOpacity={0.8}
style={{
backgroundColor: '#0a0a0a',
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: '#ffffff',
}}
>
{t('auth.device_locked_use_original')}
</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity onPress={onBack} activeOpacity={0.7} style={{ alignItems: 'center', paddingVertical: 8 }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
}}
>
{t('auth.device_locked_back')}
</Text>
</TouchableOpacity>
</View>
);
}
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
@ -53,6 +200,7 @@ export default function SignInScreen() {
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
const [deviceLocked, setDeviceLocked] = useState<DeviceLockedError | null>(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 (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }}>
<DeviceLockedPanel locked={deviceLocked} onBack={() => setDeviceLocked(null)} />
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAwareScreen

View File

@ -3,16 +3,25 @@ import type { Session, User } from '@supabase/supabase-js';
import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking';
import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api';
WebBrowser.maybeCompleteAuthSession();
export type DeviceLockedError = {
type: 'DEVICE_LOCKED';
lockedUntil: string | null;
releaseRequestable: boolean;
};
type SignInResult = { error?: string; deviceLocked?: DeviceLockedError };
type AuthState = {
user: User | null;
session: Session | null;
loading: boolean;
init: () => Promise<void>;
signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>;
signInWithPassword: (email: string, password: string) => Promise<SignInResult>;
signUp: (
email: string,
password: string,
@ -47,6 +56,41 @@ export const useAuthStore = create<AuthState>((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 {};
},