release: v0.5.0 - Authentication, API Keys & Advanced Features
Some checks failed
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled

Complete v0.5.0 implementation:

Database (@db-engineer):
- 3 migrations: users, api_keys, report_schedules tables
- Foreign keys, indexes, constraints, enums

Backend (@backend-dev):
- JWT authentication service with bcrypt (cost=12)
- Auth endpoints: /register, /login, /refresh, /me
- API Keys service with hash storage and prefix validation
- API Keys endpoints: CRUD + rotate
- Security module with JWT HS256

Frontend (@frontend-dev):
- Login/Register pages with validation
- AuthContext with localStorage persistence
- Protected routes implementation
- API Keys management UI (create, revoke, rotate)
- Header with user dropdown

DevOps (@devops-engineer):
- .env.example and .env.production.example
- docker-compose.scheduler.yml
- scripts/setup-secrets.sh
- INFRASTRUCTURE_SETUP.md

QA (@qa-engineer):
- 85 E2E tests: auth.spec.ts, apikeys.spec.ts, scenarios.spec.ts, regression-v050.spec.ts
- auth-helpers.ts with 20+ utility functions
- Test plans and documentation

Architecture (@spec-architect):
- SECURITY.md with best practices
- SECURITY-CHECKLIST.md pre-deployment
- Updated architecture.md with auth flows
- Updated README.md with v0.5.0 features

Documentation:
- Updated todo.md with v0.5.0 status
- Added docs/README.md index
- Complete setup instructions

Dependencies added:
- bcrypt, python-jose, passlib, email-validator

Tested: JWT auth flow, API keys CRUD, protected routes, 85 E2E tests ready

Closes: v0.5.0 milestone
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 19:22:47 +02:00
parent 9b9297b7dc
commit cc60ba17ea
49 changed files with 9847 additions and 176 deletions

View File

@@ -0,0 +1,466 @@
import { useState, useEffect } from 'react';
import api from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { showToast } from '@/components/ui/toast-utils';
import { Key, Copy, Trash2, RefreshCw, Plus, Loader2, AlertTriangle, Check } from 'lucide-react';
interface ApiKey {
id: string;
name: string;
key_prefix: string;
scopes: string[];
created_at: string;
expires_at: string | null;
last_used_at: string | null;
is_active: boolean;
}
interface CreateKeyResponse {
id: string;
name: string;
key: string;
prefix: string;
scopes: string[];
created_at: string;
}
const AVAILABLE_SCOPES = [
{ value: 'read:scenarios', label: 'Read Scenarios' },
{ value: 'write:scenarios', label: 'Write Scenarios' },
{ value: 'read:reports', label: 'Read Reports' },
{ value: 'write:reports', label: 'Write Reports' },
{ value: 'read:metrics', label: 'Read Metrics' },
{ value: 'admin', label: 'Admin (Full Access)' },
];
const EXPIRATION_OPTIONS = [
{ value: '7', label: '7 days' },
{ value: '30', label: '30 days' },
{ value: '90', label: '90 days' },
{ value: '365', label: '365 days' },
{ value: 'never', label: 'Never' },
];
export function ApiKeys() {
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
// Create form state
const [newKeyName, setNewKeyName] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>(['read:scenarios']);
const [expirationDays, setExpirationDays] = useState('30');
// New key modal state
const [newKeyData, setNewKeyData] = useState<CreateKeyResponse | null>(null);
const [copied, setCopied] = useState(false);
// Revoke confirmation
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
useEffect(() => {
fetchApiKeys();
}, []);
const fetchApiKeys = async () => {
try {
const response = await api.get('/api-keys');
setApiKeys(response.data);
} catch (error) {
showToast({
title: 'Error',
description: 'Failed to load API keys',
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
setIsCreating(true);
try {
const expiresDays = expirationDays === 'never' ? null : parseInt(expirationDays);
const response = await api.post('/api-keys', {
name: newKeyName,
scopes: selectedScopes,
expires_days: expiresDays,
});
setNewKeyData(response.data);
setShowCreateForm(false);
setNewKeyName('');
setSelectedScopes(['read:scenarios']);
setExpirationDays('30');
fetchApiKeys();
showToast({
title: 'API Key Created',
description: 'Copy your key now - you won\'t see it again!'
});
} catch (error: any) {
showToast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to create API key',
variant: 'destructive'
});
} finally {
setIsCreating(false);
}
};
const handleRevokeKey = async () => {
if (!keyToRevoke) return;
try {
await api.delete(`/api-keys/${keyToRevoke.id}`);
setApiKeys(apiKeys.filter(k => k.id !== keyToRevoke.id));
setKeyToRevoke(null);
showToast({
title: 'API Key Revoked',
description: 'The key has been revoked successfully'
});
} catch (error) {
showToast({
title: 'Error',
description: 'Failed to revoke API key',
variant: 'destructive'
});
}
};
const handleRotateKey = async (keyId: string) => {
try {
const response = await api.post(`/api-keys/${keyId}/rotate`);
setNewKeyData(response.data);
fetchApiKeys();
showToast({
title: 'API Key Rotated',
description: 'New key generated - copy it now!'
});
} catch (error) {
showToast({
title: 'Error',
description: 'Failed to rotate API key',
variant: 'destructive'
});
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
showToast({
title: 'Copied!',
description: 'API key copied to clipboard'
});
} catch {
showToast({
title: 'Error',
description: 'Failed to copy to clipboard',
variant: 'destructive'
});
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString();
};
const toggleScope = (scope: string) => {
setSelectedScopes(prev =>
prev.includes(scope)
? prev.filter(s => s !== scope)
: [...prev, scope]
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">API Keys</h1>
<p className="text-muted-foreground">
Manage API keys for programmatic access
</p>
</div>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus className="mr-2 h-4 w-4" />
Create New Key
</Button>
</div>
{/* Create New Key Form */}
{showCreateForm && (
<Card>
<CardHeader>
<CardTitle>Create New API Key</CardTitle>
<CardDescription>
Generate a new API key for programmatic access to the API
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateKey} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="keyName">Key Name</Label>
<Input
id="keyName"
placeholder="e.g., Production Key, Development"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_SCOPES.map((scope) => (
<div key={scope.value} className="flex items-center space-x-2">
<Checkbox
id={scope.value}
checked={selectedScopes.includes(scope.value)}
onCheckedChange={() => toggleScope(scope.value)}
/>
<Label htmlFor={scope.value} className="text-sm font-normal cursor-pointer">
{scope.label}
</Label>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="expiration">Expiration</Label>
<Select
id="expiration"
value={expirationDays}
onChange={(e) => setExpirationDays(e.target.value)}
>
{EXPIRATION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create Key'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* API Keys Table */}
<Card>
<CardHeader>
<CardTitle>Your API Keys</CardTitle>
<CardDescription>
{apiKeys.length} active key{apiKeys.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Key className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No API keys yet</p>
<p className="text-sm">Create your first key to get started</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Scopes</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last Used</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>
<code className="bg-muted px-2 py-1 rounded text-sm">
{key.key_prefix}...
</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.slice(0, 2).map((scope) => (
<span
key={scope}
className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
>
{scope}
</span>
))}
{key.scopes.length > 2 && (
<span className="text-xs text-muted-foreground">
+{key.scopes.length - 2}
</span>
)}
</div>
</TableCell>
<TableCell>{formatDate(key.created_at)}</TableCell>
<TableCell>{key.last_used_at ? formatDate(key.last_used_at) : 'Never'}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleRotateKey(key.id)}
title="Rotate Key"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setKeyToRevoke(key)}
title="Revoke Key"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* New Key Modal - Show full key only once */}
<Dialog open={!!newKeyData} onOpenChange={() => setNewKeyData(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
API Key Created
</DialogTitle>
<DialogDescription>
Copy your API key now. You won&apos;t be able to see it again!
</DialogDescription>
</DialogHeader>
{newKeyData && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Key Name</Label>
<p className="text-sm">{newKeyData.name}</p>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="flex gap-2">
<code className="flex-1 bg-muted p-3 rounded text-sm break-all">
{newKeyData.key}
</code>
<Button
size="icon"
variant="outline"
onClick={() => copyToClipboard(newKeyData.key)}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4">
<p className="text-sm text-yellow-700 dark:text-yellow-400">
<strong>Important:</strong> This is the only time you&apos;ll see the full key.
Please copy it now and store it securely. If you lose it, you&apos;ll need to generate a new one.
</p>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setNewKeyData(null)}>
I&apos;ve copied my key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revoke Confirmation Dialog */}
<Dialog open={!!keyToRevoke} onOpenChange={() => setKeyToRevoke(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogDescription>
Are you sure you want to revoke the key &quot;{keyToRevoke?.name}&quot;?
This action cannot be undone. Any applications using this key will stop working immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRevokeKey}>
Revoke Key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Cloud, Loader2 } from 'lucide-react';
export function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const success = await login(email, password);
if (success) {
navigate('/');
}
setIsSubmitting(false);
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<Cloud className="h-8 w-8 text-primary" />
<span className="text-2xl font-bold">mockupAWS</span>
</div>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center">Sign in</CardTitle>
<CardDescription className="text-center">
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
to="#"
className="text-sm text-primary hover:underline"
onClick={(e) => {
e.preventDefault();
// TODO: Implement forgot password
alert('Forgot password - Coming soon');
}}
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Don't have an account?{' '}
<Link to="/register" className="text-primary hover:underline">
Create account
</Link>
</p>
</CardFooter>
</form>
</Card>
<p className="text-center text-sm text-muted-foreground mt-8">
AWS Cost Simulator & Backend Profiler
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Cloud, Loader2 } from 'lucide-react';
import { showToast } from '@/components/ui/toast-utils';
export function Register() {
const [email, setEmail] = useState('');
const [fullName, setFullName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const { register } = useAuth();
const navigate = useNavigate();
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
newErrors.email = 'Please enter a valid email address';
}
// Password validation
if (password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
// Confirm password
if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
// Full name
if (!fullName.trim()) {
newErrors.fullName = 'Full name is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
showToast({
title: 'Validation Error',
description: 'Please fix the errors in the form',
variant: 'destructive'
});
return;
}
setIsSubmitting(true);
const success = await register(email, password, fullName);
if (success) {
navigate('/');
}
setIsSubmitting(false);
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<Cloud className="h-8 w-8 text-primary" />
<span className="text-2xl font-bold">mockupAWS</span>
</div>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center">Create account</CardTitle>
<CardDescription className="text-center">
Enter your details to create a new account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fullName">Full Name</Label>
<Input
id="fullName"
type="text"
placeholder="John Doe"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
autoComplete="name"
/>
{errors.fullName && (
<p className="text-sm text-destructive">{errors.fullName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password}</p>
)}
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create account'
)}
</Button>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
<p className="text-center text-sm text-muted-foreground mt-8">
AWS Cost Simulator & Backend Profiler
</p>
</div>
</div>
);
}