Implement authentication system with access control UI

This commit is contained in:
Mondo Diaz
2026-01-12 10:52:35 -06:00
committed by Dane Moss
parent 1cbd335443
commit 617bcbe89c
39 changed files with 8561 additions and 116 deletions

View File

@@ -0,0 +1,580 @@
.api-keys-page {
max-width: 900px;
margin: 0 auto;
}
.api-keys-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
gap: 24px;
}
.api-keys-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.api-keys-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.api-keys-create-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.api-keys-create-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.api-keys-create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.api-keys-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.api-keys-error svg {
flex-shrink: 0;
}
.api-keys-error span {
flex: 1;
}
.api-keys-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.api-keys-error-dismiss:hover {
opacity: 1;
}
.api-keys-new-key-banner {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.08) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.api-keys-new-key-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
color: var(--accent-primary);
}
.api-keys-new-key-title {
font-size: 1rem;
font-weight: 600;
}
.api-keys-new-key-warning {
background: var(--warning-bg);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 16px;
}
.api-keys-new-key-value-container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.api-keys-new-key-value {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 14px 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, monospace;
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
line-height: 1.5;
}
.api-keys-copy-button {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.api-keys-copy-button:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.api-keys-done-button {
padding: 10px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-done-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.api-keys-create-form-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.api-keys-create-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.api-keys-create-form-header h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.api-keys-create-form-close {
background: transparent;
border: none;
padding: 4px;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.api-keys-create-form-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.api-keys-create-error {
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
margin-bottom: 16px;
}
.api-keys-create-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.api-keys-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.api-keys-form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.api-keys-form-group input {
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.api-keys-form-group input::placeholder {
color: var(--text-muted);
}
.api-keys-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.api-keys-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.api-keys-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-keys-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.api-keys-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.api-keys-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.api-keys-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 110px;
}
.api-keys-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.api-keys-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.api-keys-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: api-keys-spin 0.6s linear infinite;
}
@keyframes api-keys-spin {
to {
transform: rotate(360deg);
}
}
.api-keys-list-container {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.api-keys-list-loading,
.api-keys-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.api-keys-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: api-keys-spin 0.6s linear infinite;
}
.api-keys-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
}
.api-keys-empty-icon {
color: var(--text-muted);
margin-bottom: 16px;
opacity: 0.5;
}
.api-keys-empty h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.api-keys-empty p {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.api-keys-list {
display: flex;
flex-direction: column;
}
.api-keys-list-header {
display: grid;
grid-template-columns: 1fr 160px 160px 140px;
gap: 16px;
padding: 14px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.api-keys-list-item {
display: grid;
grid-template-columns: 1fr 160px 160px 140px;
gap: 16px;
padding: 16px 20px;
align-items: center;
border-bottom: 1px solid var(--border-primary);
transition: background var(--transition-fast);
}
.api-keys-list-item:last-child {
border-bottom: none;
}
.api-keys-list-item:hover {
background: var(--bg-tertiary);
}
.api-keys-item-name {
font-weight: 500;
color: var(--text-primary);
font-size: 0.9375rem;
}
.api-keys-item-description {
color: var(--text-tertiary);
font-size: 0.8125rem;
margin-top: 4px;
}
.api-keys-col-created,
.api-keys-col-used {
color: var(--text-secondary);
font-size: 0.8125rem;
}
.api-keys-col-actions {
display: flex;
justify-content: flex-end;
}
.api-keys-revoke-button {
padding: 6px 14px;
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: 500;
color: var(--error);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-revoke-button:hover {
background: var(--error-bg);
border-color: rgba(239, 68, 68, 0.5);
}
.api-keys-delete-confirm {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.api-keys-confirm-yes {
padding: 4px 12px;
background: var(--error);
border: none;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-confirm-yes:hover:not(:disabled) {
opacity: 0.9;
}
.api-keys-confirm-yes:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.api-keys-confirm-no {
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.api-keys-confirm-no:hover:not(:disabled) {
background: var(--bg-hover);
}
.api-keys-confirm-no:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.api-keys-header {
flex-direction: column;
align-items: stretch;
}
.api-keys-create-button {
align-self: flex-start;
}
.api-keys-list-header {
display: none;
}
.api-keys-list-item {
grid-template-columns: 1fr;
gap: 8px;
}
.api-keys-col-name {
order: 1;
}
.api-keys-col-created,
.api-keys-col-used {
font-size: 0.75rem;
}
.api-keys-col-created::before {
content: 'Created: ';
color: var(--text-muted);
}
.api-keys-col-used::before {
content: 'Last used: ';
color: var(--text-muted);
}
.api-keys-col-actions {
justify-content: flex-start;
margin-top: 8px;
}
.api-keys-new-key-value-container {
flex-direction: column;
}
.api-keys-copy-button {
align-self: flex-start;
}
}

View File

@@ -0,0 +1,371 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { listAPIKeys, createAPIKey, deleteAPIKey } from '../api';
import { APIKey, APIKeyCreateResponse } from '../types';
import './APIKeysPage.css';
function APIKeysPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [keys, setKeys] = useState<APIKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createName, setCreateName] = useState('');
const [createDescription, setCreateDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<APIKeyCreateResponse | null>(null);
const [copied, setCopied] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/settings/api-keys' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user) {
loadKeys();
}
}, [user]);
async function loadKeys() {
setLoading(true);
setError(null);
try {
const data = await listAPIKeys();
setKeys(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load API keys');
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!createName.trim()) {
setCreateError('Name is required');
return;
}
setIsCreating(true);
setCreateError(null);
try {
const response = await createAPIKey({
name: createName.trim(),
description: createDescription.trim() || undefined,
});
setNewlyCreatedKey(response);
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
await loadKeys();
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create API key');
} finally {
setIsCreating(false);
}
}
async function handleDelete(id: string) {
setIsDeleting(true);
try {
await deleteAPIKey(id);
setDeleteConfirmId(null);
await loadKeys();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke API key');
} finally {
setIsDeleting(false);
}
}
async function handleCopyKey() {
if (newlyCreatedKey) {
try {
await navigator.clipboard.writeText(newlyCreatedKey.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError('Failed to copy to clipboard');
}
}
}
function handleDismissNewKey() {
setNewlyCreatedKey(null);
setCopied(false);
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
if (authLoading) {
return (
<div className="api-keys-page">
<div className="api-keys-loading">
<div className="api-keys-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="api-keys-page">
<div className="api-keys-header">
<div className="api-keys-header-content">
<h1>API Keys</h1>
<p className="api-keys-subtitle">
Manage API keys for programmatic access to Orchard
</p>
</div>
<button
className="api-keys-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create New Key
</button>
</div>
{error && (
<div className="api-keys-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>
<button onClick={() => setError(null)} className="api-keys-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{newlyCreatedKey && (
<div className="api-keys-new-key-banner">
<div className="api-keys-new-key-header">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span className="api-keys-new-key-title">New API Key Created</span>
</div>
<div className="api-keys-new-key-warning">
Copy this key now! It won't be shown again.
</div>
<div className="api-keys-new-key-value-container">
<code className="api-keys-new-key-value">{newlyCreatedKey.key}</code>
<button
className="api-keys-copy-button"
onClick={handleCopyKey}
title="Copy to clipboard"
>
{copied ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
)}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button className="api-keys-done-button" onClick={handleDismissNewKey}>
Done
</button>
</div>
)}
{showCreateForm && (
<div className="api-keys-create-form-card">
<div className="api-keys-create-form-header">
<h2>Create New API Key</h2>
<button
className="api-keys-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="api-keys-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="api-keys-create-form">
<div className="api-keys-form-group">
<label htmlFor="key-name">Name</label>
<input
id="key-name"
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder="e.g., CI/CD Pipeline, Local Development"
autoFocus
disabled={isCreating}
/>
</div>
<div className="api-keys-form-group">
<label htmlFor="key-description">Description (optional)</label>
<input
id="key-description"
type="text"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="What will this key be used for?"
disabled={isCreating}
/>
</div>
<div className="api-keys-form-actions">
<button
type="button"
className="api-keys-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateName('');
setCreateDescription('');
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="api-keys-submit-button"
disabled={isCreating || !createName.trim()}
>
{isCreating ? (
<>
<span className="api-keys-button-spinner"></span>
Creating...
</>
) : (
'Create Key'
)}
</button>
</div>
</form>
</div>
)}
<div className="api-keys-list-container">
{loading ? (
<div className="api-keys-list-loading">
<div className="api-keys-spinner"></div>
<span>Loading API keys...</span>
</div>
) : keys.length === 0 ? (
<div className="api-keys-empty">
<div className="api-keys-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
</div>
<h3>No API Keys</h3>
<p>Create an API key to access Orchard programmatically</p>
</div>
) : (
<div className="api-keys-list">
<div className="api-keys-list-header">
<span className="api-keys-col-name">Name</span>
<span className="api-keys-col-created">Created</span>
<span className="api-keys-col-used">Last Used</span>
<span className="api-keys-col-actions">Actions</span>
</div>
{keys.map((key) => (
<div key={key.id} className="api-keys-list-item">
<div className="api-keys-col-name">
<div className="api-keys-item-name">{key.name}</div>
{key.description && (
<div className="api-keys-item-description">{key.description}</div>
)}
</div>
<div className="api-keys-col-created">
{formatDate(key.created_at)}
</div>
<div className="api-keys-col-used">
{formatDate(key.last_used)}
</div>
<div className="api-keys-col-actions">
{deleteConfirmId === key.id ? (
<div className="api-keys-delete-confirm">
<span>Revoke?</span>
<button
className="api-keys-confirm-yes"
onClick={() => handleDelete(key.id)}
disabled={isDeleting}
>
{isDeleting ? 'Revoking...' : 'Yes'}
</button>
<button
className="api-keys-confirm-no"
onClick={() => setDeleteConfirmId(null)}
disabled={isDeleting}
>
No
</button>
</div>
) : (
<button
className="api-keys-revoke-button"
onClick={() => setDeleteConfirmId(key.id)}
>
Revoke
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default APIKeysPage;

View File

@@ -0,0 +1,405 @@
.admin-oidc-page {
max-width: 800px;
margin: 0 auto;
}
.admin-oidc-header {
margin-bottom: 32px;
}
.admin-oidc-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.admin-oidc-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-oidc-success {
display: flex;
align-items: center;
gap: 10px;
background: var(--success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
animation: admin-oidc-fade-in 0.2s ease;
}
@keyframes admin-oidc-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.admin-oidc-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.admin-oidc-error svg {
flex-shrink: 0;
}
.admin-oidc-error span {
flex: 1;
}
.admin-oidc-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.admin-oidc-error-dismiss:hover {
opacity: 1;
}
.admin-oidc-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.admin-oidc-access-denied-icon {
color: var(--error);
margin-bottom: 24px;
opacity: 0.8;
}
.admin-oidc-access-denied h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.admin-oidc-access-denied p {
color: var(--text-tertiary);
font-size: 0.9375rem;
max-width: 400px;
}
.admin-oidc-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.admin-oidc-section {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-primary);
}
.admin-oidc-section:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.admin-oidc-section h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.admin-oidc-form-group {
margin-bottom: 16px;
}
.admin-oidc-form-group:last-child {
margin-bottom: 0;
}
.admin-oidc-form-group label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.admin-oidc-form-group input[type="text"],
.admin-oidc-form-group input[type="password"],
.admin-oidc-form-group input[type="url"] {
width: 100%;
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.admin-oidc-form-group input::placeholder {
color: var(--text-muted);
}
.admin-oidc-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.admin-oidc-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.admin-oidc-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-oidc-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.admin-oidc-field-help {
margin-top: 6px;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.admin-oidc-field-help code {
background: var(--bg-tertiary);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.6875rem;
}
.admin-oidc-secret-status {
color: var(--success);
font-weight: 400;
font-size: 0.75rem;
}
.admin-oidc-toggle-group {
margin-bottom: 16px;
}
.admin-oidc-toggle-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
user-select: none;
}
.admin-oidc-toggle-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.admin-oidc-toggle-custom {
width: 44px;
height: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 12px;
transition: all var(--transition-fast);
position: relative;
flex-shrink: 0;
}
.admin-oidc-toggle-custom::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 18px;
height: 18px;
background: var(--text-muted);
border-radius: 50%;
transition: all var(--transition-fast);
}
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom::after {
left: 22px;
background: white;
}
.admin-oidc-toggle-label input[type="checkbox"]:focus + .admin-oidc-toggle-custom {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
.admin-oidc-toggle-label:hover .admin-oidc-toggle-custom {
border-color: var(--accent-primary);
}
.admin-oidc-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-primary);
}
.admin-oidc-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-oidc-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-oidc-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-oidc-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 160px;
}
.admin-oidc-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.admin-oidc-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-oidc-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: admin-oidc-spin 0.6s linear infinite;
}
@keyframes admin-oidc-spin {
to {
transform: rotate(360deg);
}
}
.admin-oidc-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-oidc-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-oidc-spin 0.6s linear infinite;
}
.admin-oidc-info-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 20px 24px;
}
.admin-oidc-info-card h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.admin-oidc-info-card p {
font-size: 0.8125rem;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.admin-oidc-callback-url {
display: block;
background: var(--bg-tertiary);
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
}
@media (max-width: 640px) {
.admin-oidc-form-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { getOIDCConfig, updateOIDCConfig } from '../api';
import { OIDCConfig } from '../types';
import './AdminOIDCPage.css';
function AdminOIDCPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [config, setConfig] = useState<OIDCConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Form state
const [enabled, setEnabled] = useState(false);
const [issuerUrl, setIssuerUrl] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [scopes, setScopes] = useState('openid profile email');
const [autoCreateUsers, setAutoCreateUsers] = useState(true);
const [adminGroup, setAdminGroup] = useState('');
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/oidc' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user && user.is_admin) {
loadConfig();
}
}, [user]);
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000);
return () => clearTimeout(timer);
}
}, [successMessage]);
async function loadConfig() {
setLoading(true);
setError(null);
try {
const data = await getOIDCConfig();
setConfig(data);
setEnabled(data.enabled);
setIssuerUrl(data.issuer_url);
setClientId(data.client_id);
setScopes(data.scopes.join(' '));
setAutoCreateUsers(data.auto_create_users);
setAdminGroup(data.admin_group);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration');
} finally {
setLoading(false);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (enabled && !issuerUrl.trim()) {
setError('Issuer URL is required when OIDC is enabled');
return;
}
if (enabled && !clientId.trim()) {
setError('Client ID is required when OIDC is enabled');
return;
}
setIsSaving(true);
setError(null);
try {
const scopesList = scopes.split(/\s+/).filter(s => s.length > 0);
const updateData: Record<string, unknown> = {
enabled,
issuer_url: issuerUrl.trim(),
client_id: clientId.trim(),
scopes: scopesList,
auto_create_users: autoCreateUsers,
admin_group: adminGroup.trim(),
};
if (clientSecret) {
updateData.client_secret = clientSecret;
}
await updateOIDCConfig(updateData);
setSuccessMessage('OIDC configuration saved successfully');
setClientSecret('');
await loadConfig();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration');
} finally {
setIsSaving(false);
}
}
if (authLoading) {
return (
<div className="admin-oidc-page">
<div className="admin-oidc-loading">
<div className="admin-oidc-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
if (!user.is_admin) {
return (
<div className="admin-oidc-page">
<div className="admin-oidc-access-denied">
<div className="admin-oidc-access-denied-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<h2>Access Denied</h2>
<p>You do not have permission to access this page. Admin privileges are required.</p>
</div>
</div>
);
}
return (
<div className="admin-oidc-page">
<div className="admin-oidc-header">
<div className="admin-oidc-header-content">
<h1>Single Sign-On (OIDC)</h1>
<p className="admin-oidc-subtitle">
Configure OpenID Connect for SSO authentication
</p>
</div>
</div>
{successMessage && (
<div className="admin-oidc-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>{successMessage}</span>
</div>
)}
{error && (
<div className="admin-oidc-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>
<button onClick={() => setError(null)} className="admin-oidc-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{loading ? (
<div className="admin-oidc-card">
<div className="admin-oidc-loading">
<div className="admin-oidc-spinner"></div>
<span>Loading configuration...</span>
</div>
</div>
) : (
<form onSubmit={handleSave} className="admin-oidc-card">
<div className="admin-oidc-section">
<h2>Status</h2>
<div className="admin-oidc-toggle-group">
<label className="admin-oidc-toggle-label">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={isSaving}
/>
<span className="admin-oidc-toggle-custom"></span>
Enable OIDC Authentication
</label>
<p className="admin-oidc-field-help">
When enabled, users can sign in using your organization's identity provider.
</p>
</div>
</div>
<div className="admin-oidc-section">
<h2>Provider Configuration</h2>
<div className="admin-oidc-form-group">
<label htmlFor="issuer-url">Issuer URL</label>
<input
id="issuer-url"
type="url"
value={issuerUrl}
onChange={(e) => setIssuerUrl(e.target.value)}
placeholder="https://your-provider.com"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
The base URL of your OIDC provider. Discovery document will be fetched from <code>/.well-known/openid-configuration</code>.
</p>
</div>
<div className="admin-oidc-form-row">
<div className="admin-oidc-form-group">
<label htmlFor="client-id">Client ID</label>
<input
id="client-id"
type="text"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="your-client-id"
disabled={isSaving}
/>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="client-secret">
Client Secret
{config?.has_client_secret && (
<span className="admin-oidc-secret-status"> (configured)</span>
)}
</label>
<input
id="client-secret"
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'}
disabled={isSaving}
/>
</div>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="scopes">Scopes</label>
<input
id="scopes"
type="text"
value={scopes}
onChange={(e) => setScopes(e.target.value)}
placeholder="openid profile email"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups.
</p>
</div>
</div>
<div className="admin-oidc-section">
<h2>User Provisioning</h2>
<div className="admin-oidc-toggle-group">
<label className="admin-oidc-toggle-label">
<input
type="checkbox"
checked={autoCreateUsers}
onChange={(e) => setAutoCreateUsers(e.target.checked)}
disabled={isSaving}
/>
<span className="admin-oidc-toggle-custom"></span>
Auto-create users on first login
</label>
<p className="admin-oidc-field-help">
When enabled, new users will be created automatically when they sign in via OIDC for the first time.
</p>
</div>
<div className="admin-oidc-form-group">
<label htmlFor="admin-group">Admin Group (optional)</label>
<input
id="admin-group"
type="text"
value={adminGroup}
onChange={(e) => setAdminGroup(e.target.value)}
placeholder="admin, orchard-admins"
disabled={isSaving}
/>
<p className="admin-oidc-field-help">
Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment.
</p>
</div>
</div>
<div className="admin-oidc-form-actions">
<button
type="button"
className="admin-oidc-cancel-button"
onClick={loadConfig}
disabled={isSaving}
>
Reset
</button>
<button
type="submit"
className="admin-oidc-submit-button"
disabled={isSaving}
>
{isSaving ? (
<>
<span className="admin-oidc-button-spinner"></span>
Saving...
</>
) : (
'Save Configuration'
)}
</button>
</div>
</form>
)}
<div className="admin-oidc-info-card">
<h3>Callback URL</h3>
<p>Configure your identity provider with the following callback URL:</p>
<code className="admin-oidc-callback-url">
{window.location.origin}/api/v1/auth/oidc/callback
</code>
</div>
</div>
);
}
export default AdminOIDCPage;

View File

@@ -0,0 +1,667 @@
.admin-users-page {
max-width: 1100px;
margin: 0 auto;
}
.admin-users-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
gap: 24px;
}
.admin-users-header-content h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.admin-users-subtitle {
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-users-create-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.admin-users-create-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.admin-users-create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-users-success {
display: flex;
align-items: center;
gap: 10px;
background: var(--success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
animation: admin-users-fade-in 0.2s ease;
}
@keyframes admin-users-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.admin-users-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.admin-users-error svg {
flex-shrink: 0;
}
.admin-users-error span {
flex: 1;
}
.admin-users-error-dismiss {
background: transparent;
border: none;
padding: 4px;
color: var(--error);
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.admin-users-error-dismiss:hover {
opacity: 1;
}
.admin-users-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
}
.admin-users-access-denied-icon {
color: var(--error);
margin-bottom: 24px;
opacity: 0.8;
}
.admin-users-access-denied h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.admin-users-access-denied p {
color: var(--text-tertiary);
font-size: 0.9375rem;
max-width: 400px;
}
.admin-users-create-form-card,
.admin-users-reset-password-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.admin-users-create-form-header,
.admin-users-reset-password-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.admin-users-create-form-header h2,
.admin-users-reset-password-header h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.admin-users-create-form-close {
background: transparent;
border: none;
padding: 4px;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.admin-users-create-form-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.admin-users-reset-password-info {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 16px;
}
.admin-users-reset-password-info strong {
color: var(--text-primary);
}
.admin-users-create-error {
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 0.8125rem;
margin-bottom: 16px;
}
.admin-users-create-form,
.admin-users-reset-password-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-users-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-users-form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.admin-users-form-group input[type="text"],
.admin-users-form-group input[type="password"],
.admin-users-form-group input[type="email"] {
padding: 12px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.admin-users-form-group input::placeholder {
color: var(--text-muted);
}
.admin-users-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.admin-users-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.admin-users-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-users-checkbox-group {
flex-direction: row;
align-items: center;
}
.admin-users-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 400;
color: var(--text-secondary);
user-select: none;
}
.admin-users-checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.admin-users-checkbox-custom {
width: 18px;
height: 18px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
position: relative;
}
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.admin-users-checkbox-label input[type="checkbox"]:checked + .admin-users-checkbox-custom::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.admin-users-checkbox-label input[type="checkbox"]:focus + .admin-users-checkbox-custom {
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
.admin-users-checkbox-label:hover .admin-users-checkbox-custom {
border-color: var(--accent-primary);
}
.admin-users-form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.admin-users-cancel-button {
padding: 10px 18px;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-users-cancel-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-users-cancel-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-users-submit-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
min-width: 120px;
}
.admin-users-submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.admin-users-submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.admin-users-button-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
@keyframes admin-users-spin {
to {
transform: rotate(360deg);
}
}
.admin-users-list-container {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.admin-users-list-loading,
.admin-users-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 64px 24px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.admin-users-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
.admin-users-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
}
.admin-users-empty-icon {
color: var(--text-muted);
margin-bottom: 16px;
opacity: 0.5;
}
.admin-users-empty h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.admin-users-empty p {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.admin-users-list {
display: flex;
flex-direction: column;
}
.admin-users-list-header {
display: grid;
grid-template-columns: 2fr 100px 140px 140px 1fr;
gap: 16px;
padding: 14px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-users-list-item {
display: grid;
grid-template-columns: 2fr 100px 140px 140px 1fr;
gap: 16px;
padding: 16px 20px;
align-items: center;
border-bottom: 1px solid var(--border-primary);
transition: background var(--transition-fast);
}
.admin-users-list-item:last-child {
border-bottom: none;
}
.admin-users-list-item:hover {
background: var(--bg-tertiary);
}
.admin-users-list-item.admin-users-inactive {
opacity: 0.6;
}
.admin-users-col-user {
display: flex;
align-items: center;
gap: 12px;
}
.admin-users-item-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.admin-users-item-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.admin-users-item-username {
font-weight: 500;
color: var(--text-primary);
font-size: 0.9375rem;
display: flex;
align-items: center;
gap: 8px;
}
.admin-users-admin-badge {
display: inline-flex;
padding: 2px 8px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.1) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20px;
font-size: 0.6875rem;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-users-item-email {
color: var(--text-tertiary);
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-users-col-status {
display: flex;
align-items: center;
}
.admin-users-status-badge {
display: inline-flex;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.admin-users-status-badge.active {
background: var(--success-bg);
color: var(--success);
}
.admin-users-status-badge.inactive {
background: var(--error-bg);
color: var(--error);
}
.admin-users-col-created,
.admin-users-col-login {
color: var(--text-secondary);
font-size: 0.8125rem;
}
.admin-users-col-actions {
display: flex;
justify-content: flex-end;
}
.admin-users-actions-menu {
display: flex;
gap: 6px;
}
.admin-users-action-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.admin-users-action-button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-secondary);
color: var(--text-primary);
}
.admin-users-action-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.admin-users-action-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border-secondary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: admin-users-spin 0.6s linear infinite;
}
@media (max-width: 1024px) {
.admin-users-list-header {
grid-template-columns: 2fr 100px 1fr;
}
.admin-users-list-item {
grid-template-columns: 2fr 100px 1fr;
}
.admin-users-col-created,
.admin-users-col-login {
display: none;
}
.admin-users-list-header .admin-users-col-created,
.admin-users-list-header .admin-users-col-login {
display: none;
}
}
@media (max-width: 768px) {
.admin-users-header {
flex-direction: column;
align-items: stretch;
}
.admin-users-create-button {
align-self: flex-start;
}
.admin-users-list-header {
display: none;
}
.admin-users-list-item {
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.admin-users-col-user {
order: 1;
}
.admin-users-col-status {
order: 2;
}
.admin-users-col-actions {
order: 3;
justify-content: flex-start;
}
.admin-users-actions-menu {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,529 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { listUsers, createUser, updateUser, resetUserPassword } from '../api';
import { AdminUser } from '../types';
import './AdminUsersPage.css';
function AdminUsersPage() {
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createEmail, setCreateEmail] = useState('');
const [createIsAdmin, setCreateIsAdmin] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [resetPasswordUsername, setResetPasswordUsername] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [isResetting, setIsResetting] = useState(false);
const [togglingUser, setTogglingUser] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !user) {
navigate('/login', { state: { from: '/admin/users' } });
}
}, [user, authLoading, navigate]);
useEffect(() => {
if (user && user.is_admin) {
loadUsers();
}
}, [user]);
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => setSuccessMessage(null), 3000);
return () => clearTimeout(timer);
}
}, [successMessage]);
async function loadUsers() {
setLoading(true);
setError(null);
try {
const data = await listUsers();
setUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!createUsername.trim()) {
setCreateError('Username is required');
return;
}
if (!createPassword.trim()) {
setCreateError('Password is required');
return;
}
setIsCreating(true);
setCreateError(null);
try {
await createUser({
username: createUsername.trim(),
password: createPassword,
email: createEmail.trim() || undefined,
is_admin: createIsAdmin,
});
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setSuccessMessage('User created successfully');
await loadUsers();
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setIsCreating(false);
}
}
async function handleToggleAdmin(targetUser: AdminUser) {
setTogglingUser(targetUser.username);
try {
await updateUser(targetUser.username, { is_admin: !targetUser.is_admin });
setSuccessMessage(`${targetUser.username} is ${!targetUser.is_admin ? 'now' : 'no longer'} an admin`);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
} finally {
setTogglingUser(null);
}
}
async function handleToggleActive(targetUser: AdminUser) {
setTogglingUser(targetUser.username);
try {
await updateUser(targetUser.username, { is_active: !targetUser.is_active });
setSuccessMessage(`${targetUser.username} has been ${!targetUser.is_active ? 'enabled' : 'disabled'}`);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
} finally {
setTogglingUser(null);
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault();
if (!resetPasswordUsername || !newPassword.trim()) {
return;
}
setIsResetting(true);
try {
await resetUserPassword(resetPasswordUsername, newPassword);
setResetPasswordUsername(null);
setNewPassword('');
setSuccessMessage(`Password reset for ${resetPasswordUsername}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
} finally {
setIsResetting(false);
}
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
if (authLoading) {
return (
<div className="admin-users-page">
<div className="admin-users-loading">
<div className="admin-users-spinner"></div>
<span>Loading...</span>
</div>
</div>
);
}
if (!user) {
return null;
}
if (!user.is_admin) {
return (
<div className="admin-users-page">
<div className="admin-users-access-denied">
<div className="admin-users-access-denied-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<h2>Access Denied</h2>
<p>You do not have permission to access this page. Admin privileges are required.</p>
</div>
</div>
);
}
return (
<div className="admin-users-page">
<div className="admin-users-header">
<div className="admin-users-header-content">
<h1>User Management</h1>
<p className="admin-users-subtitle">
Manage user accounts and permissions
</p>
</div>
<button
className="admin-users-create-button"
onClick={() => setShowCreateForm(true)}
disabled={showCreateForm}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create User
</button>
</div>
{successMessage && (
<div className="admin-users-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>{successMessage}</span>
</div>
)}
{error && (
<div className="admin-users-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>
<button onClick={() => setError(null)} className="admin-users-error-dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{showCreateForm && (
<div className="admin-users-create-form-card">
<div className="admin-users-create-form-header">
<h2>Create New User</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{createError && (
<div className="admin-users-create-error">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="admin-users-create-form">
<div className="admin-users-form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
placeholder="Enter username"
autoFocus
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder="Enter password"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group">
<label htmlFor="email">Email (optional)</label>
<input
id="email"
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
placeholder="user@example.com"
disabled={isCreating}
/>
</div>
<div className="admin-users-form-group admin-users-checkbox-group">
<label className="admin-users-checkbox-label">
<input
type="checkbox"
checked={createIsAdmin}
onChange={(e) => setCreateIsAdmin(e.target.checked)}
disabled={isCreating}
/>
<span className="admin-users-checkbox-custom"></span>
Grant admin privileges
</label>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreatePassword('');
setCreateEmail('');
setCreateIsAdmin(false);
setCreateError(null);
}}
disabled={isCreating}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isCreating || !createUsername.trim() || !createPassword.trim()}
>
{isCreating ? (
<>
<span className="admin-users-button-spinner"></span>
Creating...
</>
) : (
'Create User'
)}
</button>
</div>
</form>
</div>
)}
{resetPasswordUsername && (
<div className="admin-users-reset-password-card">
<div className="admin-users-reset-password-header">
<h2>Reset Password</h2>
<button
className="admin-users-create-form-close"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p className="admin-users-reset-password-info">
Set a new password for <strong>{resetPasswordUsername}</strong>
</p>
<form onSubmit={handleResetPassword} className="admin-users-reset-password-form">
<div className="admin-users-form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
autoFocus
disabled={isResetting}
/>
</div>
<div className="admin-users-form-actions">
<button
type="button"
className="admin-users-cancel-button"
onClick={() => {
setResetPasswordUsername(null);
setNewPassword('');
}}
disabled={isResetting}
>
Cancel
</button>
<button
type="submit"
className="admin-users-submit-button"
disabled={isResetting || !newPassword.trim()}
>
{isResetting ? (
<>
<span className="admin-users-button-spinner"></span>
Resetting...
</>
) : (
'Reset Password'
)}
</button>
</div>
</form>
</div>
)}
<div className="admin-users-list-container">
{loading ? (
<div className="admin-users-list-loading">
<div className="admin-users-spinner"></div>
<span>Loading users...</span>
</div>
) : users.length === 0 ? (
<div className="admin-users-empty">
<div className="admin-users-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h3>No Users</h3>
<p>Create a user to get started</p>
</div>
) : (
<div className="admin-users-list">
<div className="admin-users-list-header">
<span className="admin-users-col-user">User</span>
<span className="admin-users-col-status">Status</span>
<span className="admin-users-col-created">Created</span>
<span className="admin-users-col-login">Last Login</span>
<span className="admin-users-col-actions">Actions</span>
</div>
{users.map((u) => (
<div key={u.id} className={`admin-users-list-item ${!u.is_active ? 'admin-users-inactive' : ''}`}>
<div className="admin-users-col-user">
<div className="admin-users-item-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="admin-users-item-info">
<div className="admin-users-item-username">
{u.username}
{u.is_admin && <span className="admin-users-admin-badge">Admin</span>}
</div>
{u.email && (
<div className="admin-users-item-email">{u.email}</div>
)}
</div>
</div>
<div className="admin-users-col-status">
<span className={`admin-users-status-badge ${u.is_active ? 'active' : 'inactive'}`}>
{u.is_active ? 'Active' : 'Disabled'}
</span>
</div>
<div className="admin-users-col-created">
{formatDate(u.created_at)}
</div>
<div className="admin-users-col-login">
{formatDate(u.last_login)}
</div>
<div className="admin-users-col-actions">
<div className="admin-users-actions-menu">
<button
className="admin-users-action-button"
onClick={() => handleToggleAdmin(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_admin ? 'Remove admin' : 'Make admin'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
)}
{u.is_admin ? 'Revoke' : 'Admin'}
</button>
<button
className="admin-users-action-button"
onClick={() => handleToggleActive(u)}
disabled={togglingUser === u.username || u.username === user.username}
title={u.is_active ? 'Disable user' : 'Enable user'}
>
{togglingUser === u.username ? (
<span className="admin-users-action-spinner"></span>
) : u.is_active ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
)}
{u.is_active ? 'Disable' : 'Enable'}
</button>
<button
className="admin-users-action-button"
onClick={() => setResetPasswordUsername(u.username)}
disabled={togglingUser === u.username}
title="Reset password"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Reset
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default AdminUsersPage;

View 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;

View File

@@ -474,3 +474,16 @@
margin-top: 4px;
font-size: 0.9375rem;
}
/* Lock icon for private projects */
.lock-icon {
color: var(--warning);
flex-shrink: 0;
}
/* Project badges container */
.project-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}

View File

@@ -7,8 +7,19 @@ import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterDropdown, FilterOption } from '../components/FilterDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import { useAuth } from '../contexts/AuthContext';
import './Home.css';
// Lock icon SVG component
function LockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lock-icon">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
@@ -23,6 +34,7 @@ const VISIBILITY_OPTIONS: FilterOption[] = [
function Home() {
const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true);
@@ -117,9 +129,15 @@ function Home() {
<div className="home">
<div className="page-header">
<h1>Projects</h1>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Project'}
</button>
{user ? (
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Project'}
</button>
) : (
<Link to="/login" className="btn btn-secondary">
Login to create projects
</Link>
)}
</div>
{error && <div className="error-message">{error}</div>}
@@ -199,12 +217,32 @@ function Home() {
<div className="project-grid">
{projects.map((project) => (
<Link to={`/project/${project.name}`} key={project.id} className="project-card card">
<h3>{project.name}</h3>
<h3>
{!project.is_public && <LockIcon />}
{project.name}
</h3>
{project.description && <p>{project.description}</p>}
<div className="project-meta">
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
<div className="project-badges">
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
{user && project.access_level && (
<Badge
variant={
project.is_owner
? 'success'
: project.access_level === 'admin'
? 'success'
: project.access_level === 'write'
? 'info'
: 'default'
}
>
{project.is_owner ? 'Owner' : project.access_level.charAt(0).toUpperCase() + project.access_level.slice(1)}
</Badge>
)}
</div>
<div className="project-meta__dates">
<span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
{project.updated_at !== project.created_at && (

View File

@@ -0,0 +1,292 @@
/* Login Page - Full viewport centered layout */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
padding: 24px;
position: relative;
overflow: hidden;
}
/* Subtle background pattern */
.login-page::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 50%, rgba(16, 185, 129, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.login-container {
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
}
/* Card styling */
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: 40px;
box-shadow: var(--shadow-lg);
}
/* Header section */
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: var(--accent-gradient);
border-radius: var(--radius-lg);
color: white;
margin-bottom: 24px;
box-shadow: var(--shadow-glow);
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.login-subtitle {
color: var(--text-tertiary);
font-size: 0.875rem;
}
.login-subtitle.login-warning {
color: var(--warning);
font-weight: 500;
}
/* Error message */
.login-error {
display: flex;
align-items: center;
gap: 10px;
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 24px;
font-size: 0.875rem;
}
.login-error svg {
flex-shrink: 0;
}
/* Form styling */
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.login-form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.login-form-group label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.login-form-group input {
width: 100%;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.login-form-group input::placeholder {
color: var(--text-muted);
}
.login-form-group input:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-elevated);
}
.login-form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.login-form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Submit button */
.login-submit {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 20px;
background: var(--accent-gradient);
border: none;
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.login-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.login-submit:active:not(:disabled) {
transform: translateY(0);
}
.login-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
/* Loading spinner */
.login-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Loading state */
.login-loading {
text-align: center;
padding: 64px 32px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
/* Footer */
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 24px;
}
.login-footer p {
color: var(--text-muted);
font-size: 0.8125rem;
}
/* SSO Divider */
.login-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 24px 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-primary);
}
.login-divider span {
font-size: 0.8125rem;
color: var(--text-muted);
text-transform: lowercase;
}
/* SSO Button */
.login-sso-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 20px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.login-sso-button:hover {
background: var(--bg-hover);
border-color: var(--border-secondary);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.login-sso-button:active {
transform: translateY(0);
}
.login-sso-button svg {
color: var(--accent-primary);
}
/* Responsive adjustments */
@media (max-width: 480px) {
.login-card {
padding: 32px 24px;
}
.login-logo {
width: 64px;
height: 64px;
}
.login-logo svg {
width: 36px;
height: 36px;
}
.login-header h1 {
font-size: 1.25rem;
}
}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { getOIDCStatus, getOIDCLoginUrl } from '../api';
import { OIDCStatus } from '../types';
import './LoginPage.css';
function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [oidcStatus, setOidcStatus] = useState<OIDCStatus | null>(null);
const [searchParams] = useSearchParams();
const { user, login, loading: authLoading, refreshUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Get the return URL from location state, default to home
const from = (location.state as { from?: string })?.from || '/';
// Load OIDC status on mount
useEffect(() => {
getOIDCStatus()
.then(setOidcStatus)
.catch(() => setOidcStatus({ enabled: false }));
}, []);
// Handle SSO callback - check for oidc_success or oidc_error params
useEffect(() => {
const oidcSuccess = searchParams.get('oidc_success');
const oidcError = searchParams.get('oidc_error');
if (oidcSuccess === 'true') {
refreshUser().then(() => {
navigate(from, { replace: true });
});
} else if (oidcError) {
setError(decodeURIComponent(oidcError));
}
}, [searchParams, refreshUser, navigate, from]);
// Redirect if already logged in
useEffect(() => {
if (user && !authLoading) {
navigate(from, { replace: true });
}
}, [user, authLoading, navigate, from]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!username.trim() || !password) {
setError('Please enter both username and password');
return;
}
setIsSubmitting(true);
setError(null);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
} finally {
setIsSubmitting(false);
}
}
// Show loading while checking auth state
if (authLoading) {
return (
<div className="login-page">
<div className="login-container">
<div className="login-loading">Checking session...</div>
</div>
</div>
);
}
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>Sign in to Orchard</h1>
<p className="login-subtitle">Content-Addressable Storage</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="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
autoComplete="username"
autoFocus
disabled={isSubmitting}
/>
</div>
<div className="login-form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password"
disabled={isSubmitting}
/>
</div>
<button
type="submit"
className="login-submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span className="login-spinner"></span>
Signing in...
</>
) : (
'Sign in'
)}
</button>
</form>
{oidcStatus?.enabled && (
<>
<div className="login-divider">
<span>or</span>
</div>
<a
href={getOIDCLoginUrl(from !== '/' ? from : undefined)}
className="login-sso-button"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Sign in with SSO
</a>
</>
)}
</div>
<div className="login-footer">
<p>Artifact storage and management system</p>
</div>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse } from '../types';
import { listTags, getDownloadUrl, getPackage } from '../api';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
@@ -10,6 +10,7 @@ import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination';
import { DragDropUpload, UploadResult } from '../components/DragDropUpload';
import { useAuth } from '../contexts/AuthContext';
import './Home.css';
import './PackagePage.css';
@@ -56,15 +57,22 @@ 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();
const [pkg, setPkg] = useState<Package | null>(null);
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState('');
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
// Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
// Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10);
@@ -92,19 +100,32 @@ function PackagePage() {
try {
setLoading(true);
const [pkgData, tagsResult] = await Promise.all([
setAccessDenied(false);
const [pkgData, tagsResult, accessResult] = await Promise.all([
getPackage(projectName, packageName),
listTags(projectName, packageName, { page, search, sort, order }),
getMyProjectAccess(projectName),
]);
setPkg(pkgData);
setTagsData(tagsResult);
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();
@@ -226,6 +247,28 @@ function PackagePage() {
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 (
<div className="home">
<Breadcrumb
@@ -286,28 +329,41 @@ function PackagePage() {
{error && <div className="error-message">{error}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
<div className="upload-section card">
<h3>Upload Artifact</h3>
<div className="upload-form">
<div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="upload-tag"
type="text"
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
{user && (
<div className="upload-section card">
<h3>Upload Artifact</h3>
{canWrite ? (
<div className="upload-form">
<div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="upload-tag"
type="text"
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
) : (
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
disabled={true}
disabledReason="You have read-only access to this project and cannot upload artifacts."
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
)}
</div>
</div>
)}
<div className="section-header">
<h2>Tags / Versions</h2>

View File

@@ -1,13 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Project, Package, PaginatedResponse } from '../types';
import { getProject, listPackages, createPackage } from '../api';
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext';
import './Home.css';
const SORT_OPTIONS: SortOption[] = [
@@ -29,15 +31,24 @@ function formatBytes(bytes: number): string {
function ProjectPage() {
const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
const [project, setProject] = useState<Project | null>(null);
const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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);
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [isOwner, setIsOwner] = useState(false);
// Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin';
const canAdmin = accessLevel === 'admin';
// Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10);
@@ -66,19 +77,33 @@ function ProjectPage() {
try {
setLoading(true);
const [projectData, packagesResult] = await Promise.all([
setAccessDenied(false);
const [projectData, packagesResult, accessResult] = await Promise.all([
getProject(projectName),
listPackages(projectName, { page, search, sort, order, format: format || undefined }),
getMyProjectAccess(projectName),
]);
setProject(projectData);
setPackagesData(packagesResult);
setAccessLevel(accessResult.access_level);
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();
@@ -139,6 +164,23 @@ function ProjectPage() {
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) {
return <div className="error-message">Project not found</div>;
}
@@ -159,6 +201,11 @@ function ProjectPage() {
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
{accessLevel && (
<Badge variant={accessLevel === 'admin' ? 'success' : accessLevel === 'write' ? 'info' : 'default'}>
{isOwner ? 'Owner' : accessLevel.charAt(0).toUpperCase() + accessLevel.slice(1)}
</Badge>
)}
</div>
{project.description && <p className="description">{project.description}</p>}
<div className="page-header__meta">
@@ -169,14 +216,20 @@ function ProjectPage() {
<span className="meta-item">by {project.created_by}</span>
</div>
</div>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'}
</button>
{canWrite ? (
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'}
</button>
) : user ? (
<span className="text-muted" title="You have read-only access to this project">
Read-only access
</span>
) : null}
</div>
{error && <div className="error-message">{error}</div>}
{showForm && (
{showForm && canWrite && (
<form className="form card" onSubmit={handleCreatePackage}>
<h3>Create New Package</h3>
<div className="form-row">
@@ -316,6 +369,10 @@ function ProjectPage() {
)}
</>
)}
{canAdmin && projectName && (
<AccessManagement projectName={projectName} />
)}
</div>
);
}