Add profile description and gender

This commit is contained in:
MartinBraquet
2025-07-28 20:46:15 +02:00
parent 8de775302d
commit f4ae4f3218
10 changed files with 317 additions and 102 deletions

View File

@@ -18,6 +18,15 @@ To contribute, please submit a pull request or issue, or fill out this [form](ht
- [ ] Search through all the profile variables
- [ ] (Set up chat / direct messaging)
#### Secondary To Do
Any action item is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
- [ ] Clean up terms and conditions
- [ ] Clean up privacy notice
- [ ] Clean up learn more page
- [ ] Add dark theme
## Implementation
The web app is coded in Typescript using React as front-end and prisma as back-end. It includes:

View File

@@ -1,89 +1,5 @@
import NextAuth, {type NextAuthOptions} from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import {PrismaAdapter} from "@auth/prisma-adapter";
import {prisma} from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
},
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: {label: "Email", type: "email"},
password: {label: "Password", type: "password"},
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Email and password are required");
}
const user = await prisma.user.findUnique({
where: {email: credentials.email},
});
if (!user || !user.password) {
throw new Error("Invalid email or password");
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.password
);
if (!isCorrectPassword) {
throw new Error("Invalid email or password");
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
pages: {
signIn: "/login",
error: "/login",
},
callbacks: {
async jwt({token, user}) {
if (user) {
token.id = user.id;
token.email = user.email;
token.name = user.name;
token.picture = user.image;
}
return token;
},
async session({session, token}) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.name = token.name as string;
session.user.email = token.email as string;
session.user.image = token.picture as string;
}
return session;
},
async redirect({url, baseUrl}) {
if (url.startsWith("/")) return `${baseUrl}${url}`;
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
},
secret: process.env.NEXTAUTH_SECRET,
debug: process.env.NODE_ENV === "development",
} satisfies NextAuthOptions;
import NextAuth from "next-auth";
import {authOptions} from "@/lib/auth";
const authHandler = NextAuth(authOptions);
export {authHandler as GET, authHandler as POST};

View File

@@ -0,0 +1,50 @@
import {NextResponse} from "next/server";
import {prisma} from "@/lib/prisma";
import {getSession} from "@/lib/auth";
export async function POST(req: Request) {
try {
const session = await getSession();
if (!session?.user?.email) {
return NextResponse.json(
{error: "Not authenticated"},
{status: 401}
);
}
const {description, gender} = await req.json();
// Validate required fields
if (!gender) {
return NextResponse.json(
{error: "Gender is required"},
{status: 400}
);
}
// Update user with the new profile information
const updatedUser = await prisma.user.update({
where: {email: session.user.email},
data: {
description: description || null,
gender: gender || null,
},
select: {
id: true,
email: true,
name: true,
description: true,
gender: true,
},
});
return NextResponse.json(updatedUser);
} catch (error) {
console.error('Profile update error:', error);
return NextResponse.json(
{error: "Failed to update profile"},
{status: 500}
);
}
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
export default function CompleteProfile() {
const [description, setDescription] = useState('');
const [gender, setGender] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const { data: session, update } = useSession();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!gender) {
setError('Please select your gender');
return;
}
try {
setIsSubmitting(true);
setError('');
const response = await fetch('/api/user/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
description,
gender,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update profile');
}
// Update the session to reflect the changes
await update();
// Redirect to the home page or dashboard
router.push('/');
} catch (error) {
console.error('Profile update error:', error);
setError(error instanceof Error ? error.message : 'Failed to update profile');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Complete Your Profile
</h2>
{/*<p className="mt-2 text-center text-sm text-gray-600">*/}
{/* Help us know you better (this information can be updated later)*/}
{/*</p>*/}
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="gender" className="block text-sm font-medium text-gray-700 mb-1">
Gender <span className="text-red-500">*</span>
</label>
<select
id="gender"
name="gender"
required
value={gender}
onChange={(e) => setGender(e.target.value)}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
>
<option value="">Select your gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
{/*<option value="non-binary">Non-binary</option>*/}
<option value="other">Other</option>
{/*<option value="prefer-not-to-say">Prefer not to say</option>*/}
</select>
</div>
<div className="pt-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
About You <span className="text-red-500">*</span>
</label>
<textarea
id="description"
name="description"
rows={4}
required
value={description}
onChange={(e) => setDescription(e.target.value)}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Tell us a bit about yourself"
/>
<p className="mt-1 text-xs text-gray-500">
This will be visible on your public profile
</p>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
isSubmitting ? 'opacity-70 cursor-not-allowed' : ''
}`}
>
{isSubmitting ? 'Saving...' : 'Save and Continue'}
</button>
{/*<div className="mt-4 text-center">*/}
{/* <button*/}
{/* type="button"*/}
{/* onClick={() => router.push('/')}*/}
{/* className="text-sm font-medium text-blue-600 hover:text-blue-500"*/}
{/* >*/}
{/* Skip for now*/}
{/* </button>*/}
{/*</div>*/}
</div>
</form>
</div>
</div>
);
}

View File

@@ -23,21 +23,14 @@ export default async function Post({ params }: { params: Promise<{ id: string }>
<article className="max-w-3xl w-full bg-white shadow-lg rounded-lg p-8">
{/* Post Title */}
<h1 className="text-5xl font-extrabold text-gray-900 mb-4">
{profile.email}
{profile.name}
</h1>
{/* Author Information */}
<p className="text-lg text-gray-600 mb-4">
by <span className="font-medium text-gray-800">{profile.name}</span>
</p>
{/* Content Section */}
<div className="text-lg text-gray-800 leading-relaxed space-y-6 border-t pt-6">
{profile.email ? (
<p>{profile.email}</p>
) : (
<p className="italic text-gray-500">No content available for this post.</p>
)}
<p>Gender: {profile.gender}</p>
<p>{profile.description}</p>
</div>
</article>

View File

@@ -38,8 +38,7 @@ export default async function PostsPage() {
{profiles.map((profile) => (
<Link key={profile.id} href={`/profiles/${profile.id}`} className="group">
<div className="border rounded-lg shadow-md bg-white p-6 hover:shadow-lg transition-shadow duration-300">
<h2 className="text-2xl font-semibold text-gray-900 group-hover:underline mb-2">{profile.email}</h2>
<p className="text-sm text-gray-500">by {profile.name}</p>
<h2 className="text-2xl font-semibold text-gray-900 group-hover:underline mb-2">{profile.name}</h2>
<p className="text-xs text-gray-400 mb-4">
{new Date(profile.createdAt).toLocaleDateString("en-US", {
year: "numeric",

View File

@@ -58,13 +58,15 @@ export default function RegisterPage() {
const response = await signIn("credentials", {
email,
password,
redirect: true,
callbackUrl: "/",
redirect: false,
});
if (response?.error) {
throw new Error("Failed to sign in after registration");
}
// Redirect to complete profile page
window.location.href = '/complete-profile';
} catch (error) {
setError(error instanceof Error ? error.message : "Registration failed");
setIsLoading(false);

89
lib/auth.ts Normal file
View File

@@ -0,0 +1,89 @@
import type {NextAuthOptions} from "next-auth";
import {getServerSession} from "next-auth";
import {PrismaAdapter} from "@auth/prisma-adapter";
import {prisma} from "@/lib/prisma";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
},
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: {label: "Email", type: "email"},
password: {label: "Password", type: "password"},
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Email and password are required");
}
const user = await prisma.user.findUnique({
where: {email: credentials.email},
});
if (!user || !user.password) {
throw new Error("Invalid email or password");
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.password
);
if (!isCorrectPassword) {
throw new Error("Invalid email or password");
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
pages: {
signIn: "/login",
error: "/login",
},
callbacks: {
async jwt({token, user}) {
if (user) {
token.id = user.id;
token.email = user.email;
token.name = user.name;
token.picture = user.image;
}
return token;
},
async session({session, token}) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.name = token.name as string;
session.user.email = token.email as string;
session.user.image = token.picture as string;
}
return session;
},
async redirect({url, baseUrl}) {
if (url.startsWith("/")) return `${baseUrl}${url}`;
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
},
secret: process.env.NEXTAUTH_SECRET,
debug: process.env.NODE_ENV === "development",
} satisfies NextAuthOptions;
export const getSession = () => getServerSession(authOptions);

View File

@@ -18,6 +18,11 @@ model User {
image String?
accounts Account[]
sessions Session[]
// Profile Information
gender String?
description String?
// Optional for WebAuthn support
Authenticator Authenticator[]
@@ -80,4 +85,4 @@ model Authenticator {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, credentialID])
}
}

View File

@@ -10,6 +10,7 @@ async function main() {
email: 'alice@example.com',
name: 'Alice',
password: await bcrypt.hash('password123', 10),
description: 'Alice in Wonderland'
},
}),
]);