chahinebrini 0d073b398f 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>
2026-05-16 00:37:22 +02:00

223 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
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<SignInResult>;
signUp: (
email: string,
password: string,
metadata: { username: string; firstName?: string; lastName?: string; avatarId: string; avatarUrl: string }
) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
signInWithOAuth: (provider: 'google' | 'apple') => Promise<{ error?: string }>;
resetPasswordForEmail: (email: string) => Promise<{ error?: string }>;
verifyOtp: (email: string, token: string) => Promise<{ error?: string }>;
resendConfirmation: (email: string) => Promise<{ error?: string }>;
};
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
loading: true,
init: async () => {
const { data } = await supabase.auth.getSession();
set({
session: data.session,
user: data.session?.user ?? null,
loading: false,
});
supabase.auth.onAuthStateChange((_event, session) => {
set({ session, user: session?.user ?? null });
});
},
signInWithPassword: async (email, password) => {
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 {};
},
signUp: async (email, password, metadata) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
username: metadata.username,
first_name: metadata.firstName ?? '',
last_name: metadata.lastName ?? '',
avatar_id: metadata.avatarId,
avatar_url: metadata.avatarUrl,
},
// Deep-link redirect for email confirmation — scheme registered in app.config.ts
emailRedirectTo: 'rebreak://auth/confirm',
},
});
if (error) return { error: error.message };
set({ session: data.session, user: data.user ?? null });
return {};
},
signOut: async () => {
await supabase.auth.signOut();
// Google-OAuth-Cookie in SafariViewController/Chrome Custom Tabs leeren,
// sonst springt der nächste Sign-in stillschweigend auf den vorigen Account
// statt den Account-Picker zu zeigen.
try {
await WebBrowser.coolDownAsync();
} catch {
// ignore
}
set({ session: null, user: null });
},
signInWithOAuth: async (provider) => {
const redirectUri = Linking.createURL('auth/callback');
if (provider === 'apple') {
// TODO: configure Apple Sign-In
// Requires expo-apple-authentication to be installed + Apple Developer entitlement.
// Apple Client ID = Bundle ID (org.rebreak.app) for native flow.
// For now we fall through to the Supabase OAuth web flow as a temporary path.
}
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: redirectUri,
skipBrowserRedirect: true,
// Google: prompt=select_account erzwingt den Account-Picker,
// auch wenn der Browser noch eine aktive Google-Session hat.
...(provider === 'google' ? { queryParams: { prompt: 'select_account' } } : {}),
},
});
if (error) return { error: error.message };
if (!data.url) return { error: 'Kein OAuth-URL erhalten' };
// Cleanup eines evtl. noch offenen WebBrowser-Sessions aus einem vorherigen,
// abgebrochenen OAuth-Versuch — sonst wirft openAuthSessionAsync mit
// „Another web browser is already open". Idempotent, safe auch wenn nichts
// offen ist.
try {
await WebBrowser.dismissAuthSession();
} catch {
// ignore
}
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri);
if (result.type !== 'success') {
return result.type === 'cancel' ? {} : { error: 'OAuth fehlgeschlagen' };
}
// Extract tokens from the deep-link URL fragment
const url = result.url;
const params = new URLSearchParams(url.split('#')[1] ?? url.split('?')[1] ?? '');
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (!accessToken || !refreshToken) {
// Session may already be set via onAuthStateChange — check
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData.session) {
set({ session: sessionData.session, user: sessionData.session.user });
return {};
}
return { error: 'Session konnte nicht gelesen werden' };
}
const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
if (sessionError) return { error: sessionError.message };
set({ session: sessionData.session, user: sessionData.session?.user ?? null });
return {};
},
resetPasswordForEmail: async (email) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'rebreak://auth/reset-password',
});
if (error) return { error: error.message };
return {};
},
verifyOtp: async (email, token) => {
const { data, error } = await supabase.auth.verifyOtp({
email,
token,
type: 'signup',
});
if (error) return { error: error.message };
if (!data.session) return { error: 'Bestätigung fehlgeschlagen bitte erneut versuchen.' };
set({ session: data.session, user: data.user ?? null });
return {};
},
resendConfirmation: async (email) => {
const { error } = await supabase.auth.resend({ type: 'signup', email });
if (error) return { error: error.message };
return {};
},
}));