initial commit

This commit is contained in:
2026-04-07 17:41:25 +02:00
commit 1ed9bdfa55
45 changed files with 4712 additions and 0 deletions

5
frontend/dev.Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
CMD ["npm", "run", "dev", "--", "--host"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProtonVPN Config Generator</title>
</head>
<body class="bg-zinc-950 text-white min-h-screen font-sans antialiased selection:bg-purple-500/30">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "protonvpn-webui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.4",
"postcss": "^8.4.38",
"autoprefixer": "^10.4.19"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.2.2",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

404
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,404 @@
import React, { useState, useEffect, useRef } from 'react';
const COUNTRIES = [
{ code: 'AR', name: 'Argentina' },
{ code: 'AU', name: 'Australia' },
{ code: 'AT', name: 'Austria' },
{ code: 'BE', name: 'Belgium' },
{ code: 'BR', name: 'Brazil' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'CA', name: 'Canada' },
{ code: 'CL', name: 'Chile' },
{ code: 'CO', name: 'Colombia' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'HR', name: 'Croatia' },
{ code: 'CY', name: 'Cyprus' },
{ code: 'CZ', name: 'Czechia' },
{ code: 'DK', name: 'Denmark' },
{ code: 'EE', name: 'Estonia' },
{ code: 'FI', name: 'Finland' },
{ code: 'FR', name: 'France' },
{ code: 'GE', name: 'Georgia' },
{ code: 'DE', name: 'Germany' },
{ code: 'GR', name: 'Greece' },
{ code: 'HK', name: 'Hong Kong' },
{ code: 'HU', name: 'Hungary' },
{ code: 'IS', name: 'Iceland' },
{ code: 'IN', name: 'India' },
{ code: 'IE', name: 'Ireland' },
{ code: 'IL', name: 'Israel' },
{ code: 'IT', name: 'Italy' },
{ code: 'JP', name: 'Japan' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'MX', name: 'Mexico' },
{ code: 'MD', name: 'Moldova' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'NO', name: 'Norway' },
{ code: 'PE', name: 'Peru' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PL', name: 'Poland' },
{ code: 'PT', name: 'Portugal' },
{ code: 'RO', name: 'Romania' },
{ code: 'RS', name: 'Serbia' },
{ code: 'SG', name: 'Singapore' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'KR', name: 'South Korea' },
{ code: 'ES', name: 'Spain' },
{ code: 'SE', name: 'Sweden' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'TW', name: 'Taiwan' },
{ code: 'TR', name: 'Turkey' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'UK', name: 'United Kingdom' },
{ code: 'US', name: 'United States' },
{ code: 'VN', name: 'Vietnam' }
];
const getFlagImg = (countryCode: string) => {
return (
<img
src={`https://flagcdn.com/w40/${countryCode.toLowerCase()}.png`}
width="24"
alt={countryCode}
className="inline-block rounded-[2px] shadow-[0_0_2px_rgba(0,0,0,0.5)]"
/>
);
};
export default function App() {
const [selectedCountries, setSelectedCountries] = useState<string[]>([]);
const [isCountryOpen, setIsCountryOpen] = useState(false);
const [options, setOptions] = useState({
p2pOnly: true,
secureCore: false,
freeOnly: false,
ipv6: false,
accelerator: true,
duration: '365d'
});
const [taskId, setTaskId] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [logs, setLogs] = useState<{text: string, error?: boolean}[]>([]);
const [require2FA, setRequire2FA] = useState(false);
const [twoFACode, setTwoFACode] = useState('');
const [downloadData, setDownloadData] = useState<string | null>(null);
const logsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const toggleCountry = (code: string) => {
setSelectedCountries(prev =>
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
);
};
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedCountries.length === 0) {
alert("Please select at least one country");
return;
}
setIsGenerating(true);
setLogs([]);
setRequire2FA(false);
setDownloadData(null);
setTaskId(null);
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
countries: selectedCountries.join(','),
...options
})
});
const { taskId } = await res.json();
setTaskId(taskId);
const es = new EventSource(`/api/stream/${taskId}`);
es.addEventListener('log', (e) => {
const data = JSON.parse(e.data);
setLogs(prev => [...prev, data]);
});
es.addEventListener('require_2fa', () => {
setRequire2FA(true);
});
es.addEventListener('complete', (e) => {
const data = JSON.parse(e.data);
setLogs(prev => [...prev, { text: `Process completed with code ${data.code}` }]);
setIsGenerating(false);
if (data.success && data.fileContent) {
setDownloadData(data.fileContent);
}
es.close();
});
es.onerror = () => {
setLogs(prev => [...prev, { text: 'Lost connection to stream', error: true }]);
setIsGenerating(false);
es.close();
};
} catch (err) {
setLogs([{ text: `Failed to initiate generation: ${err}`, error: true }]);
setIsGenerating(false);
}
};
const submit2FA = async (e: React.FormEvent) => {
e.preventDefault();
if (!taskId || !twoFACode) return;
setRequire2FA(false);
setLogs(prev => [...prev, { text: `> Submitting 2FA Code: ***`, error: false }]);
await fetch('/api/2fa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId, code: twoFACode })
});
setTwoFACode('');
};
const downloadFile = () => {
if (!downloadData) return;
const blob = new Blob([downloadData], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `protonvpn-${selectedCountries.join('_')}.conf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="min-h-screen bg-[#121015] flex flex-col items-center p-6 relative">
{/* Background aesthetic */}
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-[#6d4aff]/10 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute top-[30%] right-[-10%] w-[40%] h-[60%] bg-[#2bbd7e]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="max-w-6xl w-full flex flex-col gap-8 z-10">
<header className="flex flex-col md:flex-row items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-[#6d4aff] to-[#937bff] uppercase tracking-wider">
Proton WireGuard
</h1>
<p className="text-zinc-400 mt-1 font-medium">Headless Configuration Generator</p>
</div>
<p className="text-xs text-zinc-500 bg-white/5 py-2 px-4 rounded-full border border-white/10 backdrop-blur-sm">
Powered by open-source protonvpn-wg-confgen
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Controls Panel */}
<div className="bg-[#1c1924]/80 p-8 rounded-3xl border border-white/5 shadow-2xl backdrop-blur-md flex flex-col gap-6">
<h2 className="text-xl font-bold border-b border-white/5 pb-4">Connection Settings</h2>
<form id="gen-form" onSubmit={handleGenerate} className="flex flex-col gap-6">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-3 uppercase tracking-wide">Target Countries</label>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setIsCountryOpen(!isCountryOpen)}
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-left focus:outline-none focus:border-[#6d4aff] transition-colors flex justify-between items-center text-zinc-300 shadow-sm"
>
<span className="flex items-center gap-2 flex-wrap">
{selectedCountries.length === 0
? 'Toggle countries list...'
: selectedCountries.map(c => (
<span key={c} className="flex items-center gap-1.5 bg-white/10 px-2 py-0.5 rounded-md text-xs font-bold text-white shadow-sm">
{getFlagImg(c)} {c}
</span>
))}
</span>
<svg className={`w-5 h-5 shrink-0 transition-transform ${isCountryOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isCountryOpen && (
<div className="w-full bg-black/20 border border-white/5 rounded-xl max-h-72 overflow-y-auto overflow-x-hidden p-2 backdrop-blur-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{COUNTRIES.map(country => (
<label
key={country.code}
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors border max-w-full ${
selectedCountries.includes(country.code)
? 'bg-[#6d4aff]/20 border-[#6d4aff]/50 text-white'
: 'border-transparent hover:bg-white/5 text-zinc-300'
}`}
>
<input
type="checkbox"
className="hidden"
checked={selectedCountries.includes(country.code)}
onChange={() => toggleCountry(country.code)}
/>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
selectedCountries.includes(country.code) ? 'bg-[#6d4aff] border-[#6d4aff]' : 'border-white/20 bg-black/40'
}`}>
{selectedCountries.includes(country.code) && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="shrink-0 flex items-center justify-center w-[24px]">
{getFlagImg(country.code)}
</span>
<span className="text-sm font-medium truncate shrink min-w-0" title={country.name}>{country.name}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-3 uppercase tracking-wide">Advanced Options</label>
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'p2pOnly', label: 'P2P Optimized' },
{ key: 'secureCore', label: 'Secure Core' },
{ key: 'freeOnly', label: 'Free Tier Only' },
{ key: 'ipv6', label: 'Enable IPv6' },
{ key: 'accelerator', label: 'VPN Accelerator' }
].map(opt => (
<label key={opt.key} className="flex items-center gap-3 cursor-pointer group">
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
options[opt.key as keyof typeof options] ? 'bg-[#6d4aff] border-[#6d4aff]' : 'border-white/20 bg-black/40 group-hover:border-white/40'
}`}>
{options[opt.key as keyof typeof options] && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-sm text-zinc-300">{opt.label}</span>
<input
type="checkbox"
className="hidden"
checked={options[opt.key as keyof typeof options] as boolean}
onChange={e => setOptions({...options, [opt.key]: e.target.checked})}
/>
</label>
))}
</div>
</div>
<div className="pt-4 border-t border-white/5">
<button
type="submit"
disabled={isGenerating}
className="w-full bg-gradient-to-r from-[#6d4aff] to-[#937bff] hover:opacity-90 text-white font-bold py-4 rounded-xl shadow-lg transition-transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? 'Generating...' : 'Generate Configuration'}
</button>
</div>
</form>
</div>
{/* Terminal / Output Panel */}
<div className="bg-black/80 p-6 rounded-3xl border border-white/10 shadow-2xl backdrop-blur-xl flex flex-col h-[600px]">
<div className="flex items-center gap-2 mb-4 px-2">
<div className="w-3 h-3 rounded-full bg-red-500/80"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500/80"></div>
<div className="w-3 h-3 rounded-full bg-green-500/80"></div>
<span className="ml-2 text-xs font-mono text-zinc-500">generator.log</span>
</div>
<div className="flex-1 overflow-y-auto terminal-scroll font-mono text-xs md:text-sm bg-[#050505] rounded-xl p-4 border border-white/5">
{logs.length === 0 && !isGenerating && (
<p className="text-zinc-600 italic">Waiting for execution...</p>
)}
{logs.map((log, i) => (
<div key={i} className={`mb-1 ${log.error ? 'text-red-400' : 'text-zinc-300'}`}>
{log.text.split('\n').map((line, j) => (
<div key={j} className="break-all whitespace-pre-wrap">{line}</div>
))}
</div>
))}
<div ref={logsEndRef} />
</div>
{/* Success Download */}
{downloadData && (
<div className="mt-4 animate-in fade-in slide-in-from-bottom-4">
<button
onClick={downloadFile}
className="w-full bg-[#2bbd7e] border border-[#2bbd7e] text-[#2bbd7e] bg-opacity-20 font-bold py-3 rounded-xl hover:bg-opacity-30 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download WireGuard Config
</button>
</div>
)}
</div>
</div>
</div>
{/* 2FA Modal */}
{require2FA && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-[#1c1924] border border-[#6d4aff]/30 p-8 rounded-3xl max-w-sm w-full shadow-2xl">
<h3 className="text-2xl font-bold mb-2 flex items-center gap-2">
<svg className="w-6 h-6 text-[#6d4aff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Two-Factor Auth
</h3>
<p className="text-zinc-400 text-sm mb-6">Your Proton account requires a TOTP code to authenticate the VPN API.</p>
<form onSubmit={submit2FA}>
<input
type="text"
autoFocus
maxLength={6}
value={twoFACode}
onChange={e => setTwoFACode(e.target.value.replace(/\D/g, ''))}
placeholder="000 000"
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-center text-2xl tracking-widest font-mono focus:outline-none focus:border-[#6d4aff] transition-colors mb-6"
/>
<button
type="submit"
disabled={twoFACode.length !== 6}
className="w-full bg-white text-black font-bold py-3 rounded-xl disabled:opacity-50 transition-opacity"
>
Verify & Continue
</button>
</form>
</div>
</div>
)}
</div>
);
}

31
frontend/src/index.css Normal file
View File

@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-proton-purple: #6d4aff;
--color-proton-dark: #121015;
--color-proton-card: #1c1924;
--color-proton-accent: #937bff;
--color-proton-green: #2bbd7e;
--font-sans: "Inter", system-ui, sans-serif;
}
body {
background-color: var(--color-proton-dark);
}
/* Custom scrollbar for terminal */
.terminal-scroll::-webkit-scrollbar {
width: 8px;
}
.terminal-scroll::-webkit-scrollbar-track {
background: rgba(0,0,0,0.2);
}
.terminal-scroll::-webkit-scrollbar-thumb {
background: rgba(109, 74, 255, 0.4);
border-radius: 4px;
}
.terminal-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(109, 74, 255, 0.6);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react(),
],
server: {
host: '0.0.0.0', // needed for docker
port: 5173,
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true
}
}
}
})