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