1
This commit is contained in:
commit
d194a8f0df
207
app.js
Normal file
207
app.js
Normal 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
152
index.html
Normal 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
31
style.css
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user