mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-15 19:03:38 +00:00
feat: robust login with rate limiting + diff propagation across tree
Login hardening: - API: in-memory rate limiter (10 attempts / IP / 15 min), timing-safe credential comparison, strict body validation - Middleware: validate session token timestamp expiry (7-day window) not just HMAC signature - Logout: also clears oidc_token_pub cookie - Login page: try/catch around fetch to surface network errors, parse OIDC error query params, disable inputs during loading, wrap useSearchParams in Suspense Diff propagation: - DiffViewer: shows inherited ancestor patches in a collapsible section above own patches - CVTree: annotates each branch with ↑N ancestor patch count badge; propagate (↓) button on hover for versions with own patches and child branches - Dashboard: computeInheritedPatches/getDescendants helpers; Propagate ↓ action button in version header; handlePropagate sends current version's patches to all descendants via appendPatches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Stdyi4YmG1BUSurArZPe8w
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
const OIDC_ERROR_LABELS: Record<string, string> = {
|
||||
no_code: 'No authorization code received from provider.',
|
||||
oidc_not_configured: 'OIDC provider not configured.',
|
||||
token_exchange: 'Failed to exchange token with provider.',
|
||||
};
|
||||
|
||||
function authentikBase(url?: string | null) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.origin.replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; }
|
||||
}
|
||||
|
||||
function authentikUrl() {
|
||||
@@ -18,37 +19,45 @@ function authentikUrl() {
|
||||
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
if (!baseHost || !clientId) return null;
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: `${base}/api/auth/callback`,
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
return `${baseHost}/application/o/authorize/?${params}`;
|
||||
return `${baseHost}/application/o/authorize/?${new URLSearchParams({
|
||||
response_type: 'code', client_id: clientId,
|
||||
redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile',
|
||||
})}`;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
|
||||
|
||||
useEffect(() => {
|
||||
const e = params.get('error');
|
||||
if (e) setError(OIDC_ERROR_LABELS[e] ?? `Auth error: ${e}`);
|
||||
}, [params]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
if (!username || !password || loading) return;
|
||||
setLoading(true); setError('');
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
setError(j.error ?? 'Login failed');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
setError(j.error ?? `Login failed (${res.status})`);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
setError('Network error — check your connection and try again.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -59,17 +68,11 @@ export default function LoginPage() {
|
||||
justifyContent: 'center', background: 'var(--bg)',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
|
||||
{/* brand */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
|
||||
cvfs
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Sign in to your account
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>cvfs</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Sign in to your account</div>
|
||||
</div>
|
||||
|
||||
{/* form card */}
|
||||
<div style={{
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, padding: '24px 24px 20px',
|
||||
@@ -82,7 +85,7 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="text" autoComplete="username" autoFocus
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
placeholder="admin" disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -92,7 +95,7 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="password" autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
placeholder="••••••••" disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -142,3 +145,11 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user