-
Notifications
You must be signed in to change notification settings - Fork 146
feat(auth):login and role-based authentication system #135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
dc75879
feat(backend): add environment config and database schema
Saahi30 75a4514
feat(auth): implement signup/login with Supabase integration
Saahi30 bc9bc96
feat(dashboard): add role-based routing and home pages
Saahi30 4f78b01
feat(auth): atomic signup, docs, and guides
Saahi30 499523e
feat(auth): enforce Supabase email verification
Saahi30 c63037b
fix(auth): robust rollback error handling for orphaned users
Saahi30 57df8d0
fix(auth): use service role key for admin ops
Saahi30 bad59d0
fix(auth): consistent exception chaining in login endpoint
Saahi30 5768858
fix(auth):better error handling
Saahi30 44f1276
chore(auth):better error comment
Saahi30 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Example environment file for backend | ||
| # Application Settings | ||
| APP_NAME=InPactAI | ||
|
|
||
|
|
||
| # Supabase Configuration | ||
| SUPABASE_URL=https://yoursupabaseurl.supabase.co | ||
| SUPABASE_KEY=your-supabase-anon-key-here | ||
| SUPABASE_SERVICE_KEY=your-service-role-key | ||
|
|
||
|
|
||
| # Database Configuration (Supabase PostgreSQL) | ||
| # Get this from: Settings β Database β Connection string β URI | ||
| DATABASE_URL=postgresql://postgres.your-project-ref:[YOUR-PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres | ||
|
|
||
| # AI Configuration | ||
| GROQ_API_KEY=your-groq-api-key | ||
| AI_API_KEY=your-openai-api-key-optional | ||
|
|
||
| # CORS Origins (comma-separated) | ||
| ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 | ||
|
|
||
| # Server Configuration | ||
| HOST=0.0.0.0 | ||
| PORT=8000 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| -- Table for user profiles | ||
| create table if not exists profiles ( | ||
| id uuid references auth.users(id) on delete cascade, | ||
| name text not null, | ||
| role text check (role in ('Creator', 'Brand')) not null, | ||
| created_at timestamp with time zone default timezone('utc', now()), | ||
| primary key (id) | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| from fastapi import APIRouter, HTTPException | ||
| from pydantic import BaseModel, EmailStr, Field | ||
| from gotrue.errors import AuthApiError | ||
|
|
||
| from supabase import create_client, Client | ||
| from app.core.config import settings | ||
|
|
||
| router = APIRouter() | ||
| # Use anon key for end-user auth flows and service role for admin ops | ||
| supabase_public: Client = create_client(settings.supabase_url, settings.supabase_key) | ||
| supabase_admin: Client = create_client(settings.supabase_url, settings.supabase_service_key) | ||
|
|
||
|
|
||
| class SignupRequest(BaseModel): | ||
| """ | ||
| Request schema for user signup. | ||
| """ | ||
| name: str = Field(..., min_length=2) | ||
| email: EmailStr | ||
| password: str = Field(..., min_length=8) | ||
| role: str = Field(..., pattern="^(Creator|Brand)$") | ||
|
|
||
|
|
||
| class SignupResponse(BaseModel): | ||
| """ | ||
| Response schema for user signup. | ||
| """ | ||
| message: str | ||
| user_id: str | None = None | ||
|
|
||
|
|
||
| @router.post("/api/auth/signup", response_model=SignupResponse) | ||
| async def signup_user(payload: SignupRequest): | ||
| """ | ||
| Atomic signup: creates Supabase Auth user and profile row together. | ||
| Supabase sends verification email automatically. If profile creation fails, user is not rolled back (no admin access). | ||
| """ | ||
| try: | ||
| # 1. Create user via Supabase Auth (sends verification email automatically) | ||
| try: | ||
| auth_resp = supabase_public.auth.sign_up({ | ||
| "email": payload.email, | ||
| "password": payload.password, | ||
| }) | ||
| except AuthApiError as e: | ||
| status = 409 if getattr(e, "code", None) == "user_already_exists" else getattr(e, "status", 400) or 400 | ||
| raise HTTPException(status_code=status, detail=str(e)) from e | ||
| user = getattr(auth_resp, "user", None) | ||
| if not user or not getattr(user, "id", None): | ||
| error_msg = getattr(auth_resp, "error", None) | ||
| raise HTTPException(status_code=400, detail=f"Failed to create auth user. {error_msg}") | ||
|
|
||
| # 2. Insert profile row | ||
| profile = { | ||
| "id": user.id, | ||
| "name": payload.name, | ||
| "role": payload.role | ||
| } | ||
| res = supabase_admin.table("profiles").insert(profile).execute() | ||
| if not res.data: | ||
| # 3. Rollback: delete auth user if profile insert fails | ||
| rollback_error = None | ||
| for attempt in range(2): # try up to 2 times | ||
| try: | ||
| supabase_admin.auth.admin.delete_user(user.id) | ||
| break | ||
| except Exception as rollback_err: | ||
| rollback_error = rollback_err | ||
| if attempt == 0: | ||
| continue # retry once | ||
| if rollback_error: | ||
| # Log the orphaned user for manual cleanup | ||
| # logger.error(f"Failed to rollback user {user.id}: {rollback_error}") | ||
| raise HTTPException( | ||
| status_code=500, | ||
| detail=f"Failed to create profile and rollback failed. User {user.id} may be orphaned. Error: {rollback_error}" | ||
| ) from rollback_error | ||
| raise HTTPException(status_code=500, detail="Failed to create profile. User rolled back.") | ||
|
|
||
| return SignupResponse(message="Signup successful! Please check your inbox to verify your email.", user_id=user.id) | ||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| raise HTTPException(status_code=500, detail=f"Signup failed: {str(e)}") from e | ||
|
|
||
| # ------------------- LOGIN ROUTE ------------------- | ||
| class LoginRequest(BaseModel): | ||
| """ | ||
| Request schema for user login. | ||
| """ | ||
| email: EmailStr | ||
| password: str = Field(..., min_length=8) | ||
|
|
||
| class LoginResponse(BaseModel): | ||
| """ | ||
| Response schema for user login. | ||
| """ | ||
| message: str | ||
| user_id: str | None = None | ||
| email: str | None = None | ||
| role: str | None = None | ||
| name: str | None = None | ||
|
|
||
| @router.post("/api/auth/login", response_model=LoginResponse) | ||
| async def login_user(payload: LoginRequest): | ||
| """ | ||
| Login route: authenticates user and enforces email verification. | ||
| If email is not verified, returns 403 with a helpful message. | ||
| Includes user profile info in response. | ||
| """ | ||
| try: | ||
| # 1. Authenticate user | ||
| try: | ||
| auth_resp = supabase_public.auth.sign_in_with_password({ | ||
| "email": payload.email, | ||
| "password": payload.password | ||
| }) | ||
| user = getattr(auth_resp, "user", None) | ||
| except Exception as e: | ||
| # Supabase Python SDK v2 raises exceptions for auth errors | ||
| # Import AuthApiError if available | ||
| if hasattr(e, "code") and e.code == "email_not_confirmed": | ||
| raise HTTPException(status_code=403, detail="Please verify your email before logging in.") | ||
| raise HTTPException(status_code=401, detail=str(e)) | ||
| if not user or not getattr(user, "id", None): | ||
| raise HTTPException(status_code=401, detail="Invalid credentials.") | ||
|
|
||
| # 2. Fetch user profile | ||
| profile_res = supabase_admin.table("profiles").select("id, name, role").eq("id", user.id).single().execute() | ||
| profile = profile_res.data if hasattr(profile_res, "data") else None | ||
| if not profile: | ||
| raise HTTPException(status_code=404, detail="User profile not found.") | ||
|
|
||
| return LoginResponse( | ||
| message="Login successful.", | ||
| user_id=user.id, | ||
| email=user.email, | ||
| role=profile.get("role"), | ||
| name=profile.get("name") | ||
| ) | ||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| raise HTTPException(status_code=500, detail=f"Login failed: {str(e)}") from e | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| "use client"; | ||
|
|
||
| import AuthGuard from "@/components/auth/AuthGuard"; | ||
| import { getUserProfile, signOut } from "@/lib/auth-helpers"; | ||
| import { Briefcase, Loader2, LogOut } from "lucide-react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| export default function BrandHomePage() { | ||
| const router = useRouter(); | ||
| const [userName, setUserName] = useState<string>(""); | ||
| const [isLoggingOut, setIsLoggingOut] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| async function loadProfile() { | ||
| const profile = await getUserProfile(); | ||
| if (profile) { | ||
| setUserName(profile.name); | ||
| } | ||
| } | ||
| loadProfile(); | ||
| }, []); | ||
|
|
||
| const handleLogout = async () => { | ||
| try { | ||
| setIsLoggingOut(true); | ||
| await signOut(); | ||
| router.push("/login"); | ||
| } catch (error) { | ||
| console.error("Logout error:", error); | ||
| setIsLoggingOut(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <AuthGuard requiredRole="Brand"> | ||
| <div className="min-h-screen bg-linear-to-br from-blue-50 via-white to-purple-50"> | ||
| {/* Header */} | ||
| <header className="border-b border-gray-200 bg-white"> | ||
| <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8"> | ||
| <div className="flex items-center gap-2"> | ||
| <Briefcase className="h-6 w-6 text-blue-600" /> | ||
| <h1 className="bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-2xl font-bold text-transparent"> | ||
| InPactAI | ||
| </h1> | ||
| </div> | ||
| <button | ||
| onClick={handleLogout} | ||
| disabled={isLoggingOut} | ||
| className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50" | ||
| > | ||
| {isLoggingOut ? ( | ||
| <Loader2 className="h-4 w-4 animate-spin" /> | ||
| ) : ( | ||
| <LogOut className="h-4 w-4" /> | ||
| )} | ||
| {isLoggingOut ? "Logging out..." : "Logout"} | ||
| </button> | ||
| </div> | ||
| </header> | ||
|
|
||
| {/* Main Content */} | ||
| <main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8"> | ||
| <div className="text-center"> | ||
| {/* Welcome Message */} | ||
| <div className="mb-8"> | ||
| <div className="mb-4 inline-flex h-20 w-20 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-500"> | ||
| <Briefcase className="h-10 w-10 text-white" /> | ||
| </div> | ||
| <h2 className="mb-2 text-4xl font-bold text-gray-900"> | ||
| Hello {userName || "Brand"}! | ||
| </h2> | ||
| <p className="text-xl text-gray-600"> | ||
| You are a{" "} | ||
| <span className="font-semibold text-blue-600">Brand</span> | ||
| </p> | ||
| </div> | ||
|
|
||
| {/* Info Card */} | ||
| <div className="mx-auto max-w-2xl rounded-2xl border border-gray-100 bg-white p-8 shadow-xl"> | ||
| <div className="space-y-4"> | ||
| <div className="flex items-start gap-3 text-left"> | ||
| <div className="mt-2 h-2 w-2 rounded-full bg-blue-600"></div> | ||
| <div> | ||
| <h3 className="mb-1 font-semibold text-gray-900"> | ||
| Welcome to InPactAI Brand Dashboard | ||
| </h3> | ||
| <p className="text-sm text-gray-600"> | ||
| Discover and collaborate with talented creators to amplify | ||
| your brand's message and reach. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex items-start gap-3 text-left"> | ||
| <div className="mt-2 h-2 w-2 rounded-full bg-purple-600"></div> | ||
| <div> | ||
| <h3 className="mb-1 font-semibold text-gray-900"> | ||
| Create Campaigns | ||
| </h3> | ||
| <p className="text-sm text-gray-600"> | ||
| Launch influencer marketing campaigns, set your budget, | ||
| and find the perfect creators for your brand. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex items-start gap-3 text-left"> | ||
| <div className="mt-2 h-2 w-2 rounded-full bg-blue-600"></div> | ||
| <div> | ||
| <h3 className="mb-1 font-semibold text-gray-900"> | ||
| Measure Impact | ||
| </h3> | ||
| <p className="text-sm text-gray-600"> | ||
| Track campaign performance, ROI, and engagement metrics | ||
| with powerful analytics tools. | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="mt-8 border-t border-gray-200 pt-8"> | ||
| <button className="rounded-lg bg-linear-to-r from-blue-600 to-purple-600 px-8 py-3 font-medium text-white transition-all hover:from-blue-700 hover:to-purple-700"> | ||
| Create Your First Campaign | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| </div> | ||
| </AuthGuard> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.