This commit is contained in:
Eliezer Croitoru 2026-10-02 04:38:35 +03:00
commit d194a8f0df
3 changed files with 390 additions and 0 deletions

207
app.js Normal file
View File

@ -0,0 +1,207 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const form = document.getElementById('urlForm');
const hostnameInput = document.getElementById('hostname');
const domainSelect = document.getElementById('domain');
const portInput = document.getElementById('port');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const includeAuthCheck = document.getElementById('includeAuthCheck');
const authStatusText = document.getElementById('authStatusText');
const credentialsFields = document.getElementById('credentialsFields');
const preventSaveCheck = document.getElementById('preventPasswordSave');
const togglePasswordBtn = document.getElementById('togglePasswordVisibility');
const hostnameFeedback = document.getElementById('hostnameFeedback');
const urlDisplay = document.getElementById('urlDisplay');
const launchBtn = document.getElementById('launchBtn');
const copyBtn = document.getElementById('copyBtn');
const copyBtnText = document.getElementById('copyBtnText');
const autoOpenNotice = document.getElementById('autoOpenNotice');
// Validation patterns: IPv4, Router Serial (alphanumeric 10-14 chars), and FQDN
const serialRegex = /^[a-zA-Z0-9]{10,14}$/;
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
let currentWinboxUri = '';
// Validate and categorize user input
function validateHostname(raw) {
let clean = raw.trim();
let port = null;
// Extract port if typed directly into host field (e.g., 10.0.0.1:8292)
const portMatch = clean.match(/:(\d{1,5})$/);
if (portMatch) {
port = portMatch[1];
clean = clean.replace(/:\d{1,5}$/, '');
}
if (!clean) {
hostnameFeedback.innerHTML = '';
return { type: 'empty', value: '', port };
}
if (ipv4Regex.test(clean)) {
hostnameFeedback.innerHTML = '<span class="text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200">IPv4 Address</span>';
return { type: 'ipv4', value: clean, port };
}
if (serialRegex.test(clean)) {
hostnameFeedback.innerHTML = '<span class="text-blue-600 bg-blue-50 px-2 py-0.5 rounded border border-blue-200">Router Serial</span>';
return { type: 'serial', value: clean, port };
}
if (domainRegex.test(clean)) {
hostnameFeedback.innerHTML = '<span class="text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded border border-indigo-200">Domain / FQDN</span>';
return { type: 'domain', value: clean, port };
}
hostnameFeedback.innerHTML = '<span class="text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200">Unrecognized Format</span>';
return { type: 'raw', value: clean, port };
}
// Convert router serial into MikroTik cloud DDNS or VPN hostname
function resolveHost(value, type) {
if (type === 'serial') {
return domainSelect.value === 'vpn'
? `${value.toLowerCase()}.mt.vpn.sn`
: `${value.toLowerCase()}.sn.mynetname.net`;
}
return value;
}
// Build the winbox:// URI and update the UI
function updateUi() {
const val = validateHostname(hostnameInput.value);
if (val.port) {
portInput.value = val.port;
}
const host = resolveHost(val.value, val.type);
const port = portInput.value.trim();
const includeAuth = includeAuthCheck.checked;
const username = includeAuth ? usernameInput.value.trim() : '';
const password = includeAuth ? passwordInput.value : '';
if (!host) {
currentWinboxUri = '';
urlDisplay.textContent = 'winbox://';
launchBtn.disabled = true;
launchBtn.classList.add('opacity-50', 'cursor-not-allowed');
return;
}
launchBtn.disabled = false;
launchBtn.classList.remove('opacity-50', 'cursor-not-allowed');
let uri = 'winbox://';
let displayUri = 'winbox://';
if (username && password) {
uri += `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`;
displayUri += `${encodeURIComponent(username)}:••••••••@`;
} else if (username) {
uri += `${encodeURIComponent(username)}@`;
displayUri += `${encodeURIComponent(username)}@`;
}
uri += host;
displayUri += host;
if (port && port !== '8291') {
uri += `:${port}`;
displayUri += `:${port}`;
}
currentWinboxUri = uri;
urlDisplay.textContent = displayUri;
}
// Launch action
function openWinbox() {
if (!currentWinboxUri) return;
window.location.href = currentWinboxUri;
}
// Form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
openWinbox();
});
// Copy to clipboard
copyBtn.addEventListener('click', async () => {
if (!currentWinboxUri) return;
try {
await navigator.clipboard.writeText(currentWinboxUri);
copyBtnText.textContent = 'Copied!';
setTimeout(() => { copyBtnText.textContent = 'Copy URI'; }, 1800);
} catch {
prompt('Copy WinBox URI:', currentWinboxUri);
}
});
// Password visibility toggle
togglePasswordBtn.addEventListener('click', () => {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
togglePasswordBtn.innerHTML = isPassword
? `<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l18 18"/></svg>`
: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>`;
});
// Toggle credentials block on/off
includeAuthCheck.addEventListener('change', () => {
const enabled = includeAuthCheck.checked;
authStatusText.textContent = enabled ? 'Include' : 'Omit';
credentialsFields.classList.toggle('disabled-state', !enabled);
updateUi();
});
// Disallow browser password save prompt
preventSaveCheck.addEventListener('change', () => {
if (preventSaveCheck.checked) {
passwordInput.setAttribute('autocomplete', 'new-password');
usernameInput.setAttribute('autocomplete', 'off');
} else {
passwordInput.setAttribute('autocomplete', 'current-password');
usernameInput.setAttribute('autocomplete', 'username');
}
});
// Handle incoming URL parameters (e.g., ?hostname=192.168.88.1&user=admin&autoclose=1)
function handleQueryParams() {
const params = new URLSearchParams(window.location.search);
const hostParam = params.get('hostname') || params.get('host');
if (!hostParam) return;
hostnameInput.value = hostParam;
if (params.get('port')) portInput.value = params.get('port');
if (params.get('username') || params.get('user')) usernameInput.value = params.get('username') || params.get('user');
if (params.get('password') || params.get('pass')) passwordInput.value = params.get('password') || params.get('pass');
if (params.get('domain')) domainSelect.value = params.get('domain');
updateUi();
if (currentWinboxUri) {
openWinbox();
autoOpenNotice.classList.remove('hidden');
if (params.get('autoclose') === '1') {
setTimeout(() => {
window.close();
}, 500);
}
}
}
// Input listeners
[hostnameInput, domainSelect, portInput, usernameInput, passwordInput].forEach(el => {
el.addEventListener('input', updateUi);
});
// Initialization
updateUi();
handleQueryParams();
});

152
index.html Normal file
View File

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MikroTik WinBox URL Launcher</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 50: '#eef2ff', 500: '#2563eb', 600: '#1d4ed8', 700: '#1e40af' }
}
}
}
}
</script>
<link rel="stylesheet" href="style.css">
</head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center p-4 selection:bg-blue-500 selection:text-white">
<div class="bg-white w-full max-w-lg rounded-2xl shadow-xl border border-slate-200/80 overflow-hidden transition-all">
<!-- Header -->
<header class="bg-slate-900 px-6 py-5 text-white flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-9 h-9 rounded-lg bg-blue-600 flex items-center justify-center font-black text-lg shadow-inner">
M
</div>
<div>
<h1 class="text-lg font-bold tracking-tight">MikroTik URL Generator</h1>
<p class="text-xs text-slate-400">Launch WinBox sessions directly from URI</p>
</div>
</div>
<a href="https://r.ngtech.co.il/winbox-uri-handler" target="_blank" title="Get WinBox URL Handler Installer"
class="text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 px-3 rounded-lg border border-slate-700 transition flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span>Installer</span>
</a>
</header>
<form id="urlForm" class="p-6 space-y-4" autocomplete="off">
<!-- Host / IPv4 / Serial -->
<div>
<div class="flex justify-between items-center mb-1">
<label for="hostname" class="text-xs font-semibold uppercase tracking-wider text-slate-600">Target Host / IPv4 / Serial</label>
<span id="hostnameFeedback" class="text-xs font-medium"></span>
</div>
<div class="relative">
<input type="text" id="hostname" required placeholder="e.g. 192.168.88.1, router.lan, or HEC01XYZ"
class="w-full px-3.5 py-2.5 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition">
</div>
</div>
<!-- Domain Type & Port Row -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="sm:col-span-2">
<label for="domain" class="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">Serial Suffix</label>
<select id="domain" class="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
<option value="mikrotik" selected>.sn.mynetname.net (Cloud IP)</option>
<option value="vpn">.mt.vpn.sn (VPN Domain)</option>
</select>
</div>
<div>
<label for="port" class="block text-xs font-semibold uppercase tracking-wider text-slate-600 mb-1">Port</label>
<input type="number" id="port" value="8291" min="1" max="65535"
class="w-full px-3 py-2 bg-slate-50 border border-slate-300 rounded-lg text-sm focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
</div>
</div>
<!-- Credentials Section -->
<div class="p-3.5 bg-slate-50 border border-slate-200 rounded-xl space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-600">Authentication</span>
<label class="inline-flex items-center cursor-pointer select-none">
<input type="checkbox" id="includeAuthCheck" checked class="sr-only peer">
<div class="w-9 h-5 bg-slate-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
<span class="ml-2 text-xs font-medium text-slate-600" id="authStatusText">Include</span>
</label>
</div>
<div id="credentialsFields" class="grid grid-cols-1 sm:grid-cols-2 gap-3 transition-opacity">
<div>
<label for="username" class="block text-xs font-medium text-slate-500 mb-1">Username</label>
<input type="text" id="username" placeholder="admin" autocomplete="off"
class="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
</div>
<div>
<label for="password" class="block text-xs font-medium text-slate-500 mb-1">Password</label>
<div class="relative">
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password"
class="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-sm pr-9 focus:outline-none focus:ring-2 focus:ring-blue-500 transition">
<button type="button" id="togglePasswordVisibility" title="Show password"
class="absolute inset-y-0 right-0 px-2.5 flex items-center text-slate-400 hover:text-slate-600">
<svg id="eyeIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-1 border-t border-slate-200/60 text-xs">
<label for="preventPasswordSave" class="text-slate-500 cursor-pointer select-none">Disallow browser password prompts</label>
<input type="checkbox" id="preventPasswordSave" checked class="rounded border-slate-300 text-blue-600 focus:ring-blue-500 h-3.5 w-3.5">
</div>
</div>
<!-- Output URL -->
<div class="pt-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-600">Generated URI</span>
<button type="button" id="copyBtn" class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span id="copyBtnText">Copy URI</span>
</button>
</div>
<div id="urlDisplay" class="w-full p-2.5 bg-slate-900 text-emerald-400 font-mono text-xs rounded-lg break-all select-all border border-slate-800 shadow-inner">
winbox://
</div>
</div>
<!-- Action Buttons -->
<div class="pt-2 space-y-2">
<button type="submit" id="launchBtn"
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white font-medium rounded-lg shadow-md hover:shadow transition flex items-center justify-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
<span>Launch WinBox</span>
</button>
</div>
</form>
<!-- Status Footer (Visible on Auto-open) -->
<div id="autoOpenNotice" class="hidden bg-blue-50 border-t border-blue-100 p-3 text-center text-xs text-blue-800">
Triggered WinBox connection. You may now close this tab.
</div>
</div>
<!-- Application Script -->
<script src="app.js"></script>
</body>
</html>

31
style.css Normal file
View File

@ -0,0 +1,31 @@
/* Additional custom styles & overrides */
:root {
color-scheme: light;
}
/* Ensure monospace fonts render consistently across OS platforms */
#urlDisplay {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
letter-spacing: -0.01em;
}
/* Smooth fade and disabled states for credentials card */
#credentialsFields.disabled-state {
opacity: 0.35;
pointer-events: none;
user-select: none;
}
/* Focus outline refinements for form elements */
input:focus, select:focus {
outline: none;
}
/* Eye button toggle animation */
#togglePasswordVisibility svg {
transition: transform 0.15s ease-in-out;
}
#togglePasswordVisibility:active svg {
transform: scale(0.9);
}