Add user authentication system with API key management (#50)
- Add User, Session, AuthSettings models with bcrypt password hashing - Add auth endpoints: login, logout, change-password, me - Add API key CRUD: create (orch_xxx format), list, revoke - Add admin user management: list, create, update, reset-password - Create default admin user on startup (admin/admin) - Add frontend: Login page, API Keys page, Admin Users page - Add AuthContext for session state management - Add user menu to Layout header with login/logout/settings - Add 15 integration tests for auth system - Add migration 006_auth_tables.sql
This commit is contained in:
371
frontend/src/pages/APIKeysPage.tsx
Normal file
371
frontend/src/pages/APIKeysPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user