feat(auth): implement email/password login form
This commit is contained in:
@@ -10,9 +10,30 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/themes": "^3.2.1",
|
||||||
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"firebase": "^12.8.0",
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-datepicker": "^9.1.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
3301
apps/web/pnpm-lock.yaml
generated
3301
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
return (
|
||||||
<div className="bg-black">
|
<Provider store={store}>
|
||||||
<h1 className="text-white">Hello World</h1>
|
<QueryClientProvider client={queryClient}>
|
||||||
</div>
|
<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";
|
@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