From 0bdc2830573ddf225bdce350d7bcbd6f0c5c81cc Mon Sep 17 00:00:00 2001 From: mohoaj Date: Sat, 15 Feb 2025 14:17:53 -0800 Subject: [PATCH] Registration and login and logout --- .env | 7 + .env.example | 7 + index.html | 3 + package.json | 8 + src/Actions/actions.js | 13 + src/App.js | 98 +++-- src/Firebase/firebase.js | 19 + src/Firebase/useAuth.js | 34 ++ src/components/header/AppHeaderDropdown.js | 19 +- src/views/pages/login/Login.js | 153 ++++++- src/views/pages/login/authSlice.js | 27 ++ src/views/pages/register/Register.js | 455 +++++++++++++++++++-- 12 files changed, 739 insertions(+), 104 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 src/Actions/actions.js create mode 100644 src/Firebase/firebase.js create mode 100644 src/Firebase/useAuth.js create mode 100644 src/views/pages/login/authSlice.js diff --git a/.env b/.env new file mode 100644 index 000000000..2a8da77da --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +VITE_FIREBASE_API_KEY=AIzaSyDs0-gR5PrwDPRpyE6ZE0ua9sALYICwvcI +VITE_FIREBASE_AUTH_DOMAIN=tarbiyah-sms.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=tarbiyah-sms +VITE_FIREBASE_STORAGE_BUCKET=tarbiyah-sms.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=329966751488 +VITE_FIREBASE_APP_ID=1:329966751488:web:e4d4fd53397e7c21ccede5 +VITE_APP_FIREBASE_MEASUREMENT_ID=G-Y12T1TPW92 diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..2a8da77da --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +VITE_FIREBASE_API_KEY=AIzaSyDs0-gR5PrwDPRpyE6ZE0ua9sALYICwvcI +VITE_FIREBASE_AUTH_DOMAIN=tarbiyah-sms.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=tarbiyah-sms +VITE_FIREBASE_STORAGE_BUCKET=tarbiyah-sms.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=329966751488 +VITE_FIREBASE_APP_ID=1:329966751488:web:e4d4fd53397e7c21ccede5 +VITE_APP_FIREBASE_MEASUREMENT_ID=G-Y12T1TPW92 diff --git a/index.html b/index.html index 9613ef3ee..ec89877d1 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,5 @@ +<<<<<<< Updated upstream +======= +>>>>>>> Stashed changes diff --git a/package.json b/package.json index 7e992e447..32f3b0314 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,15 @@ "@popperjs/core": "^2.11.8", "chart.js": "^4.4.7", "classnames": "^2.5.1", +<<<<<<< Updated upstream "core-js": "^3.40.0", +======= + "core-js": "^3.39.0", + "cors": "^2.8.5", + "express": "^4.21.2", + "firebase": "^11.2.0", + "firebase-admin": "^13.1.0", +>>>>>>> Stashed changes "prop-types": "^15.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/Actions/actions.js b/src/Actions/actions.js new file mode 100644 index 000000000..e1b1340a3 --- /dev/null +++ b/src/Actions/actions.js @@ -0,0 +1,13 @@ +export const loginSuccess = (user) => ({ + type: 'LOGIN_SUCCESS', + payload: user, + }); + + export const logout = () => ({ + type: 'LOGOUT', + }); + + export const setLoading = (loading) => ({ + type: 'SET_LOADING', + payload: loading, + }); \ No newline at end of file diff --git a/src/App.js b/src/App.js index f5b22393e..689061eab 100644 --- a/src/App.js +++ b/src/App.js @@ -1,59 +1,67 @@ -import React, { Suspense, useEffect } from 'react' -import { HashRouter, Route, Routes } from 'react-router-dom' -import { useSelector } from 'react-redux' +import React, { Suspense, useEffect } from 'react'; +import { HashRouter, Route, Routes, Navigate } from 'react-router-dom'; +import { CSpinner, useColorModes } from '@coreui/react'; +import useAuth from './Firebase/useAuth'; +import './scss/style.scss'; +import './scss/examples.scss'; -import { CSpinner, useColorModes } from '@coreui/react' -import './scss/style.scss' - -// We use those styles to show code examples, you should remove them in your application. -import './scss/examples.scss' - -// Containers -const DefaultLayout = React.lazy(() => import('./layout/DefaultLayout')) - -// Pages -const Login = React.lazy(() => import('./views/pages/login/Login')) -const Register = React.lazy(() => import('./views/pages/register/Register')) -const Page404 = React.lazy(() => import('./views/pages/page404/Page404')) -const Page500 = React.lazy(() => import('./views/pages/page500/Page500')) +const DefaultLayout = React.lazy(() => import('./layout/DefaultLayout')); +const Login = React.lazy(() => import('./views/pages/login/Login')); +const Register = React.lazy(() => import('./views/pages/register/Register')); +const Page404 = React.lazy(() => import('./views/pages/page404/Page404')); +const Page500 = React.lazy(() => import('./views/pages/page500/Page500')); const App = () => { - const { isColorModeSet, setColorMode } = useColorModes('coreui-free-react-admin-template-theme') - const storedTheme = useSelector((state) => state.theme) + const { isColorModeSet, setColorMode } = useColorModes('coreui-free-react-admin-template-theme'); + const { user, loading } = useAuth(); useEffect(() => { - const urlParams = new URLSearchParams(window.location.href.split('?')[1]) - const theme = urlParams.get('theme') && urlParams.get('theme').match(/^[A-Za-z0-9\s]+/)[0] - if (theme) { - setColorMode(theme) - } + const urlParams = new URLSearchParams(window.location.href.split('?')[1]); + const theme = urlParams.get('theme')?.match(/^[A-Za-z0-9\s]+/)?.[0]; + if (theme) setColorMode(theme); + if (!isColorModeSet()) setColorMode('light'); + }, [isColorModeSet, setColorMode]); - if (isColorModeSet()) { - return - } - - setColorMode(storedTheme) - }, []) // eslint-disable-line react-hooks/exhaustive-deps + if (loading) { + return ( +
+ +
+ ); + } return ( - - - - } - > + + + + }> - } /> - } /> - } /> - } /> - } /> + {/* Public routes */} + : } + /> + } /> + } /> + } /> + + {/* Protected routes */} + : } + /> + + {/* Redirect unmatched routes */} + } + /> - ) -} + ); +}; -export default App +export default App; \ No newline at end of file diff --git a/src/Firebase/firebase.js b/src/Firebase/firebase.js new file mode 100644 index 000000000..54ade01c6 --- /dev/null +++ b/src/Firebase/firebase.js @@ -0,0 +1,19 @@ +import { initializeApp } from "firebase/app"; +import { getAnalytics } from "firebase/analytics"; +import { getAuth } from "firebase/auth"; +import { getFirestore } from "firebase/firestore"; + +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, + }; +const app = initializeApp(firebaseConfig); +const analytics = getAnalytics(app); +const auth = getAuth(app); +const firestore = getFirestore(app); + +export { app, analytics, auth, firestore }; diff --git a/src/Firebase/useAuth.js b/src/Firebase/useAuth.js new file mode 100644 index 000000000..d5272f990 --- /dev/null +++ b/src/Firebase/useAuth.js @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { onAuthStateChanged, signOut as firebaseSignOut } from 'firebase/auth'; +import { auth } from './firebase'; + +const useAuth = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (user) => { + if (user) { + setUser(user); + } else { + setUser(null); + } + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + + const signOut = async () => { + try { + await firebaseSignOut(auth); + } catch (error) { + console.error("Error signing out:", error); + throw error; + } + }; + + return { user, loading, signOut }; +}; + +export default useAuth; \ No newline at end of file diff --git a/src/components/header/AppHeaderDropdown.js b/src/components/header/AppHeaderDropdown.js index 30c0df82b..dced5906c 100644 --- a/src/components/header/AppHeaderDropdown.js +++ b/src/components/header/AppHeaderDropdown.js @@ -21,10 +21,23 @@ import { cilUser, } from '@coreui/icons' import CIcon from '@coreui/icons-react' - +import { useNavigate } from 'react-router-dom'; +import useAuth from '../../Firebase/useAuth' import avatar8 from './../../assets/images/avatars/8.jpg' const AppHeaderDropdown = () => { + const { signOut } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await signOut(); + navigate('/login'); + } catch (error) { + console.error('Logout error:', error); + } + }; + return ( @@ -84,9 +97,9 @@ const AppHeaderDropdown = () => { - + - Lock Account + Log Out diff --git a/src/views/pages/login/Login.js b/src/views/pages/login/Login.js index 1b2ee0baa..b507f8dff 100644 --- a/src/views/pages/login/Login.js +++ b/src/views/pages/login/Login.js @@ -1,5 +1,17 @@ -import React from 'react' -import { Link } from 'react-router-dom' +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + signInWithEmailAndPassword, + signInWithPopup, + GoogleAuthProvider +} from 'firebase/auth'; +import { + getFirestore, + doc, + setDoc, + getDoc +} from 'firebase/firestore'; +import { auth } from '../../../Firebase/firebase'; import { CButton, CCard, @@ -12,11 +24,88 @@ import { CInputGroup, CInputGroupText, CRow, -} from '@coreui/react' -import CIcon from '@coreui/icons-react' -import { cilLockLocked, cilUser } from '@coreui/icons' + CAlert, +} from '@coreui/react'; +import CIcon from '@coreui/icons-react'; +import { cilLockLocked, cilUser } from '@coreui/icons'; + +// Configure Google Provider +const googleProvider = new GoogleAuthProvider(); +googleProvider.addScope('https://www.googleapis.com/auth/forms.body'); +googleProvider.addScope('https://www.googleapis.com/auth/presentations'); +googleProvider.addScope('https://www.googleapis.com/auth/drive.file'); + +const db = getFirestore(); const Login = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await signInWithEmailAndPassword(auth, email, password); + navigate('/'); + } catch (err) { + handleAuthError(err); + } finally { + setLoading(false); + } + }; + + const handleGoogleLogin = async () => { + try { + const result = await signInWithPopup(auth, googleProvider); + const user = result.user; + + // Check/create user document + const userRef = doc(db, 'users', user.uid); + const userDoc = await getDoc(userRef); + + if (!userDoc.exists()) { + const [firstName, lastName] = user.displayName?.split(' ') || ['', '']; + await setDoc(userRef, { + firstName, + lastName, + email: user.email, + role: 'parent', + createdAt: serverTimestamp(), + lastLogin: serverTimestamp(), + loginCount: 0 + }); + } + + navigate('/'); + } catch (err) { + handleAuthError(err); + } + }; + + const handleAuthError = (error) => { + switch (error.code) { + case 'auth/user-not-found': + setError('No account found with this email'); + break; + case 'auth/wrong-password': + setError('Incorrect password'); + break; + case 'auth/popup-closed-by-user': + setError('Google sign-in window closed'); + break; + case 'auth/account-exists-with-different-credential': + setError('Account exists with different login method'); + break; + default: + setError('Login failed. Please try again.'); + } + }; + return (
@@ -25,15 +114,25 @@ const Login = () => { - +

Login

Sign In to your account

+ {error && {error}} + - + setEmail(e.target.value)} + required + /> + @@ -42,12 +141,21 @@ const Login = () => { type="password" placeholder="Password" autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + required /> + - - Login + + {loading ? 'Loading...' : 'Login'} @@ -56,17 +164,30 @@ const Login = () => { + +
+ + + Continue with Google + + + Google login creates a parent account by default + +
+

Sign up

-

- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. -

+

Don't have an account? Register now to access all features!

Register Now! @@ -80,7 +201,7 @@ const Login = () => {
- ) -} + ); +}; -export default Login +export default Login; \ No newline at end of file diff --git a/src/views/pages/login/authSlice.js b/src/views/pages/login/authSlice.js new file mode 100644 index 000000000..213ae0d3f --- /dev/null +++ b/src/views/pages/login/authSlice.js @@ -0,0 +1,27 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + user: null, + isAuthenticated: false, + loading: true +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + loginSuccess: (state, action) => { + state.user = action.payload; + state.isAuthenticated = true; + state.loading = false; + }, + logout: (state) => { + state.user = null; + state.isAuthenticated = false; + state.loading = false; + } + } +}); + +export const { loginSuccess, logout } = authSlice.actions; +export default authSlice.reducer; \ No newline at end of file diff --git a/src/views/pages/register/Register.js b/src/views/pages/register/Register.js index d78b24c8f..994fd64bf 100644 --- a/src/views/pages/register/Register.js +++ b/src/views/pages/register/Register.js @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { CButton, CCard, @@ -7,14 +8,396 @@ import { CContainer, CForm, CFormInput, + CFormSelect, CInputGroup, CInputGroupText, CRow, -} from '@coreui/react' -import CIcon from '@coreui/icons-react' -import { cilLockLocked, cilUser } from '@coreui/icons' + CAlert, + CProgress +} from '@coreui/react'; +import CIcon from '@coreui/icons-react'; +import { cilLockLocked, cilUser, cilPhone, cilContact, cilShieldAlt } from '@coreui/icons'; +import { + createUserWithEmailAndPassword, + signInWithPopup, + GoogleAuthProvider +} from "firebase/auth"; +import { auth, firestore } from '../../../Firebase/firebase'; +import { doc, setDoc, serverTimestamp } from 'firebase/firestore'; + +// Google Auth Provider Configuration +const googleProvider = new GoogleAuthProvider(); const Register = () => { + const [step, setStep] = useState(1); + const [formData, setFormData] = useState({ + email: '', + password: '', + confirmPassword: '', + firstName: '', + lastName: '', + phone: '', + role: '', + emergencyNumber: '', + subjects: '', + permissions: '' + }); + + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (step === 1) { + if (!validateStep1()) return; + setStep(2); + return; + } + + if (step === 2) { + if (!validateStep2()) return; + setStep(3); + return; + } + + await handleFinalSubmission(); + }; + + const handleGoogleSignUp = async () => { + try { + const result = await signInWithPopup(auth, googleProvider); + const user = result.user; + const [firstName, lastName] = user.displayName?.split(' ') || ['', '']; + + const userData = { + firstName, + lastName, + email: user.email, + phone: user.phoneNumber || '', + role: 'parent', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + lastLogin: serverTimestamp(), + loginCount: 0, + parentProfile: { + emergencyNumber: '', + children: [], + communicationPreferences: { email: true, sms: true } + } + }; + + await setDoc(doc(firestore, 'users', user.uid), userData); + navigate('/dashboard'); + } catch (error) { + handleFirebaseError(error); + } + }; + + const validateStep1 = () => { + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + return false; + } + if (formData.password.length < 6) { + setError('Password must be at least 6 characters'); + return false; + } + return true; + }; + + const validateStep2 = () => { + if (!formData.firstName || !formData.lastName || !formData.phone || !formData.role) { + setError('All fields are required'); + return false; + } + return true; + }; + + const handleFinalSubmission = async () => { + setLoading(true); + try { + const userCredential = await createUserWithEmailAndPassword( + auth, + formData.email, + formData.password + ); + + const userData = { + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + phone: formData.phone, + role: formData.role, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + lastLogin: serverTimestamp(), + loginCount: 0, + ...getRoleSpecificData() + }; + + await setDoc(doc(firestore, 'users', userCredential.user.uid), userData); + navigate('/dashboard'); + } catch (err) { + handleFirebaseError(err); + } finally { + setLoading(false); + } + }; + + const getRoleSpecificData = () => { + switch(formData.role) { + case 'parent': + return { + parentProfile: { + emergencyNumber: formData.emergencyNumber, + children: [], + communicationPreferences: { email: true, sms: true } + } + }; + case 'teacher': + return { + teacherProfile: { + subjects: formData.subjects.split(',').map(s => s.trim()), + classesManaged: [], + teacherPerformance: { averageClassAttendance: 0, feedbackScore: 0 } + } + }; + case 'admin': + return { + adminProfile: { + permissions: formData.permissions.split(',').map(p => p.trim()), + auditMetrics: { lastAudit: null, actionsLogged: 0 } + } + }; + default: + return {}; + } + }; + + const handleChange = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const handleFirebaseError = (error) => { + switch (error.code) { + case 'auth/email-already-in-use': + setError('Email is already registered'); + break; + case 'auth/invalid-email': + setError('Invalid email address'); + break; + case 'auth/weak-password': + setError('Password must be at least 6 characters'); + break; + case 'auth/popup-closed-by-user': + setError('Google sign-up window was closed'); + break; + case 'auth/account-exists-with-different-credential': + setError('Email already exists with different login method'); + break; + default: + setError('Registration failed. Please try again.'); + } + }; + + const renderStep = () => { + switch (step) { + case 1: + return ( + <> + + + + + handleChange('email', e.target.value)} + required + /> + + + Use your official email address + + + + + + + handleChange('password', e.target.value)} + required + /> + + + Minimum 6 characters with letters and numbers + + + + + + + handleChange('confirmPassword', e.target.value)} + required + /> + + +
+ + + Sign up with Google + + + Google sign-up defaults to Parent role + +
+ + ); + + case 2: + return ( + <> + + + + + handleChange('firstName', e.target.value)} + required + /> + + + Legal name as per official documents + + + + + + + handleChange('lastName', e.target.value)} + required + /> + + + + + + + handleChange('phone', e.target.value)} + required + /> + + + Include country code for SMS notifications + + + + + + + handleChange('role', e.target.value)} + required + > + + + + + + + + Choose your primary role in the institution + + + ); + + case 3: + return ( + <> + {formData.role === 'parent' && ( + <> + + + + + handleChange('emergencyNumber', e.target.value)} + required + /> + + + Primary emergency contact number + + + )} + + {formData.role === 'teacher' && ( + <> + + + + + handleChange('subjects', e.target.value)} + required + /> + + + Example: Mathematics, Physics, Chemistry + + + )} + + {formData.role === 'admin' && ( + <> + + + + + handleChange('permissions', e.target.value)} + required + /> + + + Example: user_management, system_config + + + )} + + ); + } + }; + return (
@@ -22,41 +405,33 @@ const Register = () => { - -

Register

-

Create your account

- - - - - - - - @ - - - - - - - - - - - - - +
+ - +

Register ({step}/3)

+

+ {step === 1 && 'Account Setup'} + {step === 2 && 'Personal Information'} + {step === 3 && `${formData.role.charAt(0).toUpperCase() + formData.role.slice(1)} Details`} +

+
+ + {error && {error}} + + {renderStep()} +
- Create Account + + {loading ? 'Processing...' : step === 3 ? 'Complete Registration' : 'Next Step'} +
@@ -65,7 +440,7 @@ const Register = () => {
- ) -} + ); +}; -export default Register +export default Register; \ No newline at end of file