diff --git a/convex/CustomPassword.ts b/convex/CustomPassword.ts index 1224ee9..f4d06cf 100644 --- a/convex/CustomPassword.ts +++ b/convex/CustomPassword.ts @@ -1,5 +1,6 @@ import { ConvexError } from 'convex/values'; import { Password } from '@convex-dev/auth/providers/Password'; +import { validatePassword } from './auth'; import type { DataModel } from './_generated/dataModel'; export default Password({ @@ -10,12 +11,7 @@ export default Password({ }; }, validatePasswordRequirements: (password: string) => { - if ( - password.length < 8 || - !/\d/.test(password) || - !/[a-z]/.test(password) || - !/[A-Z]/.test(password) - ) { + if (!validatePassword(password)) { throw new ConvexError('Invalid password.'); } }, diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6f58851..ea7f6a1 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -17,7 +17,7 @@ import type * as CustomPassword from "../CustomPassword.js"; import type * as auth from "../auth.js"; import type * as files from "../files.js"; import type * as http from "../http.js"; -import type * as myFunctions from "../myFunctions.js"; +import type * as statuses from "../statuses.js"; /** * A utility for referencing Convex functions in your app's API. @@ -32,7 +32,7 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; files: typeof files; http: typeof http; - myFunctions: typeof myFunctions; + statuses: typeof statuses; }>; export declare const api: FilterApi< typeof fullApi, diff --git a/convex/auth.ts b/convex/auth.ts index e60f97c..34d65cf 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,7 +1,13 @@ import { ConvexError, v } from 'convex/values'; -import { convexAuth, getAuthUserId } from '@convex-dev/auth/server'; -import { mutation, query } from './_generated/server'; +import { + convexAuth, + getAuthUserId, + retrieveAccount, + modifyAccountCredentials, +} from '@convex-dev/auth/server'; +import { api } from './_generated/api'; import { type Id } from './_generated/dataModel'; +import { action, mutation, query } from './_generated/server'; import Password from './CustomPassword'; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ @@ -69,3 +75,44 @@ export const updateUserImage = mutation({ return { success: true }; }, }); + +export const validatePassword = (password: string): boolean => { + if ( + password.length < 8 || + password.length > 100 || + !/\d/.test(password) || + !/[a-z]/.test(password) || + !/[A-Z]/.test(password) + ) { + return false; + } + return true; +}; + +export const updateUserPassword = action({ + args: { + currentPassword: v.string(), + newPassword: v.string(), + }, + handler: async (ctx, {currentPassword, newPassword}) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new ConvexError('Not authenticated.'); + const user = await ctx.runQuery(api.auth.getUser); + if (!user?.email) throw new ConvexError('User not found.'); + const verified = await retrieveAccount(ctx, { + provider: 'password', + account: { id: user.email, secret: currentPassword } + }); + if (!verified) throw new ConvexError('Current password is incorrect.'); + + if (!validatePassword(newPassword)) + throw new ConvexError('Invalid password.'); + + await modifyAccountCredentials(ctx, { + provider: 'password', + account: { id: user.email, secret: newPassword } + }); + + return { success: true }; + }, +}) diff --git a/convex/myFunctions.ts b/convex/myFunctions.ts deleted file mode 100644 index aee258a..0000000 --- a/convex/myFunctions.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { v } from 'convex/values'; -import { query, mutation, action } from './_generated/server'; -import { api } from './_generated/api'; -import { getAuthUserId } from '@convex-dev/auth/server'; - -// Write your Convex functions in any file inside this directory (`convex`). -// See https://docs.convex.dev/functions for more. - -// You can read data from the database via a query: -export const listNumbers = query({ - // Validators for arguments. - args: { - count: v.number(), - }, - - // Query implementation. - handler: async (ctx, args) => { - //// Read the database as many times as you need here. - //// See https://docs.convex.dev/database/reading-data. - const numbers = await ctx.db - .query('numbers') - // Ordered by _creationTime, return most recent - .order('desc') - .take(args.count); - const userId = await getAuthUserId(ctx); - const user = userId === null ? null : await ctx.db.get(userId); - return { - viewer: user?.email ?? null, - numbers: numbers.reverse().map((number) => number.value), - }; - }, -}); - -// You can write data to the database via a mutation: -export const addNumber = mutation({ - // Validators for arguments. - args: { - value: v.number(), - }, - - // Mutation implementation. - handler: async (ctx, args) => { - //// Insert or modify documents in the database here. - //// Mutations can also read from the database like queries. - //// See https://docs.convex.dev/database/writing-data. - - const id = await ctx.db.insert('numbers', { value: args.value }); - - console.log('Added new document with id:', id); - // Optionally, return a value from your mutation. - // return id; - }, -}); - -// You can fetch data from and send data to third-party APIs via an action: -export const myAction = action({ - // Validators for arguments. - args: { - first: v.number(), - second: v.string(), - }, - - // Action implementation. - handler: async (ctx, args) => { - //// Use the browser-like `fetch` API to send HTTP requests. - //// See https://docs.convex.dev/functions/actions#calling-third-party-apis-and-using-npm-packages. - // const response = await ctx.fetch("https://api.thirdpartyservice.com"); - // const data = await response.json(); - - //// Query data by running Convex queries. - const data = await ctx.runQuery(api.myFunctions.listNumbers, { - count: 10, - }); - console.log(data); - - //// Write data by running Convex mutations. - await ctx.runMutation(api.myFunctions.addNumber, { - value: args.first, - }); - }, -}); diff --git a/convex/schema.ts b/convex/schema.ts index 3bcda69..7e31b7a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,7 +7,24 @@ import { authTables } from '@convex-dev/auth/server'; // The schema provides more precise TypeScript types. export default defineSchema({ ...authTables, - numbers: defineTable({ - value: v.number(), - }), + users: defineTable({ + name: v.optional(v.string()), + image: v.optional(v.string()), + email: v.optional(v.string()), + currentStatusId: v.optional(v.id('statuses')), + emailVerificationTime: v.optional(v.number()), + phone: v.optional(v.string()), + phoneVerificationTime: v.optional(v.number()), + isAnonymous: v.optional(v.boolean()), + }) + .index("email", ["email"]) + .index("phone", ["phone"]), + statuses: defineTable({ + userId: v.id('users'), + message: v.string(), + updatedAt: v.number(), + updatedBy: v.id('users'), + }) + .index('by_user', ['userId']) + .index('by_user_updatedAt', ['userId', 'updatedAt']), }); diff --git a/convex/statuses.ts b/convex/statuses.ts new file mode 100644 index 0000000..308f652 --- /dev/null +++ b/convex/statuses.ts @@ -0,0 +1,202 @@ +import { ConvexError, v } from 'convex/values'; +import { getAuthUserId } from '@convex-dev/auth/server'; +import { mutation, query } from './_generated/server'; +import type { Doc, Id } from './_generated/dataModel'; +import { paginationOptsValidator } from 'convex/server'; + +// NEW: import ctx and data model types +import type { MutationCtx, QueryCtx } from './_generated/server'; + +// NEW: shared ctx type for helpers +type RWCtx = MutationCtx | QueryCtx; + +// CHANGED: typed helpers +const ensureUser = async ( + ctx: RWCtx, + userId: Id<'users'>, +) => { + const user = await ctx.db.get(userId); + if (!user) throw new ConvexError('User not found.'); + return user; +}; + +const latestStatusForOwner = async ( + ctx: RWCtx, + ownerId: Id<'users'>, +) => { + const [latest] = await ctx.db + .query('statuses') + .withIndex('by_user_updatedAt', q => q.eq('userId', ownerId)) + .order('desc') + .take(1); + return latest as Doc<'statuses'> | null; +}; + +/** + * Create a new status for a single user. + * - Defaults userId to the caller. + * - updatedBy defaults to the caller. + * - Updates the user's currentStatusId pointer. + */ +export const create = mutation({ + args: { + message: v.string(), + userId: v.optional(v.id('users')), + updatedBy: v.optional(v.id('users')), + }, + handler: async (ctx, args) => { + const authUserId = await getAuthUserId(ctx); + if (!authUserId) throw new ConvexError('Not authenticated.'); + + const userId = args.userId ?? authUserId; + await ensureUser(ctx, userId); + + const updatedBy = args.updatedBy ?? authUserId; + await ensureUser(ctx, updatedBy); + + const message = args.message.trim(); + if (message.length === 0) { + throw new ConvexError('Message cannot be empty.'); + } + + const statusId = await ctx.db.insert('statuses', { + message, + userId, + updatedBy, + updatedAt: Date.now(), + }); + + await ctx.db.patch(userId, { currentStatusId: statusId }); + + return { statusId }; + }, +}); + +/** + * Bulk create the same status for many users. + * - updatedBy defaults to the caller. + * - Updates each user's currentStatusId pointer. + */ +export const bulkCreate = mutation({ + args: { + message: v.string(), + ownerIds: v.array(v.id('users')), + updatedBy: v.optional(v.id('users')), + }, + handler: async (ctx, args) => { + const authUserId = await getAuthUserId(ctx); + if (!authUserId) throw new ConvexError('Not authenticated.'); + + if (args.ownerIds.length === 0) return { statusIds: [] }; + + const updatedBy = args.updatedBy ?? authUserId; + await ensureUser(ctx, updatedBy); + + const message = args.message.trim(); + if (message.length === 0) { + throw new ConvexError('Message cannot be empty.'); + } + + const statusIds: Id<'statuses'>[] = []; + const now = Date.now(); + + // Sequential to keep load predictable; switch to Promise.all + // if your ownerIds lists are small and bounded. + for (const userId of args.ownerIds) { + await ensureUser(ctx, userId); + + const statusId = await ctx.db.insert('statuses', { + message, + userId, + updatedBy, + updatedAt: now, + }); + + await ctx.db.patch(userId, { currentStatusId: statusId }); + statusIds.push(statusId); + } + + return { statusIds }; + }, +}); + +/** + * Current status for a specific user. + * - Uses users.currentStatusId if present, + * otherwise falls back to latest by index. + */ +export const getCurrentForUser = query({ + args: { userId: v.id('users') }, + handler: async (ctx, { userId }) => { + const user = await ensureUser(ctx, userId); + + if (user.currentStatusId) { + const status = await ctx.db.get(user.currentStatusId); + if (status) return status; + } + + return await latestStatusForOwner(ctx, userId); + }, +}); + +/** + * Current statuses for all users. + * - Reads each user's currentStatusId pointer. + * - Falls back to latest-by-index if pointer is missing. + */ +export const getCurrentForAll = query({ + args: {}, + handler: async (ctx) => { + const users = await ctx.db.query('users').collect(); + + const results = await Promise.all( + users.map(async (u) => { + let status = null; + if (u.currentStatusId) { + status = await ctx.db.get(u.currentStatusId); + } + status ??= await latestStatusForOwner(ctx, u._id); + return { + userId: u._id as Id<'users'>, + status, + }; + }), + ); + + return results; + }, +}); + +/** + * Paginated history for a specific user (newest first). + */ +export const listHistoryByUser = query({ + args: { + userId: v.id('users'), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, { userId, paginationOpts }) => { + await ensureUser(ctx, userId); + + return await ctx.db + .query('statuses') + .withIndex('by_user_updatedAt', q => q.eq('userId', userId)) + .order('desc') + .paginate(paginationOpts); + }, +}); + +/** + * Global paginated history (all users, newest first). + * - Add an index on updatedAt if you want to avoid full-table scans + * when the collection grows large. + */ +export const listHistoryAll = query({ + args: { paginationOpts: paginationOptsValidator }, + handler: async (ctx, { paginationOpts }) => { + return await ctx.db + .query('statuses') + .order('desc') + .paginate(paginationOpts); + }, +}); diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx index 89f98fa..dc89e1c 100644 --- a/src/app/(auth)/profile/page.tsx +++ b/src/app/(auth)/profile/page.tsx @@ -1,8 +1,14 @@ 'use server'; import { preloadQuery } from 'convex/nextjs'; import { api } from '~/convex/_generated/api'; -import { AvatarUpload, ProfileHeader, UserInfoForm } from '@/components/layout/profile'; import { Card, Separator } from '@/components/ui'; +import { + AvatarUpload, + ProfileHeader, + ResetPasswordForm, + SignOutForm, + UserInfoForm +} from '@/components/layout/profile'; const Profile = async () => { const preloadedUser = await preloadQuery(api.auth.getUser); @@ -13,6 +19,9 @@ const Profile = async () => { + + + ); }; diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index ba99fc5..016c6c3 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -17,67 +17,56 @@ import { FormLabel, FormMessage, Input, - Separator, - StatusMessage, SubmitButton, Tabs, TabsContent, TabsList, TabsTrigger, } from '@/components/ui'; +import { toast } from 'sonner'; const signInFormSchema = z.object({ email: z.email({ message: 'Please enter a valid email address.', }), - password: z - .string() - .min(8, { - message: 'Password must be at least 8 characters.', + password: z.string() + .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { + message: 'Incorrect password. Does not meet requirements.' }) - .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, { - message: - 'Password must contain at least one digit, ' + - 'one uppercase letter, one lowercase letter, ' + - 'and one special character.', - }), }); -const signUpFormSchema = z - .object({ - name: z.string().min(2, { - message: 'Name must be at least 2 characters.', +const signUpFormSchema = z.object({ + name: z.string().min(2, { + message: 'Name must be at least 2 characters.', + }), + email: z.email({ + message: 'Please enter a valid email address.', + }), + password: z.string() + .min(8, { message: 'Password must be at least 8 characters.' }) + .max(100, { message: 'Password must be no more than 100 characters.' }) + .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(/[@#$%^&+=]/, { + message: 'Password must contain at least one special character.' }), - email: z.email({ - message: 'Please enter a valid email address.', - }), - password: z - .string() - .min(8, { - message: 'Password must be at least 8 characters.', - }) - .regex( - /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/, - { - message: - 'Password must contain at least one digit, ' + - 'one uppercase letter, one lowercase letter, ' + - 'and one special character.', - }, - ), - confirmPassword: z.string().min(8, { - message: 'Password must be at least 8 characters.', - }), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match!', - path: ['confirmPassword'], - }); + confirmPassword: z.string().min(8, { + message: 'Password must be at least 8 characters.', + }), +}) +.refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], +}); export default function SignIn() { const { signIn } = useAuthActions(); const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn'); - const [statusMessage, setStatusMessage] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); @@ -97,25 +86,39 @@ export default function SignIn() { }); const handleSignIn = async (values: z.infer) => { + const formData = new FormData(); + formData.append('email', values.email); + formData.append('password', values.password); + formData.append('flow', flow); + setLoading(true); try { - setLoading(true); - setStatusMessage(''); - const formData = new FormData(); - formData.append('email', values.email); - formData.append('password', values.password); - formData.append('flow', flow); - if (flow === 'signUp') { - formData.append('name', values.name); - if (values.confirmPassword !== values.password) - throw new ConvexError({ message: 'Passwords do not match!' }); - } await signIn('password', formData); signInForm.reset(); router.push('/'); } catch (error) { - setStatusMessage( - `Error signing in: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + console.error('Error signing in:', error); + toast.error('Error signing in.'); + } finally { + setLoading(false); + } + }; + + const handleSignUp = async (values: z.infer) => { + const formData = new FormData(); + formData.append('email', values.email); + formData.append('password', values.password); + formData.append('flow', flow); + formData.append('name', values.name); + setLoading(true); + try { + if (values.confirmPassword !== values.password) + throw new ConvexError('Passwords do not match.'); + await signIn('password', formData); + signUpForm.reset(); + router.push('/'); + } catch (error) { + console.error('Error signing up:', error); + toast.error('Error signing up.'); } finally { setLoading(false); } @@ -194,15 +197,6 @@ export default function SignIn() { )} /> - {statusMessage && ( - - )}
)} /> - {statusMessage && ( - - )} { } const uploadResponse = await result.json() as { storageId: Id<'_storage'> }; await updateUserImage({ storageId: uploadResponse.storageId }); - toast('Profile picture updated.'); + toast.success('Profile picture updated.'); } catch (error) { console.error('Upload failed:', error); - toast('Upload failed. Please try again.'); + toast.error('Upload failed. Please try again.'); } finally { setIsUploading(false); if (inputRef.current) inputRef.current.value = ''; diff --git a/src/components/layout/profile/index.tsx b/src/components/layout/profile/index.tsx index 93978ed..0384bcb 100644 --- a/src/components/layout/profile/index.tsx +++ b/src/components/layout/profile/index.tsx @@ -1,3 +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'; diff --git a/src/components/layout/profile/reset-password.tsx b/src/components/layout/profile/reset-password.tsx new file mode 100644 index 0000000..b990b12 --- /dev/null +++ b/src/components/layout/profile/reset-password.tsx @@ -0,0 +1,164 @@ +'use client'; +import { useState } from 'react'; +import { useAction } from 'convex/react'; +import { api } from '~/convex/_generated/api'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + SubmitButton, +} from '@/components/ui'; +import { toast } from 'sonner'; + +const formSchema = z.object({ + currentPassword: z.string() + .regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, { + message: 'Incorrect current password. Does not meet requirements.', + }), + newPassword: z.string() + .min(8, { message: 'New password must be at least 8 characters.' }) + .max(100, { message: 'New password must be less than 100 characters.' }) + .regex(/[0-9]/, { message: 'New password must contain at least one digit.' }) + .regex(/[a-z]/, { + message: 'New password must contain at least one lowercase letter.' + }) + .regex(/[A-Z]/, { + message: 'New password must contain at least one uppercase letter.' + }) + .regex(/[@#$%^&+=]/, { + message: 'New password must contain at least one special character.' + }), + 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'], +}); + +export const ResetPasswordForm = () => { + const [loading, setLoading] = useState(false); + + const changePassword = useAction(api.auth.updateUserPassword); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }); + + const handleSubmit = async (values: z.infer) => { + 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) + } + }; + + return ( + <> + + Change Password + + Update your password to keep your account secure + + + + + + ( + + Current Password + + + + + Enter your current password. + + + + )} + /> + ( + + New Password + + + + + Enter your new password. Must be at least 8 characters. + + + + )} + /> + ( + + Confirm Password + + + + + Please re-enter your new password to confirm. + + + + )} + /> +
+ + Update Password + +
+ + +
+ + ); +}; + diff --git a/src/components/layout/profile/sign-out.tsx b/src/components/layout/profile/sign-out.tsx new file mode 100644 index 0000000..b58763a --- /dev/null +++ b/src/components/layout/profile/sign-out.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { useAuthActions } from '@convex-dev/auth/react'; +import { + CardHeader, + SubmitButton, +} from '@/components/ui'; + +export const SignOutForm = () => { + const { signOut } = useAuthActions(); + const router = useRouter(); + + return ( +
+ void signOut().then(() => router.push('/signin'))} + > + Sign Out + +
+ ); +} diff --git a/src/components/layout/profile/user-info.tsx b/src/components/layout/profile/user-info.tsx index c24a5a5..89185e3 100644 --- a/src/components/layout/profile/user-info.tsx +++ b/src/components/layout/profile/user-info.tsx @@ -69,6 +69,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => { try { await Promise.all(ops); form.reset({ name, email}); + toast.success('Profile updated successfully.'); } catch (error) { console.error(error); toast.error('Error updating profile.')