feat(auth): implement email/password login form
This commit is contained in:
@@ -1,11 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import AppRoutes from './routes';
|
||||
import { store } from './store/store';
|
||||
import { initializeAuthPersistence } from './services/authService';
|
||||
|
||||
// Initialize the QueryClient
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
// Initialize Firebase Auth persistence
|
||||
initializeAuthPersistence();
|
||||
|
||||
/**
|
||||
* Root Application Component.
|
||||
* Wraps the app with Redux Provider and React Query Provider.
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<div className="bg-black">
|
||||
<h1 className="text-white">Hello World</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppRoutes />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
BIN
apps/web/src/assets/login-hero.png
Normal file
BIN
apps/web/src/assets/login-hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
BIN
apps/web/src/assets/logo.png
Normal file
BIN
apps/web/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
40
apps/web/src/common/components/ui/button.tsx
Normal file
40
apps/web/src/common/components/ui/button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
const baseStyles = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
const variants = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className || ''}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
18
apps/web/src/common/components/ui/input.tsx
Normal file
18
apps/web/src/common/components/ui/input.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={`flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm transition-colors ${className || ''}`}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
16
apps/web/src/common/components/ui/label.tsx
Normal file
16
apps/web/src/common/components/ui/label.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
329
apps/web/src/features/auth/Login.tsx
Normal file
329
apps/web/src/features/auth/Login.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import loginHero from "../../assets/login-hero.png";
|
||||
import logo from "../../assets/logo.png";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Input } from "../../common/components/ui/input";
|
||||
import { Button } from "../../common/components/ui/button";
|
||||
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
|
||||
import type { User } from "firebase/auth";
|
||||
import { app as firebaseApp } from "../../services/authService";
|
||||
import { FirebaseError } from "firebase/app";
|
||||
|
||||
/**
|
||||
* Login Page Component.
|
||||
* Features a modern split-screen layout with an inspirational hero image.
|
||||
* Handles user authentication via email/password with client-side validation.
|
||||
*/
|
||||
|
||||
type LocationState = {
|
||||
from?: {
|
||||
pathname: string;
|
||||
};
|
||||
};
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [isFormValid, setIsFormValid] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormValid(validateForm(email, password));
|
||||
}, [email, password]);
|
||||
|
||||
// Validate email format
|
||||
const validateEmail = (value: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
};
|
||||
|
||||
// Validate password length
|
||||
const validatePassword = (value: string): boolean => {
|
||||
return value.length >= 8;
|
||||
};
|
||||
|
||||
// Validate entire form
|
||||
const validateForm = (emailValue: string, passwordValue: string): boolean => {
|
||||
return validateEmail(emailValue) && validatePassword(passwordValue);
|
||||
};
|
||||
|
||||
// Handle email input change
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEmail(value);
|
||||
|
||||
if (value.trim() && !validateEmail(value)) {
|
||||
setEmailError("Please enter a valid email address");
|
||||
} else {
|
||||
setEmailError("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle password input change
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setPassword(value);
|
||||
|
||||
if (value && !validatePassword(value)) {
|
||||
setPasswordError("Password must be at least 8 characters");
|
||||
} else {
|
||||
setPasswordError("");
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate user after login (customize as needed)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const state = location.state as LocationState | null;
|
||||
const from = state?.from?.pathname;
|
||||
|
||||
navigate(from ?? "/dashboard", { replace: true });
|
||||
}, [user, navigate, location.state]);
|
||||
|
||||
// Clear error message when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setError("");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate before submission
|
||||
if (!isFormValid) {
|
||||
if (!validateEmail(email)) {
|
||||
setEmailError("Please enter a valid email address");
|
||||
}
|
||||
if (!validatePassword(password)) {
|
||||
setPasswordError("Password must be at least 8 characters");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const auth = getAuth(firebaseApp);
|
||||
const userCredential = await signInWithEmailAndPassword(
|
||||
auth,
|
||||
email,
|
||||
password,
|
||||
);
|
||||
setUser(userCredential.user);
|
||||
} catch (err: unknown) {
|
||||
let message = "Failed to sign in. Please try again.";
|
||||
|
||||
if (err instanceof FirebaseError) {
|
||||
switch (err.code) {
|
||||
case "auth/user-not-found":
|
||||
case "auth/wrong-password":
|
||||
message = "Invalid email or password.";
|
||||
break;
|
||||
|
||||
case "auth/too-many-requests":
|
||||
message = "Too many failed attempts. Please try again later.";
|
||||
break;
|
||||
|
||||
default:
|
||||
message = err.message;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50/50">
|
||||
{/* Left Side: Hero Image (Hidden on Mobile) */}
|
||||
<div className="hidden lg:flex lg:w-3/5 xl:w-[65%] relative overflow-hidden">
|
||||
<img
|
||||
src={loginHero}
|
||||
alt="Modern workspace"
|
||||
className="absolute inset-0 w-full h-full object-cover transform scale-105 hover:scale-100 transition-transform duration-10000 ease-in-out"
|
||||
/>
|
||||
{/* Cinematic Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-black/20 to-transparent z-10" />
|
||||
<div className="absolute inset-0 bg-primary/10 backdrop-blur-[2px] z-0" />
|
||||
|
||||
{/* Top Left Logo */}
|
||||
<div className="absolute top-12 left-12 z-20">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Krow Logo"
|
||||
className="h-10 w-auto brightness-0 invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-12 left-12 right-12 text-white z-10">
|
||||
<h1 className="text-5xl font-bold mb-4 tracking-tight leading-tight">
|
||||
Streamline your workforce <br /> with{" "}
|
||||
<span className="text-secondary underline decoration-4 underline-offset-8">
|
||||
KROW
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg text-slate-100/90 max-w-lg leading-relaxed">
|
||||
The all-in-one platform for managing staff, orders, and professional
|
||||
relationships with precision and ease.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Login Form */}
|
||||
<div className="w-full lg:w-2/5 xl:w-[35%] flex flex-col justify-center items-center p-8 sm:p-12 md:p-16 lg:p-20 bg-white border-l border-slate-200 z-10">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Logo / Branding */}
|
||||
<div className="flex flex-col items-center lg:items-start space-y-4">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Krow Logo"
|
||||
className="h-10 w-auto lg:hidden mb-2"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-primary-text">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="text-secondary-text font-medium">
|
||||
Please enter your details to sign in
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
||||
<AlertCircle className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-semibold">
|
||||
Work Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isLoading}
|
||||
required
|
||||
aria-describedby={emailError ? "email-error" : undefined}
|
||||
className={
|
||||
emailError
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{emailError && (
|
||||
<p
|
||||
id="email-error"
|
||||
className="text-xs text-destructive font-medium"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="password" className="text-sm font-semibold">
|
||||
Password
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-primary hover:underline underline-offset-2"
|
||||
onClick={() => console.log("TODO: Implement forgot password")}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
disabled={isLoading}
|
||||
required
|
||||
aria-describedby={passwordError ? "password-error" : undefined}
|
||||
className={
|
||||
passwordError
|
||||
? "border-destructive focus-visible:ring-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p
|
||||
id="password-error"
|
||||
className="text-xs text-destructive font-medium"
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !isFormValid}
|
||||
size="default"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-3 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
"Sign In to Workspace"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Helper for MVP Testing / Demo purposes */}
|
||||
<div className="mt-8 p-6 bg-slate-50/80 rounded-2xl border border-slate-100 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-bold text-muted-text uppercase tracking-widest mb-4">
|
||||
Quick Login (Development)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Admin", email: "admin@krow.com" },
|
||||
{ label: "Client", email: "client@krow.com" },
|
||||
{ label: "Vendor", email: "vendor@krow.com" },
|
||||
].map((cred) => (
|
||||
<button
|
||||
key={cred.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail(cred.email);
|
||||
setPassword("password");
|
||||
}}
|
||||
className="px-4 py-2 bg-white border border-slate-200 text-[11px] font-bold rounded-xl hover:border-primary hover:text-primary transition-all"
|
||||
>
|
||||
{cred.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
141
apps/web/src/features/auth/authSlice.ts
Normal file
141
apps/web/src/features/auth/authSlice.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||
import { loginWithEmail, logout, getCurrentUser } from "../../services/authService";
|
||||
import type { User } from "firebase/auth";
|
||||
|
||||
export interface AuthUser {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
status: "idle" | "loading" | "succeeded" | "failed";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
status: "idle",
|
||||
error: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Async thunk for user login
|
||||
*/
|
||||
export const loginUser = createAsyncThunk(
|
||||
"auth/loginUser",
|
||||
async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => {
|
||||
const result = await loginWithEmail(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return rejectWithValue(result.error);
|
||||
}
|
||||
|
||||
const firebaseUser = result.user as User;
|
||||
return {
|
||||
uid: firebaseUser.uid,
|
||||
email: firebaseUser.email,
|
||||
displayName: firebaseUser.displayName,
|
||||
photoURL: firebaseUser.photoURL,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Async thunk for user logout
|
||||
*/
|
||||
export const logoutUser = createAsyncThunk("auth/logoutUser", async (_, { rejectWithValue }) => {
|
||||
const result = await logout();
|
||||
|
||||
if (!result.success) {
|
||||
return rejectWithValue(result.error);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Async thunk to check if user is already logged in
|
||||
*/
|
||||
export const checkAuthStatus = createAsyncThunk("auth/checkAuthStatus", async () => {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (currentUser) {
|
||||
return {
|
||||
uid: currentUser.uid,
|
||||
email: currentUser.email,
|
||||
displayName: currentUser.displayName,
|
||||
photoURL: currentUser.photoURL,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: "auth",
|
||||
initialState,
|
||||
reducers: {
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
setRole: (state, action: PayloadAction<string>) => {
|
||||
if (state.user) {
|
||||
state.user.role = action.payload;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// Login thunk
|
||||
builder
|
||||
.addCase(loginUser.pending, (state) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginUser.fulfilled, (state, action) => {
|
||||
state.status = "succeeded";
|
||||
state.isAuthenticated = true;
|
||||
state.user = action.payload;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginUser.rejected, (state, action) => {
|
||||
state.status = "failed";
|
||||
state.error = action.payload as string;
|
||||
state.isAuthenticated = false;
|
||||
});
|
||||
|
||||
// Logout thunk
|
||||
builder
|
||||
.addCase(logoutUser.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.isAuthenticated = false;
|
||||
state.status = "idle";
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(logoutUser.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Check auth status thunk
|
||||
builder
|
||||
.addCase(checkAuthStatus.fulfilled, (state, action) => {
|
||||
if (action.payload) {
|
||||
state.user = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
} else {
|
||||
state.user = null;
|
||||
state.isAuthenticated = false;
|
||||
}
|
||||
state.status = "idle";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearError, setRole } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
23
apps/web/src/features/auth/firebase.ts
Normal file
23
apps/web/src/features/auth/firebase.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Import the functions you need from the SDKs you need
|
||||
import { initializeApp } from "firebase/app";
|
||||
import { getAnalytics } from "firebase/analytics";
|
||||
import { getAuth } from "firebase/auth";
|
||||
// TODO: Add SDKs for Firebase products that you want to use
|
||||
// https://firebase.google.com/docs/web/setup#available-libraries
|
||||
|
||||
// Your web app's Firebase configuration
|
||||
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
export const app = initializeApp(firebaseConfig);
|
||||
export const analytics = getAnalytics(app);
|
||||
export const auth = getAuth(app);
|
||||
8
apps/web/src/features/dashboard/Dashboard.tsx
Normal file
8
apps/web/src/features/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div>Dashboard</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -1 +1,162 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
--color-primary-text: hsl(var(--text-primary));
|
||||
--color-secondary-text: hsl(var(--text-secondary));
|
||||
--color-muted-text: hsl(var(--text-muted));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 20% 98%;
|
||||
--foreground: 226 30% 12%;
|
||||
--card: 220 20% 98%;
|
||||
--card-foreground: 226 30% 10%;
|
||||
--popover: 220 20% 98%;
|
||||
--popover-foreground: 226 30% 10%;
|
||||
--primary: 226 91% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 220 15% 95%;
|
||||
--secondary-foreground: 226 30% 25%;
|
||||
--muted: 220 15% 95%;
|
||||
--muted-foreground: 220 9% 45%;
|
||||
--accent: 53 94% 62%;
|
||||
--accent-foreground: 53 94% 15%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 220 15% 90%;
|
||||
--input: 220 15% 90%;
|
||||
--ring: 226 91% 45%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
--text-primary: 226 30% 12%;
|
||||
--text-secondary: 226 20% 35%;
|
||||
--text-muted: 226 15% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 226 30% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 226 30% 7%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 226 30% 5%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 226 91% 55%;
|
||||
--primary-foreground: 220 20% 10%;
|
||||
--secondary: 226 30% 15%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 226 30% 15%;
|
||||
--muted-foreground: 220 9% 65%;
|
||||
--accent: 53 94% 62%;
|
||||
--accent-foreground: 53 94% 15%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 226 30% 15%;
|
||||
--input: 226 30% 15%;
|
||||
--ring: 226 91% 55%;
|
||||
|
||||
--text-primary: 210 40% 98%;
|
||||
--text-secondary: 220 9% 80%;
|
||||
--text-muted: 220 9% 65%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fade-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-in-slide-up {
|
||||
animation: slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(15px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.transition-premium {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
13
apps/web/src/lib/utils.ts
Normal file
13
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes conditionally.
|
||||
* Combines clsx for conditional logic and tailwind-merge to handle conflicts.
|
||||
*
|
||||
* @param inputs - List of class values (strings, objects, arrays)
|
||||
* @returns Merged class string
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
30
apps/web/src/routes.tsx
Normal file
30
apps/web/src/routes.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './features/auth/Login';
|
||||
import Dashboard from './features/dashboard/Dashboard';
|
||||
|
||||
|
||||
/**
|
||||
* AppRoutes Component
|
||||
* Defines the main routing structure of the application.
|
||||
* Groups routes by Layout (Public vs App).
|
||||
*/
|
||||
const AppRoutes: React.FC = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<Login />
|
||||
}
|
||||
/>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
106
apps/web/src/services/authService.ts
Normal file
106
apps/web/src/services/authService.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
signOut,
|
||||
onAuthStateChanged,
|
||||
setPersistence,
|
||||
browserLocalPersistence,
|
||||
sendPasswordResetEmail,
|
||||
} from "firebase/auth";
|
||||
import type { User, AuthError } from "firebase/auth";
|
||||
import { app} from "../features/auth/firebase"
|
||||
import { getAuth } from "firebase/auth";
|
||||
|
||||
const auth = getAuth(app);
|
||||
|
||||
/**
|
||||
* Initialize Firebase Auth persistence
|
||||
* Ensures user session persists across page refreshes
|
||||
*/
|
||||
export const initializeAuthPersistence = async () => {
|
||||
try {
|
||||
await setPersistence(auth, browserLocalPersistence);
|
||||
} catch (error) {
|
||||
console.error("Error setting auth persistence:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Login user with email and password
|
||||
*/
|
||||
export const loginWithEmail = async (email: string, password: string) => {
|
||||
try {
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
return {
|
||||
success: true,
|
||||
user: userCredential.user,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
return {
|
||||
success: false,
|
||||
user: null,
|
||||
error: getAuthErrorMessage(authError.code),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign out the current user
|
||||
*/
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await signOut(auth);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
export const sendPasswordReset = async (email: string) => {
|
||||
try {
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
return { success: false, error: getAuthErrorMessage(authError.code) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to auth state changes
|
||||
* Returns unsubscribe function
|
||||
*/
|
||||
export const subscribeToAuthState = (callback: (user: User | null) => void) => {
|
||||
return onAuthStateChanged(auth, callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user synchronously
|
||||
*/
|
||||
export const getCurrentUser = () => {
|
||||
return auth.currentUser;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Firebase error codes to user-friendly messages
|
||||
*/
|
||||
const getAuthErrorMessage = (errorCode: string): string => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
"auth/invalid-email": "Invalid email address format.",
|
||||
"auth/user-disabled": "This user account has been disabled.",
|
||||
"auth/user-not-found": "Invalid email or password.",
|
||||
"auth/wrong-password": "Invalid email or password.",
|
||||
"auth/invalid-credential": "Invalid email or password.",
|
||||
"auth/too-many-requests": "Too many login attempts. Please try again later.",
|
||||
"auth/operation-not-allowed": "Login is currently disabled. Please try again later.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection.",
|
||||
};
|
||||
|
||||
return errorMessages[errorCode] || "An error occurred during login. Please try again.";
|
||||
};
|
||||
export { app };
|
||||
|
||||
15
apps/web/src/store/store.ts
Normal file
15
apps/web/src/store/store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import authReducer from "../features/auth/authSlice";
|
||||
|
||||
/**
|
||||
* Redux Store Configuration
|
||||
* Centralizes all state management for the application
|
||||
*/
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
40
apps/web/src/types/auth.ts
Normal file
40
apps/web/src/types/auth.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Authentication Type Definitions
|
||||
* Defines types for auth-related operations and state management
|
||||
*/
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
user: AuthUser | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
photoURL: string | null;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
status: "idle" | "loading" | "succeeded" | "failed";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PasswordResetResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user