- 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
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
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;
|