Made great progress on monorepo & auth for next. Very happy with work!

This commit is contained in:
2026-01-12 11:55:15 -06:00
parent 72f11f0b02
commit 321fecb5e1
58 changed files with 1266 additions and 222 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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}

View 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>
);
};

View 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 &amp; how it appears to others.
</CardDescription>
</CardHeader>
);
};
export { ProfileHeader };

View 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';

View 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>
</>
);
};

View 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>
);
};

View 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>
</>
);
};

View File

@@ -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);

View File

@@ -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',

View File

@@ -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']);