From 3ebdf5110584947128c911f553a21d0322c43b32 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 9 Jan 2026 13:14:05 -0600 Subject: [PATCH] Add password change flow and auth error handling - Add ChangePasswordPage component for forced password changes - Add RequirePasswordChange wrapper in App.tsx to redirect users - Add custom error classes (UnauthorizedError, ForbiddenError) in api.ts - Add 401/403 error handling in ProjectPage and PackagePage - Add refreshUser function to AuthContext - Add must_change_password field to User type - Add access denied UI for forbidden resources --- frontend/src/App.tsx | 51 +++++-- frontend/src/api.ts | 48 ++++++- frontend/src/contexts/AuthContext.tsx | 12 +- frontend/src/pages/ChangePasswordPage.tsx | 156 ++++++++++++++++++++++ frontend/src/pages/LoginPage.css | 5 + frontend/src/pages/PackagePage.tsx | 41 +++++- frontend/src/pages/ProjectPage.tsx | 36 ++++- frontend/src/types.ts | 1 + 8 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 frontend/src/pages/ChangePasswordPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a6f583..995f167 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,41 @@ -import { Routes, Route } from 'react-router-dom'; -import { AuthProvider } from './contexts/AuthContext'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import Layout from './components/Layout'; import Home from './pages/Home'; import ProjectPage from './pages/ProjectPage'; import PackagePage from './pages/PackagePage'; import Dashboard from './pages/Dashboard'; import LoginPage from './pages/LoginPage'; +import ChangePasswordPage from './pages/ChangePasswordPage'; import APIKeysPage from './pages/APIKeysPage'; import AdminUsersPage from './pages/AdminUsersPage'; -function App() { +// Component that checks if user must change password +function RequirePasswordChange({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return null; + } + + // If user is logged in and must change password, redirect to change password page + if (user?.must_change_password && location.pathname !== '/change-password') { + return ; + } + + return <>{children}; +} + +function AppRoutes() { return ( - - - } /> - + } /> + } /> + } /> @@ -27,9 +46,17 @@ function App() { } /> - } - /> - + + } + /> + + ); +} + +function App() { + return ( + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9adafe..0a417a2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -33,10 +33,43 @@ import { const API_BASE = '/api/v1'; +// Custom error classes for better error handling +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +export class UnauthorizedError extends ApiError { + constructor(message: string = 'Not authenticated') { + super(message, 401); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends ApiError { + constructor(message: string = 'Access denied') { + super(message, 403); + this.name = 'ForbiddenError'; + } +} + async function handleResponse(response: Response): Promise { if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Unknown error' })); - throw new Error(error.detail || `HTTP ${response.status}`); + const message = error.detail || `HTTP ${response.status}`; + + if (response.status === 401) { + throw new UnauthorizedError(message); + } + if (response.status === 403) { + throw new ForbiddenError(message); + } + throw new ApiError(message, response.status); } return response.json(); } @@ -74,6 +107,19 @@ export async function logout(): Promise { } } +export async function changePassword(currentPassword: string, newPassword: string): Promise { + const response = await fetch(`${API_BASE}/auth/change-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + export async function getCurrentUser(): Promise { try { const response = await fetch(`${API_BASE}/auth/me`, { diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index fe2ae30..b63c4f6 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -8,6 +8,7 @@ interface AuthContextType { error: string | null; login: (username: string, password: string) => Promise; logout: () => Promise; + refreshUser: () => Promise; clearError: () => void; } @@ -71,8 +72,17 @@ export function AuthProvider({ children }: AuthProviderProps) { setError(null); }, []); + const refreshUser = useCallback(async () => { + try { + const currentUser = await getCurrentUser(); + setUser(currentUser); + } catch { + setUser(null); + } + }, []); + return ( - + {children} ); diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx new file mode 100644 index 0000000..3d97953 --- /dev/null +++ b/frontend/src/pages/ChangePasswordPage.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { changePassword } from '../api'; +import './LoginPage.css'; + +function ChangePasswordPage() { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { user, refreshUser } = useAuth(); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!currentPassword || !newPassword || !confirmPassword) { + setError('Please fill in all fields'); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match'); + return; + } + + if (newPassword.length < 8) { + setError('New password must be at least 8 characters'); + return; + } + + if (newPassword === currentPassword) { + setError('New password must be different from current password'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await changePassword(currentPassword, newPassword); + // Refresh user to clear must_change_password flag + await refreshUser(); + navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to change password'); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+
+ + + + + + + + + +
+

Change Password

+ {user?.must_change_password && ( +

+ You must change your password before continuing +

+ )} +
+ + {error && ( +
+ + + + + + {error} +
+ )} + +
+
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + autoComplete="current-password" + autoFocus + disabled={isSubmitting} + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Enter new password (min 8 characters)" + autoComplete="new-password" + disabled={isSubmitting} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + autoComplete="new-password" + disabled={isSubmitting} + /> +
+ + +
+
+ +
+

Artifact storage and management system

+
+
+
+ ); +} + +export default ChangePasswordPage; diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index a422802..bb9902e 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -69,6 +69,11 @@ font-size: 0.875rem; } +.login-subtitle.login-warning { + color: var(--warning); + font-weight: 500; +} + /* Error message */ .login-error { display: flex; diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index dd5e422..80f4f33 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; +import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types'; -import { listTags, getDownloadUrl, getPackage, getMyProjectAccess } from '../api'; +import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; @@ -57,6 +57,7 @@ function CopyButton({ text }: { text: string }) { function PackagePage() { const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { user } = useAuth(); @@ -64,6 +65,7 @@ function PackagePage() { const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [uploadTag, setUploadTag] = useState(''); const [uploadSuccess, setUploadSuccess] = useState(null); const [artifactIdInput, setArtifactIdInput] = useState(''); @@ -98,6 +100,7 @@ function PackagePage() { try { setLoading(true); + setAccessDenied(false); const [pkgData, tagsResult, accessResult] = await Promise.all([ getPackage(projectName, packageName), listTags(projectName, packageName, { page, search, sort, order }), @@ -108,11 +111,21 @@ function PackagePage() { setAccessLevel(accessResult.access_level); setError(null); } catch (err) { + if (err instanceof UnauthorizedError) { + navigate('/login', { state: { from: location.pathname } }); + return; + } + if (err instanceof ForbiddenError) { + setAccessDenied(true); + setError('You do not have access to this package'); + setLoading(false); + return; + } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } - }, [projectName, packageName, page, search, sort, order]); + }, [projectName, packageName, page, search, sort, order, navigate, location.pathname]); useEffect(() => { loadData(); @@ -234,6 +247,28 @@ function PackagePage() { return
Loading...
; } + if (accessDenied) { + return ( +
+ +
+

Access Denied

+

You do not have permission to view this package.

+ {!user && ( +

+ Sign in +

+ )} +
+
+ ); + } + return (
(); const navigate = useNavigate(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); const { user } = useAuth(); @@ -38,6 +39,7 @@ function ProjectPage() { const [packagesData, setPackagesData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [showForm, setShowForm] = useState(false); const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' }); const [creating, setCreating] = useState(false); @@ -75,6 +77,7 @@ function ProjectPage() { try { setLoading(true); + setAccessDenied(false); const [projectData, packagesResult, accessResult] = await Promise.all([ getProject(projectName), listPackages(projectName, { page, search, sort, order, format: format || undefined }), @@ -86,11 +89,21 @@ function ProjectPage() { setIsOwner(accessResult.is_owner); setError(null); } catch (err) { + if (err instanceof UnauthorizedError) { + navigate('/login', { state: { from: location.pathname } }); + return; + } + if (err instanceof ForbiddenError) { + setAccessDenied(true); + setError('You do not have access to this project'); + setLoading(false); + return; + } setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { setLoading(false); } - }, [projectName, page, search, sort, order, format]); + }, [projectName, page, search, sort, order, format, navigate, location.pathname]); useEffect(() => { loadData(); @@ -151,6 +164,23 @@ function ProjectPage() { return
Loading...
; } + if (accessDenied) { + return ( +
+ +
+

Access Denied

+

You do not have permission to view this project.

+ {!user && ( +

+ Sign in +

+ )} +
+
+ ); + } + if (!project) { return
Project not found
; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e020e1a..389601c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -238,6 +238,7 @@ export interface User { username: string; display_name: string | null; is_admin: boolean; + must_change_password?: boolean; } export interface LoginCredentials {