Made great progress on monorepo & auth for next. Very happy with work!
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import { withPlausibleProxy } from 'next-plausible';
|
||||
|
||||
import { env } from './src/env.js';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
|
||||
14
apps/next/src/app/(auth)/forgot-password/layout.tsx
Normal file
14
apps/next/src/app/(auth)/forgot-password/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Forgot Password',
|
||||
};
|
||||
};
|
||||
|
||||
const ForgotPasswordLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
export default ForgotPasswordLayout;
|
||||
300
apps/next/src/app/(auth)/forgot-password/page.tsx
Normal file
300
apps/next/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PASSWORD_MAX, PASSWORD_MIN } from '@gib/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
SubmitButton,
|
||||
} from '@gib/ui';
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.email({ message: 'Invalid email.' }),
|
||||
});
|
||||
|
||||
const resetVerificationSchema = z
|
||||
.object({
|
||||
code: z.string({ message: 'Invalid code.' }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
|
||||
})
|
||||
.regex(/^\S+$/, {
|
||||
message: 'Password must not contain whitespace.',
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: 'Password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[\p{P}\p{S}]/u, {
|
||||
message: 'Password must contain at least one symbol.',
|
||||
}),
|
||||
confirmPassword: z.string().min(PASSWORD_MIN, {
|
||||
message: `Password must be at least ${PASSWORD_MIN} characters.`,
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match!',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const { signIn } = useAuthActions();
|
||||
const [flow, setFlow] = useState<'reset' | 'reset-verification'>('reset');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
const router = useRouter();
|
||||
|
||||
const forgotPasswordForm = useForm<z.infer<typeof forgotPasswordSchema>>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
defaultValues: { email },
|
||||
});
|
||||
|
||||
const resetVerificationForm = useForm<
|
||||
z.infer<typeof resetVerificationSchema>
|
||||
>({
|
||||
resolver: zodResolver(resetVerificationSchema),
|
||||
defaultValues: { code, newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
const handleForgotPasswordSubmit = async (
|
||||
values: z.infer<typeof forgotPasswordSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => {
|
||||
setEmail(values.email);
|
||||
setFlow('reset-verification');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
forgotPasswordForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetVerificationSubmit = async (
|
||||
values: z.infer<typeof resetVerificationSchema>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('newPassword', values.newPassword);
|
||||
formData.append('email', email);
|
||||
formData.append('flow', flow);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData);
|
||||
router.push('/');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
resetVerificationForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Card className="bg-card/25 min-h-[400px] w-sm p-4 lg:w-md">
|
||||
<CardHeader className="flex flex-col items-center gap-4">
|
||||
{flow === 'reset' ? (
|
||||
<>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Forgot Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we will send you a link to reset
|
||||
your password.
|
||||
</CardDescription>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Reset Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your code and new password and we will reset your
|
||||
password.
|
||||
</CardDescription>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className="bg-card/50">
|
||||
<CardContent>
|
||||
{flow === 'reset' ? (
|
||||
<Form {...forgotPasswordForm}>
|
||||
<form
|
||||
onSubmit={forgotPasswordForm.handleSubmit(
|
||||
handleForgotPasswordSubmit,
|
||||
)}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={forgotPasswordForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText="Sending Email..."
|
||||
className="mx-auto w-2/3 text-xl font-semibold"
|
||||
>
|
||||
Send Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...resetVerificationForm}>
|
||||
<form
|
||||
onSubmit={resetVerificationForm.handleSubmit(
|
||||
handleResetVerificationSubmit,
|
||||
)}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSeparator />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your
|
||||
phone.
|
||||
</FormDescription>
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">
|
||||
New Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={resetVerificationForm.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xl">
|
||||
Confirm Passsword
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText="Resetting Password..."
|
||||
className="mx-auto w-2/3 text-xl font-semibold"
|
||||
>
|
||||
Reset Password
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ForgotPassword;
|
||||
14
apps/next/src/app/(auth)/profile/layout.tsx
Normal file
14
apps/next/src/app/(auth)/profile/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Profile',
|
||||
};
|
||||
};
|
||||
|
||||
const ProfileLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
export default ProfileLayout;
|
||||
29
apps/next/src/app/(auth)/profile/page.tsx
Normal file
29
apps/next/src/app/(auth)/profile/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@gib/ui';
|
||||
|
||||
const Profile = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser);
|
||||
return (
|
||||
<Card className="mx-auto mb-8 max-w-xl min-w-xs sm:min-w-md">
|
||||
<ProfileHeader preloadedUser={preloadedUser} />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<UserInfoForm preloadedUser={preloadedUser} />
|
||||
<ResetPasswordForm preloadedUser={preloadedUser} />
|
||||
<Separator />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default Profile;
|
||||
14
apps/next/src/app/(auth)/sign-in/layout.tsx
Normal file
14
apps/next/src/app/(auth)/sign-in/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: 'Sign In',
|
||||
};
|
||||
};
|
||||
|
||||
const SignInLayout = ({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
export default SignInLayout;
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import Link from 'next/link';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ConvexError } from 'convex/values';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GibsAuthSignInButton } from '@/components/layout/auth/buttons';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ConvexError } from 'convex/values';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,8 +25,8 @@ import {
|
||||
Input,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
@@ -29,11 +34,6 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@gib/ui';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
GibsAuthSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@gib/backend/types';
|
||||
|
||||
const signInFormSchema = z.object({
|
||||
email: z.email({
|
||||
@@ -179,24 +179,24 @@ const SignIn = () => {
|
||||
|
||||
if (flow === 'email-verification') {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<div className="flex flex-col items-center">
|
||||
<Card className="bg-card/25 min-h-[720px] w-md p-4">
|
||||
<CardContent>
|
||||
<div className='text-center mb-6'>
|
||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold">Verify Your Email</h2>
|
||||
<p className="text-muted-foreground">We sent a code to {email}</p>
|
||||
</div>
|
||||
<Form {...verifyEmailForm}>
|
||||
<form
|
||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||
className='flex flex-col space-y-8'
|
||||
className="flex flex-col space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={verifyEmailForm.control}
|
||||
name='code'
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Code</FormLabel>
|
||||
<FormLabel className="text-xl">Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
@@ -217,25 +217,25 @@ const SignIn = () => {
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your email.
|
||||
</FormDescription>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
pendingText="Signing Up..."
|
||||
className="mx-auto w-2/3 text-xl font-semibold"
|
||||
>
|
||||
Verify Email
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='text-center mt-4'>
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setFlow('signUp')}
|
||||
className='text-sm text-muted-foreground hover:underline'
|
||||
className="text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
Back to Sign Up
|
||||
</button>
|
||||
@@ -247,207 +247,204 @@ const SignIn = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
||||
<div className="flex flex-col items-center">
|
||||
<Card className="bg-card/25 min-h-[720px] w-md p-4">
|
||||
<Tabs
|
||||
defaultValue={flow}
|
||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||
className='items-center'
|
||||
className="items-center"
|
||||
>
|
||||
<TabsList className='py-6'>
|
||||
<TabsList className="py-6">
|
||||
<TabsTrigger
|
||||
value='signIn'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
value="signIn"
|
||||
className="cursor-pointer p-6 text-2xl font-bold"
|
||||
>
|
||||
Sign In
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='signUp'
|
||||
className='p-6 text-2xl font-bold cursor-pointer'
|
||||
value="signUp"
|
||||
className="cursor-pointer p-6 text-2xl font-bold"
|
||||
>
|
||||
Sign Up
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='signIn'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<TabsContent value="signIn">
|
||||
<Card className="bg-card/50 min-w-xs sm:min-w-sm">
|
||||
<CardContent>
|
||||
<Form {...signInForm}>
|
||||
<form
|
||||
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||
className='flex flex-col space-y-8'
|
||||
className="flex flex-col space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='email'
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Email</FormLabel>
|
||||
<FormLabel className="text-xl">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signInForm.control}
|
||||
name='password'
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel className='text-xl'>Password</FormLabel>
|
||||
<Link href='/forgot-password'>
|
||||
<div className="flex justify-between">
|
||||
<FormLabel className="text-xl">Password</FormLabel>
|
||||
<Link href="/forgot-password">
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing in...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
pendingText="Signing in..."
|
||||
className="mx-auto w-2/3 text-xl font-semibold"
|
||||
>
|
||||
Sign In
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex justify-center'>
|
||||
<div
|
||||
className='flex flex-row items-center
|
||||
my-2.5 mx-auto justify-center w-1/4'
|
||||
>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
<div className="flex justify-center">
|
||||
<div className="mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center">
|
||||
<Separator className="mr-3 py-0.5" />
|
||||
<span className="text-lg font-semibold">or</span>
|
||||
<Separator className="ml-3 py-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<GibsAuthSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='signUp'>
|
||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
||||
<TabsContent value="signUp">
|
||||
<Card className="bg-card/50 min-w-xs sm:min-w-sm">
|
||||
<CardContent>
|
||||
<Form {...signUpForm}>
|
||||
<form
|
||||
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||
className='flex flex-col space-y-8'
|
||||
className="flex flex-col space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='name'
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Name</FormLabel>
|
||||
<FormLabel className="text-xl">Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Full Name'
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='email'
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Email</FormLabel>
|
||||
<FormLabel className="text-xl">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='password'
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>Password</FormLabel>
|
||||
<FormLabel className="text-xl">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={signUpForm.control}
|
||||
name='confirmPassword'
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
<FormLabel className="text-xl">
|
||||
Confirm Passsword
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Confirm your password'
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='flex flex-col w-full items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<FormMessage className="w-5/6 text-center" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
className='text-xl font-semibold w-2/3 mx-auto'
|
||||
pendingText="Signing Up..."
|
||||
className="mx-auto w-2/3 text-xl font-semibold"
|
||||
>
|
||||
Sign Up
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
<div className='flex my-auto justify-center w-2/3'>
|
||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
||||
<Separator className='py-0.5 mr-3' />
|
||||
<span className='font-semibold text-lg'>or</span>
|
||||
<Separator className='py-0.5 ml-3' />
|
||||
<div className="my-auto flex w-2/3 justify-center">
|
||||
<div className="my-2.5 flex w-1/3 flex-row items-center">
|
||||
<Separator className="mr-3 py-0.5" />
|
||||
<span className="text-lg font-semibold">or</span>
|
||||
<Separator className="ml-3 py-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center mt-3'>
|
||||
<GibsAuthSignInButton type='signUp' />
|
||||
<div className="mt-3 flex justify-center">
|
||||
<GibsAuthSignInButton type="signUp" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
'use server';
|
||||
|
||||
const Home = () => {
|
||||
const Home = async () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<main
|
||||
className='flex min-h-screen items-center justify-center'
|
||||
>
|
||||
Hello!
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
|
||||
import type { buttonVariants } from '@gib/ui';
|
||||
import { Button } from '@gib/ui';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
interface Props {
|
||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||
@@ -11,7 +12,7 @@ interface Props {
|
||||
asChild?: boolean;
|
||||
};
|
||||
type?: 'signIn' | 'signUp';
|
||||
};
|
||||
}
|
||||
|
||||
export const GibsAuthSignInButton = ({
|
||||
buttonProps,
|
||||
@@ -20,22 +21,16 @@ export const GibsAuthSignInButton = ({
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
size="lg"
|
||||
onClick={() => signIn('authentik')}
|
||||
className='text-lg font-semibold'
|
||||
className="text-lg font-semibold"
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='flex flex-col my-auto space-x-1'>
|
||||
<p>
|
||||
{
|
||||
type === 'signIn'
|
||||
? 'Sign In'
|
||||
: 'Sign Up'
|
||||
} with
|
||||
</p>
|
||||
<div className="my-auto flex flex-col space-x-1">
|
||||
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with</p>
|
||||
<Image
|
||||
src={'/misc/auth/gibs_auth_wide_header.png'}
|
||||
className=''
|
||||
className=""
|
||||
alt="Gib's Auth"
|
||||
width={100}
|
||||
height={100}
|
||||
|
||||
221
apps/next/src/components/layout/auth/profile/avatar-upload.tsx
Normal file
221
apps/next/src/components/layout/auth/profile/avatar-upload.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
||||
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
BasedAvatar,
|
||||
Button,
|
||||
CardContent,
|
||||
ImageCrop,
|
||||
ImageCropApply,
|
||||
ImageCropContent,
|
||||
Input,
|
||||
} from '@gib/ui';
|
||||
|
||||
interface AvatarUploadProps {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
}
|
||||
|
||||
const dataUrlToBlob = async (
|
||||
dataUrl: string,
|
||||
): Promise<{ blob: Blob; type: string }> => {
|
||||
const re = /^data:([^;,]+)[;,]/;
|
||||
const m = re.exec(dataUrl);
|
||||
const type = m?.[1] ?? 'image/png';
|
||||
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
return { blob, type };
|
||||
};
|
||||
|
||||
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file.');
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setCroppedImage(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedFile(null);
|
||||
setCroppedImage(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!croppedImage) {
|
||||
toast.error('Please apply a crop first.');
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const { blob, type } = await dataUrlToBlob(croppedImage);
|
||||
const postUrl = await generateUploadUrl();
|
||||
|
||||
const result = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': type },
|
||||
body: blob,
|
||||
});
|
||||
if (!result.ok) {
|
||||
const msg = await result.text().catch(() => 'Upload failed.');
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const uploadResponse = (await result.json()) as {
|
||||
storageId: Id<'_storage'>;
|
||||
};
|
||||
|
||||
await updateUser({ image: uploadResponse.storageId });
|
||||
|
||||
toast.success('Profile picture updated.');
|
||||
handleReset();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
toast.error('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Current avatar + trigger (hidden when cropping) */}
|
||||
{!selectedFile && (
|
||||
<div
|
||||
className="group relative cursor-pointer"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className="h-42 w-42 text-6xl font-semibold"
|
||||
userIconProps={{ size: 100 }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/0 transition-all group-hover:bg-black/50">
|
||||
<Upload
|
||||
className="text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-1 flex items-end justify-end transition-all">
|
||||
<Pencil
|
||||
className="text-white opacity-100 transition-opacity group-hover:opacity-0"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File input (hidden) */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Crop UI */}
|
||||
{selectedFile && !croppedImage && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<ImageCrop
|
||||
aspect={1}
|
||||
circularCrop
|
||||
file={selectedFile}
|
||||
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
||||
onCrop={setCroppedImage}
|
||||
>
|
||||
<ImageCropContent className="max-w-sm" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageCropApply />
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</ImageCrop>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cropped preview + actions */}
|
||||
{croppedImage && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Avatar className="h-42 w-42">
|
||||
<AvatarImage alt="Cropped preview" src={croppedImage} />
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isUploading}
|
||||
className="px-4"
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save Avatar'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
size="icon"
|
||||
type="button"
|
||||
className="hover:dark:bg-accent bg-red-400/80 hover:text-red-800/80 dark:bg-red-500/30 hover:dark:text-red-300/60"
|
||||
variant="secondary"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading indicator */}
|
||||
{isUploading && !croppedImage && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
27
apps/next/src/components/layout/auth/profile/header.tsx
Normal file
27
apps/next/src/components/layout/auth/profile/header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { usePreloadedQuery } from 'convex/react';
|
||||
|
||||
import type { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
|
||||
|
||||
interface ProfileCardProps {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
}
|
||||
|
||||
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
return (
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{user?.name ?? user?.email ?? 'Your Profile'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your personal information & how it appears to others.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileHeader };
|
||||
5
apps/next/src/components/layout/auth/profile/index.tsx
Normal file
5
apps/next/src/components/layout/auth/profile/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { AvatarUpload } from './avatar-upload';
|
||||
export { ProfileHeader } from './header';
|
||||
export { ResetPasswordForm } from './reset-password';
|
||||
export { SignOutForm } from './sign-out';
|
||||
export { UserInfoForm } from './user-info';
|
||||
189
apps/next/src/components/layout/auth/profile/reset-password.tsx
Normal file
189
apps/next/src/components/layout/auth/profile/reset-password.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import { PASSWORD_MAX, PASSWORD_MIN, PASSWORD_REGEX } from '@gib/backend/types';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Separator,
|
||||
SubmitButton,
|
||||
} from '@gib/ui';
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().regex(PASSWORD_REGEX, {
|
||||
message: 'Incorrect current password. Does not meet requirements.',
|
||||
}),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(PASSWORD_MIN, {
|
||||
message: 'New password must be at least 8 characters.',
|
||||
})
|
||||
.max(PASSWORD_MAX, {
|
||||
message: 'New password must be less than 100 characters.',
|
||||
})
|
||||
.regex(/^\S+$/, {
|
||||
message: 'Password must not contain whitespace.',
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: 'Password must contain at least one digit.',
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: 'Password must contain at least one uppercase letter.',
|
||||
})
|
||||
.regex(/[\p{P}\p{S}]/u, {
|
||||
message: 'Password must contain at least one symbol.',
|
||||
}),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'New password must be different from current password.',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords do not match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ResetFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ preloadedUser }: ResetFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const changePassword = useAction(api.auth.updateUserPassword);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await changePassword({
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
if (result.success) {
|
||||
form.reset();
|
||||
toast.success('Password updated successfully.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
toast.error('Error updating password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// TO DO: Make a function to get provider type from user.
|
||||
return (
|
||||
//user.provider !== 'password'
|
||||
!user?.email
|
||||
) ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
<Separator />
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your current password.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your new password. Must be at least 8 characters.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please re-enter your new password to confirm.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<SubmitButton
|
||||
className="w-2/3 text-[1.0rem] lg:w-1/3"
|
||||
disabled={loading}
|
||||
pendingText="Updating Password..."
|
||||
>
|
||||
Update Password
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
22
apps/next/src/components/layout/auth/profile/sign-out.tsx
Normal file
22
apps/next/src/components/layout/auth/profile/sign-out.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
|
||||
import { SubmitButton } from '@gib/ui';
|
||||
|
||||
export const SignOutForm = () => {
|
||||
const { signOut } = useAuthActions();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<SubmitButton
|
||||
className="w-5/6 cursor-pointer text-[1.0rem] font-semibold hover:bg-red-700/60 lg:w-2/3 dark:hover:bg-red-300/80"
|
||||
onClick={() => void signOut().then(() => router.push('/sign-in'))}
|
||||
>
|
||||
Sign Out
|
||||
</SubmitButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
154
apps/next/src/components/layout/auth/profile/user-info.tsx
Normal file
154
apps/next/src/components/layout/auth/profile/user-info.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, usePreloadedQuery } from 'convex/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
SubmitButton,
|
||||
} from '@gib/ui';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, {
|
||||
message: 'Full name is required & must be at least 5 characters.',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Full name must be less than 50 characters.',
|
||||
}),
|
||||
email: z.email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
});
|
||||
|
||||
type UserInfoFormProps = {
|
||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
||||
};
|
||||
|
||||
export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
||||
const user = usePreloadedQuery(preloadedUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const initialValues = useMemo<z.infer<typeof formSchema>>(
|
||||
() => ({
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
}),
|
||||
[user?.name, user?.email],
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const patch: Partial<{
|
||||
name: string;
|
||||
email: string;
|
||||
lunchTime: string;
|
||||
automaticLunch: boolean;
|
||||
}> = {};
|
||||
if (name !== (user.name ?? '')) patch.name = name;
|
||||
if (email !== (user.email ?? '')) patch.email = email;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(patch);
|
||||
form.reset(patch);
|
||||
toast.success('Profile updated successfully.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Error updating profile.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Account Information</CardTitle>
|
||||
<CardDescription>Update your account information here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
//user.provider !== 'password'
|
||||
!user?.email
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your email address associated with your account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<SubmitButton
|
||||
className="w-2/3 text-[1.0rem] lg:w-1/3"
|
||||
disabled={loading}
|
||||
pendingText="Saving..."
|
||||
>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactNode } from 'react';
|
||||
import { env } from '@/env';
|
||||
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
|
||||
import { ConvexReactClient } from 'convex/react';
|
||||
|
||||
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
|
||||
@@ -43,65 +43,65 @@ export const generateMetadata = (): Metadata => {
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
],
|
||||
//shortcut: [
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: '/appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//],
|
||||
//apple: [
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//},
|
||||
//{
|
||||
//url: 'appicon/icon.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '192x192',
|
||||
//media: '(prefers-color-scheme: dark)',
|
||||
//},
|
||||
//],
|
||||
//other: [
|
||||
//{
|
||||
//rel: 'apple-touch-icon-precomposed',
|
||||
//url: '/appicon/icon-precomposed.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '180x180',
|
||||
//},
|
||||
//{
|
||||
//rel: 'apple-touch-icon-precomposed',
|
||||
//url: '/appicon/icon-precomposed.png',
|
||||
//type: 'image/png',
|
||||
//sizes: '180x180',
|
||||
//},
|
||||
//],
|
||||
},
|
||||
other: {
|
||||
...Sentry.getTraceData(),
|
||||
},
|
||||
//appleWebApp: {
|
||||
//title: 'Convex Monorepo',
|
||||
//statusBarStyle: 'black-translucent',
|
||||
//startupImage: [
|
||||
//'/icons/apple/splash-768x1004.png',
|
||||
//{
|
||||
//url: '/icons/apple/splash-1536x2008.png',
|
||||
//media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
//},
|
||||
//],
|
||||
//title: 'Convex Monorepo',
|
||||
//statusBarStyle: 'black-translucent',
|
||||
//startupImage: [
|
||||
//'/icons/apple/splash-768x1004.png',
|
||||
//{
|
||||
//url: '/icons/apple/splash-1536x2008.png',
|
||||
//media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
//},
|
||||
//],
|
||||
//},
|
||||
verification: {
|
||||
google: 'google',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
|
||||
import {
|
||||
convexAuthNextjsMiddleware,
|
||||
createRouteMatcher,
|
||||
nextjsMiddlewareRedirect,
|
||||
} from '@convex-dev/auth/nextjs/server';
|
||||
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
|
||||
|
||||
const isSignInPage = createRouteMatcher(['/sign-in']);
|
||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||
|
||||
Reference in New Issue
Block a user