mirror of
https://github.com/versity/versitygw.git
synced 2026-02-04 09:22:02 +00:00
Implements a web interface for VersityGW with role-based access: - Object explorer for all users to browse, upload, and download S3 objects - Admin dashboard showing system overview and gateway status - Admin-only user management for IAM user administration - Admin-only bucket management for creating and configuring S3 buckets - User authentication with automatic role-based page access The web UI is disabled by default and only enabled with the --webui or VGW_WEBUI_PORT env options that specify the listening address/port for the web UI server. This preserves previous version behavior to not enable any new ports/services unless opted in. Login to the web UI login page with accesskey/secretkey credentials as either user or admin account. UI functionality will auto detect login role. Regular users have access to the object explorer for managing files within their accessible buckets. Admins additionally have access to user and bucket management interfaces. The web UI is served on a separate port from the S3 server and integrates with existing S3 and Admin API endpoints. All requests to the S3 and Admin services are signed by the browser and sent directly to the S3/Admin service handlers. The login credentials are never sent over the network for security purposes. This requires the S3/Admin service to configure CORS Access-Control-Allow-Origin headers for these requests.
837 lines
31 KiB
HTML
837 lines
31 KiB
HTML
<!--
|
|
Copyright 2026 Versity Software
|
|
This file is licensed under the Apache License, Version 2.0
|
|
(the "License"); you may not use this file except in compliance
|
|
with the License. You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing,
|
|
software distributed under the License is distributed on an
|
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
KIND, either express or implied. See the License for the
|
|
specific language governing permissions and limitations
|
|
under the License.
|
|
-->
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>VersityGW Admin - Login</title>
|
|
<script src="assets/js/crypto-js.min.js"></script>
|
|
<script src="assets/css/tailwind.js"></script>
|
|
<link rel="stylesheet" href="assets/css/fonts.css">
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
primary: {
|
|
DEFAULT: '#002A7A',
|
|
50: '#E6EBF4',
|
|
100: '#B3C2E0',
|
|
200: '#809ACC',
|
|
300: '#4D71B8',
|
|
400: '#264DA3',
|
|
500: '#002A7A',
|
|
600: '#002468',
|
|
700: '#001D56',
|
|
},
|
|
accent: {
|
|
DEFAULT: '#0076CD',
|
|
50: '#E6F3FA',
|
|
100: '#B3DCF2',
|
|
500: '#0076CD',
|
|
600: '#0065AF',
|
|
},
|
|
charcoal: {
|
|
DEFAULT: '#191B2A',
|
|
300: '#757884',
|
|
400: '#565968',
|
|
},
|
|
surface: {
|
|
DEFAULT: '#F3F8FC',
|
|
}
|
|
},
|
|
fontFamily: {
|
|
sans: ['Roboto', 'system-ui', 'sans-serif'],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
body { font-family: 'Roboto', system-ui, sans-serif; }
|
|
.input-icon {
|
|
position: absolute;
|
|
left: 14px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #808080;
|
|
}
|
|
.input-with-icon {
|
|
padding-left: 44px;
|
|
}
|
|
.password-toggle {
|
|
position: absolute;
|
|
right: 14px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #808080;
|
|
cursor: pointer;
|
|
background: none;
|
|
border: none;
|
|
padding: 4px;
|
|
}
|
|
.password-toggle:hover {
|
|
color: #002A7A;
|
|
}
|
|
.advanced-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
padding: 0.75rem 0;
|
|
margin: 0.5rem 0;
|
|
background: none;
|
|
border: none;
|
|
font: inherit;
|
|
color: inherit;
|
|
text-align: left;
|
|
width: 100%;
|
|
}
|
|
.advanced-toggle:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.advanced-toggle-carat {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
flex-shrink: 0;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
.advanced-toggle.expanded .advanced-toggle-carat {
|
|
transform: rotate(90deg);
|
|
}
|
|
.advanced-toggle-label {
|
|
font-weight: 500;
|
|
color: #565968;
|
|
cursor: pointer;
|
|
}
|
|
.advanced-options {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease-out;
|
|
}
|
|
.advanced-options.show {
|
|
max-height: 500px;
|
|
}
|
|
/* Custom dropdown styles */
|
|
.custom-dropdown {
|
|
display: none;
|
|
position: absolute;
|
|
z-index: 10;
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: white;
|
|
border: 2px solid #e5e7eb;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
max-height: 12rem;
|
|
overflow: auto;
|
|
}
|
|
.custom-dropdown.show {
|
|
display: block;
|
|
}
|
|
.custom-dropdown-item {
|
|
padding: 0.75rem 1rem;
|
|
cursor: pointer;
|
|
color: #191B2A;
|
|
transition: background-color 0.15s;
|
|
}
|
|
.custom-dropdown-item:hover {
|
|
background-color: #f9fafb;
|
|
}
|
|
.custom-dropdown-item.selected {
|
|
background-color: rgba(0, 118, 205, 0.1);
|
|
color: #0076CD;
|
|
}
|
|
/* Toggle Switch Styles */
|
|
.toggle-switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 60px;
|
|
height: 28px;
|
|
}
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #e5e7eb;
|
|
transition: 0.3s;
|
|
border-radius: 28px;
|
|
}
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 20px;
|
|
width: 20px;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
background-color: white;
|
|
transition: 0.3s;
|
|
border-radius: 50%;
|
|
}
|
|
input:checked + .toggle-slider {
|
|
background-color: #0076CD;
|
|
}
|
|
input:checked + .toggle-slider:before {
|
|
transform: translateX(32px);
|
|
}
|
|
.toggle-label {
|
|
font-size: 0.875rem;
|
|
color: #565968;
|
|
font-weight: 500;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen bg-gradient-to-br from-surface to-white flex items-center justify-center p-4">
|
|
<script src="js/api.js"></script>
|
|
<script src="js/app.js"></script>
|
|
|
|
<div class="w-full max-w-md">
|
|
<!-- Login Card -->
|
|
<div class="bg-white rounded-xl shadow-lg p-8">
|
|
<!-- Logo inside card -->
|
|
<div class="flex flex-col items-center mb-6">
|
|
<img src="assets/images/Versity-logo-blue-horizontal.png" alt="Versity" class="h-12">
|
|
<span class="text-charcoal font-semibold text-lg mt-2">S3 Gateway</span>
|
|
</div>
|
|
|
|
<!-- Error Alert -->
|
|
<div id="error-alert" class="hidden mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<div class="flex items-center gap-3">
|
|
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<p id="error-message" class="text-sm text-red-700">Invalid credentials.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="login-form" action="#" method="post" class="space-y-5">
|
|
<!-- Access Key -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">Access Key</label>
|
|
<input
|
|
type="text"
|
|
id="access-key"
|
|
name="username"
|
|
required
|
|
placeholder="Enter your access key"
|
|
autocomplete="username"
|
|
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
|
>
|
|
</div>
|
|
|
|
<!-- Secret Key -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">Secret Key</label>
|
|
<div class="relative">
|
|
<input
|
|
type="password"
|
|
id="secret-key"
|
|
name="password"
|
|
required
|
|
placeholder="Enter your secret key"
|
|
autocomplete="current-password"
|
|
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all pr-12"
|
|
>
|
|
<button type="button" onclick="togglePassword()" class="password-toggle">
|
|
<svg id="eye-icon" class="w-5 h-5" 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>
|
|
<svg id="eye-off-icon" class="w-5 h-5 hidden" 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 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Remember Access Key -->
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox" id="remember-access-key" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
|
<label for="remember-access-key" class="text-sm text-charcoal-400">Remember Access Key</label>
|
|
</div>
|
|
|
|
<!-- Advanced Options Toggle -->
|
|
<button type="button" id="advanced-options-toggle" class="advanced-toggle" onclick="toggleAdvancedOptions()">
|
|
<svg class="advanced-toggle-carat w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
<span class="advanced-toggle-label">Advanced Options</span>
|
|
</button>
|
|
|
|
<!-- Advanced Options Section -->
|
|
<div id="advanced-options-section" class="advanced-options space-y-5">
|
|
<!-- S3 Endpoint URL -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">S3 API Endpoint</label>
|
|
<div class="relative" id="endpoint-container">
|
|
<input
|
|
type="url"
|
|
id="endpoint-select"
|
|
required
|
|
placeholder="http://localhost:7070"
|
|
autocomplete="off"
|
|
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
|
>
|
|
<button type="button" onclick="toggleDropdown('endpoint')" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="endpoint-dropdown" class="custom-dropdown">
|
|
<!-- Populated dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin Endpoint URL -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">Admin API Endpoint</label>
|
|
<div class="relative" id="admin-endpoint-container">
|
|
<input
|
|
type="url"
|
|
id="admin-endpoint-select"
|
|
required
|
|
placeholder="http://localhost:7070"
|
|
autocomplete="off"
|
|
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
|
>
|
|
<button type="button" onclick="toggleDropdown('admin-endpoint')" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
<div id="admin-endpoint-dropdown" class="custom-dropdown">
|
|
<!-- Populated dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Region Selector -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">Region</label>
|
|
<div class="relative" id="region-container">
|
|
<input
|
|
type="text"
|
|
id="region-display"
|
|
readonly
|
|
value="us-east-1"
|
|
onclick="toggleDropdown('region')"
|
|
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal bg-white cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
|
>
|
|
<input type="hidden" id="region" value="us-east-1">
|
|
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
<div id="region-dropdown" class="custom-dropdown">
|
|
<div class="custom-dropdown-item selected" data-value="us-east-1" onclick="selectRegion('us-east-1')">us-east-1</div>
|
|
<div class="custom-dropdown-item" data-value="us-west-2" onclick="selectRegion('us-west-2')">us-west-2</div>
|
|
<div class="custom-dropdown-item" data-value="eu-west-1" onclick="selectRegion('eu-west-1')">eu-west-1</div>
|
|
<div class="custom-dropdown-item" data-value="ap-southeast-1" onclick="selectRegion('ap-southeast-1')">ap-southeast-1</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bucket Addressing Style -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-charcoal-400 mb-2">Bucket Addressing Style</label>
|
|
<div class="flex items-center justify-between">
|
|
<span class="toggle-label">Path Style</span>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="addressing-style-toggle" onchange="toggleAddressingStyle()">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="toggle-label">Virtual Host</span>
|
|
</div>
|
|
<input type="hidden" id="addressing-style" value="path">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<button
|
|
type="submit"
|
|
id="submit-btn"
|
|
class="w-full bg-primary hover:bg-primary-600 active:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-150 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2"
|
|
>
|
|
Sign In
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<p class="text-center text-charcoal-300 text-sm mt-6">
|
|
© 2025 Versity Software Inc.
|
|
</p>
|
|
</div>
|
|
|
|
<script>
|
|
// Redirect if already authenticated
|
|
redirectIfAuthenticated();
|
|
|
|
// ============================================
|
|
// Advanced Options Toggle
|
|
// ============================================
|
|
function toggleAdvancedOptions() {
|
|
const toggle = document.getElementById('advanced-options-toggle');
|
|
const section = document.getElementById('advanced-options-section');
|
|
|
|
toggle.classList.toggle('expanded');
|
|
section.classList.toggle('show');
|
|
}
|
|
|
|
// ============================================
|
|
// Configured Gateways (from vgwmgr CLI)
|
|
// ============================================
|
|
let configuredGateways = [];
|
|
let configuredAdminGateways = [];
|
|
let configuredDefaultRegion = null;
|
|
|
|
function normalizeEndpoint(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function normalizeRegion(value) {
|
|
const s = String(value || '').trim();
|
|
return s || null;
|
|
}
|
|
|
|
function uniqNonEmpty(values) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
(values || []).forEach(v => {
|
|
const s = normalizeEndpoint(v);
|
|
if (!s) return;
|
|
const key = s.toLowerCase();
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
out.push(s);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
async function loadConfiguredGateways() {
|
|
try {
|
|
const res = await fetch('/api/gateways', { cache: 'no-store' });
|
|
if (!res.ok) return { gateways: [], adminGateways: [], defaultRegion: null };
|
|
const data = await res.json();
|
|
if (!data || !Array.isArray(data.gateways)) return { gateways: [], adminGateways: [], defaultRegion: null };
|
|
return {
|
|
gateways: data.gateways,
|
|
adminGateways: data.adminGateways || data.gateways || [],
|
|
defaultRegion: normalizeRegion(typeof data.defaultRegion === 'string' ? data.defaultRegion : null),
|
|
};
|
|
} catch (e) {
|
|
return { gateways: [], adminGateways: [], defaultRegion: null };
|
|
}
|
|
}
|
|
|
|
async function initConfiguredGateways() {
|
|
const cfg = await loadConfiguredGateways();
|
|
configuredGateways = uniqNonEmpty(cfg.gateways);
|
|
configuredAdminGateways = uniqNonEmpty(cfg.adminGateways);
|
|
configuredDefaultRegion = cfg.defaultRegion;
|
|
|
|
// Apply default region from server only if user hasn't changed it yet
|
|
if (configuredDefaultRegion) {
|
|
const hidden = document.getElementById('region');
|
|
const display = document.getElementById('region-display');
|
|
|
|
const looksUntouched =
|
|
hidden && display &&
|
|
hidden.value === 'us-east-1' &&
|
|
display.value === 'us-east-1';
|
|
|
|
if (looksUntouched) {
|
|
setRegion(configuredDefaultRegion);
|
|
}
|
|
}
|
|
|
|
// Default the endpoint input to the first configured gateway (if user hasn't typed one)
|
|
const endpointInput = document.getElementById('endpoint-select');
|
|
if (configuredGateways.length > 0 && endpointInput && !endpointInput.value.trim()) {
|
|
endpointInput.value = configuredGateways[0];
|
|
onEndpointInput(configuredGateways[0], { skipRegion: true });
|
|
}
|
|
|
|
// Default the admin-endpoint input to the first configured admin gateway (if user hasn't typed one)
|
|
const adminEndpointInput = document.getElementById('admin-endpoint-select');
|
|
if (configuredAdminGateways.length > 0 && adminEndpointInput && !adminEndpointInput.value.trim()) {
|
|
adminEndpointInput.value = configuredAdminGateways[0];
|
|
onAdminEndpointInput(configuredAdminGateways[0]);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Recent Gateways (localStorage)
|
|
// ============================================
|
|
const RECENT_GATEWAYS_KEY = 'vgw_recent_gateways';
|
|
const MAX_RECENT_GATEWAYS = 5;
|
|
|
|
// Load recent gateways from localStorage
|
|
function loadRecentGateways() {
|
|
const stored = localStorage.getItem(RECENT_GATEWAYS_KEY);
|
|
if (!stored) return [];
|
|
try {
|
|
return JSON.parse(stored);
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Save gateway to recent list (call on successful login)
|
|
function saveRecentGateway(endpoint, region, accessKey, rememberKey) {
|
|
let gateways = loadRecentGateways();
|
|
|
|
// Remove existing entry for this endpoint
|
|
gateways = gateways.filter(g => g.endpoint !== endpoint);
|
|
|
|
// Add new entry at the beginning
|
|
gateways.unshift({
|
|
endpoint,
|
|
region,
|
|
accessKey: rememberKey ? accessKey : null,
|
|
lastUsed: Date.now()
|
|
});
|
|
|
|
// Keep only last 5
|
|
gateways = gateways.slice(0, MAX_RECENT_GATEWAYS);
|
|
|
|
localStorage.setItem(RECENT_GATEWAYS_KEY, JSON.stringify(gateways));
|
|
}
|
|
|
|
// ============================================
|
|
// Custom Dropdown Functions
|
|
// ============================================
|
|
|
|
// Toggle any dropdown
|
|
function toggleDropdown(name) {
|
|
const dropdown = document.getElementById(name + '-dropdown');
|
|
const allDropdowns = document.querySelectorAll('.custom-dropdown');
|
|
|
|
// Close all other dropdowns
|
|
allDropdowns.forEach(d => {
|
|
if (d.id !== name + '-dropdown') d.classList.remove('show');
|
|
});
|
|
|
|
dropdown.classList.toggle('show');
|
|
|
|
// If opening endpoint dropdown, populate it
|
|
if (name === 'endpoint' && dropdown.classList.contains('show')) {
|
|
populateEndpointDropdown();
|
|
}
|
|
// If opening admin-endpoint dropdown, populate it
|
|
if (name === 'admin-endpoint' && dropdown.classList.contains('show')) {
|
|
populateAdminEndpointDropdown();
|
|
}
|
|
}
|
|
|
|
// Close all dropdowns when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#endpoint-container') && !e.target.closest('#admin-endpoint-container') && !e.target.closest('#region-container')) {
|
|
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
|
|
}
|
|
});
|
|
|
|
// Populate endpoint dropdown with recent gateways
|
|
function populateEndpointDropdown() {
|
|
const dropdown = document.getElementById('endpoint-dropdown');
|
|
const recent = loadRecentGateways();
|
|
|
|
// Build a combined list: configured gateways first, then recents not already listed
|
|
const configured = uniqNonEmpty(configuredGateways);
|
|
const recentEndpoints = uniqNonEmpty(recent.map(r => r.endpoint));
|
|
const configuredSet = new Set(configured.map(e => e.toLowerCase()));
|
|
const combined = configured.concat(recentEndpoints.filter(e => !configuredSet.has(e.toLowerCase())));
|
|
|
|
dropdown.innerHTML = '';
|
|
|
|
if (combined.length === 0) {
|
|
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No gateways configured</div>';
|
|
return;
|
|
}
|
|
|
|
combined.forEach(endpoint => {
|
|
const item = document.createElement('div');
|
|
item.className = 'custom-dropdown-item';
|
|
item.textContent = endpoint;
|
|
item.addEventListener('click', () => selectEndpoint(endpoint));
|
|
dropdown.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Populate admin-endpoint dropdown (with configured admin gateways)
|
|
function populateAdminEndpointDropdown() {
|
|
const dropdown = document.getElementById('admin-endpoint-dropdown');
|
|
|
|
// Build a combined list: configured admin gateways first, then all configured gateways as fallback
|
|
const configured = uniqNonEmpty(configuredAdminGateways.length > 0 ? configuredAdminGateways : configuredGateways);
|
|
|
|
dropdown.innerHTML = '';
|
|
|
|
if (configured.length === 0) {
|
|
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No admin gateways configured</div>';
|
|
return;
|
|
}
|
|
|
|
configured.forEach(endpoint => {
|
|
const item = document.createElement('div');
|
|
item.className = 'custom-dropdown-item';
|
|
item.textContent = endpoint;
|
|
item.addEventListener('click', () => selectAdminEndpoint(endpoint));
|
|
dropdown.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Select an endpoint from dropdown
|
|
function selectEndpoint(endpoint) {
|
|
document.getElementById('endpoint-select').value = endpoint;
|
|
document.getElementById('endpoint-dropdown').classList.remove('show');
|
|
onEndpointInput(endpoint);
|
|
}
|
|
|
|
// Select an admin endpoint from dropdown
|
|
function selectAdminEndpoint(endpoint) {
|
|
document.getElementById('admin-endpoint-select').value = endpoint;
|
|
document.getElementById('admin-endpoint-dropdown').classList.remove('show');
|
|
onAdminEndpointInput(endpoint);
|
|
}
|
|
|
|
// Select a region from dropdown
|
|
function selectRegion(value) {
|
|
const display = document.getElementById('region-display');
|
|
const hidden = document.getElementById('region');
|
|
const dropdown = document.getElementById('region-dropdown');
|
|
|
|
// Update selected state
|
|
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
|
item.classList.toggle('selected', item.dataset.value === value);
|
|
});
|
|
|
|
display.value = value;
|
|
hidden.value = value;
|
|
|
|
dropdown.classList.remove('show');
|
|
}
|
|
|
|
// Toggle addressing style between path and virtual-host
|
|
function toggleAddressingStyle() {
|
|
const toggle = document.getElementById('addressing-style-toggle');
|
|
const hidden = document.getElementById('addressing-style');
|
|
|
|
// When toggle is checked, use virtual-host; unchecked is path
|
|
hidden.value = toggle.checked ? 'virtual-host' : 'path';
|
|
}
|
|
|
|
// Auto-fill access key, region and checkbox when endpoint is selected
|
|
function onEndpointInput(endpoint, opts = {}) {
|
|
const normalized = normalizeEndpoint(endpoint);
|
|
const gateways = loadRecentGateways();
|
|
const match = gateways.find(g => g.endpoint === normalized);
|
|
if (match) {
|
|
// Auto-fill access key if remembered
|
|
if (match.accessKey) {
|
|
document.getElementById('access-key').value = match.accessKey;
|
|
// Check the "Remember Access Key" checkbox since it was previously remembered
|
|
document.getElementById('remember-access-key').checked = true;
|
|
} else {
|
|
// Access key not remembered - uncheck the checkbox
|
|
document.getElementById('remember-access-key').checked = false;
|
|
}
|
|
// Auto-fill region
|
|
if (!opts.skipRegion && match.region) {
|
|
setRegion(match.region);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle admin endpoint input (placeholder for future admin-specific logic)
|
|
function onAdminEndpointInput(endpoint) {
|
|
// For now, just acknowledge the change. Can be extended with admin-specific logic.
|
|
}
|
|
|
|
// Keep behavior consistent if user types an endpoint manually
|
|
document.getElementById('endpoint-select').addEventListener('input', (e) => {
|
|
onEndpointInput(e.target.value);
|
|
});
|
|
|
|
document.getElementById('admin-endpoint-select').addEventListener('input', (e) => {
|
|
onAdminEndpointInput(e.target.value);
|
|
});
|
|
|
|
// Helper to set region (works with custom dropdown)
|
|
function setRegion(region) {
|
|
const dropdown = document.getElementById('region-dropdown');
|
|
|
|
const normalized = normalizeRegion(region);
|
|
if (!normalized) return;
|
|
|
|
const existingItem = dropdown.querySelector(`.custom-dropdown-item[data-value="${CSS.escape(normalized)}"]`);
|
|
if (!existingItem) {
|
|
const item = document.createElement('div');
|
|
item.className = 'custom-dropdown-item';
|
|
item.dataset.value = normalized;
|
|
item.textContent = normalized;
|
|
item.addEventListener('click', () => selectRegion(normalized));
|
|
|
|
// Insert at the top of the list so the default is visible
|
|
dropdown.insertBefore(item, dropdown.firstChild);
|
|
}
|
|
|
|
selectRegion(normalized);
|
|
}
|
|
|
|
// Load configured gateways ASAP (needs setRegion defined)
|
|
initConfiguredGateways();
|
|
|
|
function getSelectedRegion() {
|
|
return document.getElementById('region').value;
|
|
}
|
|
|
|
function togglePassword() {
|
|
const input = document.getElementById('secret-key');
|
|
const eyeIcon = document.getElementById('eye-icon');
|
|
const eyeOffIcon = document.getElementById('eye-off-icon');
|
|
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
eyeIcon.classList.add('hidden');
|
|
eyeOffIcon.classList.remove('hidden');
|
|
} else {
|
|
input.type = 'password';
|
|
eyeIcon.classList.remove('hidden');
|
|
eyeOffIcon.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function showError(message) {
|
|
const alert = document.getElementById('error-alert');
|
|
const msgEl = document.getElementById('error-message');
|
|
msgEl.textContent = message;
|
|
alert.classList.remove('hidden');
|
|
}
|
|
|
|
function hideError() {
|
|
document.getElementById('error-alert').classList.add('hidden');
|
|
}
|
|
|
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
hideError();
|
|
|
|
const s3Endpoint = document.getElementById('endpoint-select').value.trim();
|
|
const adminEndpoint = document.getElementById('admin-endpoint-select').value.trim();
|
|
const accessKey = document.getElementById('access-key').value.trim();
|
|
const secretKey = document.getElementById('secret-key').value;
|
|
const region = getSelectedRegion();
|
|
const addressingStyle = document.getElementById('addressing-style').value;
|
|
|
|
// Validate inputs
|
|
if (!s3Endpoint) {
|
|
showError('Please enter an S3 API endpoint.');
|
|
return;
|
|
}
|
|
if (!adminEndpoint) {
|
|
showError('Please enter an Admin API endpoint.');
|
|
return;
|
|
}
|
|
if (!accessKey || !secretKey) {
|
|
showError('Please enter both access key and secret key.');
|
|
return;
|
|
}
|
|
|
|
// Validate that virtual host style is not used with IP addresses
|
|
if (addressingStyle === 'virtual-host') {
|
|
try {
|
|
const url = new URL(s3Endpoint);
|
|
const hostname = url.hostname;
|
|
// Check for IPv4 (e.g., 192.168.1.1) or IPv6 (e.g., [::1] or 2001:db8::1)
|
|
const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
|
|
const isIPv6 = hostname.includes(':') || hostname.startsWith('[');
|
|
|
|
if (isIPv4 || isIPv6) {
|
|
showError('Virtual Host addressing style cannot be used with IP addresses. Please use a domain name or switch to Path Style.');
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
// If URL parsing fails, let it continue and fail later with a more specific error
|
|
}
|
|
}
|
|
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
setLoading(submitBtn, true);
|
|
|
|
try {
|
|
// Set credentials with admin endpoint, then configure s3 endpoint separately
|
|
api.setCredentials(adminEndpoint, accessKey, secretKey, region);
|
|
api.setS3Endpoint(s3Endpoint);
|
|
api.setAddressingStyle(addressingStyle);
|
|
const role = await api.detectRole();
|
|
|
|
if (role === 'none') {
|
|
api.logout();
|
|
showError('Invalid credentials or no access. Please check your access key and secret key.');
|
|
return;
|
|
}
|
|
|
|
// Store user type based on role
|
|
// Admin role means they have Admin API access
|
|
let userType = role === 'admin' ? 'admin' : 'user';
|
|
api.setUserContext(userType, []);
|
|
|
|
// Save gateway to recent list
|
|
const rememberKey = document.getElementById('remember-access-key').checked;
|
|
saveRecentGateway(s3Endpoint, region, accessKey, rememberKey);
|
|
|
|
// Navigate based on role
|
|
if (role === 'admin') {
|
|
// Admin user - redirect to dashboard
|
|
window.location.href = 'dashboard.html';
|
|
} else {
|
|
// Regular user with S3 access - redirect to explorer
|
|
window.location.href = 'explorer.html';
|
|
}
|
|
} catch (error) {
|
|
api.logout();
|
|
console.error('Login error:', error);
|
|
|
|
if (error.message.includes('CORS blocked')) {
|
|
showError(error.message);
|
|
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
|
showError('Unable to connect to the gateway. Please check the endpoint URL and ensure the server is running.');
|
|
} else if (error.message.includes('SignatureDoesNotMatch')) {
|
|
showError('Invalid credentials. Please check your access key and secret key.');
|
|
} else {
|
|
showError(error.message || 'An error occurred. Please try again.');
|
|
}
|
|
} finally {
|
|
setLoading(submitBtn, false);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|