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 {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -10,7 +10,8 @@ import { useRouter } from 'expo-router';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
@ -34,6 +35,152 @@ function AppleIcon() {
|
|||||||
|
|
||||||
type OAuthProvider = 'google' | 'apple' | null;
|
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 = {
|
const INPUT_STYLE = {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
@ -53,6 +200,7 @@ export default function SignInScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
|
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
|
||||||
|
const [deviceLocked, setDeviceLocked] = useState<DeviceLockedError | null>(null);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!email.trim() || !password) return;
|
if (!email.trim() || !password) return;
|
||||||
@ -60,6 +208,10 @@ export default function SignInScreen() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const res = await signInWithPassword(email.trim(), password);
|
const res = await signInWithPassword(email.trim(), password);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
if (res.deviceLocked) {
|
||||||
|
setDeviceLocked(res.deviceLocked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setError(res.error);
|
setError(res.error);
|
||||||
return;
|
return;
|
||||||
@ -81,6 +233,14 @@ export default function SignInScreen() {
|
|||||||
|
|
||||||
const isLoading = submitting || oauthLoading !== null;
|
const isLoading = submitting || oauthLoading !== null;
|
||||||
|
|
||||||
|
if (deviceLocked) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }}>
|
||||||
|
<DeviceLockedPanel locked={deviceLocked} onBack={() => setDeviceLocked(null)} />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
<KeyboardAwareScreen
|
<KeyboardAwareScreen
|
||||||
|
|||||||
@ -3,16 +3,25 @@ import type { Session, User } from '@supabase/supabase-js';
|
|||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import * as Linking from 'expo-linking';
|
import * as Linking from 'expo-linking';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
export type DeviceLockedError = {
|
||||||
|
type: 'DEVICE_LOCKED';
|
||||||
|
lockedUntil: string | null;
|
||||||
|
releaseRequestable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignInResult = { error?: string; deviceLocked?: DeviceLockedError };
|
||||||
|
|
||||||
type AuthState = {
|
type AuthState = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
signInWithPassword: (email: string, password: string) => Promise<{ error?: string }>;
|
signInWithPassword: (email: string, password: string) => Promise<SignInResult>;
|
||||||
signUp: (
|
signUp: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@ -47,6 +56,41 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
set({ session: data.session, user: data.user });
|
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 {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user