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
This commit is contained in:
@@ -1,22 +1,41 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import ProjectPage from './pages/ProjectPage';
|
import ProjectPage from './pages/ProjectPage';
|
||||||
import PackagePage from './pages/PackagePage';
|
import PackagePage from './pages/PackagePage';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import ChangePasswordPage from './pages/ChangePasswordPage';
|
||||||
import APIKeysPage from './pages/APIKeysPage';
|
import APIKeysPage from './pages/APIKeysPage';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage';
|
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 <Navigate to="/change-password" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
|
<RequirePasswordChange>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
@@ -27,9 +46,17 @@ function App() {
|
|||||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
}
|
</RequirePasswordChange>
|
||||||
/>
|
}
|
||||||
</Routes>
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,43 @@ import {
|
|||||||
|
|
||||||
const API_BASE = '/api/v1';
|
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<T>(response: Response): Promise<T> {
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
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();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -74,6 +107,19 @@ export async function logout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
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<User | null> {
|
export async function getCurrentUser(): Promise<User | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/auth/me`, {
|
const response = await fetch(`${API_BASE}/auth/me`, {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface AuthContextType {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +72,17 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const currentUser = await getCurrentUser();
|
||||||
|
setUser(currentUser);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError }}>
|
<AuthContext.Provider value={{ user, loading, error, login, logout, refreshUser, clearError }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
156
frontend/src/pages/ChangePasswordPage.tsx
Normal file
156
frontend/src/pages/ChangePasswordPage.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-container">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<div className="login-logo">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
||||||
|
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
|
||||||
|
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
|
||||||
|
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
||||||
|
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Change Password</h1>
|
||||||
|
{user?.must_change_password && (
|
||||||
|
<p className="login-subtitle login-warning">
|
||||||
|
You must change your password before continuing
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
<div className="login-form-group">
|
||||||
|
<label htmlFor="currentPassword">Current Password</label>
|
||||||
|
<input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-form-group">
|
||||||
|
<label htmlFor="newPassword">New Password</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password (min 8 characters)"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-form-group">
|
||||||
|
<label htmlFor="confirmPassword">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="login-submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="login-spinner"></span>
|
||||||
|
Changing password...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Change Password'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
<p>Artifact storage and management system</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangePasswordPage;
|
||||||
@@ -69,6 +69,11 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-subtitle.login-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Error message */
|
/* Error message */
|
||||||
.login-error {
|
.login-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { 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 { Breadcrumb } from '../components/Breadcrumb';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { SearchInput } from '../components/SearchInput';
|
import { SearchInput } from '../components/SearchInput';
|
||||||
@@ -57,6 +57,7 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
function PackagePage() {
|
function PackagePage() {
|
||||||
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
|
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ function PackagePage() {
|
|||||||
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
const [uploadTag, setUploadTag] = useState('');
|
const [uploadTag, setUploadTag] = useState('');
|
||||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||||
const [artifactIdInput, setArtifactIdInput] = useState('');
|
const [artifactIdInput, setArtifactIdInput] = useState('');
|
||||||
@@ -98,6 +100,7 @@ function PackagePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAccessDenied(false);
|
||||||
const [pkgData, tagsResult, accessResult] = await Promise.all([
|
const [pkgData, tagsResult, accessResult] = await Promise.all([
|
||||||
getPackage(projectName, packageName),
|
getPackage(projectName, packageName),
|
||||||
listTags(projectName, packageName, { page, search, sort, order }),
|
listTags(projectName, packageName, { page, search, sort, order }),
|
||||||
@@ -108,11 +111,21 @@ function PackagePage() {
|
|||||||
setAccessLevel(accessResult.access_level);
|
setAccessLevel(accessResult.access_level);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} 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');
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectName, packageName, page, search, sort, order]);
|
}, [projectName, packageName, page, search, sort, order, navigate, location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -234,6 +247,28 @@ function PackagePage() {
|
|||||||
return <div className="loading">Loading...</div>;
|
return <div className="loading">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessDenied) {
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: 'Projects', href: '/' },
|
||||||
|
{ label: projectName!, href: `/project/${projectName}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
<p>You do not have permission to view this package.</p>
|
||||||
|
{!user && (
|
||||||
|
<p style={{ marginTop: '16px' }}>
|
||||||
|
<a href="/login" className="btn btn-primary">Sign in</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
|
import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
|
||||||
import { getProject, listPackages, createPackage, getMyProjectAccess } from '../api';
|
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
|
||||||
import { Breadcrumb } from '../components/Breadcrumb';
|
import { Breadcrumb } from '../components/Breadcrumb';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { SearchInput } from '../components/SearchInput';
|
import { SearchInput } from '../components/SearchInput';
|
||||||
@@ -31,6 +31,7 @@ function formatBytes(bytes: number): string {
|
|||||||
function ProjectPage() {
|
function ProjectPage() {
|
||||||
const { projectName } = useParams<{ projectName: string }>();
|
const { projectName } = useParams<{ projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function ProjectPage() {
|
|||||||
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
|
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
|
const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -75,6 +77,7 @@ function ProjectPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAccessDenied(false);
|
||||||
const [projectData, packagesResult, accessResult] = await Promise.all([
|
const [projectData, packagesResult, accessResult] = await Promise.all([
|
||||||
getProject(projectName),
|
getProject(projectName),
|
||||||
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
|
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
|
||||||
@@ -86,11 +89,21 @@ function ProjectPage() {
|
|||||||
setIsOwner(accessResult.is_owner);
|
setIsOwner(accessResult.is_owner);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} 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');
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectName, page, search, sort, order, format]);
|
}, [projectName, page, search, sort, order, format, navigate, location.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -151,6 +164,23 @@ function ProjectPage() {
|
|||||||
return <div className="loading">Loading...</div>;
|
return <div className="loading">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessDenied) {
|
||||||
|
return (
|
||||||
|
<div className="home">
|
||||||
|
<Breadcrumb items={[{ label: 'Projects', href: '/' }]} />
|
||||||
|
<div className="error-message" style={{ textAlign: 'center', padding: '48px 24px' }}>
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
<p>You do not have permission to view this project.</p>
|
||||||
|
{!user && (
|
||||||
|
<p style={{ marginTop: '16px' }}>
|
||||||
|
<a href="/login" className="btn btn-primary">Sign in</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <div className="error-message">Project not found</div>;
|
return <div className="error-message">Project not found</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
must_change_password?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
|
|||||||
Reference in New Issue
Block a user