feat: web auth routes done for mvp

pull/715/head
Travis Fischer 2025-06-16 10:13:33 +07:00
rodzic eaa0e3aa33
commit 222b1d43f6
13 zmienionych plików z 421 dodań i 272 usunięć

Wyświetl plik

@ -0,0 +1,4 @@
## TODO
- better auth error handling
- display validation errors in auth forms

Wyświetl plik

@ -35,14 +35,24 @@ function SuccessPage({
throw new Error('Missing code or challenge') throw new Error('Missing code or challenge')
} }
if (!ctx) {
return
}
// TODO: make generic using `provider` // TODO: make generic using `provider`
const authSession = await ctx!.api.exchangeOAuthCodeWithGitHub({ try {
code 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]) }, [code, ctx])

Wyświetl plik

@ -177,43 +177,6 @@ a:hover .link {
transition-duration: 300ms; 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 { main section {
width: 100%; width: 100%;
display: flex; display: flex;

Wyświetl plik

@ -3,6 +3,7 @@
import type { PasswordLoginError } from '@agentic/openauth/provider/password' import type { PasswordLoginError } from '@agentic/openauth/provider/password'
import { isValidEmail, isValidPassword } from '@agentic/platform-validators' import { isValidEmail, isValidPassword } from '@agentic/platform-validators'
import { useForm } from '@tanstack/react-form' import { useForm } from '@tanstack/react-form'
import { Loader2Icon } from 'lucide-react'
import { redirect, RedirectType } from 'next/navigation' import { redirect, RedirectType } from 'next/navigation'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { z } from 'zod' import { z } from 'zod'
@ -35,7 +36,7 @@ export default function LoginPage() {
password: '' password: ''
}, },
validators: { validators: {
onBlurAsync: z.object({ onChange: z.object({
email: z email: z
.string() .string()
.email() .email()
@ -50,13 +51,14 @@ export default function LoginPage() {
password: value.password password: value.password
}) })
// TODO
console.log('login success', res) console.log('login success', res)
redirect('/app', RedirectType.push)
} catch (err) { } catch (err) {
// TODO // TODO
console.error('login error', err) console.error('login error', err)
throw err
} }
return redirect('/app', RedirectType.push)
} }
}) })
@ -68,129 +70,127 @@ export default function LoginPage() {
redirect(url, RedirectType.push) redirect(url, RedirectType.push)
}, [ctx]) }, [ctx])
// TODO:
if (!ctx) return null
return ( return (
<> <>
<section> <section>
<h1 className='my-0! text-center text-balance leading-snug md:leading-none'> <div className='flex-col flex-1 items-center justify-center w-full max-w-xs'>
Log In <form
</h1> className={cn('flex flex-col gap-6 w-full')}
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Login to your account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your email below to login to your account
</p>
</div>
<div className='flex flex-1 items-center justify-center'> <div className='grid gap-6'>
<div className='w-full max-w-xs'> <form.Field
<form name='email'
className={cn('flex flex-col gap-6')} children={(field) => (
onSubmit={(e) => { <div className='grid gap-3'>
e.preventDefault() <Label htmlFor={field.name}>Email</Label>
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Login to your account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your email below to login to your account
</p>
</div>
<div className='grid gap-6'> <Input
<form.Field id={field.name}
name='email' name={field.name}
children={(field) => ( type='email'
<div className='grid gap-3'> required
<Label htmlFor={field.name}>Email</Label> placeholder='Email'
autoComplete='email'
<Input autoFocus={!error}
id={field.name} value={field.state.value}
name={field.name} onBlur={field.handleBlur}
type='email' onChange={(e: any) => field.handleChange(e.target.value)}
required
placeholder='Email'
autoFocus={!error}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) =>
field.handleChange(e.target.value)
}
/>
</div>
)}
/>
<form.Field
name='password'
children={(field) => (
<div className='grid gap-3'>
<div className='flex items-center'>
<Label htmlFor={field.name}>Password</Label>
<a
href='/forgot-password'
className='ml-auto text-sm underline-offset-4 hover:underline'
>
Forgot your password?
</a>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='current-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button
type='submit'
disabled={!canSubmit || !ctx}
className='w-full'
>
{isSubmitting ? '...' : 'Login'}
</Button>
)}
/>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
<Button
variant='outline'
className='w-full'
onClick={onAuthWithGitHub}
>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>
<path
d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
fill='currentColor'
/> />
</svg> </div>
Login with GitHub )}
</Button> />
<form.Field
name='password'
children={(field) => (
<div className='grid gap-3'>
<div className='flex items-center'>
<Label htmlFor={field.name}>Password</Label>
<a
href='/forgot-password'
className='ml-auto text-xs underline-offset-4 hover:underline'
tabIndex={-1}
>
Forgot your password?
</a>
</div>
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='current-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<form.Subscribe
selector={(state) => [
state.canSubmit,
state.isSubmitting,
state.isTouched
]}
children={([canSubmit, isSubmitting, isTouched]) => (
<Button
type='submit'
disabled={!(isTouched && canSubmit && ctx)}
className='w-full'
>
{isSubmitting && <Loader2Icon className='animate-spin' />}
<span>Login</span>
</Button>
)}
/>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div> </div>
<div className='text-center text-sm'> <Button
Don&apos;t have an account?{' '} variant='outline'
<a href='/signup' className='underline underline-offset-4'> className='w-full'
Sign up onClick={onAuthWithGitHub}
</a> >
</div> <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>
</form> <path
</div> d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'
fill='currentColor'
/>
</svg>
<span>Login with GitHub</span>
</Button>
</div>
<div className='text-center text-xs'>
Don&apos;t have an account?{' '}
<a href='/signup' className='underline underline-offset-4'>
Sign up
</a>
</div>
</form>
</div> </div>
</section> </section>
</> </>

Wyświetl plik

@ -11,7 +11,7 @@ export default function LogoutPage() {
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
if (ctx) { if (ctx) {
await ctx.api.logout() ctx.logout()
redirect('/', RedirectType.replace) redirect('/', RedirectType.replace)
} }
})() })()

Wyświetl plik

@ -1,120 +1,227 @@
'use client' 'use client'
import type { import type { PasswordLoginError } from '@agentic/openauth/provider/password'
PasswordRegisterError, import {
PasswordRegisterState isValidEmail,
} from '@agentic/openauth/provider/password' isValidPassword,
import { useState } from 'react' 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() { export default function LoginPage() {
// TODO const [error] = useState<PasswordLoginError | undefined>(undefined)
const [error, setError] = useState<PasswordRegisterError | undefined>( const ctx = useUnauthenticatedAgentic()
undefined
)
const [state, setState] = useState<PasswordRegisterState>({ type: 'start' })
const [form, setForm] = useState<FormData | undefined>(undefined)
const emailError = ['invalid_email', 'email_taken'].includes( const form = useForm({
error?.type || '' 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 = [ const res = await ctx!.api.signUpWithPassword({
'invalid_password', email: value.email,
'password_mismatch', username: value.username,
'validation_error' password: value.password
].includes(error?.type || '') })
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 ( return (
<> <>
<section> <section>
<h1 className='my-0! text-center text-balance leading-snug md:leading-none'> <div className='flex-col flex-1 items-center justify-center w-full max-w-xs'>
{authCopy.register} <form
</h1> className={cn('flex flex-col gap-6 w-full')}
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<div className='flex flex-col items-center gap-2 text-center'>
<h1 className='text-2xl font-bold'>Create an account</h1>
<p className='text-muted-foreground text-sm text-balance'>
Enter your info below to create an account
</p>
</div>
<form data-component='form' method='post'> <div className='grid gap-6'>
{/* <FormAlert <form.Field
message={
error?.type
? error.type === 'validation_error'
? (error.message ?? authCopy?.[`error_${error.type}`])
: authCopy?.[`error_${error.type}`]
: undefined
}
/> */}
{state.type === 'start' && (
<>
<input type='hidden' name='action' value='register' />
<input
data-component='input'
autoFocus={!error || emailError}
type='email'
name='email' name='email'
value={!emailError ? form?.get('email')?.toString() : ''} children={(field) => (
required <div className='grid gap-3'>
placeholder={authCopy.input_email} <Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type='email'
required
placeholder='Email'
autoComplete='email'
autoFocus={!error}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) => field.handleChange(e.target.value)}
/>
</div>
)}
/> />
<input <form.Field
data-component='input' name='username'
autoFocus={passwordError} children={(field) => (
type='password' <div className='grid gap-3'>
<Label htmlFor={field.name}>Username</Label>
<Input
id={field.name}
name={field.name}
type='text'
required
placeholder='Username'
autoComplete='username'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e: any) => field.handleChange(e.target.value)}
/>
</div>
)}
/>
<form.Field
name='password' name='password'
placeholder={authCopy.input_password} children={(field) => (
required <div className='grid gap-3'>
value={!passwordError ? form?.get('password')?.toString() : ''} <Label htmlFor={field.name}>Password</Label>
autoComplete='new-password'
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoFocus={error?.type === 'invalid_password'}
autoComplete='new-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/> />
<input <form.Field
data-component='input'
type='password'
name='repeat' name='repeat'
required children={(field) => (
autoFocus={passwordError} <div className='grid gap-3'>
placeholder={authCopy.input_repeat} <Label htmlFor={field.name}>Repeat password</Label>
autoComplete='new-password'
<Input
id={field.name}
name={field.name}
type='password'
required
placeholder='Password'
autoComplete='new-password'
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
/> />
<button data-component='button'> <form.Subscribe
{authCopy.button_continue} selector={(state) => [
</button> state.canSubmit,
state.isSubmitting,
state.isTouched
]}
children={([canSubmit, isSubmitting, isTouched]) => (
<Button
type='submit'
disabled={!(isTouched && canSubmit && ctx)}
className='w-full'
>
{isSubmitting && <Loader2Icon className='animate-spin' />}
<span>Sign up</span>
</Button>
)}
/>
<div data-component='form-footer'> <div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span> <span className='bg-background text-muted-foreground relative z-10 px-2'>
{authCopy.login_prompt}{' '} Or continue with
<a data-component='link' href='/login'>
{authCopy.login}
</a>
</span> </span>
</div> </div>
</>
)}
{state.type === 'code' && ( <Button
<> variant='outline'
<input type='hidden' name='action' value='verify' /> className='w-full'
onClick={onAuthWithGitHub}
>
<GitHubIcon />
<input <span>Sign up with GitHub</span>
data-component='input' </Button>
autoFocus </div>
name='code'
minLength={6}
maxLength={6}
required
placeholder={authCopy.input_code}
autoComplete='one-time-code'
/>
<button data-component='button'> <div className='text-center text-xs'>
{authCopy.button_continue} Already have an account?{' '}
</button> <a href='/login' className='underline underline-offset-4'>
</> Login
)} </a>
</form> </div>
</form>
</div>
</section> </section>
</> </>
) )

Wyświetl plik

@ -8,6 +8,7 @@ import { redirect, RedirectType } from 'next/navigation'
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
useCallback,
useContext, useContext,
useEffect, useEffect,
useRef, useRef,
@ -17,17 +18,22 @@ import { useLocalStorage } from 'react-use'
import * as config from '@/lib/config' import * as config from '@/lib/config'
type AgenticContext = { type AgenticContextType = {
api: AgenticApiClient api: AgenticApiClient
logout: () => void
} }
const AgenticContext = createContext<AgenticContext | undefined>(undefined) const AgenticContext = createContext<AgenticContextType | undefined>(undefined)
export function AgenticProvider({ children }: { children: ReactNode }) { export function AgenticProvider({ children }: { children: ReactNode }) {
const [authSession, setAuthSession] = useLocalStorage<AuthSession>( const [authSession, setAuthSession] = useLocalStorage<AuthSession | null>(
'agentic-auth-session' 'agentic-auth-session'
) )
const agenticContext = useRef<AgenticContext>({ const logout = useCallback(() => {
setAuthSession(null)
}, [setAuthSession])
const agenticContext = useRef<AgenticContextType>({
api: new AgenticApiClient({ api: new AgenticApiClient({
apiBaseUrl: config.apiBaseUrl, apiBaseUrl: config.apiBaseUrl,
onUpdateAuth: (updatedAuthSession) => { onUpdateAuth: (updatedAuthSession) => {
@ -43,12 +49,19 @@ export function AgenticProvider({ children }: { children: ReactNode }) {
console.log('auth session not updated') console.log('auth session not updated')
} }
} }
}) }),
logout
}) })
useEffect(() => { useEffect(() => {
console.log('setting session from localStorage', authSession?.token) console.log('updating session from localStorage', authSession?.token)
agenticContext.current.api.authSession = authSession if (agenticContext.current) {
if (authSession) {
agenticContext.current.api.authSession = authSession
} else {
agenticContext.current.api.authSession = undefined
}
}
}, [agenticContext, authSession]) }, [agenticContext, authSession])
return ( return (
@ -58,7 +71,7 @@ export function AgenticProvider({ children }: { children: ReactNode }) {
) )
} }
export function useAgentic(): AgenticContext { export function useAgentic(): AgenticContextType {
const ctx = useContext(AgenticContext) const ctx = useContext(AgenticContext)
if (!ctx) { if (!ctx) {
@ -68,7 +81,7 @@ export function useAgentic(): AgenticContext {
return ctx return ctx
} }
export function useUnauthenticatedAgentic(): AgenticContext | undefined { export function useUnauthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic() const ctx = useAgentic()
const [isMounted, setIsMounted] = useState(false) const [isMounted, setIsMounted] = useState(false)
@ -86,7 +99,7 @@ export function useUnauthenticatedAgentic(): AgenticContext | undefined {
return isMounted ? ctx : undefined return isMounted ? ctx : undefined
} }
export function useAuthenticatedAgentic(): AgenticContext | undefined { export function useAuthenticatedAgentic(): AgenticContextType | undefined {
const ctx = useAgentic() const ctx = useAgentic()
const [isMounted, setIsMounted] = useState(false) const [isMounted, setIsMounted] = useState(false)
@ -97,7 +110,7 @@ export function useAuthenticatedAgentic(): AgenticContext | undefined {
} }
if (!ctx.api.isAuthenticated) { if (!ctx.api.isAuthenticated) {
redirect('/', RedirectType.replace) redirect('/login', RedirectType.replace)
} }
}, [isMounted, setIsMounted, ctx]) }, [isMounted, setIsMounted, ctx])

Wyświetl plik

@ -1,8 +1,8 @@
import Link from 'next/link' import Link from 'next/link'
import { ActiveLink } from '@/components/active-link' import { ActiveLink } from '@/components/active-link'
import { GitHub } from '@/icons/github' import { GitHubIcon } from '@/icons/github'
import { Twitter } from '@/icons/twitter' import { TwitterIcon } from '@/icons/twitter'
import { copyright, githubUrl, twitterUrl } from '@/lib/config' import { copyright, githubUrl, twitterUrl } from '@/lib/config'
export function Footer() { export function Footer() {
@ -54,7 +54,8 @@ export function Footer() {
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
> >
<Twitter className='h-4 w-4' /> <TwitterIcon className='h-4 w-4' />
<span>Twitter</span> <span>Twitter</span>
</Link> </Link>
@ -64,7 +65,8 @@ export function Footer() {
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
> >
<GitHub className='h-4 w-4' /> <GitHubIcon className='h-4 w-4' />
<span>GitHub</span> <span>GitHub</span>
</Link> </Link>
</nav> </nav>

Wyświetl plik

@ -5,7 +5,7 @@ import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( 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: { variants: {
variant: { variant: {

Wyświetl plik

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'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',
className
)}
{...props}
/>
)
}
export { Input }

Wyświetl plik

@ -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<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
}
export { Label }

Wyświetl plik

@ -1,7 +1,12 @@
export function GitHub({ className }: { className?: string }) { export function GitHubIcon({ className }: { className?: string }) {
return ( return (
<svg viewBox='0 0 24 24' fill='currentcolor' className={className}> <svg
<path d='M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22'></path> xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='currentColor'
className={className}
>
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
</svg> </svg>
) )
} }

Wyświetl plik

@ -1,4 +1,4 @@
export function Twitter({ className }: { className?: string }) { export function TwitterIcon({ className }: { className?: string }) {
return ( return (
<svg <svg
role='img' role='img'