mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-30 11:14:05 -04:00
Add profile description and gender
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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};
|
||||
|
||||
50
app/api/user/update-profile/route.ts
Normal file
50
app/api/user/update-profile/route.ts
Normal 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}
|
||||
);
|
||||
}
|
||||
}
|
||||
151
app/complete-profile/page.tsx
Normal file
151
app/complete-profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
89
lib/auth.ts
Normal 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);
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ async function main() {
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
description: 'Alice in Wonderland'
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user