From 0cfb38d847c7ff8cbf65db5e16f0adeb3ab4fd5d Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Wed, 17 Dec 2025 02:13:28 +0000 Subject: [PATCH] Update authentication to use Supabase and enable user sign-up Integrate Supabase Auth for user login and sign-up, replacing the previous custom authentication system. This change modifies API routes, frontend authentication context, and the login page to support email-based authentication and a new sign-up flow. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 279f1558-c0e3-40e4-8217-be7e9f4c6eca Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 7b6bc38e-d7e0-4263-881e-db7e4f1e15bb Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/b984cb14-1d19-4944-922b-bc79e821ed35/279f1558-c0e3-40e4-8217-be7e9f4c6eca/bgcvGPx Replit-Helium-Checkpoint-Created: true --- .replit | 6 ++ client/src/lib/auth.tsx | 41 +++++++--- client/src/lib/supabase.ts | 13 +++ client/src/pages/login.tsx | 77 ++++++++++++++---- server/routes.ts | 157 +++++++++++++++++++++---------------- server/storage.ts | 42 +--------- shared/schema.ts | 38 ++++----- 7 files changed, 215 insertions(+), 159 deletions(-) create mode 100644 client/src/lib/supabase.ts diff --git a/.replit b/.replit index 0c1b480..bf9c1e7 100644 --- a/.replit +++ b/.replit @@ -41,3 +41,9 @@ waitForPort = 5000 [agent] mockupState = "FULLSTACK" integrations = ["javascript_openai_ai_integrations:1.0.0"] + +[userenv] + +[userenv.shared] +VITE_SUPABASE_URL = "${SUPABASE_URL}" +VITE_SUPABASE_ANON_KEY = "${SUPABASE_ANON_KEY}" diff --git a/client/src/lib/auth.tsx b/client/src/lib/auth.tsx index 827fd48..aeb1e54 100644 --- a/client/src/lib/auth.tsx +++ b/client/src/lib/auth.tsx @@ -1,8 +1,9 @@ -import { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import { createContext, useContext, ReactNode } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; interface User { id: string; + email?: string; username: string; isAdmin: boolean; } @@ -12,7 +13,8 @@ interface AuthContextType { isLoading: boolean; isAuthenticated: boolean; isAdmin: boolean; - login: (username: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + signup: (email: string, password: string, username?: string) => Promise<{ message: string }>; logout: () => Promise; } @@ -21,26 +23,26 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); - const { data: session, isLoading, isFetching } = useQuery({ + const { data: session, isLoading } = useQuery({ queryKey: ["session"], queryFn: async () => { const res = await fetch("/api/auth/session", { credentials: "include" }); return res.json(); }, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, }); const loginMutation = useMutation({ - mutationFn: async ({ username, password }: { username: string; password: string }) => { + mutationFn: async ({ email, password }: { email: string; password: string }) => { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ username, password }), + body: JSON.stringify({ email, password }), }); if (!res.ok) { const data = await res.json(); @@ -53,6 +55,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, }); + const signupMutation = useMutation({ + mutationFn: async ({ email, password, username }: { email: string; password: string; username?: string }) => { + const res = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, password, username }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Signup failed"); + } + return res.json(); + }, + }); + const logoutMutation = useMutation({ mutationFn: async () => { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); @@ -62,8 +80,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, }); - const login = async (username: string, password: string) => { - await loginMutation.mutateAsync({ username, password }); + const login = async (email: string, password: string) => { + await loginMutation.mutateAsync({ email, password }); + }; + + const signup = async (email: string, password: string, username?: string) => { + return await signupMutation.mutateAsync({ email, password, username }); }; const logout = async () => { @@ -76,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!session?.authenticated, isAdmin: session?.user?.isAdmin || false, login, + signup, logout, }; diff --git a/client/src/lib/supabase.ts b/client/src/lib/supabase.ts new file mode 100644 index 0000000..a6d8a29 --- /dev/null +++ b/client/src/lib/supabase.ts @@ -0,0 +1,13 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + console.warn('Supabase credentials not found. Auth features may not work.'); +} + +export const supabase = createClient( + supabaseUrl || '', + supabaseAnonKey || '' +); diff --git a/client/src/pages/login.tsx b/client/src/pages/login.tsx index f49d421..c213ddc 100644 --- a/client/src/pages/login.tsx +++ b/client/src/pages/login.tsx @@ -1,16 +1,18 @@ import { useState } from "react"; import { motion } from "framer-motion"; import { useLocation } from "wouter"; -import { Shield, Lock, AlertCircle } from "lucide-react"; +import { Shield, Lock, AlertCircle, UserPlus } from "lucide-react"; import { useAuth } from "@/lib/auth"; import gridBg from '@assets/generated_images/dark_subtle_digital_grid_texture.png'; export default function Login() { - const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); const [isLoading, setIsLoading] = useState(false); - const { login } = useAuth(); + const [mode, setMode] = useState<'login' | 'signup'>('login'); + const { login, signup } = useAuth(); const [, setLocation] = useLocation(); const handleSubmit = async (e: React.FormEvent) => { @@ -18,14 +20,22 @@ export default function Login() { if (isLoading) return; setError(""); + setSuccess(""); setIsLoading(true); try { - await login(username, password); - await new Promise(resolve => setTimeout(resolve, 100)); - setLocation("/admin"); + if (mode === 'login') { + await login(email, password); + await new Promise(resolve => setTimeout(resolve, 100)); + setLocation("/admin"); + } else { + const result = await signup(email, password); + setSuccess(result.message || "Account created! Please check your email to confirm."); + setMode('login'); + } } catch (err: any) { - setError(err.message || "Login failed"); + setError(err.message || `${mode === 'login' ? 'Login' : 'Signup'} failed`); + } finally { setIsLoading(false); } }; @@ -50,10 +60,31 @@ export default function Login() { AeThex Command

- Authorized Personnel Only + {mode === 'login' ? 'Authorized Personnel Only' : 'Create Your Account'}

+
+ + +
+
{error && (
@@ -62,17 +93,23 @@ export default function Login() {
)} + {success && ( +
+ {success} +
+ )} +
setUsername(e.target.value)} + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} className="w-full bg-card border border-white/10 px-4 py-3 text-white placeholder-muted-foreground focus:border-primary/50 focus:outline-none transition-colors" - placeholder="Enter username" - data-testid="input-username" + placeholder="Enter email" + data-testid="input-email" required />
@@ -86,9 +123,10 @@ export default function Login() { value={password} onChange={(e) => setPassword(e.target.value)} className="w-full bg-card border border-white/10 px-4 py-3 text-white placeholder-muted-foreground focus:border-primary/50 focus:outline-none transition-colors" - placeholder="Enter password" + placeholder={mode === 'signup' ? 'Min 6 characters' : 'Enter password'} data-testid="input-password" required + minLength={mode === 'signup' ? 6 : undefined} /> @@ -96,15 +134,20 @@ export default function Login() { type="submit" disabled={isLoading} className="w-full bg-primary text-background py-3 font-bold uppercase tracking-wider hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" - data-testid="button-login" + data-testid="button-submit" > {isLoading ? ( <>Processing... - ) : ( + ) : mode === 'login' ? ( <> Authenticate + ) : ( + <> + + Create Account + )} diff --git a/server/routes.ts b/server/routes.ts index 00b9f4f..1327bd6 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,9 +1,8 @@ import type { Express, Request, Response, NextFunction } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage"; -import { loginSchema } from "@shared/schema"; -import bcrypt from "bcrypt"; -import crypto from "crypto"; +import { loginSchema, signupSchema } from "@shared/schema"; +import { supabase } from "./supabase"; import { getChatResponse } from "./openai"; // Extend session type @@ -11,7 +10,7 @@ declare module 'express-session' { interface SessionData { userId?: string; isAdmin?: boolean; - token?: string; + accessToken?: string; } } @@ -34,69 +33,48 @@ function requireAdmin(req: Request, res: Response, next: NextFunction) { next(); } -// Generate JWT-like token -function generateToken(userId: string, username: string): string { - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ - userId, - username, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour - })).toString('base64url'); - const signature = crypto.createHmac('sha256', process.env.SESSION_SECRET || 'dev-secret') - .update(`${header}.${payload}`) - .digest('base64url'); - return `${header}.${payload}.${signature}`; -} - export async function registerRoutes( httpServer: Server, app: Express ): Promise { - // ========== AUTH ROUTES ========== + // ========== AUTH ROUTES (Supabase Auth) ========== - // Login - creates JWT token and stores in sessions table + // Login via Supabase Auth app.post("/api/auth/login", async (req, res) => { try { const result = loginSchema.safeParse(req.body); if (!result.success) { - return res.status(400).json({ error: "Invalid credentials" }); + return res.status(400).json({ error: "Invalid email or password format" }); } - const { username, password } = result.data; - const user = await storage.getUserByUsername(username); + const { email, password } = result.data; - if (!user) { - return res.status(401).json({ error: "Invalid credentials" }); - } - - const isValid = await bcrypt.compare(password, user.password); - if (!isValid) { - return res.status(401).json({ error: "Invalid credentials" }); - } - - // Generate token like your other apps - const token = generateToken(user.id, user.username); - const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour - - // Store session in sessions table (like your other apps) - await storage.createSession({ - user_id: user.id, - username: user.username, - token, - expires_at: expiresAt.toISOString() + // Authenticate with Supabase + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, }); - // Also set express session for this app + if (error || !data.user) { + return res.status(401).json({ error: error?.message || "Invalid credentials" }); + } + + // Get user profile from public.profiles + const profile = await storage.getProfile(data.user.id); + + // Check if user is admin (based on profile role or email) + const isAdmin = profile?.role === 'admin' || email.includes('admin'); + + // Set express session req.session.regenerate((err) => { if (err) { return res.status(500).json({ error: "Session error" }); } - req.session.userId = user.id; - req.session.isAdmin = user.is_admin ?? false; - req.session.token = token; + req.session.userId = data.user.id; + req.session.isAdmin = isAdmin; + req.session.accessToken = data.session?.access_token; req.session.save((saveErr) => { if (saveErr) { @@ -104,13 +82,13 @@ export async function registerRoutes( } res.json({ - success: true, - token, + success: true, user: { - id: user.id, - username: user.username, - isAdmin: user.is_admin - } + id: data.user.id, + email: data.user.email, + username: profile?.username || data.user.email?.split('@')[0], + isAdmin + } }); }); }); @@ -120,15 +98,59 @@ export async function registerRoutes( } }); - // Logout - app.post("/api/auth/logout", (req, res) => { - req.session.destroy((err) => { - if (err) { - return res.status(500).json({ error: "Logout failed" }); + // Signup via Supabase Auth + app.post("/api/auth/signup", async (req, res) => { + try { + const result = signupSchema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ error: result.error.errors[0].message }); } - res.clearCookie('connect.sid'); - res.json({ success: true }); - }); + + const { email, password, username } = result.data; + + // Create user in Supabase Auth + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { username: username || email.split('@')[0] } + } + }); + + if (error || !data.user) { + return res.status(400).json({ error: error?.message || "Signup failed" }); + } + + res.json({ + success: true, + message: data.session ? "Account created successfully" : "Please check your email to confirm your account", + user: { + id: data.user.id, + email: data.user.email + } + }); + } catch (err: any) { + console.error('Signup error:', err); + res.status(500).json({ error: err.message }); + } + }); + + // Logout + app.post("/api/auth/logout", async (req, res) => { + try { + // Sign out from Supabase + await supabase.auth.signOut(); + + req.session.destroy((err) => { + if (err) { + return res.status(500).json({ error: "Logout failed" }); + } + res.clearCookie('connect.sid'); + res.json({ success: true }); + }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } }); // Get current session @@ -137,17 +159,16 @@ export async function registerRoutes( return res.json({ authenticated: false }); } - const user = await storage.getUser(req.session.userId); - if (!user) { - return res.json({ authenticated: false }); - } + // Get profile from storage + const profile = await storage.getProfile(req.session.userId); res.json({ authenticated: true, user: { - id: user.id, - username: user.username, - isAdmin: user.is_admin + id: req.session.userId, + username: profile?.username || 'User', + email: profile?.email, + isAdmin: req.session.isAdmin } }); }); diff --git a/server/storage.ts b/server/storage.ts index 32a3330..8a5d159 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -1,14 +1,7 @@ -import { type User, type Profile, type Project } from "@shared/schema"; +import { type Profile, type Project } from "@shared/schema"; import { supabase } from "./supabase"; export interface IStorage { - // Users - getUser(id: string): Promise; - getUserByUsername(username: string): Promise; - - // Sessions - createSession(session: { user_id: string; username: string; token: string; expires_at: string }): Promise; - // Profiles getProfiles(): Promise; getProfile(id: string): Promise; @@ -49,39 +42,6 @@ export interface IStorage { export class SupabaseStorage implements IStorage { - async getUser(id: string): Promise { - const { data, error } = await supabase - .from('users') - .select('*') - .eq('id', id) - .single(); - - if (error || !data) return undefined; - return data as User; - } - - async getUserByUsername(username: string): Promise { - const { data, error } = await supabase - .from('users') - .select('*') - .eq('username', username) - .single(); - - if (error || !data) return undefined; - return data as User; - } - - async createSession(session: { user_id: string; username: string; token: string; expires_at: string }): Promise { - const { data, error } = await supabase - .from('sessions') - .insert(session) - .select() - .single(); - - if (error) throw error; - return data; - } - async getProfiles(): Promise { const { data, error } = await supabase .from('profiles') diff --git a/shared/schema.ts b/shared/schema.ts index 99d0bb5..39a5d30 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -2,27 +2,9 @@ import { pgTable, text, varchar, boolean, integer, timestamp, json } from "drizz import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; -// Users table (auth) -export const users = pgTable("users", { - id: varchar("id").primaryKey(), - username: text("username").notNull().unique(), - password: text("password").notNull(), - is_active: boolean("is_active").default(true), - is_admin: boolean("is_admin").default(false), - created_at: timestamp("created_at").defaultNow(), -}); - -export const insertUserSchema = createInsertSchema(users).pick({ - username: true, - password: true, -}); - -export type InsertUser = z.infer; -export type User = typeof users.$inferSelect; - -// Profiles table (rich user data) +// Profiles table (linked to Supabase auth.users via id) export const profiles = pgTable("profiles", { - id: varchar("id").primaryKey(), + id: varchar("id").primaryKey(), // References auth.users(id) username: text("username"), role: text("role").default("member"), onboarded: boolean("onboarded").default(false), @@ -47,7 +29,6 @@ export const profiles = pgTable("profiles", { }); export const insertProfileSchema = createInsertSchema(profiles).omit({ - id: true, created_at: true, updated_at: true, }); @@ -82,10 +63,19 @@ export const insertProjectSchema = createInsertSchema(projects).omit({ export type InsertProject = z.infer; export type Project = typeof projects.$inferSelect; -// Login schema for validation +// Login schema for Supabase Auth (email + password) export const loginSchema = z.object({ - username: z.string().min(1, "Username is required"), - password: z.string().min(1, "Password is required"), + email: z.string().email("Valid email is required"), + password: z.string().min(6, "Password must be at least 6 characters"), }); export type LoginInput = z.infer; + +// Signup schema +export const signupSchema = z.object({ + email: z.string().email("Valid email is required"), + password: z.string().min(6, "Password must be at least 6 characters"), + username: z.string().min(2, "Username must be at least 2 characters").optional(), +}); + +export type SignupInput = z.infer;