diff --git a/web/src/App.tsx b/web/src/App.tsx index c0e335a..00f542c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -518,7 +518,7 @@ function PortalRoutes() { } export default function App() { - const { user, loading, authError, signInWithGoogle } = useAuth(); + const { user, loading, authError, signInWithGoogle, signInWithEmail } = useAuth(); // Trigger AI insight generation on contractor login useEffect(() => { @@ -596,7 +596,11 @@ export default function App() { ) : isPortalUser ? ( ) : ( - + ) } /> diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index c51f339..148deef 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -3,6 +3,7 @@ import { onAuthStateChanged, signInWithPopup, signInWithRedirect, + signInWithEmailAndPassword, signOut, GoogleAuthProvider, signInWithCustomToken, @@ -12,10 +13,16 @@ import { auth } from '../lib/firebase'; const googleProvider = new GoogleAuthProvider(); -// Returns true if the user authenticated via Google Sign-In (contractor). -// Custom-token users (portal clients) have providerData from a custom provider. +// Demo/dev allowlist — must match the domain in firestore.rules isContractor(). +const DEV_EMAIL_DOMAIN = '@ten99.local'; + +// Returns true if the user authenticated via Google Sign-In (contractor) or +// via password with an allowlisted dev email. Custom-token users (portal +// clients) have providerData from a custom provider. export function isContractorUser(user: User): boolean { - return user.providerData.some((p) => p.providerId === 'google.com'); + if (user.providerData.some((p) => p.providerId === 'google.com')) return true; + const hasPassword = user.providerData.some((p) => p.providerId === 'password'); + return hasPassword && (user.email ?? '').endsWith(DEV_EMAIL_DOMAIN); } export function useAuth() { @@ -78,6 +85,24 @@ export function useAuth() { } }; + const signInWithEmail = async (email: string, password: string) => { + setAuthError(null); + if (!email.endsWith(DEV_EMAIL_DOMAIN)) { + const msg = `Dev sign-in is only available for ${DEV_EMAIL_DOMAIN} accounts.`; + setAuthError(msg); + throw new Error(msg); + } + try { + await signInWithEmailAndPassword(auth, email, password); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Email sign-in failed.'; + console.error('Email sign-in error:', err); + setAuthError(message); + throw err; + } + }; + const signInWithToken = async (token: string) => { setAuthError(null); try { @@ -97,5 +122,14 @@ export function useAuth() { setUser(null); }; - return { user, claims, loading, authError, signInWithGoogle, signInWithToken, logout }; + return { + user, + claims, + loading, + authError, + signInWithGoogle, + signInWithEmail, + signInWithToken, + logout, + }; } diff --git a/web/src/routes/Login.tsx b/web/src/routes/Login.tsx index 2977761..de8cd76 100644 --- a/web/src/routes/Login.tsx +++ b/web/src/routes/Login.tsx @@ -3,11 +3,15 @@ import { BrandWordmark } from '../components/Brand'; interface LoginProps { onSignIn: () => Promise | void; + onDevSignIn?: (email: string, password: string) => Promise | void; error?: string | null; } -export default function Login({ onSignIn, error }: LoginProps) { +export default function Login({ onSignIn, onDevSignIn, error }: LoginProps) { const [loading, setLoading] = useState(false); + const [showDev, setShowDev] = useState(false); + const [email, setEmail] = useState('demo@ten99.local'); + const [password, setPassword] = useState(''); async function handleSignIn() { setLoading(true); @@ -20,6 +24,19 @@ export default function Login({ onSignIn, error }: LoginProps) { } } + async function handleDevSignIn(e: React.FormEvent) { + e.preventDefault(); + if (!onDevSignIn) return; + setLoading(true); + try { + await onDevSignIn(email, password); + } catch { + // Error display handled via the error prop + } finally { + setLoading(false); + } + } + return (
@@ -68,6 +85,59 @@ export default function Login({ onSignIn, error }: LoginProps) { {loading ? 'Signing in...' : 'Sign in with Google'} + + {onDevSignIn && ( + <> + + + {showDev && ( +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg-input)] border border-[var(--border)] text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg-input)] border border-[var(--border)] text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + autoComplete="current-password" + /> +
+ +

+ Only @ten99.local demo emails are allowed for dev sign-in. +

+
+ )} + + )}