mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat: add clerk auth support with convex (#548)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import z from 'zod'
|
||||
const {$authClient} = useNuxtApp()
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const emit = defineEmits(['switchToSignUp'])
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const schema = z.object({
|
||||
email: z.email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function onSubmit (event: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
await $authClient.signIn.email(
|
||||
{
|
||||
email: event.data.email,
|
||||
password: event.data.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({ title: 'Sign in successful' })
|
||||
navigateTo('/dashboard', { replace: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign in failed', description: error.error.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password">
|
||||
<UInput v-model="state.password" type="password" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="loading">
|
||||
Sign In
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<UButton
|
||||
variant="link"
|
||||
@click="$emit('switchToSignUp')"
|
||||
class="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import z from 'zod'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
const {$authClient} = useNuxtApp()
|
||||
|
||||
const emit = defineEmits(['switchToSignIn'])
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function onSubmit (event: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
await $authClient.signUp.email(
|
||||
{
|
||||
name: event.data.name,
|
||||
email: event.data.email,
|
||||
password: event.data.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.add({ title: 'Sign up successful' })
|
||||
navigateTo('/dashboard', { replace: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign up failed', description: error.error.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Name" name="name">
|
||||
<UInput v-model="state.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password">
|
||||
<UInput v-model="state.password" type="password" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" block :loading="loading">
|
||||
Sign Up
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<UButton
|
||||
variant="link"
|
||||
@click="$emit('switchToSignIn')"
|
||||
class="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const {$authClient} = useNuxtApp()
|
||||
const session = $authClient.useSession()
|
||||
const toast = useToast()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await $authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: async () => {
|
||||
toast.add({ title: 'Signed out successfully' })
|
||||
await navigateTo('/', { replace: true, external: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({ title: 'Sign out failed', description: error?.error?.message || 'Unknown error'})
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.add({ title: 'An unexpected error occurred during sign out', description: error.message || 'Please try again.'})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<USkeleton v-if="session.isPending" class="h-9 w-24" />
|
||||
|
||||
<UButton v-else-if="!session.data" variant="outline" to="/login">
|
||||
Sign In
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-else
|
||||
variant="solid"
|
||||
icon="i-lucide-log-out"
|
||||
label="Sign out"
|
||||
@click="handleSignOut()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
if (import.meta.server) return
|
||||
|
||||
const { $authClient } = useNuxtApp()
|
||||
const session = $authClient.useSession()
|
||||
|
||||
if (session.value.isPending || !session.value) {
|
||||
if (to.path === "/dashboard") {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
{{#if (eq api "orpc")}}
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
{{/if}}
|
||||
const {$authClient} = useNuxtApp()
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth']
|
||||
})
|
||||
|
||||
const { $orpc } = useNuxtApp()
|
||||
|
||||
const session = $authClient.useSession()
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery($orpc.privateData.queryOptions())
|
||||
{{/if}}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<div v-if="session?.data?.user">
|
||||
<p class="mb-2">Welcome \{{ session.data.user.name }}</p>
|
||||
</div>
|
||||
{{#if (eq api "orpc")}}
|
||||
<div v-if="privateData.status.value === 'pending'">Loading private data...</div>
|
||||
<div v-else-if="privateData.status.value === 'error'">Error loading private data: \{{ privateData.error.value?.message }}</div>
|
||||
<p v-else-if="privateData.data.value">Private Data: \{{ privateData.data.value.message }}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const { $authClient } = useNuxtApp();
|
||||
import SignInForm from "~/components/SignInForm.vue";
|
||||
import SignUpForm from "~/components/SignUpForm.vue";
|
||||
|
||||
const session = $authClient.useSession();
|
||||
const showSignIn = ref(true);
|
||||
|
||||
watchEffect(() => {
|
||||
if (!session?.value.isPending && session?.value.data) {
|
||||
navigateTo("/dashboard", { replace: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Loader v-if="session.isPending" />
|
||||
<div v-else-if="!session.data">
|
||||
<SignInForm v-if="showSignIn" @switch-to-sign-up="showSignIn = false" />
|
||||
<SignUpForm v-else @switch-to-sign-in="showSignIn = true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createAuthClient } from "better-auth/vue";
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
const config = useRuntimeConfig()
|
||||
const serverUrl = config.public.serverURL
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: serverUrl
|
||||
})
|
||||
|
||||
return {
|
||||
provide: {
|
||||
authClient: authClient
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL:
|
||||
{{#if (includes frontend "next")}}
|
||||
process.env.NEXT_PUBLIC_SERVER_URL,
|
||||
{{else}}
|
||||
import.meta.env.VITE_SERVER_URL,
|
||||
{{/if}}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !isPending) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [session, isPending]);
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session?.user.name}</p>
|
||||
{{#if (eq api "orpc")}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import { useState } from "react";
|
||||
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SignInForm({
|
||||
onSwitchToSignUp,
|
||||
}: {
|
||||
onSwitchToSignUp: () => void;
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push("/dashboard")
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SignUpForm({
|
||||
onSwitchToSignIn,
|
||||
}: {
|
||||
onSwitchToSignIn: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push("/dashboard");
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function UserMenu() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-9 w-24" />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{session.user.name}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignInForm({
|
||||
onSwitchToSignUp,
|
||||
}: {
|
||||
onSwitchToSignUp: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/dashboard");
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignUpForm({
|
||||
onSwitchToSignIn,
|
||||
}: {
|
||||
onSwitchToSignIn: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/dashboard");
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-9 w-24" />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/login">Sign In</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{session.user.name}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
{{/if}}
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !isPending) {
|
||||
navigate("/login");
|
||||
}
|
||||
}, [session, isPending]);
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session?.user.name}</p>
|
||||
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Login() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignInForm({
|
||||
onSwitchToSignUp,
|
||||
}: {
|
||||
onSwitchToSignUp: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignUpForm({
|
||||
onSwitchToSignIn,
|
||||
}: {
|
||||
onSwitchToSignIn: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-9 w-24" />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/login">Sign In</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{session.user.name}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
{{/if}}
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !isPending) {
|
||||
navigate({
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
}, [session, isPending]);
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session?.user.name}</p>
|
||||
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignInForm({
|
||||
onSwitchToSignUp,
|
||||
}: {
|
||||
onSwitchToSignUp: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import Loader from "./loader";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export default function SignUpForm({
|
||||
onSwitchToSignIn,
|
||||
}: {
|
||||
onSwitchToSignIn: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!state.canSubmit || state.isSubmitting}
|
||||
>
|
||||
{state.isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-9 w-24" />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/login">Sign In</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{session.user.name}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "trpc")}}
|
||||
import { useTRPC } from "@/utils/trpc";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
{{/if}}
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = Route.useNavigate();
|
||||
{{#if (eq api "trpc")}}
|
||||
const trpc = useTRPC();
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
{{/if}}
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
{{#if (eq api "trpc")}}
|
||||
const privateData = useQuery(trpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !isPending) {
|
||||
navigate({
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
}, [session, isPending]);
|
||||
|
||||
if (isPending) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session?.user.name}</p>
|
||||
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { createForm } from "@tanstack/solid-form";
|
||||
import { useNavigate } from "@tanstack/solid-router";
|
||||
import z from "zod";
|
||||
import { For } from "solid-js";
|
||||
|
||||
export default function SignInForm({
|
||||
onSwitchToSignUp,
|
||||
}: {
|
||||
onSwitchToSignUp: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
|
||||
const form = createForm(() => ({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
console.log("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error.error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div class="space-y-2">
|
||||
<label for={field().name}>Email</label>
|
||||
<input
|
||||
id={field().name}
|
||||
name={field().name}
|
||||
type="email"
|
||||
value={field().state.value}
|
||||
onBlur={field().handleBlur}
|
||||
onInput={(e) => field().handleChange(e.currentTarget.value)}
|
||||
class="w-full rounded border p-2"
|
||||
/>
|
||||
<For each={field().state.meta.errors}>
|
||||
{(error) => (
|
||||
<p class="text-sm text-red-600">{error?.message}</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div class="space-y-2">
|
||||
<label for={field().name}>Password</label>
|
||||
<input
|
||||
id={field().name}
|
||||
name={field().name}
|
||||
type="password"
|
||||
value={field().state.value}
|
||||
onBlur={field().handleBlur}
|
||||
onInput={(e) => field().handleChange(e.currentTarget.value)}
|
||||
class="w-full rounded border p-2"
|
||||
/>
|
||||
<For each={field().state.meta.errors}>
|
||||
{(error) => (
|
||||
<p class="text-sm text-red-600">{error?.message}</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-indigo-600 p-2 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
disabled={!state().canSubmit || state().isSubmitting}
|
||||
>
|
||||
{state().isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { createForm } from "@tanstack/solid-form";
|
||||
import { useNavigate } from "@tanstack/solid-router";
|
||||
import z from "zod";
|
||||
import { For } from "solid-js";
|
||||
|
||||
export default function SignUpForm({
|
||||
onSwitchToSignIn,
|
||||
}: {
|
||||
onSwitchToSignIn: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
|
||||
const form = createForm(() => ({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
console.log("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error.error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div class="space-y-2">
|
||||
<label for={field().name}>Name</label>
|
||||
<input
|
||||
id={field().name}
|
||||
name={field().name}
|
||||
value={field().state.value}
|
||||
onBlur={field().handleBlur}
|
||||
onInput={(e) => field().handleChange(e.currentTarget.value)}
|
||||
class="w-full rounded border p-2"
|
||||
/>
|
||||
<For each={field().state.meta.errors}>
|
||||
{(error) => (
|
||||
<p class="text-sm text-red-600">{error?.message}</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div class="space-y-2">
|
||||
<label for={field().name}>Email</label>
|
||||
<input
|
||||
id={field().name}
|
||||
name={field().name}
|
||||
type="email"
|
||||
value={field().state.value}
|
||||
onBlur={field().handleBlur}
|
||||
onInput={(e) => field().handleChange(e.currentTarget.value)}
|
||||
class="w-full rounded border p-2"
|
||||
/>
|
||||
<For each={field().state.meta.errors}>
|
||||
{(error) => (
|
||||
<p class="text-sm text-red-600">{error?.message}</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div class="space-y-2">
|
||||
<label for={field().name}>Password</label>
|
||||
<input
|
||||
id={field().name}
|
||||
name={field().name}
|
||||
type="password"
|
||||
value={field().state.value}
|
||||
onBlur={field().handleBlur}
|
||||
onInput={(e) => field().handleChange(e.currentTarget.value)}
|
||||
class="w-full rounded border p-2"
|
||||
/>
|
||||
<For each={field().state.meta.errors}>
|
||||
{(error) => (
|
||||
<p class="text-sm text-red-600">{error?.message}</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe>
|
||||
{(state) => (
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-indigo-600 p-2 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
disabled={!state().canSubmit || state().isSubmitting}
|
||||
>
|
||||
{state().isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useNavigate, Link } from "@tanstack/solid-router";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const session = authClient.useSession();
|
||||
const [isMenuOpen, setIsMenuOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="relative inline-block text-left">
|
||||
<Show when={session().isPending}>
|
||||
<div class="h-9 w-24 animate-pulse rounded" />
|
||||
</Show>
|
||||
|
||||
<Show when={!session().isPending && !session().data}>
|
||||
<Link to="/login" class="inline-block border rounded px-4 text-sm">
|
||||
Sign In
|
||||
</Link>
|
||||
</Show>
|
||||
|
||||
<Show when={!session().isPending && session().data}>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block border rounded px-4 text-sm"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen())}
|
||||
>
|
||||
{session().data?.user.name}
|
||||
</button>
|
||||
|
||||
<Show when={isMenuOpen()}>
|
||||
<div class="absolute right-0 mt-2 w-56 rounded p-1 shadow-sm">
|
||||
<div class="px-4 text-sm">{session().data?.user.email}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 w-full border rounded px-4 text-center text-sm"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/" });
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/solid";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_SERVER_URL,
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
{{/if}}
|
||||
import { createFileRoute } from "@tanstack/solid-router";
|
||||
import { createEffect, Show } from "solid-js";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const session = authClient.useSession();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateData = useQuery(() => orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
createEffect(() => {
|
||||
if (!session().data && !session().isPending) {
|
||||
navigate({
|
||||
to: "/login",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Show when={session().isPending}>
|
||||
<div>Loading...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!session().isPending && session().data}>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session().data?.user.name}</p>
|
||||
{{#if (eq api "orpc")}}
|
||||
<p>privateData: {privateData.data?.message}</p>
|
||||
{{/if}}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
import { createFileRoute } from "@tanstack/solid-router";
|
||||
import { createSignal, Match, Switch } from "solid-js";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [showSignIn, setShowSignIn] = createSignal(false);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={showSignIn()}>
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
</Match>
|
||||
<Match when={!showSignIn()}>
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { createForm } from '@tanstack/svelte-form';
|
||||
import z from 'zod';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { switchToSignUp } = $props<{ switchToSignUp: () => void }>();
|
||||
|
||||
const validationSchema = z.object({
|
||||
email: z.email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
const form = createForm(() => ({
|
||||
defaultValues: { email: '', password: '' },
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{ email: value.email, password: value.password },
|
||||
{
|
||||
onSuccess: () => goto('/dashboard'),
|
||||
onError: (error) => {
|
||||
console.log(error.error.message || 'Sign in failed. Please try again.');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
},
|
||||
validators: {
|
||||
onSubmit: validationSchema,
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-10 w-full max-w-md p-6">
|
||||
<h1 class="mb-6 text-center font-bold text-3xl">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="email">
|
||||
{#snippet children(field)}
|
||||
<div class="space-y-1">
|
||||
<label for={field.name}>Email</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
class="w-full border"
|
||||
onblur={field.handleBlur}
|
||||
value={field.state.value}
|
||||
oninput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
field.handleChange(target.value)
|
||||
}} />
|
||||
{#if field.state.meta.isTouched}
|
||||
{#each field.state.meta.errors as error}
|
||||
<p class="text-sm text-red-500" role="alert">{error}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{#snippet children(field)}
|
||||
<div class="space-y-1">
|
||||
<label for={field.name}>Password</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
class="w-full border"
|
||||
onblur={field.handleBlur}
|
||||
value={field.state.value}
|
||||
oninput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
field.handleChange(target.value)
|
||||
}}
|
||||
/>
|
||||
{#if field.state.meta.isTouched}
|
||||
{#each field.state.meta.errors as error}
|
||||
<p class="text-sm text-red-500" role="alert">{error}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
|
||||
{#snippet children(state)}
|
||||
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
|
||||
{state.isSubmitting ? 'Submitting...' : 'Sign In'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignUp}>
|
||||
Need an account? Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { createForm } from '@tanstack/svelte-form';
|
||||
import z from 'zod';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { switchToSignIn } = $props<{ switchToSignIn: () => void }>();
|
||||
|
||||
const validationSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
|
||||
const form = createForm(() => ({
|
||||
defaultValues: { name: '', email: '', password: '' },
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
goto('/dashboard');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error.error.message || 'Sign up failed. Please try again.');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
},
|
||||
validators: {
|
||||
onSubmit: validationSchema,
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto mt-10 w-full max-w-md p-6">
|
||||
<h1 class="mb-6 text-center font-bold text-3xl">Create Account</h1>
|
||||
|
||||
<form
|
||||
id="form"
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{#snippet children(field)}
|
||||
<div class="space-y-1">
|
||||
<label for={field.name}>Name</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
class="w-full border"
|
||||
onblur={field.handleBlur}
|
||||
value={field.state.value}
|
||||
oninput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
field.handleChange(target.value)
|
||||
}}
|
||||
/>
|
||||
{#if field.state.meta.isTouched}
|
||||
{#each field.state.meta.errors as error}
|
||||
<p class="text-sm text-red-500" role="alert">{error}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{#snippet children(field)}
|
||||
<div class="space-y-1">
|
||||
<label for={field.name}>Email</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
class="w-full border"
|
||||
onblur={field.handleBlur}
|
||||
value={field.state.value}
|
||||
oninput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
field.handleChange(target.value)
|
||||
}}
|
||||
/>
|
||||
{#if field.state.meta.isTouched}
|
||||
{#each field.state.meta.errors as error}
|
||||
<p class="text-sm text-red-500" role="alert">{error}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{#snippet children(field)}
|
||||
<div class="space-y-1">
|
||||
<label for={field.name}>Password</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
class="w-full border"
|
||||
onblur={field.handleBlur}
|
||||
value={field.state.value}
|
||||
oninput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
field.handleChange(target.value)
|
||||
}}
|
||||
/>
|
||||
{#if field.state.meta.errors}
|
||||
{#each field.state.meta.errors as error}
|
||||
<p class="text-sm text-red-500" role="alert">{error}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
|
||||
{#snippet children(state)}
|
||||
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
|
||||
{state.isSubmitting ? 'Submitting...' : 'Sign Up'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignIn}>
|
||||
Already have an account? Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const sessionQuery = authClient.useSession();
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
goto('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Sign out failed:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if $sessionQuery.isPending}
|
||||
<div class="h-8 w-24 animate-pulse rounded bg-neutral-700"></div>
|
||||
{:else if $sessionQuery.data?.user}
|
||||
{@const user = $sessionQuery.data.user}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-neutral-300 hidden sm:inline" title={user.email}>
|
||||
{user.name || user.email?.split('@')[0] || 'User'}
|
||||
</span>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="rounded px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={goToLogin}
|
||||
class="rounded px-3 py-1 text-sm bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PUBLIC_SERVER_URL } from "$env/static/public";
|
||||
import { createAuthClient } from "better-auth/svelte";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: PUBLIC_SERVER_URL,
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authClient } from '$lib/auth-client';
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from '$lib/orpc';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
{{/if}}
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const sessionQuery = authClient.useSession();
|
||||
|
||||
{{#if (eq api "orpc")}}
|
||||
const privateDataQuery = createQuery(orpc.privateData.queryOptions());
|
||||
{{/if}}
|
||||
|
||||
onMount(() => {
|
||||
const { data: session, isPending } = get(sessionQuery);
|
||||
if (!session && !isPending) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $sessionQuery.isPending}
|
||||
<div>Loading...</div>
|
||||
{:else if !$sessionQuery.data}
|
||||
{:else}
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {$sessionQuery.data.user.name}</p>
|
||||
{{#if (eq api "orpc")}}
|
||||
<p>privateData: {$privateDataQuery.data?.message}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import SignInForm from '../../components/SignInForm.svelte';
|
||||
import SignUpForm from '../../components/SignUpForm.svelte';
|
||||
|
||||
let showSignIn = $state(true);
|
||||
</script>
|
||||
|
||||
{#if showSignIn}
|
||||
<SignInForm switchToSignUp={() => showSignIn = false} />
|
||||
{:else}
|
||||
<SignUpForm switchToSignIn={() => showSignIn = true} />
|
||||
{/if}
|
||||
Reference in New Issue
Block a user