clerkauth

implemented, sync with db to be added
This commit is contained in:
dimitar committed 2025-11-10 04:16:31 +01:00
1 parent ca790a7b97
commit 3a58d420d6
49 files changed
+9423 -779

No files matched your search

@@ -0,0 +1,77 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { setUserRole, isAdmin, type UserRole } from '@/lib/clerk-helpers';
export async function POST(req: Request) {
try {
// Authenticate the request
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check if the requesting user is an admin
const requestingUserIsAdmin = await isAdmin(userId);
if (!requestingUserIsAdmin) {
return NextResponse.json(
{ error: 'Forbidden: Admin access required' },
{ status: 403 }
);
}
// Parse request body
const body = await req.json();
const { targetUserId, role } = body;
// Validate inputs
if (!targetUserId || typeof targetUserId !== 'string') {
return NextResponse.json(
{ error: 'Invalid or missing targetUserId' },
{ status: 400 }
);
}
if (!role || !['admin', 'trainer', 'client'].includes(role)) {
return NextResponse.json(
{ error: 'Invalid role. Must be admin, trainer, or client' },
{ status: 400 }
);
}
// Prevent admin from changing their own role
if (userId === targetUserId) {
return NextResponse.json(
{ error: 'Cannot change your own role' },
{ status: 400 }
);
}
// Set the user's role
const updatedUser = await setUserRole(targetUserId, role as UserRole);
return NextResponse.json({
success: true,
message: `User role updated to ${role}`,
user: {
id: updatedUser.id,
email: updatedUser.emailAddresses[0]?.emailAddress,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
role: updatedUser.publicMetadata.role,
},
});
} catch (error) {
console.error('Error setting user role:', error);
if (error instanceof Error && error.message.includes('not found')) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { WebhookEvent } from '@clerk/nextjs/server';
import { db } from '@fitai/database';
import { users } from '@fitai/database/schema';
import { eq } from 'drizzle-orm';
export async function POST(req: Request) {
// Get the webhook secret from environment variables
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error('Please add CLERK_WEBHOOK_SECRET to your environment variables');
}
// Get the headers
const headerPayload = await headers();
const svix_id = headerPayload.get('svix-id');
const svix_timestamp = headerPayload.get('svix-timestamp');
const svix_signature = headerPayload.get('svix-signature');
// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error: Missing svix headers', {
status: 400,
});
}
// Get the body
const payload = await req.json();
const body = JSON.stringify(payload);
// Create a new Svix instance with your webhook secret
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent;
// Verify the webhook signature
try {
evt = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error('Error verifying webhook:', err);
return new Response('Error: Verification failed', {
status: 400,
});
}
// Handle the webhook
const eventType = evt.type;
console.log(`Received webhook with ID ${evt.data.id} and type ${eventType}`);
try {
switch (eventType) {
case 'user.created': {
const { id, email_addresses, first_name, last_name, public_metadata } = evt.data;
// Get primary email
const primaryEmail = email_addresses.find(
(email) => email.id === evt.data.primary_email_address_id
);
if (!primaryEmail?.email_address) {
console.error('No primary email found for user:', id);
return new Response('Error: No primary email', { status: 400 });
}
// Determine role from metadata or default to 'client'
const role = (public_metadata?.role as 'admin' | 'trainer' | 'client') || 'client';
// Insert user into database
await db.insert(users).values({
id,
email: primaryEmail.email_address,
firstName: first_name || '',
lastName: last_name || '',
password: '', // Clerk handles authentication, no password needed
role,
phone: null,
createdAt: new Date(),
updatedAt: new Date(),
});
console.log(`✅ User ${id} created in database`);
break;
}
case 'user.updated': {
const { id, email_addresses, first_name, last_name, public_metadata } = evt.data;
// Get primary email
const primaryEmail = email_addresses.find(
(email) => email.id === evt.data.primary_email_address_id
);
if (!primaryEmail?.email_address) {
console.error('No primary email found for user:', id);
return new Response('Error: No primary email', { status: 400 });
}
// Determine role from metadata
const role = (public_metadata?.role as 'admin' | 'trainer' | 'client') || 'client';
// Update user in database
await db
.update(users)
.set({
email: primaryEmail.email_address,
firstName: first_name || '',
lastName: last_name || '',
role,
updatedAt: new Date(),
})
.where(eq(users.id, id));
console.log(`✅ User ${id} updated in database`);
break;
}
case 'user.deleted': {
const { id } = evt.data;
if (!id) {
console.error('No user ID provided for deletion');
return new Response('Error: No user ID', { status: 400 });
}
// Delete user from database (cascade will handle related records)
await db.delete(users).where(eq(users.id, id));
console.log(`✅ User ${id} deleted from database`);
break;
}
default:
console.log(`Unhandled webhook event type: ${eventType}`);
}
return new Response('Webhook processed successfully', { status: 200 });
} catch (error) {
console.error('Error processing webhook:', error);
return new Response('Error: Processing failed', { status: 500 });
}
}
+43 -13
View File
@@ -1,22 +1,52 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import {
ClerkProvider,
SignedIn,
SignedOut,
SignInButton,
UserButton,
} from "@clerk/nextjs";
const inter = Inter({ subsets: ['latin'] })
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'FitAI Admin',
description: 'Fitness management admin dashboard',
}
title: "FitAI Admin",
description: "Fitness management admin dashboard",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
<header className="bg-white shadow-sm border-b">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-900">FitAI Admin</h1>
</div>
<div className="flex items-center gap-4">
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
<SignedOut>
<SignInButton mode="modal">
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
Sign In
</button>
</SignInButton>
</SignedOut>
</div>
</div>
</header>
{children}
</body>
</html>
</ClerkProvider>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface UserProfile {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
}
export default function ProfilePage() {
const { user, isLoaded } = useUser();
const [profile, setProfile] = useState<UserProfile>({
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
});
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (isLoaded && user) {
setProfile({
firstName: user.firstName || "",
lastName: user.lastName || "",
email: user.emailAddresses[0]?.emailAddress || "",
phoneNumber: user.phoneNumbers[0]?.phoneNumber || "",
});
}
}, [isLoaded, user]);
const handleSave = async () => {
try {
await user?.update({
firstName: profile.firstName,
lastName: profile.lastName,
});
setIsEditing(false);
} catch (error) {
console.error("Error updating profile:", error);
}
};
if (!isLoaded) {
return <div>Loading...</div>;
}
return (
<div className="container mx-auto py-8">
<div className="max-w-2xl mx-auto bg-white p-8 rounded-lg shadow">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Profile</h1>
<Button
variant={isEditing ? "default" : "outline"}
onClick={() => setIsEditing(!isEditing)}
>
{isEditing ? "Cancel" : "Edit Profile"}
</Button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">First Name</label>
<Input
type="text"
value={profile.firstName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile({ ...profile, firstName: e.target.value })
}
disabled={!isEditing}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name</label>
<Input
type="text"
value={profile.lastName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile({ ...profile, lastName: e.target.value })
}
disabled={!isEditing}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<Input type="email" value={profile.email} disabled />
</div>
<div>
<label className="block text-sm font-medium mb-1">
Phone Number
</label>
<Input type="tel" value={profile.phoneNumber} disabled />
</div>
{isEditing && (
<div className="pt-4">
<Button onClick={handleSave}>Save Changes</Button>
</div>
)}
</div>
</div>
</div>
);
}