399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
IonButton,
|
|
IonCard,
|
|
IonCardContent,
|
|
IonContent,
|
|
IonIcon,
|
|
IonInput,
|
|
IonItem,
|
|
IonLabel,
|
|
IonPage,
|
|
IonSegment,
|
|
IonSegmentButton,
|
|
IonText,
|
|
useIonViewWillEnter,
|
|
} from '@ionic/react';
|
|
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import { Redirect, useHistory } from 'react-router-dom';
|
|
import {
|
|
Style,
|
|
setStatusBarBackground,
|
|
setStatusBarStyle,
|
|
} from '../utils/statusBar';
|
|
import { supabase } from '../supabase';
|
|
|
|
const Auth: React.FC = () => {
|
|
const history = useHistory();
|
|
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [googleLoading, setGoogleLoading] = useState(false);
|
|
const [appleLoading, setAppleLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [info, setInfo] = useState('');
|
|
const [verificationMessage, setVerificationMessage] = useState('');
|
|
const [sessionChecked, setSessionChecked] = useState(false);
|
|
const [hasSession, setHasSession] = useState(false);
|
|
|
|
const showError = (msg: string) => {
|
|
setError(msg);
|
|
setTimeout(() => {
|
|
setError((current) => (current === msg ? '' : current));
|
|
}, 4000);
|
|
};
|
|
|
|
useIonViewWillEnter(() => {
|
|
setStatusBarStyle(Style.Dark);
|
|
setStatusBarBackground('#f6f7fb');
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
|
|
supabase.auth.getSession().then(({ data }) => {
|
|
if (!mounted) return;
|
|
setHasSession(Boolean(data.session));
|
|
setSessionChecked(true);
|
|
});
|
|
|
|
const {
|
|
data: { subscription },
|
|
} = supabase.auth.onAuthStateChange((_event, session) => {
|
|
if (!mounted) return;
|
|
setHasSession(Boolean(session));
|
|
setSessionChecked(true);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
const formValid = useMemo(() => {
|
|
return email.trim().length > 3 && password.length >= 6;
|
|
}, [email, password]);
|
|
|
|
const handleSubmit = async () => {
|
|
setError('');
|
|
setInfo('');
|
|
setVerificationMessage('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (mode === 'login') {
|
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
|
email: email.trim(),
|
|
password,
|
|
});
|
|
|
|
if (signInError) {
|
|
showError(signInError.message);
|
|
return;
|
|
}
|
|
|
|
history.replace('/home');
|
|
return;
|
|
}
|
|
|
|
const { data, error: signUpError } = await supabase.auth.signUp({
|
|
email: email.trim(),
|
|
password,
|
|
});
|
|
|
|
if (signUpError) {
|
|
showError(signUpError.message);
|
|
return;
|
|
}
|
|
|
|
if (data.session) {
|
|
history.replace('/home');
|
|
return;
|
|
}
|
|
|
|
setVerificationMessage(
|
|
'Account created! Check your email for a verification link.'
|
|
);
|
|
setInfo('Once verified, come back and sign in.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGoogleLogin = async () => {
|
|
setError('');
|
|
setInfo('');
|
|
setVerificationMessage('');
|
|
setGoogleLoading(true);
|
|
|
|
if (!Capacitor.isNativePlatform()) {
|
|
showError(
|
|
'Google Sign-In is only available in the native app. Use email to sign in here.'
|
|
);
|
|
setGoogleLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await FirebaseAuthentication.signInWithGoogle();
|
|
const idToken = result.credential?.idToken;
|
|
|
|
if (!idToken) {
|
|
showError('Google sign-in did not return an ID token.');
|
|
return;
|
|
}
|
|
|
|
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
|
|
provider: 'google',
|
|
token: idToken,
|
|
});
|
|
|
|
if (googleAuthError) {
|
|
showError(googleAuthError.message);
|
|
return;
|
|
}
|
|
|
|
history.replace('/home');
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : 'Google sign-in failed.';
|
|
showError(message);
|
|
} finally {
|
|
setGoogleLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAppleLogin = async () => {
|
|
setError('');
|
|
setInfo('');
|
|
setVerificationMessage('');
|
|
setAppleLoading(true);
|
|
|
|
if (!Capacitor.isNativePlatform()) {
|
|
showError(
|
|
'Apple Sign-In is only available in the native app. Use email to sign in here.'
|
|
);
|
|
setAppleLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await FirebaseAuthentication.signInWithApple({
|
|
skipNativeAuth: true,
|
|
});
|
|
const idToken = result.credential?.idToken;
|
|
const rawNonce = result.credential?.nonce;
|
|
|
|
if (!idToken) {
|
|
showError('Apple sign-in did not return an ID token.');
|
|
return;
|
|
}
|
|
|
|
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
|
|
provider: 'apple',
|
|
token: idToken,
|
|
nonce: rawNonce || undefined,
|
|
});
|
|
|
|
if (appleAuthError) {
|
|
showError(appleAuthError.message);
|
|
return;
|
|
}
|
|
|
|
history.replace('/home');
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : 'Apple sign-in failed.';
|
|
showError(message);
|
|
} finally {
|
|
setAppleLoading(false);
|
|
}
|
|
};
|
|
|
|
if (sessionChecked && hasSession) {
|
|
return <Redirect to="/home" />;
|
|
}
|
|
|
|
return (
|
|
<IonPage>
|
|
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
|
|
<div className="auth-shell">
|
|
<div className="auth-hero">
|
|
<span className="eyebrow">Protect your streaks</span>
|
|
<h1>Build habits that actually stick.</h1>
|
|
<p>
|
|
Track daily wins, recover faster from misses, and see which habits
|
|
are getting real momentum.
|
|
</p>
|
|
</div>
|
|
|
|
<IonCard className="auth-card">
|
|
<IonCardContent>
|
|
<IonSegment
|
|
value={mode}
|
|
onIonChange={(e) =>
|
|
setMode(e.detail.value as 'login' | 'signup')
|
|
}
|
|
>
|
|
<IonSegmentButton value="login">
|
|
<IonLabel>Login</IonLabel>
|
|
</IonSegmentButton>
|
|
<IonSegmentButton value="signup">
|
|
<IonLabel>Sign Up</IonLabel>
|
|
</IonSegmentButton>
|
|
</IonSegment>
|
|
|
|
{verificationMessage ? (
|
|
<div className="auth-message-block">
|
|
<h2>Check your inbox</h2>
|
|
<p>{verificationMessage}</p>
|
|
<IonText color="medium">
|
|
<p>{info}</p>
|
|
</IonText>
|
|
<IonButton fill="clear" onClick={() => setMode('login')}>
|
|
Back to Login
|
|
</IonButton>
|
|
</div>
|
|
) : (
|
|
<div className="auth-form">
|
|
<IonItem lines="none" className="auth-input-item">
|
|
<IonInput
|
|
type="email"
|
|
label="Email"
|
|
labelPlacement="stacked"
|
|
value={email}
|
|
placeholder="you@example.com"
|
|
onIonInput={(e) => setEmail(e.detail.value ?? '')}
|
|
/>
|
|
</IonItem>
|
|
|
|
<IonItem lines="none" className="auth-input-item">
|
|
<IonInput
|
|
type="password"
|
|
label="Password"
|
|
labelPlacement="stacked"
|
|
value={password}
|
|
placeholder="At least 6 characters"
|
|
onIonInput={(e) => setPassword(e.detail.value ?? '')}
|
|
/>
|
|
</IonItem>
|
|
|
|
{error ? (
|
|
<IonText color="danger">
|
|
<p className="auth-feedback">{error}</p>
|
|
</IonText>
|
|
) : null}
|
|
|
|
{info ? (
|
|
<IonText color="medium">
|
|
<p className="auth-feedback">{info}</p>
|
|
</IonText>
|
|
) : null}
|
|
|
|
<IonButton
|
|
expand="block"
|
|
disabled={
|
|
!formValid || loading || googleLoading || appleLoading
|
|
}
|
|
onClick={handleSubmit}
|
|
>
|
|
{loading
|
|
? 'Please wait…'
|
|
: mode === 'login'
|
|
? 'Log In'
|
|
: 'Create Account'}
|
|
</IonButton>
|
|
|
|
{mode === 'login' ? (
|
|
<>
|
|
<div className="auth-divider">
|
|
<span>or</span>
|
|
</div>
|
|
|
|
<IonButton
|
|
expand="block"
|
|
fill="outline"
|
|
className="google-auth-button"
|
|
disabled={loading || googleLoading || appleLoading}
|
|
onClick={handleGoogleLogin}
|
|
>
|
|
<span
|
|
slot="start"
|
|
className="google-svg-wrap"
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
marginRight: '8px',
|
|
}}
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
style={{ display: 'block' }}
|
|
>
|
|
<path
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
fill="#4285F4"
|
|
/>
|
|
<path
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
fill="#34A853"
|
|
/>
|
|
<path
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
|
fill="#FBBC05"
|
|
/>
|
|
<path
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
fill="#EA4335"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
{googleLoading ? 'Connecting…' : 'Continue with Google'}
|
|
</IonButton>
|
|
|
|
<IonButton
|
|
expand="block"
|
|
fill="outline"
|
|
className="apple-auth-button"
|
|
disabled={loading || googleLoading || appleLoading}
|
|
onClick={handleAppleLogin}
|
|
>
|
|
<span
|
|
slot="start"
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
marginRight: '8px',
|
|
}}
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
style={{ display: 'block' }}
|
|
>
|
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
|
</svg>
|
|
</span>
|
|
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
|
|
</IonButton>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</IonCardContent>
|
|
</IonCard>
|
|
</div>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
};
|
|
|
|
export default Auth;
|