diff --git a/apps/web/readme.md b/apps/web/readme.md new file mode 100644 index 00000000..caf02438 --- /dev/null +++ b/apps/web/readme.md @@ -0,0 +1,4 @@ +## TODO + +- better auth error handling +- display validation errors in auth forms diff --git a/apps/web/src/app/auth/[provider]/success/page.tsx b/apps/web/src/app/auth/[provider]/success/page.tsx index b5c14e2b..3e76fa91 100644 --- a/apps/web/src/app/auth/[provider]/success/page.tsx +++ b/apps/web/src/app/auth/[provider]/success/page.tsx @@ -35,14 +35,24 @@ function SuccessPage({ throw new Error('Missing code or challenge') } + if (!ctx) { + return + } + // TODO: make generic using `provider` - const authSession = await ctx!.api.exchangeOAuthCodeWithGitHub({ - code - }) + try { + const authSession = await ctx.api.exchangeOAuthCodeWithGitHub({ + code + }) - console.log('AUTH SUCCESS', { authSession }) + console.log('AUTH SUCCESS', { authSession }) + } catch (err) { + console.error('AUTH ERROR', err) - redirect('/app', RedirectType.replace) + return redirect('/login', RedirectType.replace) + } + + return redirect('/app', RedirectType.replace) })() }, [code, ctx]) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 89706f12..bf794ab8 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -177,43 +177,6 @@ a:hover .link { transition-duration: 300ms; } -h1 { - font-size: 2.5rem; - font-weight: 600; - @apply mb-8; -} - -h2 { - font-size: 2rem; - font-weight: 500; - @apply mb-7; -} - -h3 { - font-size: 1.5rem; - font-weight: 450; - margin-bottom: 1em; - @apply mb-6; -} - -h4 { - font-size: 1.3rem; - font-weight: 400; - @apply mb-5; -} - -h5 { - font-size: 1.2rem; - font-weight: 350; - @apply mb-4; -} - -h6 { - font-size: 1rem; - font-weight: 350; - @apply mb-2; -} - main section { width: 100%; display: flex; diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 04ad9e7c..db525f05 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -3,6 +3,7 @@ import type { PasswordLoginError } from '@agentic/openauth/provider/password' import { isValidEmail, isValidPassword } from '@agentic/platform-validators' import { useForm } from '@tanstack/react-form' +import { Loader2Icon } from 'lucide-react' import { redirect, RedirectType } from 'next/navigation' import { useCallback, useState } from 'react' import { z } from 'zod' @@ -35,7 +36,7 @@ export default function LoginPage() { password: '' }, validators: { - onBlurAsync: z.object({ + onChange: z.object({ email: z .string() .email() @@ -50,13 +51,14 @@ export default function LoginPage() { password: value.password }) - // TODO console.log('login success', res) - redirect('/app', RedirectType.push) } catch (err) { // TODO console.error('login error', err) + throw err } + + return redirect('/app', RedirectType.push) } }) @@ -68,129 +70,127 @@ export default function LoginPage() { redirect(url, RedirectType.push) }, [ctx]) - // TODO: - if (!ctx) return null - return ( <>
-

- Log In -

+
+
{ + e.preventDefault() + void form.handleSubmit() + }} + > +
+

Login to your account

+

+ Enter your email below to login to your account +

+
-
-
- { - e.preventDefault() - void form.handleSubmit() - }} - > -
-

Login to your account

-

- Enter your email below to login to your account -

-
+
+ ( +
+ -
- ( -
- - - - field.handleChange(e.target.value) - } - /> -
- )} - /> - - ( -
-
- - - - Forgot your password? - - - field.handleChange(e.target.value)} - /> -
-
- )} - /> - - [state.canSubmit, state.isSubmitting]} - children={([canSubmit, isSubmitting]) => ( - - )} - /> - -
- - Or continue with - -
- - +
+ )} + /> + + ( +
+ + + field.handleChange(e.target.value)} + /> +
+ )} + /> + + [ + state.canSubmit, + state.isSubmitting, + state.isTouched + ]} + children={([canSubmit, isSubmitting, isTouched]) => ( + + )} + /> + +
+ + Or continue with +
-
- Don't have an account?{' '} - - Sign up - -
- -
+ +
+ +
+ Don't have an account?{' '} + + Sign up + +
+
diff --git a/apps/web/src/app/logout/page.tsx b/apps/web/src/app/logout/page.tsx index ffefa38b..0e22c00f 100644 --- a/apps/web/src/app/logout/page.tsx +++ b/apps/web/src/app/logout/page.tsx @@ -11,7 +11,7 @@ export default function LogoutPage() { useEffect(() => { ;(async () => { if (ctx) { - await ctx.api.logout() + ctx.logout() redirect('/', RedirectType.replace) } })() diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx index a8636587..406cd9c7 100644 --- a/apps/web/src/app/signup/page.tsx +++ b/apps/web/src/app/signup/page.tsx @@ -1,120 +1,227 @@ 'use client' -import type { - PasswordRegisterError, - PasswordRegisterState -} from '@agentic/openauth/provider/password' -import { useState } from 'react' +import type { PasswordLoginError } from '@agentic/openauth/provider/password' +import { + isValidEmail, + isValidPassword, + isValidUsername +} from '@agentic/platform-validators' +import { useForm } from '@tanstack/react-form' +import { Loader2Icon } from 'lucide-react' +import { redirect, RedirectType } from 'next/navigation' +import { useCallback, useState } from 'react' +import { toast } from 'sonner' +import { z } from 'zod' -import { authCopy } from '@/lib/auth-copy' +import { useUnauthenticatedAgentic } from '@/components/agentic-provider' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { GitHubIcon } from '@/icons/github' +import { cn } from '@/lib/utils' -export default function SignupPage() { - // TODO - const [error, setError] = useState( - undefined - ) - const [state, setState] = useState({ type: 'start' }) - const [form, setForm] = useState(undefined) +export default function LoginPage() { + const [error] = useState(undefined) + const ctx = useUnauthenticatedAgentic() - const emailError = ['invalid_email', 'email_taken'].includes( - error?.type || '' - ) + const form = useForm({ + defaultValues: { + email: '', + username: '', + password: '', + repeat: '' + }, + validators: { + onChange: z.object({ + email: z + .string() + .email() + .refine((email) => isValidEmail(email)), + username: z.string().refine((username) => isValidUsername(username)), + password: z.string().refine((password) => isValidPassword(password)), + repeat: z.string().refine((password) => isValidPassword(password)) + }) + }, + onSubmit: async ({ value }) => { + try { + if (value.password !== value.repeat) { + toast.error('Passwords do not match') + return + } - const passwordError = [ - 'invalid_password', - 'password_mismatch', - 'validation_error' - ].includes(error?.type || '') + const res = await ctx!.api.signUpWithPassword({ + email: value.email, + username: value.username, + password: value.password + }) + + console.log('signup success', res) + } catch (err: any) { + console.error('Signup error', err.message) + toast.error('Signup error', err.message) + return + } + + return redirect('/app', RedirectType.push) + } + }) + + const onAuthWithGitHub = useCallback(async () => { + const url = await ctx!.api.initAuthFlowWithGitHub({ + redirectUri: `${globalThis.location.origin}/auth/github/success` + }) + + redirect(url, RedirectType.push) + }, [ctx]) return ( <>
-

- {authCopy.register} -

+
+
{ + e.preventDefault() + void form.handleSubmit() + }} + > +
+

Create an account

+

+ Enter your info below to create an account +

+
- - {/* */} - - {state.type === 'start' && ( - <> - - - + ( +
+ + + field.handleChange(e.target.value)} + /> +
+ )} /> - ( +
+ + + field.handleChange(e.target.value)} + /> +
+ )} + /> + + ( +
+ + + field.handleChange(e.target.value)} + /> +
+ )} /> - ( +
+ + + field.handleChange(e.target.value)} + /> +
+ )} /> - + [ + state.canSubmit, + state.isSubmitting, + state.isTouched + ]} + children={([canSubmit, isSubmitting, isTouched]) => ( + + )} + /> -
- - {authCopy.login_prompt}{' '} - - {authCopy.login} - +
+ + Or continue with
- - )} - {state.type === 'code' && ( - <> - + +
- - - )} - +
+ Already have an account?{' '} + + Login + +
+ +
) diff --git a/apps/web/src/components/agentic-provider.tsx b/apps/web/src/components/agentic-provider.tsx index 293fe026..5c54ff8d 100644 --- a/apps/web/src/components/agentic-provider.tsx +++ b/apps/web/src/components/agentic-provider.tsx @@ -8,6 +8,7 @@ import { redirect, RedirectType } from 'next/navigation' import { createContext, type ReactNode, + useCallback, useContext, useEffect, useRef, @@ -17,17 +18,22 @@ import { useLocalStorage } from 'react-use' import * as config from '@/lib/config' -type AgenticContext = { +type AgenticContextType = { api: AgenticApiClient + logout: () => void } -const AgenticContext = createContext(undefined) +const AgenticContext = createContext(undefined) export function AgenticProvider({ children }: { children: ReactNode }) { - const [authSession, setAuthSession] = useLocalStorage( + const [authSession, setAuthSession] = useLocalStorage( 'agentic-auth-session' ) - const agenticContext = useRef({ + const logout = useCallback(() => { + setAuthSession(null) + }, [setAuthSession]) + + const agenticContext = useRef({ api: new AgenticApiClient({ apiBaseUrl: config.apiBaseUrl, onUpdateAuth: (updatedAuthSession) => { @@ -43,12 +49,19 @@ export function AgenticProvider({ children }: { children: ReactNode }) { console.log('auth session not updated') } } - }) + }), + logout }) useEffect(() => { - console.log('setting session from localStorage', authSession?.token) - agenticContext.current.api.authSession = authSession + console.log('updating session from localStorage', authSession?.token) + if (agenticContext.current) { + if (authSession) { + agenticContext.current.api.authSession = authSession + } else { + agenticContext.current.api.authSession = undefined + } + } }, [agenticContext, authSession]) return ( @@ -58,7 +71,7 @@ export function AgenticProvider({ children }: { children: ReactNode }) { ) } -export function useAgentic(): AgenticContext { +export function useAgentic(): AgenticContextType { const ctx = useContext(AgenticContext) if (!ctx) { @@ -68,7 +81,7 @@ export function useAgentic(): AgenticContext { return ctx } -export function useUnauthenticatedAgentic(): AgenticContext | undefined { +export function useUnauthenticatedAgentic(): AgenticContextType | undefined { const ctx = useAgentic() const [isMounted, setIsMounted] = useState(false) @@ -86,7 +99,7 @@ export function useUnauthenticatedAgentic(): AgenticContext | undefined { return isMounted ? ctx : undefined } -export function useAuthenticatedAgentic(): AgenticContext | undefined { +export function useAuthenticatedAgentic(): AgenticContextType | undefined { const ctx = useAgentic() const [isMounted, setIsMounted] = useState(false) @@ -97,7 +110,7 @@ export function useAuthenticatedAgentic(): AgenticContext | undefined { } if (!ctx.api.isAuthenticated) { - redirect('/', RedirectType.replace) + redirect('/login', RedirectType.replace) } }, [isMounted, setIsMounted, ctx]) diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 3455c225..dd6174a3 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -1,8 +1,8 @@ import Link from 'next/link' import { ActiveLink } from '@/components/active-link' -import { GitHub } from '@/icons/github' -import { Twitter } from '@/icons/twitter' +import { GitHubIcon } from '@/icons/github' +import { TwitterIcon } from '@/icons/twitter' import { copyright, githubUrl, twitterUrl } from '@/lib/config' export function Footer() { @@ -54,7 +54,8 @@ export function Footer() { target='_blank' rel='noopener noreferrer' > - + + Twitter @@ -64,7 +65,8 @@ export function Footer() { target='_blank' rel='noopener noreferrer' > - + + GitHub diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index c3bc5c4e..b949f99d 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { cn } from '@/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", { variants: { variant: { diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 00000000..052aa3c8 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 00000000..5b7aea5b --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +'use client' + +import * as LabelPrimitive from '@radix-ui/react-label' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/apps/web/src/icons/github.tsx b/apps/web/src/icons/github.tsx index 93d614d7..1902c213 100644 --- a/apps/web/src/icons/github.tsx +++ b/apps/web/src/icons/github.tsx @@ -1,7 +1,12 @@ -export function GitHub({ className }: { className?: string }) { +export function GitHubIcon({ className }: { className?: string }) { return ( - - + + ) } diff --git a/apps/web/src/icons/twitter.tsx b/apps/web/src/icons/twitter.tsx index 908b4ca3..7b7d8240 100644 --- a/apps/web/src/icons/twitter.tsx +++ b/apps/web/src/icons/twitter.tsx @@ -1,4 +1,4 @@ -export function Twitter({ className }: { className?: string }) { +export function TwitterIcon({ className }: { className?: string }) { return (