diff --git a/apps/frontend/src/assets/city-bg.png b/apps/frontend/src/assets/city-bg.png
new file mode 100644
index 0000000..cabbd66
Binary files /dev/null and b/apps/frontend/src/assets/city-bg.png differ
diff --git a/apps/frontend/src/assets/fcc-mark.png b/apps/frontend/src/assets/fcc-mark.png
new file mode 100644
index 0000000..963b919
Binary files /dev/null and b/apps/frontend/src/assets/fcc-mark.png differ
diff --git a/apps/frontend/src/components/ui/input-group.tsx b/apps/frontend/src/components/ui/input-group.tsx
new file mode 100644
index 0000000..ad53e41
--- /dev/null
+++ b/apps/frontend/src/components/ui/input-group.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@lib/utils';
+import { Button } from '@components/ui/button';
+import { Input } from '@components/ui/input';
+import { Textarea } from '@components/ui/textarea';
+
+function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const inputGroupAddonVariants = cva(
+ "text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
+ {
+ variants: {
+ align: {
+ 'inline-start':
+ 'pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first',
+ 'inline-end':
+ 'pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last',
+ 'block-start':
+ 'px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start',
+ 'block-end':
+ 'px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start',
+ },
+ },
+ defaultVariants: {
+ align: 'inline-start',
+ },
+ },
+);
+
+function InputGroupAddon({
+ className,
+ align = 'inline-start',
+ ...props
+}: React.ComponentProps<'div'> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest('button')) {
+ return;
+ }
+ e.currentTarget.parentElement?.querySelector('input')?.focus();
+ }}
+ {...props}
+ />
+ );
+}
+
+const inputGroupButtonVariants = cva(
+ 'gap-2 text-sm flex items-center shadow-none',
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: '',
+ 'icon-xs':
+ 'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
+ 'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
+ },
+ },
+ defaultVariants: {
+ size: 'xs',
+ },
+ },
+);
+
+function InputGroupButton({
+ className,
+ type = 'button',
+ variant = 'ghost',
+ size = 'xs',
+ ...props
+}: Omit
, 'size'> &
+ VariantProps) {
+ return (
+
+ );
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<'textarea'>) {
+ return (
+
+ );
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+};
diff --git a/apps/frontend/src/containers/auth/LoginPage.tsx b/apps/frontend/src/containers/auth/LoginPage.tsx
index e6f6fbf..ac94fbd 100644
--- a/apps/frontend/src/containers/auth/LoginPage.tsx
+++ b/apps/frontend/src/containers/auth/LoginPage.tsx
@@ -2,12 +2,32 @@ import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../components/AuthProvider';
import { Button } from '../../components/ui/button';
+import cityBg from '../../assets/city-bg.png';
+import fccMark from '../../assets/fcc-mark.png';
+import { Input } from '@components/ui/input';
+import { Label } from '@components/ui/label';
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@components/ui/input-group';
+import { EyeIcon, EyeOffIcon } from 'lucide-react';
+import { PasswordCriterion } from './PasswordCriterion';
+
+enum SignupStep {
+ ONE = 1,
+ TWO = 2,
+}
export const LoginPage: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [signupStep, setSignupStep] = useState(SignupStep.ONE);
const [error, setError] = useState('');
const [isLogin, setIsLogin] = useState(true);
@@ -22,6 +42,33 @@ export const LoginPage: React.FC = () => {
} | null;
const from = locationState?.from?.pathname || '/dashboard';
+ const hasMinLength = password.length >= 8;
+ const hasUppercase = /[A-Z]/.test(password);
+ const hasLowercase = /[a-z]/.test(password);
+ const hasNumber = /\d/.test(password);
+ const hasSpecialChar = /[^A-Za-z0-9]/.test(password);
+ const passwordsMatch =
+ password.length > 0 &&
+ confirmPassword.length > 0 &&
+ password === confirmPassword;
+ const allCriteriaMet =
+ hasMinLength &&
+ hasUppercase &&
+ hasLowercase &&
+ hasNumber &&
+ hasSpecialChar &&
+ passwordsMatch;
+
+ const resetFields = () => {
+ setEmail('');
+ setPassword('');
+ setConfirmPassword('');
+ setFirstName('');
+ setLastName('');
+ setShowPassword(false);
+ setShowConfirmPassword(false);
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -49,69 +96,227 @@ export const LoginPage: React.FC = () => {
};
return (
-
-
{isLogin ? 'Login' : 'Signup'}
-
- {error &&
{error}
}
-
-
+
+
+
+
+

+
+ {isLogin ? 'Admin Dashboard' : 'New User'}
+
+
+
+ {error &&
{error}
}
+
+
+
);
};
diff --git a/apps/frontend/src/containers/auth/PasswordCriterion.tsx b/apps/frontend/src/containers/auth/PasswordCriterion.tsx
new file mode 100644
index 0000000..8bff1ab
--- /dev/null
+++ b/apps/frontend/src/containers/auth/PasswordCriterion.tsx
@@ -0,0 +1,31 @@
+import { Check, X } from 'lucide-react';
+import React from 'react';
+
+interface PasswordCriterionProps {
+ name: string;
+ criterionMet: boolean;
+}
+
+export const PasswordCriterion: React.FC
= ({
+ name,
+ criterionMet,
+}) => {
+ const Icon = criterionMet ? Check : X;
+ const color = criterionMet ? '#12BA82' : '#737373';
+
+ return (
+
+ );
+};