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:
parent
edf047eacf
commit
0d073b398f
@ -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
|
||||
|
||||
@ -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 {};
|
||||
},
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user