Profiles page is donegit add -A!
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ConvexError } from 'convex/values';
|
import { ConvexError } from 'convex/values';
|
||||||
import { Password } from '@convex-dev/auth/providers/Password';
|
import { Password } from '@convex-dev/auth/providers/Password';
|
||||||
|
import { validatePassword } from './auth';
|
||||||
import type { DataModel } from './_generated/dataModel';
|
import type { DataModel } from './_generated/dataModel';
|
||||||
|
|
||||||
export default Password<DataModel>({
|
export default Password<DataModel>({
|
||||||
@@ -10,12 +11,7 @@ export default Password<DataModel>({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
validatePasswordRequirements: (password: string) => {
|
validatePasswordRequirements: (password: string) => {
|
||||||
if (
|
if (!validatePassword(password)) {
|
||||||
password.length < 8 ||
|
|
||||||
!/\d/.test(password) ||
|
|
||||||
!/[a-z]/.test(password) ||
|
|
||||||
!/[A-Z]/.test(password)
|
|
||||||
) {
|
|
||||||
throw new ConvexError('Invalid password.');
|
throw new ConvexError('Invalid password.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -17,7 +17,7 @@ import type * as CustomPassword from "../CustomPassword.js";
|
|||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
import type * as files from "../files.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 myFunctions from "../myFunctions.js";
|
import type * as statuses from "../statuses.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
@@ -32,7 +32,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
myFunctions: typeof myFunctions;
|
statuses: typeof statuses;
|
||||||
}>;
|
}>;
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
typeof fullApi,
|
typeof fullApi,
|
||||||
|
@@ -1,7 +1,13 @@
|
|||||||
import { ConvexError, v } from 'convex/values';
|
import { ConvexError, v } from 'convex/values';
|
||||||
import { convexAuth, getAuthUserId } from '@convex-dev/auth/server';
|
import {
|
||||||
import { mutation, query } from './_generated/server';
|
convexAuth,
|
||||||
|
getAuthUserId,
|
||||||
|
retrieveAccount,
|
||||||
|
modifyAccountCredentials,
|
||||||
|
} from '@convex-dev/auth/server';
|
||||||
|
import { api } from './_generated/api';
|
||||||
import { type Id } from './_generated/dataModel';
|
import { type Id } from './_generated/dataModel';
|
||||||
|
import { action, mutation, query } from './_generated/server';
|
||||||
import Password from './CustomPassword';
|
import Password from './CustomPassword';
|
||||||
|
|
||||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||||
@@ -69,3 +75,44 @@ export const updateUserImage = mutation({
|
|||||||
return { success: true };
|
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 };
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
@@ -7,7 +7,24 @@ import { authTables } from '@convex-dev/auth/server';
|
|||||||
// The schema provides more precise TypeScript types.
|
// The schema provides more precise TypeScript types.
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
...authTables,
|
...authTables,
|
||||||
numbers: defineTable({
|
users: defineTable({
|
||||||
value: v.number(),
|
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
202
convex/statuses.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
@@ -1,8 +1,14 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { preloadQuery } from 'convex/nextjs';
|
import { preloadQuery } from 'convex/nextjs';
|
||||||
import { api } from '~/convex/_generated/api';
|
import { api } from '~/convex/_generated/api';
|
||||||
import { AvatarUpload, ProfileHeader, UserInfoForm } from '@/components/layout/profile';
|
|
||||||
import { Card, Separator } from '@/components/ui';
|
import { Card, Separator } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
AvatarUpload,
|
||||||
|
ProfileHeader,
|
||||||
|
ResetPasswordForm,
|
||||||
|
SignOutForm,
|
||||||
|
UserInfoForm
|
||||||
|
} from '@/components/layout/profile';
|
||||||
|
|
||||||
const Profile = async () => {
|
const Profile = async () => {
|
||||||
const preloadedUser = await preloadQuery(api.auth.getUser);
|
const preloadedUser = await preloadQuery(api.auth.getUser);
|
||||||
@@ -13,6 +19,9 @@ const Profile = async () => {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<UserInfoForm preloadedUser={preloadedUser} />
|
<UserInfoForm preloadedUser={preloadedUser} />
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<ResetPasswordForm />
|
||||||
|
<Separator />
|
||||||
|
<SignOutForm />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -17,54 +17,44 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
Input,
|
Input,
|
||||||
Separator,
|
|
||||||
StatusMessage,
|
|
||||||
SubmitButton,
|
SubmitButton,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const signInFormSchema = z.object({
|
const signInFormSchema = z.object({
|
||||||
email: z.email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z
|
password: z.string()
|
||||||
.string()
|
.regex(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,100}$/, {
|
||||||
.min(8, {
|
message: 'Incorrect password. Does not meet requirements.'
|
||||||
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.',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const signUpFormSchema = z
|
const signUpFormSchema = z.object({
|
||||||
.object({
|
|
||||||
name: z.string().min(2, {
|
name: z.string().min(2, {
|
||||||
message: 'Name must be at least 2 characters.',
|
message: 'Name must be at least 2 characters.',
|
||||||
}),
|
}),
|
||||||
email: z.email({
|
email: z.email({
|
||||||
message: 'Please enter a valid email address.',
|
message: 'Please enter a valid email address.',
|
||||||
}),
|
}),
|
||||||
password: z
|
password: z.string()
|
||||||
.string()
|
.min(8, { message: 'Password must be at least 8 characters.' })
|
||||||
.min(8, {
|
.max(100, { message: 'Password must be no more than 100 characters.' })
|
||||||
message: 'Password must be at least 8 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(
|
.regex(/[A-Z]/, {
|
||||||
/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=])(?=\S+$).{8,}$/,
|
message: 'Password must contain at least one uppercase letter.'
|
||||||
{
|
})
|
||||||
message:
|
.regex(/[@#$%^&+=]/, {
|
||||||
'Password must contain at least one digit, ' +
|
message: 'Password must contain at least one special character.'
|
||||||
'one uppercase letter, one lowercase letter, ' +
|
}),
|
||||||
'and one special character.',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
confirmPassword: z.string().min(8, {
|
confirmPassword: z.string().min(8, {
|
||||||
message: 'Password must be at least 8 characters.',
|
message: 'Password must be at least 8 characters.',
|
||||||
}),
|
}),
|
||||||
@@ -77,7 +67,6 @@ const signUpFormSchema = z
|
|||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -97,25 +86,39 @@ export default function SignIn() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setStatusMessage('');
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('email', values.email);
|
formData.append('email', values.email);
|
||||||
formData.append('password', values.password);
|
formData.append('password', values.password);
|
||||||
formData.append('flow', flow);
|
formData.append('flow', flow);
|
||||||
if (flow === 'signUp') {
|
setLoading(true);
|
||||||
formData.append('name', values.name);
|
try {
|
||||||
if (values.confirmPassword !== values.password)
|
|
||||||
throw new ConvexError({ message: 'Passwords do not match!' });
|
|
||||||
}
|
|
||||||
await signIn('password', formData);
|
await signIn('password', formData);
|
||||||
signInForm.reset();
|
signInForm.reset();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(
|
console.error('Error signing in:', error);
|
||||||
`Error signing in: ${error instanceof Error ? error.message : 'Unknown 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -194,15 +197,6 @@ export default function SignIn() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{statusMessage && (
|
|
||||||
<StatusMessage
|
|
||||||
message={
|
|
||||||
statusMessage.toLowerCase().includes('error')
|
|
||||||
? { error: statusMessage }
|
|
||||||
: { success: statusMessage }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing in...'
|
pendingText='Signing in...'
|
||||||
@@ -220,7 +214,7 @@ export default function SignIn() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...signUpForm}>
|
<Form {...signUpForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={signUpForm.handleSubmit(handleSignIn)}
|
onSubmit={signUpForm.handleSubmit(handleSignUp)}
|
||||||
className='flex flex-col space-y-8'
|
className='flex flex-col space-y-8'
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -301,15 +295,6 @@ export default function SignIn() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{statusMessage && (
|
|
||||||
<StatusMessage
|
|
||||||
message={
|
|
||||||
statusMessage.toLowerCase().includes('error')
|
|
||||||
? { error: statusMessage }
|
|
||||||
: { success: statusMessage }
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
pendingText='Signing Up...'
|
pendingText='Signing Up...'
|
||||||
|
@@ -52,10 +52,10 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|||||||
}
|
}
|
||||||
const uploadResponse = await result.json() as { storageId: Id<'_storage'> };
|
const uploadResponse = await result.json() as { storageId: Id<'_storage'> };
|
||||||
await updateUserImage({ storageId: uploadResponse.storageId });
|
await updateUserImage({ storageId: uploadResponse.storageId });
|
||||||
toast('Profile picture updated.');
|
toast.success('Profile picture updated.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
toast('Upload failed. Please try again.');
|
toast.error('Upload failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
if (inputRef.current) inputRef.current.value = '';
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
export { AvatarUpload } from './avatar-upload';
|
export { AvatarUpload } from './avatar-upload';
|
||||||
export { ProfileHeader } from './header';
|
export { ProfileHeader } from './header';
|
||||||
|
export { ResetPasswordForm } from './reset-password';
|
||||||
|
export { SignOutForm } from './sign-out';
|
||||||
export { UserInfoForm } from './user-info';
|
export { UserInfoForm } from './user-info';
|
||||||
|
164
src/components/layout/profile/reset-password.tsx
Normal file
164
src/components/layout/profile/reset-password.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
25
src/components/layout/profile/sign-out.tsx
Normal file
25
src/components/layout/profile/sign-out.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -69,6 +69,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all(ops);
|
await Promise.all(ops);
|
||||||
form.reset({ name, email});
|
form.reset({ name, email});
|
||||||
|
toast.success('Profile updated successfully.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Error updating profile.')
|
toast.error('Error updating profile.')
|
||||||
|
Reference in New Issue
Block a user