|
|
|
@@ -108,8 +108,28 @@ const translations = {
|
|
|
|
|
copied: 'Copied!',
|
|
|
|
|
createUserSuccess: 'User created successfully',
|
|
|
|
|
passwordResetSuccess: 'Password reset successfully',
|
|
|
|
|
resetPasswordDesc: 'Generate a new strong password for this user and invalidate the old one.',
|
|
|
|
|
usernamePlaceholder: 'Ex: johndoe',
|
|
|
|
|
displayNamePlaceholder: 'Ex: John Doe',
|
|
|
|
|
passwordPlaceholder: 'Strong password...',
|
|
|
|
|
generate: 'Generate',
|
|
|
|
|
cancel: 'Cancel',
|
|
|
|
|
displayName: 'Display Name',
|
|
|
|
|
createUserError: 'Error creating user',
|
|
|
|
|
createUser: 'Create User',
|
|
|
|
|
dataCleanup: 'Data Cleanup',
|
|
|
|
|
dataCleanupDesc: 'Calculate and remove unreachable messages (e.g. from deleted groups) and media to free up disk space.',
|
|
|
|
|
calcCleanupBtn: 'Calculate GC',
|
|
|
|
|
runCleanupBtn: 'Run Clean',
|
|
|
|
|
cleanMessages: 'Orphaned Messages',
|
|
|
|
|
cleanMedia: 'Orphaned Media Size',
|
|
|
|
|
cleanSuccess: 'Cleanup completed successfully!',
|
|
|
|
|
testConnection: 'Test Connection',
|
|
|
|
|
turnSuccess: 'TURN Connection Successful!',
|
|
|
|
|
turnFailed: 'TURN Test Failed: ',
|
|
|
|
|
klipySuccess: 'Klipy connection successful!',
|
|
|
|
|
klipyFailed: 'Klipy Test Failed: ',
|
|
|
|
|
apiRequired: 'API Key is required',
|
|
|
|
|
},
|
|
|
|
|
ru: {
|
|
|
|
|
loginTitle: 'Вход администратора',
|
|
|
|
@@ -173,8 +193,28 @@ const translations = {
|
|
|
|
|
copied: 'Скопировано!',
|
|
|
|
|
createUserSuccess: 'Пользователь успешно создан',
|
|
|
|
|
passwordResetSuccess: 'Пароль успешно сброшен',
|
|
|
|
|
resetPasswordDesc: 'Сгенерировать новый надежный пароль для этого пользователя и аннулировать старый.',
|
|
|
|
|
usernamePlaceholder: 'Например: johndoe',
|
|
|
|
|
displayNamePlaceholder: 'Например: Иван Иванов',
|
|
|
|
|
passwordPlaceholder: 'Надежный пароль...',
|
|
|
|
|
generate: 'Сгенерировать',
|
|
|
|
|
cancel: 'Отмена',
|
|
|
|
|
displayName: 'Имя профиля',
|
|
|
|
|
createUserError: 'Ошибка при создании',
|
|
|
|
|
createUser: 'Создать',
|
|
|
|
|
dataCleanup: 'Очистка данных',
|
|
|
|
|
dataCleanupDesc: 'Вычисление и удаление недостижимых сообщений (например, из удаленных групп) и файлов для освобождения места.',
|
|
|
|
|
calcCleanupBtn: 'Оценить объем',
|
|
|
|
|
runCleanupBtn: 'Очистить данные',
|
|
|
|
|
cleanMessages: 'Сообщений к удалению',
|
|
|
|
|
cleanMedia: 'Медиа к удалению',
|
|
|
|
|
cleanSuccess: 'Очистка успешно завершена!',
|
|
|
|
|
testConnection: 'Проверить соединение',
|
|
|
|
|
turnSuccess: 'Успешное подключение к TURN!',
|
|
|
|
|
turnFailed: 'Ошибка TURN: ',
|
|
|
|
|
klipySuccess: 'Успешное подключение к Klipy!',
|
|
|
|
|
klipyFailed: 'Ошибка Klipy: ',
|
|
|
|
|
apiRequired: 'API ключ обязателен',
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -199,18 +239,26 @@ export default function AdminPage() {
|
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface CleanStats {
|
|
|
|
|
orphanedMessagesCount: number;
|
|
|
|
|
orphanedMediaBytes: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
|
|
|
const [config, setConfig] = useState<Conf | null>(null);
|
|
|
|
|
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
|
|
|
|
|
const [authHeader, setAuthHeader] = useState('');
|
|
|
|
|
const [creds, setCreds] = useState({ user: '', pass: '' });
|
|
|
|
|
const [authenticated, setAuthenticated] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Users Tab
|
|
|
|
|
const [users, setUsers] = useState<AppUser[]>([]);
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
|
const [selectedUser, setSelectedUser] = useState<AppUser | null>(null);
|
|
|
|
|
|
|
|
|
|
const [isTestingTurn, setIsTestingTurn] = useState<boolean>(false);
|
|
|
|
|
const [isTestingKlipy, setIsTestingKlipy] = useState<boolean>(false);
|
|
|
|
|
|
|
|
|
|
const [domainInput, setDomainInput] = useState('');
|
|
|
|
|
|
|
|
|
|
// Add User State
|
|
|
|
@@ -278,6 +326,95 @@ export default function AdminPage() {
|
|
|
|
|
} catch {}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleTestTurn = async () => {
|
|
|
|
|
if (!config || !config.turnHost) return;
|
|
|
|
|
setIsTestingTurn(true);
|
|
|
|
|
let resolved = false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const pc = new RTCPeerConnection({
|
|
|
|
|
iceServers: [{
|
|
|
|
|
urls: `turn:${config.turnHost}:${config.turnPort}`,
|
|
|
|
|
username: config.turnUser,
|
|
|
|
|
credential: config.turnSecret
|
|
|
|
|
}]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pc.addTransceiver('audio');
|
|
|
|
|
const offer = await pc.createOffer();
|
|
|
|
|
await pc.setLocalDescription(offer);
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
reject(new Error("Timeout gathering relay candidates"));
|
|
|
|
|
}
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
|
|
|
|
pc.onicecandidate = (e) => {
|
|
|
|
|
if (e.candidate && (e.candidate.type === 'relay' || e.candidate.type === 'srflx')) {
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (e.candidate === null) {
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
reject(new Error("Failed to gather candidates. Check credentials or network."));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
showToast(t.turnSuccess, 'success');
|
|
|
|
|
pc.close();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
showToast(t.turnFailed + err.message, 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTestingTurn(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTestKlipy = async () => {
|
|
|
|
|
if (!config || !config.klipyApiKey) {
|
|
|
|
|
showToast(t.apiRequired, 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsTestingKlipy(true);
|
|
|
|
|
try {
|
|
|
|
|
const customerId = config.klipyCustomerId || "anonymous";
|
|
|
|
|
let url = `https://api.klipy.co/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
|
|
|
|
|
|
|
|
|
|
let res = await fetch(url);
|
|
|
|
|
if (!res.ok && res.status === 404) {
|
|
|
|
|
url = `https://api.klipy.com/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`;
|
|
|
|
|
res = await fetch(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => null);
|
|
|
|
|
if (data && !data.error && (Array.isArray(data) || data.gifs || data.data)) {
|
|
|
|
|
showToast(t.klipySuccess, 'success');
|
|
|
|
|
} else {
|
|
|
|
|
showToast(t.klipyFailed + (data?.error || "Invalid response format"), 'error');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const err = await res.json().catch(() => ({}));
|
|
|
|
|
showToast(t.klipyFailed + (err?.error || err?.message || res.statusText), 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
showToast(t.klipyFailed + err.message, 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTestingKlipy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`;
|
|
|
|
@@ -330,6 +467,24 @@ export default function AdminPage() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCalcCleanup = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/admin/clean/dry-run', { headers: { Authorization: authHeader } });
|
|
|
|
|
if (res.ok) setCleanStats(await res.json());
|
|
|
|
|
} catch {}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRunCleanup = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/admin/clean/run', { method: 'POST', headers: { Authorization: authHeader } });
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
showToast(t.cleanSuccess, 'success');
|
|
|
|
|
setCleanStats(null);
|
|
|
|
|
fetchDashboard(authHeader);
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchUserDetails = async (id: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/admin/users/${id}`, { headers: { Authorization: authHeader } });
|
|
|
|
@@ -416,7 +571,7 @@ export default function AdminPage() {
|
|
|
|
|
const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-black text-white font-sans flex overflow-hidden">
|
|
|
|
|
<div className="h-[100dvh] w-full bg-black text-white font-sans flex overflow-hidden">
|
|
|
|
|
<aside className="w-64 border-r border-white/10 bg-surface/50 p-6 flex flex-col gap-2 shrink-0 overflow-y-auto">
|
|
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
|
|
<h2 className="text-xl font-bold text-accent flex items-center gap-2">
|
|
|
|
@@ -482,6 +637,38 @@ export default function AdminPage() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* DATA CLEANUP WIDGET */}
|
|
|
|
|
<div className="bg-surface border border-white/10 rounded-2xl p-6 lg:col-span-2 relative overflow-hidden">
|
|
|
|
|
<h3 className="text-gray-400 font-medium mb-2 flex items-center gap-2 relative z-10">
|
|
|
|
|
<Trash2 className="w-5 h-5"/> {t.dataCleanup}
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="text-sm text-gray-500 mb-6 relative z-10 max-w-lg">{t.dataCleanupDesc}</div>
|
|
|
|
|
|
|
|
|
|
{!cleanStats ? (
|
|
|
|
|
<button onClick={handleCalcCleanup} className="bg-accent/10 hover:bg-accent/20 text-accent font-semibold px-4 py-2 rounded-xl transition-colors shrink-0">
|
|
|
|
|
{t.calcCleanupBtn}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
|
|
|
|
|
<span className="text-gray-400">{t.cleanMessages}:</span>
|
|
|
|
|
<span className="text-white font-mono">{cleanStats.orphanedMessagesCount}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between items-center text-sm border-b border-white/10 pb-2">
|
|
|
|
|
<span className="text-gray-400">{t.cleanMedia}:</span>
|
|
|
|
|
<span className="text-white font-mono">{formatBytes(cleanStats.orphanedMediaBytes)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-start gap-4 mt-2">
|
|
|
|
|
<button onClick={handleRunCleanup} className="bg-red-500 hover:bg-red-600 text-white font-semibold px-6 py-2 rounded-xl transition-colors">
|
|
|
|
|
{t.runCleanupBtn}
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => setCleanStats(null)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
@@ -544,6 +731,12 @@ export default function AdminPage() {
|
|
|
|
|
{t.turnSecret}
|
|
|
|
|
<input type="password" className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.turnSecret} onChange={e => setConfig({...config, turnSecret: e.target.value})} />
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex justify-start mt-2">
|
|
|
|
|
<button onClick={handleTestTurn} disabled={isTestingTurn} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
|
|
|
|
|
{isTestingTurn ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
|
|
|
|
|
{t.testConnection}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
@@ -566,11 +759,17 @@ export default function AdminPage() {
|
|
|
|
|
Customer ID (for tracking/analytics)
|
|
|
|
|
<input className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent" value={config.klipyCustomerId || ''} onChange={e => setConfig({...config, klipyCustomerId: e.target.value})} />
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex justify-start mt-2">
|
|
|
|
|
<button onClick={handleTestKlipy} disabled={isTestingKlipy} className="bg-accent/10 hover:bg-accent/20 text-accent font-medium px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
|
|
|
|
|
{isTestingKlipy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Activity className="w-4 h-4" />}
|
|
|
|
|
{t.testConnection}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="sticky bottom-8 self-end">
|
|
|
|
|
<div className="mt-8 flex justify-end">
|
|
|
|
|
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
|
|
|
|
|
{t.saveChanges}
|
|
|
|
|
</button>
|
|
|
|
@@ -635,7 +834,7 @@ export default function AdminPage() {
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="sticky bottom-8 self-end">
|
|
|
|
|
<div className="mt-8 flex justify-end">
|
|
|
|
|
<button onClick={saveSettings} className="bg-accent hover:bg-accentLight text-black font-semibold py-4 px-10 rounded-xl transition-colors shadow-xl shadow-accent/20">
|
|
|
|
|
{t.saveChanges}
|
|
|
|
|
</button>
|
|
|
|
@@ -761,7 +960,7 @@ export default function AdminPage() {
|
|
|
|
|
<div className="bg-red-400/5 border border-red-400/20 p-5 rounded-xl flex flex-col md:flex-row items-center justify-between gap-4">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<h4 className="font-medium text-white">{t.resetPassword}</h4>
|
|
|
|
|
<p className="text-sm text-gray-400 mt-1">Generate a new strong password for this user and invalidate the old one.</p>
|
|
|
|
|
<p className="text-sm text-gray-400 mt-1">{t.resetPasswordDesc}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={() => handleResetPassword(selectedUser.id)} className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-6 py-2 rounded-xl transition-colors font-semibold whitespace-nowrap">
|
|
|
|
|
{t.resetPassword}
|
|
|
|
@@ -797,45 +996,45 @@ export default function AdminPage() {
|
|
|
|
|
<h2 className="text-2xl font-bold text-white mb-2">{t.addUser}</h2>
|
|
|
|
|
|
|
|
|
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
|
|
|
|
Username
|
|
|
|
|
{t.username}
|
|
|
|
|
<input
|
|
|
|
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
|
|
|
|
value={newUser.username}
|
|
|
|
|
onChange={e => setNewUser({...newUser, username: e.target.value.toLowerCase()})}
|
|
|
|
|
placeholder="Ex: john_doe"
|
|
|
|
|
placeholder={t.usernamePlaceholder}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
|
|
|
|
Display Name
|
|
|
|
|
{t.displayName}
|
|
|
|
|
<input
|
|
|
|
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent"
|
|
|
|
|
value={newUser.displayName}
|
|
|
|
|
onChange={e => setNewUser({...newUser, displayName: e.target.value})}
|
|
|
|
|
placeholder="Ex: John Doe"
|
|
|
|
|
placeholder={t.displayNamePlaceholder}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="flex flex-col gap-1 text-sm text-gray-400">
|
|
|
|
|
Password
|
|
|
|
|
{t.password}
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<input
|
|
|
|
|
className="bg-black/50 border border-white/10 rounded-xl px-4 py-3 outline-none text-white focus:border-accent flex-1 font-mono"
|
|
|
|
|
value={newUser.password}
|
|
|
|
|
onChange={e => setNewUser({...newUser, password: e.target.value})}
|
|
|
|
|
placeholder="Strong password..."
|
|
|
|
|
placeholder={t.passwordPlaceholder}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setNewUser({...newUser, password: generatePassword()})}
|
|
|
|
|
className="bg-white/10 hover:bg-white/20 px-4 rounded-xl transition-colors shrink-0"
|
|
|
|
|
>
|
|
|
|
|
Generate
|
|
|
|
|
{t.generate}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-3 mt-4 pt-4 border-t border-white/10">
|
|
|
|
|
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">Cancel</button>
|
|
|
|
|
<button onClick={() => setShowAddUserModal(false)} className="px-4 py-2 rounded-xl text-gray-400 hover:text-white transition-colors">{t.cancel}</button>
|
|
|
|
|
<button onClick={handleAddUser} disabled={!newUser.username || !newUser.displayName || !newUser.password} className="bg-accent text-black font-semibold px-6 py-2 rounded-xl hover:bg-accentLight transition-colors disabled:opacity-50">
|
|
|
|
|
{t.createUser}
|
|
|
|
|
</button>
|
|
|
|
|