feat: add ALTCHA widget to admin login page

- Create Altcha component for admin frontend
- Add ALTCHA verification to login flow
- Disable login button until ALTCHA is verified
- Use admin ALTCHA challenge endpoint
This commit is contained in:
Patrick Britton 2026-02-16 23:27:25 -06:00
parent ace3b33344
commit d190afbd19
2 changed files with 84 additions and 4 deletions

View file

@ -3,22 +3,38 @@
import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import Altcha from '@/components/Altcha';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [altchaToken, setAltchaToken] = useState('');
const [altchaVerified, setAltchaVerified] = useState(false);
const emailRef = useRef('');
const passwordRef = useRef('');
const { login } = useAuth();
const router = useRouter();
const handleAltchaStateChange = useCallback((state: any) => {
if (state.state === 'verified' && state.payload) {
setAltchaToken(state.payload);
setAltchaVerified(true);
} else {
setAltchaToken('');
setAltchaVerified(false);
}
}, []);
const performLogin = useCallback(async () => {
if (!altchaVerified) {
setError('Please complete the security verification');
return;
}
setLoading(true);
try {
// Use development bypass if in development mode
const altchaToken = process.env.NODE_ENV === 'development' ? 'BYPASS_DEV_MODE' : '';
await login(emailRef.current, passwordRef.current, altchaToken);
router.push('/');
} catch (err: any) {
@ -26,7 +42,7 @@ export default function LoginPage() {
} finally {
setLoading(false);
}
}, [login, router]);
}, [login, router, altchaToken, altchaVerified]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -85,10 +101,16 @@ export default function LoginPage() {
required
/>
</div>
<div>
<Altcha
challengeurl="https://api.sojorn.net/api/v1/admin/altcha-challenge"
onStateChange={handleAltchaStateChange}
/>
</div>
<button
type="submit"
className="btn-primary w-full"
disabled={loading}
disabled={loading || !altchaVerified}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>

View file

@ -0,0 +1,58 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface AltchaProps {
challengeurl: string;
onStateChange?: (state: any) => void;
}
export default function Altcha({ challengeurl, onStateChange }: AltchaProps) {
const widgetRef = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// Load ALTCHA widget script
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/altcha@0.5.0/dist/altcha.min.js';
script.type = 'module';
script.async = true;
script.onload = () => setLoaded(true);
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, []);
useEffect(() => {
if (!loaded || !widgetRef.current) return;
const widget = widgetRef.current.querySelector('altcha-widget');
if (!widget) return;
const handleStateChange = (event: any) => {
if (onStateChange) {
onStateChange(event.detail);
}
};
widget.addEventListener('statechange', handleStateChange);
return () => {
widget.removeEventListener('statechange', handleStateChange);
};
}, [loaded, onStateChange]);
return (
<div ref={widgetRef}>
<altcha-widget
challengeurl={challengeurl}
hidefooter="true"
hidelogo="true"
/>
</div>
);
}