Made great progress on monorepo & auth for next. Very happy with work!
This commit is contained in:
@@ -1 +1 @@
|
|||||||
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2249,"mtime":1766222924000,"hash":"64","data":"65"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639384,"hash":"66","data":"67"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639442,"hash":"68","data":"69"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1768155639734,"hash":"70","data":"71"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1766222924000,"hash":"72","data":"73"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1768155639501,"hash":"74","data":"75"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1766222924000,"hash":"76","data":"77"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"78","data":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1768155639581,"hash":"80","data":"81"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"82","data":"83"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1768155639911,"hash":"84","data":"85"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1768155639785,"hash":"86","data":"87"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1768155639819,"hash":"88","data":"89"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639965,"hash":"90","data":"91"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"92"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":275,"mtime":1768155639360,"hash":"93","data":"94"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1326,"mtime":1768155639881,"hash":"95","data":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639943,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639275,"hash":"99","data":"100"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"101"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"102","data":"103"},"d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"104"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"105"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"106"},"1c10eb388cf5dcbc87d2d63770a227f1",{"hashOfOptions":"107"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"108"},"b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"109"},"a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"110"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"111"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"112"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"113"},"cecbed1604a530a7cc099fecddddd76c",{"hashOfOptions":"114"},"4fcefde979d34a7339f7a266d3ec931b",{"hashOfOptions":"115"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"116"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"117"},"1e8ac0d261e95efb19d290ffcf70ce36","1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"118"},"5ff899a601102659dcbd2900e415ce8b",{"hashOfOptions":"119"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"120"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"121"},"863da15dbd856008b7c24077ca746d91","6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"122"},"2742538293","3367041120","3963086909","3173464737","3850474653","3346849767","2045418596","3048291867","3525769784","4000169781","471581913","1427279653","2305419495","818733105","2587128410","1262173017","408469422","2836740315","2315460658"]
|
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22"],{"key":"23","value":"24"},{"key":"25","value":"26"},{"key":"27","value":"28"},{"key":"29","value":"30"},{"key":"31","value":"32"},{"key":"33","value":"34"},{"key":"35","value":"36"},{"key":"37","value":"38"},{"key":"39","value":"40"},{"key":"41","value":"42"},{"key":"43","value":"44"},{"key":"45","value":"46"},{"key":"47","value":"48"},{"key":"49","value":"50"},{"key":"51","value":"52"},{"key":"53","value":"54"},{"key":"55","value":"56"},{"key":"57","value":"58"},{"key":"59","value":"60"},{"key":"61","value":"62"},{"key":"63","value":"64"},{"key":"65","value":"66"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2249,"mtime":1766222924000,"hash":"67","data":"68"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639000,"hash":"69","data":"70"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639000,"hash":"71","data":"72"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1768155639000,"hash":"73","data":"74"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1766222924000,"hash":"75","data":"76"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1768155639000,"hash":"77","data":"78"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.cache/.prettiercache",{"size":4787,"mtime":1768171236840,"hash":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1766222924000,"hash":"80","data":"81"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"82","data":"83"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1768155639000,"hash":"84","data":"85"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"86","data":"87"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1768155639000,"hash":"88","data":"89"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1768155639000,"hash":"90","data":"91"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1768155639000,"hash":"92","data":"93"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639000,"hash":"94","data":"95"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":275,"mtime":1768155639000,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1326,"mtime":1768155639000,"hash":"99","data":"100"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639000,"hash":"101","data":"102"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639000,"hash":"103","data":"104"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"105"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"106","data":"107"},"d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"108"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"109"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"110"},"1c10eb388cf5dcbc87d2d63770a227f1",{"hashOfOptions":"111"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"112"},"b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"113"},"75e4e9158c89e7a7bc04e94fd2366743","a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"114"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"115"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"116"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"117"},"cecbed1604a530a7cc099fecddddd76c",{"hashOfOptions":"118"},"4fcefde979d34a7339f7a266d3ec931b",{"hashOfOptions":"119"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"120"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"121"},"1e8ac0d261e95efb19d290ffcf70ce36","1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"122"},"5ff899a601102659dcbd2900e415ce8b",{"hashOfOptions":"123"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"124"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"125"},"863da15dbd856008b7c24077ca746d91","6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"126"},"2742538293","3367041120","3963086909","3173464737","3850474653","3346849767","2045418596","3048291867","3525769784","4000169781","471581913","1427279653","2305419495","818733105","2587128410","1262173017","408469422","2836740315","2315460658"]
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
|||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
import { withPlausibleProxy } from 'next-plausible';
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
import { env } from './src/env.js';
|
import { env } from './src/env.js';
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @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';
|
'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 { 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,8 +25,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
|
||||||
InputOTPSeparator,
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot,
|
||||||
Separator,
|
Separator,
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -29,11 +34,6 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from '@gib/ui';
|
} 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({
|
const signInFormSchema = z.object({
|
||||||
email: z.email({
|
email: z.email({
|
||||||
@@ -179,24 +179,24 @@ const SignIn = () => {
|
|||||||
|
|
||||||
if (flow === 'email-verification') {
|
if (flow === 'email-verification') {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center'>
|
<div className="flex flex-col items-center">
|
||||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
<Card className="bg-card/25 min-h-[720px] w-md p-4">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='text-center mb-6'>
|
<div className="mb-6 text-center">
|
||||||
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
|
<h2 className="text-2xl font-bold">Verify Your Email</h2>
|
||||||
<p className='text-muted-foreground'>We sent a code to {email}</p>
|
<p className="text-muted-foreground">We sent a code to {email}</p>
|
||||||
</div>
|
</div>
|
||||||
<Form {...verifyEmailForm}>
|
<Form {...verifyEmailForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
|
||||||
className='flex flex-col space-y-8'
|
className="flex flex-col space-y-8"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={verifyEmailForm.control}
|
control={verifyEmailForm.control}
|
||||||
name='code'
|
name="code"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>Code</FormLabel>
|
<FormLabel className="text-xl">Code</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
@@ -217,25 +217,25 @@ const SignIn = () => {
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
Please enter the one-time password sent to your email.
|
Please enter the one-time password sent to your email.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing Up...'
|
pendingText="Signing Up..."
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
className="mx-auto w-2/3 text-xl font-semibold"
|
||||||
>
|
>
|
||||||
Verify Email
|
Verify Email
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='text-center mt-4'>
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFlow('signUp')}
|
onClick={() => setFlow('signUp')}
|
||||||
className='text-sm text-muted-foreground hover:underline'
|
className="text-muted-foreground text-sm hover:underline"
|
||||||
>
|
>
|
||||||
Back to Sign Up
|
Back to Sign Up
|
||||||
</button>
|
</button>
|
||||||
@@ -247,207 +247,204 @@ const SignIn = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center'>
|
<div className="flex flex-col items-center">
|
||||||
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
|
<Card className="bg-card/25 min-h-[720px] w-md p-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue={flow}
|
defaultValue={flow}
|
||||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||||
className='items-center'
|
className="items-center"
|
||||||
>
|
>
|
||||||
<TabsList className='py-6'>
|
<TabsList className="py-6">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='signIn'
|
value="signIn"
|
||||||
className='p-6 text-2xl font-bold cursor-pointer'
|
className="cursor-pointer p-6 text-2xl font-bold"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='signUp'
|
value="signUp"
|
||||||
className='p-6 text-2xl font-bold cursor-pointer'
|
className="cursor-pointer p-6 text-2xl font-bold"
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='signIn'>
|
<TabsContent value="signIn">
|
||||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
<Card className="bg-card/50 min-w-xs sm:min-w-sm">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...signInForm}>
|
<Form {...signInForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
onSubmit={signInForm.handleSubmit(handleSignIn)}
|
||||||
className='flex flex-col space-y-8'
|
className="flex flex-col space-y-8"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={signInForm.control}
|
control={signInForm.control}
|
||||||
name='email'
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>Email</FormLabel>
|
<FormLabel className="text-xl">Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='email'
|
type="email"
|
||||||
placeholder='you@example.com'
|
placeholder="you@example.com"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={signInForm.control}
|
control={signInForm.control}
|
||||||
name='password'
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className='flex justify-between'>
|
<div className="flex justify-between">
|
||||||
<FormLabel className='text-xl'>Password</FormLabel>
|
<FormLabel className="text-xl">Password</FormLabel>
|
||||||
<Link href='/forgot-password'>
|
<Link href="/forgot-password">
|
||||||
Forgot Password?
|
Forgot Password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type="password"
|
||||||
placeholder='Your password'
|
placeholder="Your password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing in...'
|
pendingText="Signing in..."
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
className="mx-auto w-2/3 text-xl font-semibold"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='flex justify-center'>
|
<div className="flex justify-center">
|
||||||
<div
|
<div className="mx-auto my-2.5 flex w-1/4 flex-row items-center justify-center">
|
||||||
className='flex flex-row items-center
|
<Separator className="mr-3 py-0.5" />
|
||||||
my-2.5 mx-auto justify-center w-1/4'
|
<span className="text-lg font-semibold">or</span>
|
||||||
>
|
<Separator className="ml-3 py-0.5" />
|
||||||
<Separator className='py-0.5 mr-3' />
|
|
||||||
<span className='font-semibold text-lg'>or</span>
|
|
||||||
<Separator className='py-0.5 ml-3' />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-center mt-3'>
|
<div className="mt-3 flex justify-center">
|
||||||
<GibsAuthSignInButton />
|
<GibsAuthSignInButton />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='signUp'>
|
<TabsContent value="signUp">
|
||||||
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
|
<Card className="bg-card/50 min-w-xs sm:min-w-sm">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...signUpForm}>
|
<Form {...signUpForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||||
className='flex flex-col space-y-8'
|
className="flex flex-col space-y-8"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={signUpForm.control}
|
control={signUpForm.control}
|
||||||
name='name'
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>Name</FormLabel>
|
<FormLabel className="text-xl">Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='text'
|
type="text"
|
||||||
placeholder='Full Name'
|
placeholder="Full Name"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={signUpForm.control}
|
control={signUpForm.control}
|
||||||
name='email'
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>Email</FormLabel>
|
<FormLabel className="text-xl">Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='email'
|
type="email"
|
||||||
placeholder='you@example.com'
|
placeholder="you@example.com"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={signUpForm.control}
|
control={signUpForm.control}
|
||||||
name='password'
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>Password</FormLabel>
|
<FormLabel className="text-xl">Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type="password"
|
||||||
placeholder='Your password'
|
placeholder="Your password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={signUpForm.control}
|
control={signUpForm.control}
|
||||||
name='confirmPassword'
|
name="confirmPassword"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className='text-xl'>
|
<FormLabel className="text-xl">
|
||||||
Confirm Passsword
|
Confirm Passsword
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type='password'
|
type="password"
|
||||||
placeholder='Confirm your password'
|
placeholder="Confirm your password"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className='flex flex-col w-full items-center'>
|
<div className="flex w-full flex-col items-center">
|
||||||
<FormMessage className='w-5/6 text-center' />
|
<FormMessage className="w-5/6 text-center" />
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing Up...'
|
pendingText="Signing Up..."
|
||||||
className='text-xl font-semibold w-2/3 mx-auto'
|
className="mx-auto w-2/3 text-xl font-semibold"
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<div className='flex my-auto justify-center w-2/3'>
|
<div className="my-auto flex w-2/3 justify-center">
|
||||||
<div className='flex flex-row w-1/3 items-center my-2.5'>
|
<div className="my-2.5 flex w-1/3 flex-row items-center">
|
||||||
<Separator className='py-0.5 mr-3' />
|
<Separator className="mr-3 py-0.5" />
|
||||||
<span className='font-semibold text-lg'>or</span>
|
<span className="text-lg font-semibold">or</span>
|
||||||
<Separator className='py-0.5 ml-3' />
|
<Separator className="ml-3 py-0.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-center mt-3'>
|
<div className="mt-3 flex justify-center">
|
||||||
<GibsAuthSignInButton type='signUp' />
|
<GibsAuthSignInButton type="signUp" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use server';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = async () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<main
|
||||||
|
className='flex min-h-screen items-center justify-center'
|
||||||
|
>
|
||||||
Hello!
|
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 Image from 'next/image';
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
|
||||||
import type { buttonVariants } from '@gib/ui';
|
import type { buttonVariants } from '@gib/ui';
|
||||||
import { Button } from '@gib/ui';
|
import { Button } from '@gib/ui';
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
import type { VariantProps } from 'class-variance-authority';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
|
||||||
@@ -11,7 +12,7 @@ interface Props {
|
|||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
};
|
};
|
||||||
type?: 'signIn' | 'signUp';
|
type?: 'signIn' | 'signUp';
|
||||||
};
|
}
|
||||||
|
|
||||||
export const GibsAuthSignInButton = ({
|
export const GibsAuthSignInButton = ({
|
||||||
buttonProps,
|
buttonProps,
|
||||||
@@ -20,22 +21,16 @@ export const GibsAuthSignInButton = ({
|
|||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size='lg'
|
size="lg"
|
||||||
onClick={() => signIn('authentik')}
|
onClick={() => signIn('authentik')}
|
||||||
className='text-lg font-semibold'
|
className="text-lg font-semibold"
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<div className='flex flex-col my-auto space-x-1'>
|
<div className="my-auto flex flex-col space-x-1">
|
||||||
<p>
|
<p>{type === 'signIn' ? 'Sign In' : 'Sign Up'} with</p>
|
||||||
{
|
|
||||||
type === 'signIn'
|
|
||||||
? 'Sign In'
|
|
||||||
: 'Sign Up'
|
|
||||||
} with
|
|
||||||
</p>
|
|
||||||
<Image
|
<Image
|
||||||
src={'/misc/auth/gibs_auth_wide_header.png'}
|
src={'/misc/auth/gibs_auth_wide_header.png'}
|
||||||
className=''
|
className=""
|
||||||
alt="Gib's Auth"
|
alt="Gib's Auth"
|
||||||
width={100}
|
width={100}
|
||||||
height={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 type { ReactNode } from 'react';
|
||||||
import { ConvexReactClient } from "convex/react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { env } from '@/env';
|
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);
|
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
|
||||||
import {
|
import {
|
||||||
convexAuthNextjsMiddleware,
|
convexAuthNextjsMiddleware,
|
||||||
createRouteMatcher,
|
createRouteMatcher,
|
||||||
nextjsMiddlewareRedirect,
|
nextjsMiddlewareRedirect,
|
||||||
} from '@convex-dev/auth/nextjs/server';
|
} from '@convex-dev/auth/nextjs/server';
|
||||||
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
|
|
||||||
|
|
||||||
const isSignInPage = createRouteMatcher(['/sign-in']);
|
const isSignInPage = createRouteMatcher(['/sign-in']);
|
||||||
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -9,9 +9,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
|
import type * as crons from "../crons.js";
|
||||||
import type * as custom_auth_index from "../custom/auth/index.js";
|
import type * as custom_auth_index from "../custom/auth/index.js";
|
||||||
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
|
||||||
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
import type * as custom_auth_providers_usesend from "../custom/auth/providers/usesend.js";
|
||||||
|
import type * as files from "../files.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
import type * as utils from "../utils.js";
|
import type * as utils from "../utils.js";
|
||||||
|
|
||||||
@@ -31,9 +33,11 @@ import type {
|
|||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
|
crons: typeof crons;
|
||||||
"custom/auth/index": typeof custom_auth_index;
|
"custom/auth/index": typeof custom_auth_index;
|
||||||
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
"custom/auth/providers/password": typeof custom_auth_providers_password;
|
||||||
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
"custom/auth/providers/usesend": typeof custom_auth_providers_usesend;
|
||||||
|
files: typeof files;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
utils: typeof utils;
|
utils: typeof utils;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import { action, mutation, query } from './_generated/server';
|
|||||||
import { Password, validatePassword } from './custom/auth';
|
import { Password, validatePassword } from './custom/auth';
|
||||||
|
|
||||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||||
providers: [
|
providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password],
|
||||||
Authentik({ allowDangerousEmailAccountLinking: true }),
|
|
||||||
Password,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getUserById = async (
|
const getUserById = async (
|
||||||
@@ -39,9 +36,8 @@ const isSignedIn = async (ctx: QueryCtx): Promise<Doc<'users'> | null> => {
|
|||||||
export const getUser = query({
|
export const getUser = query({
|
||||||
args: { userId: v.optional(v.id('users')) },
|
args: { userId: v.optional(v.id('users')) },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const user = await isSignedIn(ctx);
|
const userId = args.userId ?? (await getAuthUserId(ctx));
|
||||||
const userId = args.userId ?? user?._id;
|
if (!userId) return null;
|
||||||
if (!userId) throw new ConvexError('Not authenticated or no ID provided.');
|
|
||||||
return getUserById(ctx, userId);
|
return getUserById(ctx, userId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
23
packages/backend/convex/crons.ts
Normal file
23
packages/backend/convex/crons.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { cronJobs } from 'convex/server';
|
||||||
|
|
||||||
|
import { api } from './_generated/api';
|
||||||
|
|
||||||
|
// Cron order: Minute Hour DayOfMonth Month DayOfWeek
|
||||||
|
const crons = cronJobs();
|
||||||
|
/* Example cron jobs
|
||||||
|
crons.cron(
|
||||||
|
// Run at 7:00 AM CST / 8:00 AM CDT
|
||||||
|
// Only on weekdays
|
||||||
|
'Schedule Automatic Lunches',
|
||||||
|
'0 13 * * 1-5',
|
||||||
|
api.statuses.automaticLunch,
|
||||||
|
);
|
||||||
|
crons.cron(
|
||||||
|
// Run at 4:00 PM CST / 5:00 PM CDT
|
||||||
|
// Only on weekdays
|
||||||
|
'End of shift (weekdays 5pm CT)',
|
||||||
|
'0 22 * * 1-5',
|
||||||
|
api.statuses.endOfShiftUpdate,
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
export default crons;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
import { Password as DefaultPassword } from '@convex-dev/auth/providers/Password';
|
||||||
import { ConvexError } from 'convex/values';
|
import { ConvexError } from 'convex/values';
|
||||||
|
|
||||||
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
|
import { UseSendOTP, UseSendOTPPasswordReset } from '..';
|
||||||
import { DataModel } from '../../../_generated/dataModel';
|
import { DataModel } from '../../../_generated/dataModel';
|
||||||
|
|
||||||
|
|||||||
18
packages/backend/convex/files.ts
Normal file
18
packages/backend/convex/files.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
export const generateUploadUrl = mutation(async (ctx) => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
|
return await ctx.storage.generateUploadUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getImageUrl = query({
|
||||||
|
args: { storageId: v.id('_storage') },
|
||||||
|
handler: async (ctx, { storageId }) => {
|
||||||
|
const url = await ctx.storage.getUrl(storageId);
|
||||||
|
return url ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { authTables } from '@convex-dev/auth/server';
|
||||||
import { defineSchema, defineTable } from 'convex/server';
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { authTables } from '@convex-dev/auth/server';
|
|
||||||
|
|
||||||
const applicationTables = {
|
const applicationTables = {
|
||||||
// Users contains name image & email.
|
// Users contains name image & email.
|
||||||
@@ -10,8 +10,7 @@ const applicationTables = {
|
|||||||
profiles: defineTable({
|
profiles: defineTable({
|
||||||
userId: v.id('users'),
|
userId: v.id('users'),
|
||||||
theme_preference: v.optional(v.string()),
|
theme_preference: v.optional(v.string()),
|
||||||
})
|
}).index('userId', ['userId']),
|
||||||
.index('userId', ['userId'])
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
@@ -29,8 +28,8 @@ export default defineSchema({
|
|||||||
phoneVerificationTime: v.optional(v.number()),
|
phoneVerificationTime: v.optional(v.number()),
|
||||||
isAnonymous: v.optional(v.boolean()),
|
isAnonymous: v.optional(v.boolean()),
|
||||||
})
|
})
|
||||||
.index("email", ["email"])
|
.index('email', ['email'])
|
||||||
.index('name', ['name'])
|
.index('name', ['name'])
|
||||||
.index("phone", ["phone"]),
|
.index('phone', ['phone']),
|
||||||
...applicationTables,
|
...applicationTables,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"author": "Gib",
|
"author": "Gib",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./types" : "./types/index.ts"
|
"./convex": "./convex/",
|
||||||
|
"./convex/*": "./convex/*",
|
||||||
|
"./types": "./types/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun with-env convex dev",
|
"dev": "bun with-env convex dev",
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose';
|
||||||
|
|
||||||
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
|
const keys = await generateKeyPair('RS256', {
|
||||||
|
|
||||||
const keys = await generateKeyPair("RS256", {
|
|
||||||
extractable: true,
|
extractable: true,
|
||||||
});
|
});
|
||||||
const privateKey = await exportPKCS8(keys.privateKey);
|
const privateKey = await exportPKCS8(keys.privateKey);
|
||||||
const publicKey = await exportJWK(keys.publicKey);
|
const publicKey = await exportJWK(keys.publicKey);
|
||||||
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
|
const jwks = JSON.stringify({ keys: [{ use: 'sig', ...publicKey }] });
|
||||||
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
|
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, ' ')}"`,
|
||||||
);
|
);
|
||||||
process.stdout.write("\n");
|
process.stdout.write('\n');
|
||||||
process.stdout.write(`JWKS=${jwks}`);
|
process.stdout.write(`JWKS=${jwks}`);
|
||||||
process.stdout.write("\n");
|
process.stdout.write('\n');
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
export {
|
export { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from './auth';
|
||||||
PASSWORD_MIN,
|
|
||||||
PASSWORD_MAX,
|
|
||||||
PASSWORD_REGEX,
|
|
||||||
} from './auth';
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { cn, AvatarImage } from '@gib/ui';
|
|
||||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AvatarImage, cn } from '@gib/ui';
|
||||||
|
|
||||||
type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & {
|
type BasedAvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
type BasedProgressProps = React.ComponentProps<
|
type BasedProgressProps = React.ComponentProps<
|
||||||
typeof ProgressPrimitive.Root
|
typeof ProgressPrimitive.Root
|
||||||
> & {
|
> & {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Checkbox({
|
function Checkbox({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Drawer({
|
function Drawer({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { cn, Label, Separator } from '@gib/ui';
|
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn, Label, Separator } from '@gib/ui';
|
||||||
|
|
||||||
export function FieldSet({
|
export function FieldSet({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn, Label } from '@gib/ui';
|
|
||||||
import type * as LabelPrimitive from '@radix-ui/react-label';
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -12,6 +11,8 @@ import {
|
|||||||
useFormState,
|
useFormState,
|
||||||
} from 'react-hook-form';
|
} from 'react-hook-form';
|
||||||
|
|
||||||
|
import { cn, Label } from '@gib/ui';
|
||||||
|
|
||||||
const Form = FormProvider;
|
const Form = FormProvider;
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||||
import { MinusIcon } from 'lucide-react';
|
import { MinusIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function InputOTP({
|
function InputOTP({
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Label({
|
function Label({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import type { Button } from '@gib/ui';
|
|
||||||
import { cn, buttonVariants } from '@gib/ui';
|
|
||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Button } from '@gib/ui';
|
||||||
|
import { buttonVariants, cn } from '@gib/ui';
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Progress({
|
function Progress({
|
||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
orientation = 'horizontal',
|
orientation = 'horizontal',
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { cn, Button } from '@gib/ui';
|
|
||||||
import { CropIcon, RotateCcwIcon } from 'lucide-react';
|
import { CropIcon, RotateCcwIcon } from 'lucide-react';
|
||||||
import { Slot } from 'radix-ui';
|
import { Slot } from 'radix-ui';
|
||||||
import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop';
|
import ReactCrop, { centerCrop, makeAspectCrop } from 'react-image-crop';
|
||||||
|
|
||||||
|
import { Button, cn } from '@gib/ui';
|
||||||
|
|
||||||
import 'react-image-crop/dist/ReactCrop.css';
|
import 'react-image-crop/dist/ReactCrop.css';
|
||||||
|
|
||||||
const centerAspectCrop = (
|
const centerAspectCrop = (
|
||||||
@@ -110,7 +111,7 @@ interface ImageCropContextType {
|
|||||||
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;
|
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;
|
||||||
applyCrop: () => Promise<void>;
|
applyCrop: () => Promise<void>;
|
||||||
resetCrop: () => void;
|
resetCrop: () => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ImageCropContext = createContext<ImageCropContextType | null>(null);
|
const ImageCropContext = createContext<ImageCropContextType | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import type { ToasterProps } from 'sonner';
|
import type { ToasterProps } from 'sonner';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
import { Toaster as Sonner } from 'sonner';
|
import { Toaster as Sonner } from 'sonner';
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
type Message = { success: string } | { error: string } | { message: string };
|
type Message = { success: string } | { error: string } | { message: string };
|
||||||
@@ -7,7 +8,7 @@ interface StatusMessageProps {
|
|||||||
message: Message;
|
message: Message;
|
||||||
containerProps?: ComponentProps<'div'>;
|
containerProps?: ComponentProps<'div'>;
|
||||||
textProps?: ComponentProps<'div'>;
|
textProps?: ComponentProps<'div'>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const StatusMessage = ({
|
export const StatusMessage = ({
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { cn, Button } from '@gib/ui';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
|
import { Button, cn } from '@gib/ui';
|
||||||
|
|
||||||
export type SubmitButtonProps = Omit<
|
export type SubmitButtonProps = Omit<
|
||||||
ComponentProps<typeof Button>,
|
ComponentProps<typeof Button>,
|
||||||
'type' | 'aria-disabled'
|
'type' | 'aria-disabled'
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Switch({
|
function Switch({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { cn } from '@gib/ui';
|
|
||||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Tabs({
|
function Tabs({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ThemeProvider = ({
|
|||||||
interface ThemeToggleProps {
|
interface ThemeToggleProps {
|
||||||
size?: number;
|
size?: number;
|
||||||
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
|
buttonProps?: Omit<ComponentProps<typeof Button>, 'onClick'>;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
|
const ThemeToggle = ({ size = 1, buttonProps }: ThemeToggleProps) => {
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
[["1","2","3","4","5"],{"key":"6","value":"7"},{"key":"8","value":"9"},{"key":"10","value":"11"},{"key":"12","value":"13"},{"key":"14","value":"15"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/package.json",{"size":979,"mtime":1766222924000,"hash":"16","data":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/nextjs.ts",{"size":440,"mtime":1768155639188,"hash":"18","data":"19"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/react.ts",{"size":592,"mtime":1768155639239,"hash":"20","data":"21"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/base.ts",{"size":2511,"mtime":1768155639128,"hash":"22","data":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"24","data":"25"},"a5326aca75246da261fd2e354257b45a",{"hashOfOptions":"26"},"25c52c46972131dcc296288599ff108d",{"hashOfOptions":"27"},"2292935ede6baf909f6a0c61486e15da",{"hashOfOptions":"28"},"9e432daa1849e911eb3c69df3d068c6d",{"hashOfOptions":"29"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"30"},"1786406903","2477157100","3372054421","3828872695","459831536"]
|
[["1","2","3","4","5","6"],{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},{"key":"13","value":"14"},{"key":"15","value":"16"},{"key":"17","value":"18"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/package.json",{"size":979,"mtime":1768166330000,"hash":"19","data":"20"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/nextjs.ts",{"size":440,"mtime":1768155639000,"hash":"21","data":"22"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/.cache/.prettiercache",{"size":1132,"mtime":1768171236844,"hash":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/react.ts",{"size":592,"mtime":1768155639000,"hash":"24","data":"25"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/base.ts",{"size":2511,"mtime":1768155639000,"hash":"26","data":"27"},"/home/gib/Documents/Code/convex-monorepo/tools/eslint/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"28","data":"29"},"a5326aca75246da261fd2e354257b45a",{"hashOfOptions":"30"},"25c52c46972131dcc296288599ff108d",{"hashOfOptions":"31"},"26c40ae02d206ffdd279df170ea85cae","2292935ede6baf909f6a0c61486e15da",{"hashOfOptions":"32"},"9e432daa1849e911eb3c69df3d068c6d",{"hashOfOptions":"33"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"34"},"1786406903","2477157100","3372054421","3828872695","459831536"]
|
||||||
@@ -1 +1 @@
|
|||||||
[["1","2","3"],{"key":"4","value":"5"},{"key":"6","value":"7"},{"key":"8","value":"9"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"10","data":"11"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/package.json",{"size":607,"mtime":1766222924000,"hash":"12","data":"13"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/index.js",{"size":1170,"mtime":1768155639114,"hash":"14","data":"15"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"16"},"11b634ce56ac720ac9a2860d77fbd2cc",{"hashOfOptions":"17"},"d6f2202018912e47fdd2016f04a4b6eb",{"hashOfOptions":"18"},"1555666866","640383157","507270250"]
|
[["1","2","3","4"],{"key":"5","value":"6"},{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"13","data":"14"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/package.json",{"size":607,"mtime":1766222924000,"hash":"15","data":"16"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/.cache/.prettiercache",{"size":685,"mtime":1768171236844,"hash":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/prettier/index.js",{"size":1170,"mtime":1768155639000,"hash":"18","data":"19"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"20"},"11b634ce56ac720ac9a2860d77fbd2cc",{"hashOfOptions":"21"},"bd15cce9406aeef5526bb5681494acce","d6f2202018912e47fdd2016f04a4b6eb",{"hashOfOptions":"22"},"1555666866","640383157","507270250"]
|
||||||
@@ -1 +1 @@
|
|||||||
[["1","2","3","4","5"],{"key":"6","value":"7"},{"key":"8","value":"9"},{"key":"10","value":"11"},{"key":"12","value":"13"},{"key":"14","value":"15"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/package.json",{"size":851,"mtime":1766222924000,"hash":"16","data":"17"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/postcss-config.js",{"size":70,"mtime":1768155639454,"hash":"18","data":"19"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/eslint.config.ts",{"size":143,"mtime":1768155639344,"hash":"20","data":"21"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/theme.css",{"size":6741,"mtime":1766222924000,"hash":"22","data":"23"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"24","data":"25"},"0d22e47f57739db9de04c6f8420d6fb5",{"hashOfOptions":"26"},"9a944fbda06979be39571bd9bd00b0d9",{"hashOfOptions":"27"},"b8fec960cb32340eea62ca1485093e68",{"hashOfOptions":"28"},"5dd421d25d104c47e1ab36df41ed0f7d",{"hashOfOptions":"29"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"30"},"1821576240","2434669165","2442413358","1994519264","1135731447"]
|
[["1","2","3","4","5","6"],{"key":"7","value":"8"},{"key":"9","value":"10"},{"key":"11","value":"12"},{"key":"13","value":"14"},{"key":"15","value":"16"},{"key":"17","value":"18"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/package.json",{"size":851,"mtime":1766222924000,"hash":"19","data":"20"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/postcss-config.js",{"size":70,"mtime":1768155639000,"hash":"21","data":"22"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/eslint.config.ts",{"size":143,"mtime":1768155639000,"hash":"23","data":"24"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/theme.css",{"size":6741,"mtime":1766222924000,"hash":"25","data":"26"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/tsconfig.json",{"size":94,"mtime":1766222924000,"hash":"27","data":"28"},"/home/gib/Documents/Code/convex-monorepo/tools/tailwind/.cache/.prettiercache",{"size":1160,"mtime":1768171236844,"hash":"29"},"0d22e47f57739db9de04c6f8420d6fb5",{"hashOfOptions":"30"},"9a944fbda06979be39571bd9bd00b0d9",{"hashOfOptions":"31"},"b8fec960cb32340eea62ca1485093e68",{"hashOfOptions":"32"},"5dd421d25d104c47e1ab36df41ed0f7d",{"hashOfOptions":"33"},"b3c77d33a30318d89c9c2cafcbe00bbe",{"hashOfOptions":"34"},"e96b5ac7b7aca2012f7ddbe4daef9e4c","1821576240","2434669165","2442413358","1994519264","1135731447"]
|
||||||
Reference in New Issue
Block a user