CI / check (push) Canceled after 0s
"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
117 lines
4.7 KiB
TypeScript
117 lines
4.7 KiB
TypeScript
import { SpaceGrotesk_500Medium, SpaceGrotesk_700Bold, useFonts } from '@expo-google-fonts/space-grotesk';
|
|
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
|
|
import * as SplashScreen from 'expo-splash-screen';
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import { useEffect } from 'react';
|
|
import { AppState, StyleSheet, View } from 'react-native';
|
|
import useSWR, { SWRConfig } from 'swr';
|
|
import type { ConnectionDTO } from '@musicshare/shared';
|
|
import { Body, Button, Title } from '@/components/ui';
|
|
import { api } from '@/lib/api';
|
|
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
|
import { syncDevice } from '@/lib/nowPlayingReader';
|
|
import { SessionProvider, useSession } from '@/lib/session';
|
|
import { colors, space } from '@/lib/theme';
|
|
|
|
SplashScreen.preventAutoHideAsync();
|
|
|
|
const theme = {
|
|
...DarkTheme,
|
|
colors: { ...DarkTheme.colors, background: colors.bg, card: colors.bg, text: colors.text, primary: colors.accent, border: colors.border },
|
|
};
|
|
|
|
export default function RootLayout() {
|
|
return (
|
|
<ThemeProvider value={theme}>
|
|
<StatusBar style="light" />
|
|
<SessionProvider>
|
|
<AppNavigator />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
function AppNavigator() {
|
|
const { status, me, retry } = useSession();
|
|
const signedIn = status === 'signedIn';
|
|
const onboarded = Boolean(me?.onboardedAt);
|
|
const [fontsLoaded, fontError] = useFonts({ SpaceGrotesk_500Medium, SpaceGrotesk_700Bold });
|
|
const ready = status !== 'loading' && (fontsLoaded || Boolean(fontError));
|
|
|
|
useEffect(() => {
|
|
if (ready) SplashScreen.hideAsync();
|
|
}, [ready]);
|
|
|
|
// Keep the Android listener pointed at this account (no-op elsewhere).
|
|
useEffect(() => {
|
|
if (signedIn) syncDevice().catch(() => {});
|
|
}, [signedIn]);
|
|
|
|
if (!ready) return null;
|
|
if (status === 'offline') {
|
|
return (
|
|
<View style={styles.offline}>
|
|
<Title>Can't reach musicshare</Title>
|
|
<Body muted>Check your connection and try again.</Body>
|
|
<Button title="Retry" variant="primary" onPress={retry} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
// Keyed by user so signing out (or in as someone else) starts from an empty cache.
|
|
<SWRConfig
|
|
key={me?.id ?? 'anon'}
|
|
value={{
|
|
fetcher: (path: string) => api.get(path),
|
|
provider: () => new Map(),
|
|
isVisible: () => AppState.currentState === 'active',
|
|
initFocus(callback) {
|
|
const sub = AppState.addEventListener('change', (s) => s === 'active' && callback());
|
|
return () => sub.remove();
|
|
},
|
|
}}>
|
|
{signedIn && appleMusicAvailable && <AppleMusicLive />}
|
|
<Stack
|
|
screenOptions={{
|
|
headerStyle: { backgroundColor: colors.bg },
|
|
headerTintColor: colors.text,
|
|
headerShadowVisible: false,
|
|
contentStyle: { backgroundColor: colors.bg },
|
|
}}>
|
|
<Stack.Protected guard={signedIn && onboarded}>
|
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
|
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
|
<Stack.Screen name="qr" options={{ title: 'My code', presentation: 'modal' }} />
|
|
<Stack.Screen name="spotify-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
|
</Stack.Protected>
|
|
{/* New accounts go through onboarding first (finishing flips onboardedAt and lands on the feed). */}
|
|
<Stack.Protected guard={signedIn && !onboarded}>
|
|
<Stack.Screen name="onboarding" options={{ headerShown: false, gestureEnabled: false }} />
|
|
</Stack.Protected>
|
|
<Stack.Protected guard={signedIn}>
|
|
{/* Reachable from onboarding too (scanning a friend's code, viewing a profile). */}
|
|
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
|
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
|
</Stack.Protected>
|
|
<Stack.Protected guard={!signedIn}>
|
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
|
<Stack.Screen name="register" options={{ title: 'Create account' }} />
|
|
<Stack.Screen name="forgot-password" options={{ title: 'Reset password' }} />
|
|
</Stack.Protected>
|
|
</Stack>
|
|
</SWRConfig>
|
|
);
|
|
}
|
|
|
|
/** iOS: mirror the Music app live while the app is open, once Apple Music is connected. */
|
|
function AppleMusicLive() {
|
|
const { data: connections } = useSWR<ConnectionDTO[]>('/connections');
|
|
useAppleMusicLive(Boolean(connections?.some((c) => c.provider === 'apple_music' && c.status === 'active')));
|
|
return null;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
offline: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: space.xl, gap: space.md },
|
|
});
|