Profiles page is donegit add -A!

This commit is contained in:
2025-09-04 14:23:24 -05:00
parent 500da1f8be
commit 56ea3e0904
13 changed files with 539 additions and 172 deletions

View File

@@ -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<DataModel>({
@@ -10,12 +11,7 @@ export default Password<DataModel>({
};
},
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.');
}
},

View File

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

View File

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

View File

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

View File

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

202
convex/statuses.ts Normal file
View File

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

View File

@@ -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 () => {
<Separator />
<UserInfoForm preloadedUser={preloadedUser} />
<Separator />
<ResetPasswordForm />
<Separator />
<SignOutForm />
</Card>
);
};

View File

@@ -17,54 +17,44 @@ 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({
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.',
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(
/^(?=.*[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.',
},
),
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.'
})
.regex(/[@#$%^&+=]/, {
message: 'Password must contain at least one special character.'
}),
confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
@@ -77,7 +67,6 @@ const signUpFormSchema = z
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<typeof signInFormSchema>) => {
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!' });
}
setLoading(true);
try {
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<typeof signUpFormSchema>) => {
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() {
</FormItem>
)}
/>
{statusMessage && (
<StatusMessage
message={
statusMessage.toLowerCase().includes('error')
? { error: statusMessage }
: { success: statusMessage }
}
/>
)}
<SubmitButton
disabled={loading}
pendingText='Signing in...'
@@ -220,7 +214,7 @@ export default function SignIn() {
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignIn)}
onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8'
>
<FormField
@@ -301,15 +295,6 @@ export default function SignIn() {
</FormItem>
)}
/>
{statusMessage && (
<StatusMessage
message={
statusMessage.toLowerCase().includes('error')
? { error: statusMessage }
: { success: statusMessage }
}
/>
)}
<SubmitButton
disabled={loading}
pendingText='Signing Up...'

View File

@@ -52,10 +52,10 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
}
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 = '';

View File

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

View File

@@ -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<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)
}
};
return (
<>
<CardHeader className='pb-5'>
<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='lg:w-1/3 w-2/3 text-[1.0rem]'
disabled={loading}
pendingText='Updating Password...'
>
Update Password
</SubmitButton>
</div>
</form>
</Form>
</CardContent>
</>
);
};

View File

@@ -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 (
<div className='flex justify-center'>
<SubmitButton
className='lg:w-2/3 w-5/6
text-[1.0rem] font-semibold cursor-pointer
hover:bg-red-700/60 dark:hover:bg-red-300/80'
onClick={() => void signOut().then(() => router.push('/signin'))}
>
Sign Out
</SubmitButton>
</div>
);
}

View File

@@ -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.')