diff --git a/projects/app/src/app/dev/auth.tsx b/projects/app/src/app/dev/auth.tsx new file mode 100644 index 0000000000..653575ef31 --- /dev/null +++ b/projects/app/src/app/dev/auth.tsx @@ -0,0 +1,229 @@ +import { useAuth } from "@/client/auth"; +import { useRizzleQuery } from "@/client/hooks/useRizzleQuery"; +import { RectButton } from "@/client/ui/RectButton"; +import { SessionStoreProvider } from "@/client/ui/SessionStoreProvider"; +import { SignInWithAppleButton } from "@/client/ui/SignInWithAppleButton"; +import { TextInputSingle } from "@/client/ui/TextInputSingle"; +import { invariant } from "@pinyinly/lib/invariant"; +import * as AppleAuthentication from "expo-apple-authentication"; +import { Link } from "expo-router"; +import { useState } from "react"; +import { Platform, Text, View } from "react-native"; +import z from "zod/v4"; + +export default function DevAuthPage() { + return ( + + + Authentication Development Tools + + Manage sessions, debug authentication flows, and test login functionality. + + + + + + ); +} + +function DeveloperDebugSection() { + const auth = useAuth(); + + return ( + + {/* Existing Sessions */} + {auth.data?.allDeviceSessions && auth.data.allDeviceSessions.length > 0 && ( + + Existing Sessions + {auth.data.allDeviceSessions.map((session, i) => ( + + + + + Session: {session.serverSessionId ?? `Anonymous`} + + + Skills: + + + DB: {session.replicacheDbName} + + + { + auth.logInToExistingDeviceSession( + (s) => s.replicacheDbName === session.replicacheDbName, + ); + }} + > + Use Session + + + + ))} + + )} + + {/* Current Session Info */} + + Current Session + + + Session ID: {auth.data?.activeDeviceSession.serverSessionId ?? `Anonymous`} + + + Database: {auth.data?.activeDeviceSession.replicacheDbName} + + + + + {/* Session ID Login */} + + Session ID Login + + + + {/* Dev Actions */} + + Development Actions + + { auth.signOut(); }} + > + Sign Out + + + UI Components + + + API Debug + + + + + {/* Deprecated Apple Sign-in */} + + Deprecated Features + + Apple Sign-in (deprecated - for development/testing only) + + + + + ); +} + +function ServerSessionIdLoginForm() { + const auth = useAuth(); + const [input, setInput] = useState(``); + + return ( + + + Enter a server session ID to log in directly + + + + { + if (e.nativeEvent.key === `Enter`) { + auth.logInWithServerSessionId(input); + e.preventDefault(); + } + }} + value={input} + onChangeText={(text) => { + setInput(text); + }} + /> + + { + auth.logInWithServerSessionId(input); + }} + disabled={!input.trim()} + > + Login + + + + ); +} + +function SkillCount() { + const result = useRizzleQuery([`wordCount`], async (r, tx) => { + const skillStates = await r.query.skillState.scan(tx).toArray(); + return skillStates.length; + }); + + return result.isPending ? ( + Loading… + ) : ( + {result.data} words + ); +} + +function AppleSignInSection() { + const auth = useAuth(); + + return ( + + {/* Apple Sign In */} + {Platform.OS === `web` ? ( + { + void auth.logInWithApple(data.authorization.id_token); + }} + redirectUri={`https://${location.hostname}/api/auth/login/apple/callback`} + /> + ) : Platform.OS === `ios` ? ( + { + let credential; + try { + credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + }); + } catch (error) { + const err = z.object({ code: z.string() }).safeParse(error); + if (err.success) { + switch (err.data.code) { + case `ERR_REQUEST_CANCELED`: { + console.error(`Apple sign-in canceled`); + break; + } + default: { + console.error(`Apple sign-in error:`, err.data); + } + } + } else { + console.error(`Unknown Apple sign-in error:`, error); + } + return; + } + + invariant(credential.identityToken != null); + void auth.logInWithApple(credential.identityToken); + }} + /> + ) : Platform.OS === `android` ? ( + Apple Sign-in not available on Android + ) : ( + Apple Sign-in not available on this platform + )} + + ); +} \ No newline at end of file diff --git a/projects/app/src/app/dev/index.tsx b/projects/app/src/app/dev/index.tsx new file mode 100644 index 0000000000..82a1a6f7a5 --- /dev/null +++ b/projects/app/src/app/dev/index.tsx @@ -0,0 +1,53 @@ +import { RectButton } from "@/client/ui/RectButton"; +import { Link } from "expo-router"; +import { Text, View } from "react-native"; + +export default function DevIndexPage() { + return ( + + + Development Tools + + Access development utilities, debugging tools, and component demos. + + + + + + + + + + + + ); +} + +function DevToolCard({ title, description, href }: { + title: string; + description: string; + href: `/dev/auth` | `/dev/ui` | `/dev/api`; +}) { + return ( + + + + {title} + {description} + + + + ); +} \ No newline at end of file diff --git a/projects/app/src/app/login.tsx b/projects/app/src/app/login.tsx index a09658fe51..133c574835 100644 --- a/projects/app/src/app/login.tsx +++ b/projects/app/src/app/login.tsx @@ -1,194 +1,149 @@ import { useAuth } from "@/client/auth"; -import { useRizzleQuery } from "@/client/hooks/useRizzleQuery"; import { RectButton } from "@/client/ui/RectButton"; -import { SessionStoreProvider } from "@/client/ui/SessionStoreProvider"; -import { SignInWithAppleButton } from "@/client/ui/SignInWithAppleButton"; import { TextInputSingle } from "@/client/ui/TextInputSingle"; -import { invariant } from "@pinyinly/lib/invariant"; -import * as AppleAuthentication from "expo-apple-authentication"; import { Link } from "expo-router"; import { useState } from "react"; import { Platform, Text, View } from "react-native"; -import z from "zod/v4"; export default function LoginPage() { - const auth = useAuth(); - - const [name, setName] = useState(``); - return ( - - Passkey - - - { - auth.logInWithPasskey().catch((error: unknown) => { - console.error(`failed to log in with passkey`, error); - }); - }} - > - Log in with Passkey - - - - { - auth.logInWithPasskey().catch((error: unknown) => { - console.error(`failed to log in with passkey`, error); - }); - }} - > - Log in with Passkey (conditional UI) - - - - - { - setName(text); - }} - value={name} - /> - { - auth.signUpWithPasskey({ name }).catch((error: unknown) => { - console.error(`failed to log in with passkey`, error); - }); - }} - > - Sign up with Passkey - - + + {/* Header */} + + Welcome to Pinyinly + + Sign in securely with your passkey or create a new account + - Login - - {auth.data?.allDeviceSessions.map((x, i) => ( - - - - - Skill count: + {/* Main Content */} + + {/* Passkey Authentication Section */} + + + + {/* Back to App Button */} + + + {__DEV__ && ( + + + + + Development Tools - Session ID: {x.serverSessionId} - DB name: {x.replicacheDbName} - - { - auth.logInToExistingDeviceSession( - (s) => s.replicacheDbName === x.replicacheDbName, - ); - }} - > - Log in - - - ))} + + + )} - - Session ID: {auth.data?.activeDeviceSession.serverSessionId} - - - DB name: {auth.data?.activeDeviceSession.replicacheDbName} + + ); +} + +function PasskeyAuthSection() { + const auth = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [mode, setMode] = useState<`signin` | `signup`>(`signin`); + const [name, setName] = useState(``); + + const handlePasskeySignIn = async () => { + setIsLoading(true); + setError(null); + try { + await auth.logInWithPasskey(); + } catch (error_) { + console.error(`Passkey sign-in failed:`, error_); + // Automatically switch to signup mode instead of showing an error + setMode(`signup`); + setError(null); + } finally { + setIsLoading(false); + } + }; + + const handlePasskeySignUp = async () => { + if (!name.trim()) { + setError(`Please enter your name to create an account.`); + return; + } + + setIsLoading(true); + setError(null); + try { + await auth.signUpWithPasskey({ name: name.trim() }); + } catch (error_) { + console.error(`Passkey sign-up failed:`, error_); + setError(`Account creation failed. Please try again.`); + } finally { + setIsLoading(false); + } + }; + + return ( + + + {mode === `signin` ? `Sign In` : `Create Account`} + {error != null && error.length > 0 && ( + + {error} + + )} + + {mode === `signup` && ( + + )} + { - auth.signOut(); + variant="filled" + onPress={() => { + void (mode === `signin` ? handlePasskeySignIn() : handlePasskeySignUp()); }} + disabled={isLoading || (mode === `signup` && !name.trim())} > - Logout + {isLoading + ? (mode === `signin` ? `Signing in...` : `Creating account...`) + : (mode === `signin` ? `🔐 Sign in with Passkey` : `🔐 Create account with Passkey`) + } - {__DEV__ ? : null} - {Platform.OS === `web` ? ( - { - void auth.logInWithApple(data.authorization.id_token); - }} - redirectUri={`https://${location.hostname}/api/auth/login/apple/callback`} + {Platform.OS === `web` && ( + - ) : null} - - {Platform.OS === `ios` ? ( - { - let credential; - try { - credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - }); - } catch (error) { - const err = z.object({ code: z.string() }).safeParse(error); - if (err.success) { - switch (err.data.code) { - case `ERR_REQUEST_CANCELED`: { - // handle that the user canceled the sign-in flow - console.error(`request canceled`); - break; - } - default: { - console.error( - `unknown error code=${err.data.code}, error=`, - err.data, - ); - } - } - } else { - console.error(`unknown error (no code), error=`, error); - } - - return; - } - - invariant(credential.identityToken != null); - - void auth.logInWithApple(credential.identityToken); + )} + + + + {mode === `signin` ? `Don't have an account?` : `Already have an account?`} + + { + setMode(mode === `signin` ? `signup` : `signin`); + setError(null); + setName(``); }} - /> - ) : null} - - UI - - - + > + + {mode === `signin` ? `Create one` : `Sign in`} + + + ); } -function ServerSessionIdLoginForm() { - const auth = useAuth(); - const [input, setInput] = useState(``); - - return ( - { - if (e.nativeEvent.key === `Enter`) { - auth.logInWithServerSessionId(input); - e.preventDefault(); - } - }} - value={input} - onChangeText={(text) => { - setInput(text); - }} - /> - ); -} - const GoHomeButton = () => ( @@ -197,15 +152,4 @@ const GoHomeButton = () => ( ); -function SkillCount() { - const result = useRizzleQuery([`wordCount`], async (r, tx) => { - const skillStates = await r.query.skillState.scan(tx).toArray(); - return skillStates.length; - }); - return result.isPending ? ( - Loading… - ) : ( - {result.data} words - ); -}