From bf89760846a5c710b5483c6d302f7376502df403 Mon Sep 17 00:00:00 2001 From: s225389846 Date: Fri, 8 May 2026 13:07:50 +1000 Subject: [PATCH 1/3] Accounts page UI improvement --- frontend/src/pages/AccountPage.tsx | 352 +++++++++++++++++++++++++++-- 1 file changed, 327 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 6886a0f8..ededffb9 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -1,6 +1,15 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { Loader2, LogOut, User } from "lucide-react"; +import { + Loader2, + LogOut, + User, + Eye, + EyeOff, + Pencil, + Save, + X, +} from "lucide-react"; import { logout as apiLogout } from "../api/client"; import { useAuth } from "../context/AuthContext"; @@ -14,6 +23,7 @@ type AuthUser = { email?: string | null; username?: string | null; name?: string | null; + organization?: string | null; id?: string | number | null; }; @@ -28,11 +38,72 @@ export default function AccountPage({ isDarkMode = true, }: AccountPageProps) { const navigate = useNavigate(); - const { user, token, logout: clearAuth } = - useAuth() as AuthContextValue; - + const { user, token, logout: clearAuth } = useAuth() as AuthContextValue; + console.log("USER DATA:", user); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const [passwordData, setPasswordData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + + const handlePasswordChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setPasswordData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const [isEditingProfile, setIsEditingProfile] = useState(false); + + const [profileData, setProfileData] = useState({ + firstName: "", + lastName: "", + organization: "", + }); + + useEffect(() => { + const fullName = user?.name ?? ""; + + setProfileData({ + firstName: fullName ? fullName.split(" ")[0] : "", + lastName: fullName ? fullName.split(" ").slice(1).join(" ") : "", + organization: user?.organization ?? "", + }); + }, [user]); + + const handleProfileChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setProfileData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleEditProfile = () => { + setIsEditingProfile(true); + }; + + const handleCancelProfileEdit = () => { + setProfileData({ + firstName: user?.name?.split(" ")[0] || "", + lastName: user?.name?.split(" ").slice(1).join(" ") || "", + organization: user?.organization || "", + }); + setIsEditingProfile(false); + }; + + const handleSaveProfile = () => { + console.log("Profile data to save:", profileData); + setIsEditingProfile(false); + }; + const primaryLabel = user?.email || user?.username || @@ -47,7 +118,7 @@ export default function AccountPage({ try { await apiLogout(token); } catch (error) { - console.warn("Logout request failed:", error); + console.warn("Logout request failed; clearing local auth anyway:", error); } finally { clearAuth(); navigate("/"); @@ -60,14 +131,14 @@ export default function AccountPage({ isDarkMode ? "bg-slate-900 text-white" : "bg-gray-100 text-black" }`} style={{ - marginLeft: sidebarWidth ? `${sidebarWidth}px` : 0, - width: sidebarWidth ? `calc(100% - ${sidebarWidth}px)` : "100%", -}} + marginLeft: `${sidebarWidth}px`, + width: `calc(100% - ${sidebarWidth}px)`, + transition: "margin-left 0.4s ease, width 0.4s ease", + }} > -
- {/* HEADER */} -
-
+
+
+

Account

@@ -79,9 +150,9 @@ export default function AccountPage({
- {/* PROFILE CARD (FIXES YOUR PR COMMENTS) */} -
-

Profile

+
+
+

Profile

+ + {!isEditingProfile ? ( + + ) : ( +
+ + + +
+ )} +
+ + {!isEditingProfile ? ( +
+
+ + Name + + + {user?.name + ? user.name + : user?.email + ? user.email.split("@")[0].replace(/\./g, " ") + : "Not available"} + +
+ +
+ + Email + + + {user?.email || "Not available"} + +
+ +
+ + Organization + + + {user?.organization || "AutoAudit"} + +
+
+ ) : ( +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ )} +
+ + {/* Change Password Card */} -
+
+

Change Password

-
-
- User - - {primaryLabel} - +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
); -} \ No newline at end of file +} From 6fe6cf1bd4b7741a687592d59931ca8308fce5e5 Mon Sep 17 00:00:00 2001 From: s225389846 Date: Fri, 8 May 2026 18:25:58 +1000 Subject: [PATCH 2/3] Account page UI and features improved --- .../d87c3bb49953_add_user_profile_fields.py | 41 +++ backend-api/app/api/v1/auth.py | 70 ++-- backend-api/app/models/user.py | 15 + backend-api/app/schemas/user.py | 24 +- frontend/src/App.tsx | 190 +++++++---- frontend/src/api/client.ts | 300 ++++++++++++------ frontend/src/context/AuthContext.tsx | 49 ++- frontend/src/pages/AccountPage.tsx | 169 ++++++++-- 8 files changed, 633 insertions(+), 225 deletions(-) create mode 100644 backend-api/alembic/versions/d87c3bb49953_add_user_profile_fields.py diff --git a/backend-api/alembic/versions/d87c3bb49953_add_user_profile_fields.py b/backend-api/alembic/versions/d87c3bb49953_add_user_profile_fields.py new file mode 100644 index 00000000..e14e385d --- /dev/null +++ b/backend-api/alembic/versions/d87c3bb49953_add_user_profile_fields.py @@ -0,0 +1,41 @@ +"""add user profile fields + +Revision ID: d87c3bb49953 +Revises: j1k2l3m4n567 +Create Date: 2026-05-08 06:36:06.259229 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "d87c3bb49953" +down_revision: Union[str, Sequence[str], None] = "j1k2l3m4n567" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user", + sa.Column("first_name", sa.String(length=100), nullable=True), + ) + + op.add_column( + "user", + sa.Column("last_name", sa.String(length=100), nullable=True), + ) + + op.add_column( + "user", + sa.Column("organization_name", sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "organization_name") + op.drop_column("user", "last_name") + op.drop_column("user", "first_name") \ No newline at end of file diff --git a/backend-api/app/api/v1/auth.py b/backend-api/app/api/v1/auth.py index fa39f0d5..7e5b4d9b 100644 --- a/backend-api/app/api/v1/auth.py +++ b/backend-api/app/api/v1/auth.py @@ -36,6 +36,35 @@ async def read_users_me(user: User = Depends(get_current_user)): """Get current authenticated user information.""" return user +#Update User +@users_router.patch("/me", summary="Update my user information", response_model=UserRead) +async def update_users_me( + user_update: UserUpdate, + user: User = Depends(get_current_user), +): + """Update current authenticated user's profile information.""" + from app.db.session import get_async_session + + async for session in get_async_session(): + db_user = await session.get(User, user.id) + + if db_user is None: + raise HTTPException(status_code=404, detail="User not found") + + if user_update.first_name is not None: + db_user.first_name = user_update.first_name + + if user_update.last_name is not None: + db_user.last_name = user_update.last_name + + if user_update.organization_name is not None: + db_user.organization_name = user_update.organization_name + + await session.commit() + await session.refresh(db_user) + + return db_user + # Change password endpoint from pydantic import BaseModel @@ -51,35 +80,34 @@ async def change_password( user: User = Depends(get_current_user), ): """Change current user's password.""" - from app.core.users import get_user_manager from app.db.session import get_async_session - from fastapi import Request - - # Create a mock request object for fastapi-users - request = Request(scope={"type": "http"}) + from app.core.users import get_user_manager async for session in get_async_session(): + db_user = await session.get(User, user.id) + + if db_user is None: + raise HTTPException(status_code=404, detail="User not found") + async for user_manager in get_user_manager(session): - try: - # Verify current password - verified, updated_password_hash = user_manager.password_helper.verify_and_update( - password_data.current_password, user.hashed_password - ) - if not verified: - raise exceptions.InvalidPasswordException() + verified, _ = user_manager.password_helper.verify_and_update( + password_data.current_password, + db_user.hashed_password, + ) - # Hash new password - new_hashed_password = user_manager.password_helper.hash(password_data.new_password) + if not verified: + raise HTTPException( + status_code=400, + detail="Current password is incorrect", + ) - # Update user password - user.hashed_password = new_hashed_password - await session.commit() + db_user.hashed_password = user_manager.password_helper.hash( + password_data.new_password + ) - return {"message": "Password changed successfully"} + await session.commit() - except exceptions.InvalidPasswordException: - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="Invalid current password") + return {"message": "Password changed successfully"} # Include users router diff --git a/backend-api/app/models/user.py b/backend-api/app/models/user.py index 7db3b99f..e267a7c9 100644 --- a/backend-api/app/models/user.py +++ b/backend-api/app/models/user.py @@ -37,6 +37,21 @@ class User(SQLAlchemyBaseUserTable[int], Base): nullable=False ) + first_name: Mapped[str | None] = mapped_column( + String(100), + nullable=True + ) + + last_name: Mapped[str | None] = mapped_column( + String(100), + nullable=True + ) + + organization_name: Mapped[str | None] = mapped_column( + String(255), + nullable=True + ) + # Inherited from SQLAlchemyBaseUserTable: # - email: str # - hashed_password: str diff --git a/backend-api/app/schemas/user.py b/backend-api/app/schemas/user.py index 46363a2c..79d4061a 100644 --- a/backend-api/app/schemas/user.py +++ b/backend-api/app/schemas/user.py @@ -1,27 +1,31 @@ from fastapi_users import schemas -from pydantic import EmailStr from app.models.user import Role class UserRead(schemas.BaseUser[int]): - """Schema for reading user data.""" role: Role + first_name: str | None = None + last_name: str | None = None + organization_name: str | None = None class UserCreate(schemas.BaseUserCreate): - """Schema for creating a new user.""" role: Role = Role.VIEWER + first_name: str | None = None + last_name: str | None = None + organization_name: str | None = None -class UserRegister(schemas.BaseUserCreate): - """ - Public registration schema. - Intentionally does NOT expose role/is_superuser/etc so a self-registering - user cannot elevate privileges. - """ +class UserRegister(schemas.BaseUserCreate): + first_name: str | None = None + last_name: str | None = None + organization_name: str | None = None class UserUpdate(schemas.BaseUserUpdate): - """Schema for updating user data.""" role: Role | None = None + + first_name: str | None = None + last_name: str | None = None + organization_name: str | None = None \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39d0e60c..bb0d72c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,32 +1,32 @@ -import React, { useState, useEffect, JSX } from 'react'; -import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import React, { useState, useEffect, JSX } from "react"; +import { Routes, Route, useLocation, useNavigate } from "react-router-dom"; // Dashboard Components -import Sidebar from './components/Sidebar'; -import Dashboard from './pages/Dashboard'; -import Evidence from './pages/Evidence'; -import SettingsPage from './pages/SettingsPage'; -import AccountPage from './pages/AccountPage'; -import StyleGuide from './pages/StyleGuide'; -import ConnectionsPage from './pages/Connections/ConnectionsPage'; -import ScansPage from './pages/Scans/ScansPage'; -import ScanDetailPage from './pages/Scans/ScanDetailPage'; +import Sidebar from "./components/Sidebar"; +import Dashboard from "./pages/Dashboard"; +import Evidence from "./pages/Evidence"; +import SettingsPage from "./pages/SettingsPage"; +import AccountPage from "./pages/AccountPage"; +import StyleGuide from "./pages/StyleGuide"; +import ConnectionsPage from "./pages/Connections/ConnectionsPage"; +import ScansPage from "./pages/Scans/ScansPage"; +import ScanDetailPage from "./pages/Scans/ScanDetailPage"; // Authentication & Landing Components -import LandingPage from './pages/Landing/LandingPage'; -import AboutUs from './pages/Landing/AboutUs'; -import ContactPage from './pages/Contact/ContactPage'; -import LoginPage from './pages/Auth/LoginPage'; -import SignUpPage from './pages/Auth/SignUpPage'; -import ContactAdminPage from './pages/Admin/ContactAdminPage'; -import GoogleCallbackPage from './pages/Auth/GoogleCallbackPage'; +import LandingPage from "./pages/Landing/LandingPage"; +import AboutUs from "./pages/Landing/AboutUs"; +import ContactPage from "./pages/Contact/ContactPage"; +import LoginPage from "./pages/Auth/LoginPage"; +import SignUpPage from "./pages/Auth/SignUpPage"; +import ContactAdminPage from "./pages/Admin/ContactAdminPage"; +import GoogleCallbackPage from "./pages/Auth/GoogleCallbackPage"; // Auth Context -import { useAuth } from './context/AuthContext'; -import { register as apiRegister } from './api/client'; +import { useAuth } from "./context/AuthContext"; +import { register as apiRegister } from "./api/client"; // Styles -import './index.css'; +import "./index.css"; type RouteWrapperProps = { children: React.ReactNode; @@ -47,8 +47,13 @@ type DashboardLayoutProps = { }; type SignUpData = { + firstName: string; + lastName: string; email: string; + organizationName: string; password: string; + confirmPassword: string; + agreeTerms: boolean; }; // Protected Route Component @@ -58,7 +63,7 @@ const ProtectedRoute: React.FC = ({ children }) => { useEffect(() => { if (!isLoading && !isAuthenticated) { - navigate('/login'); + navigate("/login"); } }, [isAuthenticated, isLoading, navigate]); @@ -67,9 +72,25 @@ const ProtectedRoute: React.FC = ({ children }) => {
- - - + + +

Loading...

@@ -89,11 +110,11 @@ const AdminRoute: React.FC = ({ children }) => { useEffect(() => { if (isLoading) return; if (!isAuthenticated) { - navigate('/login'); + navigate("/login"); return; } - if ((user as { role?: string } | null | undefined)?.role !== 'admin') { - navigate('/dashboard'); + if ((user as { role?: string } | null | undefined)?.role !== "admin") { + navigate("/dashboard"); } }, [isAuthenticated, isLoading, navigate, user]); @@ -102,9 +123,25 @@ const AdminRoute: React.FC = ({ children }) => {
- - - + + +

Loading...

@@ -113,7 +150,10 @@ const AdminRoute: React.FC = ({ children }) => { ); } - return isAuthenticated && (user as { role?: string } | null | undefined)?.role === 'admin' ? <>{children} : null; + return isAuthenticated && + (user as { role?: string } | null | undefined)?.role === "admin" ? ( + <>{children} + ) : null; }; // Dashboard Layout Component (with sidebar) @@ -127,7 +167,11 @@ const DashboardLayout: React.FC = ({ return ( <> - {React.cloneElement(children, { sidebarWidth, isDarkMode, onThemeToggle })} + {React.cloneElement(children, { + sidebarWidth, + isDarkMode, + onThemeToggle, + })} ); }; @@ -139,30 +183,32 @@ function App(): JSX.Element { // Dashboard state const getInitialSidebarWidth = (): number => { - if (typeof window === 'undefined') return 220; + if (typeof window === "undefined") return 220; try { - const stored = window.localStorage.getItem('sidebarExpanded'); + const stored = window.localStorage.getItem("sidebarExpanded"); if (stored === null) return 220; - return stored === 'true' ? 220 : 80; + return stored === "true" ? 220 : 80; } catch { return 220; } }; - const [sidebarWidth, setSidebarWidth] = useState(getInitialSidebarWidth); + const [sidebarWidth, setSidebarWidth] = useState( + getInitialSidebarWidth, + ); const [isDarkMode, setIsDarkMode] = useState(true); // Theme management useEffect(() => { - const theme = localStorage.getItem('theme') ?? 'dark'; - const dark = theme === 'dark'; + const theme = localStorage.getItem("theme") ?? "dark"; + const dark = theme === "dark"; setIsDarkMode(dark); const root = document.documentElement; if (dark) { - root.classList.remove('light'); + root.classList.remove("light"); } else { - root.classList.add('light'); + root.classList.add("light"); } }, []); @@ -171,43 +217,54 @@ function App(): JSX.Element { // - Keep hash-based anchor behavior (e.g. /#features) intact. useEffect(() => { if (location.hash) return; - window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [location.pathname, location.hash]); // Authentication handlers - const handleUserLogin = async (email: string, password: string, remember: boolean = true): Promise => { + const handleUserLogin = async ( + email: string, + password: string, + remember: boolean = true, + ): Promise => { await auth.login(email, password, remember); - navigate('/dashboard'); + navigate("/dashboard"); }; const handleUserLogout = (): void => { auth.logout(); - navigate('/'); + navigate("/"); }; const handleSignUp = async (signUpData: SignUpData): Promise => { - const email = signUpData.email; - const password = signUpData.password; + const { firstName, lastName, email, organizationName, password } = + signUpData; - if (!email || !password) { - throw new Error('Email and password are required'); + if (!firstName || !lastName || !email || !organizationName || !password) { + throw new Error("All fields are required"); } - await apiRegister(email, password); + await apiRegister({ + firstName, + lastName, + email, + organizationName, + password, + }); + await auth.login(email, password, true); - navigate('/dashboard'); + navigate("/dashboard"); }; const handleThemeToggle = (): void => { const newThemeIsDark = !isDarkMode; setIsDarkMode(newThemeIsDark); - localStorage.setItem('theme', newThemeIsDark ? 'dark' : 'light'); + localStorage.setItem("theme", newThemeIsDark ? "dark" : "light"); const root = document.documentElement; if (newThemeIsDark) { - root.classList.remove('light'); + root.classList.remove("light"); } else { - root.classList.add('light'); + root.classList.add("light"); } }; @@ -221,19 +278,17 @@ function App(): JSX.Element { {/* Public Routes */} navigate('/login')} />} + element={ navigate("/login")} />} /> navigate('/login')} /> - } + element={ navigate("/login")} />} /> navigate('/login')} />} + element={ navigate("/login")} />} /> navigate('/signup')} + onSignUpClick={() => navigate("/signup")} /> } /> @@ -253,7 +308,7 @@ function App(): JSX.Element { element={ navigate('/login')} + onBackToLogin={() => navigate("/login")} /> } /> @@ -278,7 +333,10 @@ function App(): JSX.Element { onThemeToggle={handleThemeToggle} onSidebarWidthChange={handleSidebarWidthChange} > - + } @@ -385,15 +443,11 @@ function App(): JSX.Element { {/* Fallback route */} navigate('/login')} - /> - } + element={ navigate("/login")} />} />
); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 53b2be2f..670890d9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL as string | undefined; if (!API_BASE_URL) { - throw new Error('VITE_API_URL environment variable must be set'); + throw new Error("VITE_API_URL environment variable must be set"); } type APIErrorPayload = Record | undefined; @@ -13,7 +13,7 @@ export class APIError extends Error { constructor(message: string, status: number, payload?: APIErrorPayload) { super(message); - this.name = 'APIError'; + this.name = "APIError"; this.status = status; this.payload = payload; } @@ -22,9 +22,9 @@ export class APIError extends Error { function getErrorDetail(payload: unknown, fallback: string): string { if ( payload && - typeof payload === 'object' && - 'detail' in payload && - typeof (payload as { detail?: unknown }).detail === 'string' + typeof payload === "object" && + "detail" in payload && + typeof (payload as { detail?: unknown }).detail === "string" ) { return (payload as { detail: string }).detail; } @@ -35,10 +35,10 @@ function getErrorDetail(payload: unknown, fallback: string): string { async function fetchWithAuth( endpoint: string, token: AuthToken, - options: RequestInit = {} + options: RequestInit = {}, ): Promise { const headers: Record = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...((options.headers as Record | undefined) || {}), }; @@ -53,11 +53,17 @@ async function fetchWithAuth( }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: response.statusText }))) as Record< + const error = (await response + .json() + .catch(() => ({ detail: response.statusText }))) as Record< string, unknown >; - throw new APIError(getErrorDetail(error, 'Request failed'), response.status, error); + throw new APIError( + getErrorDetail(error, "Request failed"), + response.status, + error, + ); } // Support endpoints that may return 204 No Content. @@ -70,7 +76,7 @@ async function fetchWithAuth( if (error instanceof APIError) { throw error; } - const message = error instanceof Error ? error.message : 'Network error'; + const message = error instanceof Error ? error.message : "Network error"; throw new APIError(message, 0); } } @@ -78,9 +84,9 @@ async function fetchWithAuth( // Auth endpoints export async function login(email: string, password: string): Promise { const response = await fetch(`${API_BASE_URL}/v1/auth/login`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ username: email, @@ -89,20 +95,37 @@ export async function login(email: string, password: string): Promise { }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: 'Login failed' }))) as Record< - string, - unknown - >; - throw new APIError(getErrorDetail(error, 'Invalid credentials'), response.status, error); + const error = (await response + .json() + .catch(() => ({ detail: "Login failed" }))) as Record; + throw new APIError( + getErrorDetail(error, "Invalid credentials"), + response.status, + error, + ); } return response.json(); } -export async function register(email: string, password: string): Promise { - return fetchWithAuth('/v1/auth/register', null, { - method: 'POST', - body: JSON.stringify({ email, password }), +export type RegisterPayload = { + firstName: string; + lastName: string; + email: string; + organizationName: string; + password: string; +}; + +export async function register(payload: RegisterPayload): Promise { + return fetchWithAuth("/v1/auth/register", null, { + method: "POST", + body: JSON.stringify({ + first_name: payload.firstName, + last_name: payload.lastName, + email: payload.email, + organization_name: payload.organizationName, + password: payload.password, + }), }); } @@ -112,18 +135,24 @@ export async function logout(token: AuthToken): Promise { if (!token) return; const response = await fetch(`${API_BASE_URL}/v1/auth/logout`, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: response.statusText }))) as Record< + const error = (await response + .json() + .catch(() => ({ detail: response.statusText }))) as Record< string, unknown >; - throw new APIError(getErrorDetail(error, 'Logout failed'), response.status, error); + throw new APIError( + getErrorDetail(error, "Logout failed"), + response.status, + error, + ); } // 204 No Content (common for logout); nothing to parse. @@ -134,7 +163,38 @@ export async function logout(token: AuthToken): Promise { } export async function getCurrentUser(token: AuthToken): Promise { - return fetchWithAuth('/v1/auth/users/me', token); + return fetchWithAuth("/v1/auth/users/me", token); +} + +export type UpdateCurrentUserPayload = { + first_name?: string; + last_name?: string; + organization_name?: string; +}; + +export async function updateCurrentUser( + token: AuthToken, + payload: UpdateCurrentUserPayload, +): Promise { + return fetchWithAuth("/v1/auth/users/me", token, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export type ChangePasswordPayload = { + current_password: string; + new_password: string; +}; + +export async function changePassword( + token: AuthToken, + payload: ChangePasswordPayload, +): Promise { + return fetchWithAuth("/v1/auth/users/me/change-password", token, { + method: "POST", + body: JSON.stringify(payload), + }); } // Contact submissions @@ -147,85 +207,108 @@ export type ContactSubmissionCreatePayload = { subject: string; message: string; source?: string; -} +}; -export async function createContactSubmission(payload: ContactSubmissionCreatePayload): Promise { - return fetchWithAuth('/v1/contact', null, { - method: 'POST', +export async function createContactSubmission( + payload: ContactSubmissionCreatePayload, +): Promise { + return fetchWithAuth("/v1/contact", null, { + method: "POST", body: JSON.stringify(payload), }); } export async function getContactSubmissions(token: AuthToken): Promise { - return fetchWithAuth('/v1/contact/submissions', token); + return fetchWithAuth("/v1/contact/submissions", token); } -export async function getContactSubmission(token: AuthToken, id: string | number): Promise { +export async function getContactSubmission( + token: AuthToken, + id: string | number, +): Promise { return fetchWithAuth(`/v1/contact/submissions/${id}`, token); } export async function updateContactSubmission( token: AuthToken, id: string | number, - payload: Record + payload: Record, ): Promise { return fetchWithAuth(`/v1/contact/submissions/${id}`, token, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(payload), }); } -export async function deleteContactSubmission(token: AuthToken, id: string | number): Promise { +export async function deleteContactSubmission( + token: AuthToken, + id: string | number, +): Promise { const response = await fetch(`${API_BASE_URL}/v1/contact/submissions/${id}`, { - method: 'DELETE', + method: "DELETE", headers: { - Authorization: `Bearer ${token || ''}`, + Authorization: `Bearer ${token || ""}`, }, }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: response.statusText }))) as Record< + const error = (await response + .json() + .catch(() => ({ detail: response.statusText }))) as Record< string, unknown >; - throw new APIError(getErrorDetail(error, 'Failed to delete submission'), response.status, error); + throw new APIError( + getErrorDetail(error, "Failed to delete submission"), + response.status, + error, + ); } } -export async function getContactNotes(token: AuthToken, id: string | number): Promise { +export async function getContactNotes( + token: AuthToken, + id: string | number, +): Promise { return fetchWithAuth(`/v1/contact/submissions/${id}/notes`, token); } export async function addContactNote( token: AuthToken, id: string | number, - payload: Record + payload: Record, ): Promise { return fetchWithAuth(`/v1/contact/submissions/${id}/notes`, token, { - method: 'POST', + method: "POST", body: JSON.stringify(payload), }); } -export async function getContactHistory(token: AuthToken, id: string | number): Promise { +export async function getContactHistory( + token: AuthToken, + id: string | number, +): Promise { return fetchWithAuth(`/v1/contact/submissions/${id}/history`, token); } // Settings endpoints export async function getSettings(token: AuthToken): Promise { - return fetchWithAuth('/v1/settings', token); + return fetchWithAuth("/v1/settings", token); } -export async function updateSettings(token: AuthToken, data: Record): Promise { - return fetchWithAuth('/v1/settings', token, { - method: 'PATCH', +export async function updateSettings( + token: AuthToken, + data: Record, +): Promise { + return fetchWithAuth("/v1/settings", token, { + method: "PATCH", body: JSON.stringify(data), }); } // Platform endpoints export async function getPlatforms(token: AuthToken): Promise { - return fetchWithAuth('/v1/platforms', token); + return fetchWithAuth("/v1/platforms", token); } // M365 Connection endpoints @@ -234,22 +317,25 @@ export type CreateConnectionPayload = { tenant_id: string; client_id: string; client_secret: string; -} +}; export type UpdateConnectionPayload = { name?: string; tenant_id?: string; client_id?: string; client_secret?: string; -} +}; export async function getConnections(token: AuthToken): Promise { - return fetchWithAuth('/v1/m365-connections/', token); + return fetchWithAuth("/v1/m365-connections/", token); } -export async function createConnection(token: AuthToken, data: CreateConnectionPayload): Promise { - return fetchWithAuth('/v1/m365-connections/', token, { - method: 'POST', +export async function createConnection( + token: AuthToken, + data: CreateConnectionPayload, +): Promise { + return fetchWithAuth("/v1/m365-connections/", token, { + method: "POST", body: JSON.stringify(data), }); } @@ -257,43 +343,51 @@ export async function createConnection(token: AuthToken, data: CreateConnectionP export async function updateConnection( token: AuthToken, id: string | number, - data: UpdateConnectionPayload + data: UpdateConnectionPayload, ): Promise { return fetchWithAuth(`/v1/m365-connections/${id}`, token, { - method: 'PUT', + method: "PUT", body: JSON.stringify(data), }); } -export async function deleteConnection(token: AuthToken, id: string | number): Promise { +export async function deleteConnection( + token: AuthToken, + id: string | number, +): Promise { const response = await fetch(`${API_BASE_URL}/v1/m365-connections/${id}`, { - method: 'DELETE', + method: "DELETE", headers: { - Authorization: `Bearer ${token || ''}`, + Authorization: `Bearer ${token || ""}`, }, }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: response.statusText }))) as Record< + const error = (await response + .json() + .catch(() => ({ detail: response.statusText }))) as Record< string, unknown >; - throw new Error(getErrorDetail(error, 'Failed to delete connection')); + throw new Error(getErrorDetail(error, "Failed to delete connection")); } // DELETE returns 204 No Content, so don't try to parse JSON return; } -export async function testConnection(token: AuthToken, id: string | number): Promise { +export async function testConnection( + token: AuthToken, + id: string | number, +): Promise { return fetchWithAuth(`/v1/m365-connections/${id}/test`, token, { - method: 'POST', + method: "POST", }); } // Benchmark endpoints export async function getBenchmarks(token: AuthToken): Promise { - return fetchWithAuth('/v1/benchmarks', token); + return fetchWithAuth("/v1/benchmarks", token); } // Scan endpoints @@ -302,16 +396,16 @@ export type CreateScanPayload = { framework: string; benchmark: string; version: string; -} +}; // One item in the readiness breakdown shown on the scan form. export type ScanReadinessCheck = { key: string; label: string; - status: 'pass' | 'fail' | 'warn'; - severity: 'critical' | 'warning'; + status: "pass" | "fail" | "warn"; + severity: "critical" | "warning"; message: string; -} +}; export type ScanReadinessResponse = { ready: boolean; @@ -320,19 +414,25 @@ export type ScanReadinessResponse = { missing_permissions: string[]; unverified_permissions: string[]; checks: ScanReadinessCheck[]; -} +}; export async function getScans(token: AuthToken): Promise { - return fetchWithAuth('/v1/scans/', token); + return fetchWithAuth("/v1/scans/", token); } -export async function getScan(token: AuthToken, id: string | number): Promise { +export async function getScan( + token: AuthToken, + id: string | number, +): Promise { return fetchWithAuth(`/v1/scans/${id}`, token); } -export async function createScan(token: AuthToken, data: CreateScanPayload): Promise { - return fetchWithAuth('/v1/scans/', token, { - method: 'POST', +export async function createScan( + token: AuthToken, + data: CreateScanPayload, +): Promise { + return fetchWithAuth("/v1/scans/", token, { + method: "POST", body: JSON.stringify(data), }); } @@ -344,7 +444,7 @@ export async function getScanReadiness( framework: string; benchmark: string; version: string; - } + }, ): Promise { // Readiness is a lightweight GET request because it only validates the selected connection and benchmark. It does not create or start a scan. const search = new URLSearchParams({ @@ -357,20 +457,25 @@ export async function getScanReadiness( return fetchWithAuth(`/v1/scans/readiness?${search.toString()}`, token); } -export async function deleteScan(token: AuthToken, id: string | number): Promise { +export async function deleteScan( + token: AuthToken, + id: string | number, +): Promise { const response = await fetch(`${API_BASE_URL}/v1/scans/${id}`, { - method: 'DELETE', + method: "DELETE", headers: { - Authorization: `Bearer ${token || ''}`, + Authorization: `Bearer ${token || ""}`, }, }); if (!response.ok) { - const error = (await response.json().catch(() => ({ detail: response.statusText }))) as Record< + const error = (await response + .json() + .catch(() => ({ detail: response.statusText }))) as Record< string, unknown >; - throw new Error(getErrorDetail(error, 'Failed to delete scan')); + throw new Error(getErrorDetail(error, "Failed to delete scan")); } // DELETE returns 204 No Content, so don't try to parse JSON @@ -385,15 +490,18 @@ export async function getEvidenceStrategies(): Promise { // Returns an array of strategy objects, e.g. // [{ name, description, category, severity, evidence_types }, ...] // (see backend-api/app/api/v1/evidence.py -> strategies()). - return fetchWithAuth('/v1/evidence/strategies', null); + return fetchWithAuth("/v1/evidence/strategies", null); } export type ScanEvidenceParams = { strategyName: string; file: File | Blob; -} +}; -export async function scanEvidence(token: AuthToken, { strategyName, file }: ScanEvidenceParams): Promise { +export async function scanEvidence( + token: AuthToken, + { strategyName, file }: ScanEvidenceParams, +): Promise { // Frontend -> Backend // POST /v1/evidence/scan (multipart/form-data) // @@ -401,17 +509,17 @@ export async function scanEvidence(token: AuthToken, { strategyName, file }: Sca // The user is derived from the Bearer token (server-side), not a client-provided user_id. // Backend returns a JSON payload that the UI renders in the Results section. if (!strategyName) { - throw new Error('Strategy is required'); + throw new Error("Strategy is required"); } if (!file) { - throw new Error('Evidence file is required'); + throw new Error("Evidence file is required"); } const formData = new FormData(); // These field names must match the FastAPI endpoint signature in: // backend-api/app/api/v1/evidence.py -> scan(...) - formData.append('strategy_name', strategyName); - formData.append('evidence', file); + formData.append("strategy_name", strategyName); + formData.append("evidence", file); const headers: Record = {}; if (token) { @@ -419,7 +527,7 @@ export async function scanEvidence(token: AuthToken, { strategyName, file }: Sca } const response = await fetch(`${API_BASE_URL}/v1/evidence/scan`, { - method: 'POST', + method: "POST", headers, body: formData, }); @@ -427,12 +535,18 @@ export async function scanEvidence(token: AuthToken, { strategyName, file }: Sca if (!response.ok) { // The backend may respond with JSON (FastAPI error) or plain text. // We parse best-effort and throw APIError so callers can display a message. - const raw = await response.text().catch(() => ''); + const raw = await response.text().catch(() => ""); try { - const error = (raw ? JSON.parse(raw) : { detail: response.statusText }) as Record; - throw new APIError(getErrorDetail(error, 'Scan failed'), response.status, error); + const error = ( + raw ? JSON.parse(raw) : { detail: response.statusText } + ) as Record; + throw new APIError( + getErrorDetail(error, "Scan failed"), + response.status, + error, + ); } catch { - throw new APIError(raw || 'Scan failed', response.status); + throw new APIError(raw || "Scan failed", response.status); } } @@ -442,6 +556,6 @@ export async function scanEvidence(token: AuthToken, { strategyName, file }: Sca export function getEvidenceReportUrl(filename: string): string { // Frontend helper to build a direct download URL for a generated report. // Backend endpoint: GET /v1/evidence/reports/{filename} - if (!filename) return ''; + if (!filename) return ""; return `${API_BASE_URL}/v1/evidence/reports/${encodeURIComponent(filename)}`; -} \ No newline at end of file +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 94bf357c..c868d4fc 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -13,20 +13,30 @@ export type AuthUser = { id?: number | string | null; email?: string | null; username?: string | null; + first_name?: string | null; + last_name?: string | null; + organization_name?: string | null; name?: string | null; role?: string | null; is_active?: boolean | null; -} +}; export type AuthContextValue = { user: AuthUser | null; token: string | null; isAuthenticated: boolean; isLoading: boolean; - login: (email: string, password: string, remember?: boolean) => Promise; - loginWithAccessToken: (accessToken: string, remember?: boolean) => Promise; + login: ( + email: string, + password: string, + remember?: boolean, + ) => Promise; + loginWithAccessToken: ( + accessToken: string, + remember?: boolean, + ) => Promise; logout: () => void; -} +}; const AuthContext = createContext(null); @@ -44,7 +54,10 @@ function safeJsonParse(value: string | null): unknown { function getStoredToken(): string | null { if (typeof window === "undefined") return null; - return window.localStorage.getItem(TOKEN_KEY) || window.sessionStorage.getItem(TOKEN_KEY); + return ( + window.localStorage.getItem(TOKEN_KEY) || + window.sessionStorage.getItem(TOKEN_KEY) + ); } function getStoredUser(): AuthUser | null { @@ -66,7 +79,11 @@ function clearStoredAuth(): void { window.sessionStorage.removeItem(USER_KEY); } -function persistAuth(accessToken: string, userData: AuthUser, remember: boolean): void { +function persistAuth( + accessToken: string, + userData: AuthUser, + remember: boolean, +): void { if (typeof window === "undefined") return; const storage = remember ? window.localStorage : window.sessionStorage; const other = remember ? window.sessionStorage : window.localStorage; @@ -80,7 +97,7 @@ function persistAuth(accessToken: string, userData: AuthUser, remember: boolean) type AuthProviderProps = { children: ReactNode; -} +}; export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(() => getStoredUser()); @@ -108,9 +125,12 @@ export function AuthProvider({ children }: AuthProviderProps) { setUser(userData as AuthUser); const inLocal = - typeof window !== "undefined" && window.localStorage.getItem(TOKEN_KEY) === token; + typeof window !== "undefined" && + window.localStorage.getItem(TOKEN_KEY) === token; const storage = - typeof window !== "undefined" && inLocal ? window.localStorage : window.sessionStorage; + typeof window !== "undefined" && inLocal + ? window.localStorage + : window.sessionStorage; if (typeof window !== "undefined") { storage.setItem(USER_KEY, JSON.stringify(userData)); } @@ -128,7 +148,11 @@ export function AuthProvider({ children }: AuthProviderProps) { void validateToken(); }, [token]); - async function login(email: string, password: string, remember = true): Promise { + async function login( + email: string, + password: string, + remember = true, + ): Promise { const response = await apiLogin(email, password); const accessToken = response.access_token; @@ -141,7 +165,10 @@ export function AuthProvider({ children }: AuthProviderProps) { return userData; } - async function loginWithAccessToken(accessToken: string, remember = false): Promise { + async function loginWithAccessToken( + accessToken: string, + remember = false, + ): Promise { if (!accessToken) { throw new Error("Access token is required"); } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index ededffb9..ae6e9b91 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -10,7 +10,11 @@ import { Save, X, } from "lucide-react"; -import { logout as apiLogout } from "../api/client"; +import { + logout as apiLogout, + updateCurrentUser, + changePassword, +} from "../api/client"; import { useAuth } from "../context/AuthContext"; type AccountPageProps = { @@ -23,6 +27,9 @@ type AuthUser = { email?: string | null; username?: string | null; name?: string | null; + first_name?: string | null; + last_name?: string | null; + organization_name?: string | null; organization?: string | null; id?: string | number | null; }; @@ -46,6 +53,10 @@ export default function AccountPage({ const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isChangingPassword, setIsChangingPassword] = useState(false); + const [passwordError, setPasswordError] = useState(""); + const [passwordSuccess, setPasswordSuccess] = useState(""); + const [passwordData, setPasswordData] = useState({ currentPassword: "", newPassword: "", @@ -68,13 +79,15 @@ export default function AccountPage({ organization: "", }); - useEffect(() => { - const fullName = user?.name ?? ""; + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileSuccess, setProfileSuccess] = useState(""); + useEffect(() => { setProfileData({ - firstName: fullName ? fullName.split(" ")[0] : "", - lastName: fullName ? fullName.split(" ").slice(1).join(" ") : "", - organization: user?.organization ?? "", + firstName: user?.first_name ?? "", + lastName: user?.last_name ?? "", + organization: user?.organization_name ?? "", }); }, [user]); @@ -87,21 +100,62 @@ export default function AccountPage({ }; const handleEditProfile = () => { + setProfileError(""); + setProfileSuccess(""); + setProfileData({ + firstName: user?.first_name ?? "", + lastName: user?.last_name ?? "", + organization: user?.organization_name ?? "", + }); setIsEditingProfile(true); }; const handleCancelProfileEdit = () => { setProfileData({ - firstName: user?.name?.split(" ")[0] || "", - lastName: user?.name?.split(" ").slice(1).join(" ") || "", - organization: user?.organization || "", + firstName: user?.first_name ?? "", + lastName: user?.last_name ?? "", + organization: user?.organization_name ?? "", }); + setIsEditingProfile(false); }; - const handleSaveProfile = () => { - console.log("Profile data to save:", profileData); - setIsEditingProfile(false); + const handleSaveProfile = async () => { + setProfileError(""); + setProfileSuccess(""); + + if (!profileData.firstName.trim() || !profileData.lastName.trim()) { + setProfileError("First name and last name are required."); + return; + } + + if (!profileData.organization.trim()) { + setProfileError("Organization is required."); + return; + } + + try { + setIsSavingProfile(true); + + const updatedUser = await updateCurrentUser(token, { + first_name: profileData.firstName.trim(), + last_name: profileData.lastName.trim(), + organization_name: profileData.organization.trim(), + }); + + localStorage.setItem("user", JSON.stringify(updatedUser)); + + setProfileSuccess("Profile updated successfully."); + setIsEditingProfile(false); + + window.location.reload(); + } catch (error) { + setProfileError( + error instanceof Error ? error.message : "Failed to update profile.", + ); + } finally { + setIsSavingProfile(false); + } }; const primaryLabel = @@ -125,6 +179,55 @@ export default function AccountPage({ } }; + const handleUpdatePassword = async () => { + setPasswordError(""); + setPasswordSuccess(""); + + if ( + !passwordData.currentPassword || + !passwordData.newPassword || + !passwordData.confirmPassword + ) { + setPasswordError("Please fill in all password fields."); + return; + } + + if (passwordData.currentPassword === passwordData.newPassword) { + setPasswordError( + "New password cannot be the same as the current password.", + ); + return; + } + + if (passwordData.newPassword !== passwordData.confirmPassword) { + setPasswordError("New password and confirm password do not match."); + return; + } + + try { + setIsChangingPassword(true); + + await changePassword(token, { + current_password: passwordData.currentPassword, + new_password: passwordData.newPassword, + }); + + setPasswordSuccess("Password changed successfully."); + + setPasswordData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + } catch (error) { + setPasswordError( + error instanceof Error ? error.message : "Failed to change password.", + ); + } finally { + setIsChangingPassword(false); + } + }; + return (
- Save Changes + {isSavingProfile ? "Saving..." : "Save Changes"}
)} @@ -211,11 +315,9 @@ export default function AccountPage({ Name - {user?.name - ? user.name - : user?.email - ? user.email.split("@")[0].replace(/\./g, " ") - : "Not available"} + {user?.first_name && user?.last_name + ? `${user.first_name} ${user.last_name}` + : "Not available"}
@@ -233,7 +335,7 @@ export default function AccountPage({ Organization - {user?.organization || "AutoAudit"} + {user?.organization_name || "Not available"}
@@ -294,6 +396,17 @@ export default function AccountPage({ className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white outline-none placeholder:text-slate-500 focus:border-cyan-300" />
+ {profileError && ( +

+ {profileError} +

+ )} + + {profileSuccess && ( +

+ {profileSuccess} +

+ )}
)}
@@ -405,12 +518,24 @@ export default function AccountPage({
+ {passwordError && ( +

+ {passwordError} +

+ )} + {passwordSuccess && ( +

+ {passwordSuccess} +

+ )}
From 1d087453af16d4c346538ce9d0edcddfb69097bb Mon Sep 17 00:00:00 2001 From: s225389846 Date: Fri, 8 May 2026 18:48:28 +1000 Subject: [PATCH 3/3] Account page UI and features improved --- frontend/src/pages/AccountPage.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index ae6e9b91..72ec618f 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -46,7 +46,6 @@ export default function AccountPage({ }: AccountPageProps) { const navigate = useNavigate(); const { user, token, logout: clearAuth } = useAuth() as AuthContextValue; - console.log("USER DATA:", user); const [isLoggingOut, setIsLoggingOut] = useState(false); const [showCurrentPassword, setShowCurrentPassword] = useState(false); @@ -158,12 +157,12 @@ export default function AccountPage({ } }; - const primaryLabel = - user?.email || - user?.username || - user?.name || - (user?.id != null ? String(user.id) : null) || - "Signed in"; + // const primaryLabel = + // user?.email || + // user?.username || + // user?.name || + // (user?.id != null ? String(user.id) : null) || + // "Signed in"; const handleLogout = async () => { if (isLoggingOut) return;