Files
versitygw/webui/web/index.html
T
niksis02 abb3b27149 feat: add standalone IAM support in WebGUI
Gates bucket listing behind an identity policy, lets browsers reach the standalone IAM API, and turns the WebUI into a dashboard for S3, IAM, or both.

**Bucket listing.** `ListBuckets` is now gated by the new `s3:ListAllMyBuckets` action, evaluated against `arn:aws:s3:::*`. The request names no bucket, so only identity policies apply — there is no resource policy to combine with, which is the same shape `CreateBucket` already had, so both now share one identity-only evaluation path. Root and admin bypass it, and backends with no identity-policy layer keep listing as before since their listing is already narrowed to the caller's own buckets. The action is IAM-only and is deliberately absent from the bucket-policy action list.

**Fixed bucket ownership.** The standalone IAM client has no per-user ownership to express — accounts are all plain users, cannot be enumerated, and access is decided by policy rather than ACL — so it now implements `auth.FixedBucketOwner` and every bucket is owned by root. Bucket creation stops resolving an owner, `ListBuckets` returns every bucket to every caller (what they may then do with one stays a per-request policy decision), and the admin `ChangeBucketOwner` reports method-not-supported. Other IAM backends are untouched.

**IAM service CORS.** `--cors-allow-origin` now applies to the `iam` command: it answers preflights and stamps the CORS headers, mirroring back the requested method and headers rather than enumerating the SigV4 header set. Without it no browser can reach the IAM API at all, so setting `--webui` without it falls back to `*` with a warning. The chart gets `iamServer.corsAllowOrigin`.

**WebUI.** New IAM pages for users, roles and OIDC providers, signing IAM/STS query-form requests directly from the browser. Navigation is capability-gated rather than role-gated: on sign-in the session probes the S3, admin and IAM endpoints independently and each page shows only what those credentials actually reach, so one build serves an IAM-only dashboard, an S3-only dashboard, and a combined one. The login page takes an optional IAM endpoint, seeded from the new `--webui-iam-gateways` (chart: `webui.iamGateways`) — never auto-detected, since the IAM service is a separate process. The WebUI can also be hosted by `versitygw iam` itself, for deployments with no S3 gateway behind it.

**The admin API is ignored once an IAM endpoint is in play.** The IAM service is then the user directory and bucket ownership is fixed, which leaves the admin API no job: the session is given no admin endpoint at all, its login field is hidden, `users.html` redirects to its IAM counterpart, and every admin-only surface stays off screen. Dashboard and Buckets remain available to any S3 session in such a deployment, running on the S3 and IAM APIs alone and surfacing each denial per action instead of redirecting.

Also fixes two WebUI bugs: embedded assets went out with a zero modification time and no `Cache-Control`, so browsers treated them as fresh for centuries and an upgraded gateway served new HTML against stale JS — they now revalidate against an ETag; and the login page's advanced-options section clipped its last field, since it animated to a height named in the stylesheet rather than the one it measures now.

**Usage**

IAM-only dashboard, served by the IAM service:

    versitygw iam --port :7076 --webui :8080 --cors-allow-origin http://localhost:8080/

IAM + S3, dashboard served by the IAM service — point it at the gateway with `--webui-gateways`, and let the gateway accept the dashboard's origin:

    versitygw iam --port :7076 --webui :8080 --webui-gateways http://localhost:7070/ --cors-allow-origin http://localhost:8080/
    versitygw --port :7070 --cors-allow-origin http://localhost:8080/ posix /data

IAM + S3, dashboard served by the S3 gateway — point it at the IAM service with `--webui-iam-gateways`, and let the IAM service accept the dashboard's origin:

    versitygw --port :7070 --webui :8080 --webui-iam-gateways http://localhost:7076/ posix /data
    versitygw iam --port :7076 --cors-allow-origin http://localhost:8080/
2026-08-27 20:28:51 +04:00

790 lines
34 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">
<base href="{{.BasePath}}">
<title>VersityGW Admin - Login</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/js/tailwind.js"></script>
<script src="assets/css/tailwind-config.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<link rel="stylesheet" href="assets/css/theme.css">
<link rel="icon" type="image/png" href="assets/images/favicon.png">
</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 id="s3-endpoint-field">
<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"
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 id="admin-endpoint-field">
<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"
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>
<!-- IAM Endpoint URL (optional) -->
<div id="iam-endpoint-field">
<label class="block text-sm font-medium text-charcoal-400 mb-2">IAM API Endpoint</label>
<div class="relative" id="iam-endpoint-container">
<input
type="url"
id="iam-endpoint-select"
placeholder="http://localhost:7076"
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('iam-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="iam-endpoint-dropdown" class="custom-dropdown">
<!-- Populated dynamically -->
</div>
</div>
<p class="mt-2 text-xs text-charcoal-300">Optional. Set this to the standalone IAM service to manage IAM users, roles and identity providers. When set, the IAM service is the user-management system and the Admin API is not used. Leave the S3 endpoint blank to sign in to IAM alone.</p>
</div>
<!-- Region Selector -->
<div id="region-field">
<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 id="addressing-style-field">
<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">
&copy; 2025 Versity Software Inc.
</p>
</div>
<script>
// Redirect if already authenticated
redirectIfAuthenticated();
// ============================================
// Advanced Options Toggle
// ============================================
let advancedOptionsTimer = null;
function toggleAdvancedOptions() {
const toggle = document.getElementById('advanced-options-toggle');
const section = document.getElementById('advanced-options-section');
const opening = !toggle.classList.contains('expanded');
toggle.classList.toggle('expanded', opening);
clearTimeout(advancedOptionsTimer);
// Both directions animate between 0 and a height measured here rather
// than a number named in the stylesheet, which clipped the last field
// once the fields outgrew it. Opening measures the content, closing
// measures what is on screen (possibly a part-grown section), and
// pinning the height gives a close somewhere to animate from.
const pinned = opening ? section.scrollHeight : section.getBoundingClientRect().height;
section.style.maxHeight = pinned + 'px';
section.style.overflow = '';
if (!opening) {
// Force the pinned height to take effect, so releasing it below is a
// change the browser can animate rather than one it never sees.
void section.offsetHeight;
section.style.maxHeight = '';
return;
}
advancedOptionsTimer = setTimeout(() => {
// A finished section keeps no cap, so it resizes with whichever
// fields are shown and lets dropdowns hang past its bottom edge.
// Inline rather than a class, so a browser holding a stale stylesheet
// cannot leave the section stuck shut.
section.style.maxHeight = 'none';
section.style.overflow = 'visible';
}, parseFloat(getComputedStyle(section).transitionDuration) * 1000);
}
// ============================================
// Configured Gateways
// ============================================
let configuredGateways = [];
let configuredAdminGateways = [];
let configuredIamGateways = [];
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;
}
function loadConfiguredGateways() {
const cfg = window.__VGWCONFIG__ || {};
// Each list is read on its own. The IAM service serves this page with no
// S3 gateways at all, so an absent gateways list is a real configuration
// and must not discard the IAM list injected alongside it.
const list = (value) => (Array.isArray(value) ? value : []);
const gateways = list(cfg.gateways);
return {
gateways,
adminGateways: list(cfg.adminGateways).length > 0 ? list(cfg.adminGateways) : gateways,
iamGateways: list(cfg.iamGateways),
defaultRegion: normalizeRegion(typeof cfg.defaultRegion === 'string' ? cfg.defaultRegion : null),
};
}
function initConfiguredGateways() {
const cfg = loadConfiguredGateways();
configuredGateways = uniqNonEmpty(cfg.gateways);
configuredAdminGateways = uniqNonEmpty(cfg.adminGateways);
configuredIamGateways = uniqNonEmpty(cfg.iamGateways);
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]);
}
// Default the iam-endpoint input to the first configured IAM gateway (if any).
// There is no fallback to the S3/admin gateways: the IAM service runs as a
// separate process, so an unset flag means "no IAM endpoint", not "same host".
const iamEndpointInput = document.getElementById('iam-endpoint-select');
if (configuredIamGateways.length > 0 && iamEndpointInput && !iamEndpointInput.value.trim()) {
iamEndpointInput.value = configuredIamGateways[0];
onIamEndpointInput(configuredIamGateways[0]);
}
applyEndpointVisibility();
}
// ============================================
// 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();
}
// If opening iam-endpoint dropdown, populate it
if (name === 'iam-endpoint' && dropdown.classList.contains('show')) {
populateIamEndpointDropdown();
}
}
// 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('#iam-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);
}
// Populate iam-endpoint dropdown (with configured IAM gateways)
function populateIamEndpointDropdown() {
const dropdown = document.getElementById('iam-endpoint-dropdown');
const configured = uniqNonEmpty(configuredIamGateways);
dropdown.innerHTML = '';
if (configured.length === 0) {
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No IAM gateways configured</div>';
return;
}
configured.forEach(endpoint => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.textContent = endpoint;
item.addEventListener('click', () => selectIamEndpoint(endpoint));
dropdown.appendChild(item);
});
}
// Select an IAM endpoint from dropdown
function selectIamEndpoint(endpoint) {
document.getElementById('iam-endpoint-select').value = endpoint;
document.getElementById('iam-endpoint-dropdown').classList.remove('show');
onIamEndpointInput(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.
}
// Handle IAM endpoint input. An empty value is valid and simply means the
// IAM tab never lights up for this session.
function onIamEndpointInput(endpoint) {
// No auto-fill: IAM endpoints are not part of the recent-gateways history.
applyEndpointVisibility();
}
// Region and addressing style only describe how to talk to an S3
// gateway, so an IAM-only sign-in has nothing to configure there.
//
// The Admin API endpoint hides whenever an IAM endpoint is in play at all,
// S3 or not: the IAM service manages users and bucket ownership is fixed,
// leaving the admin API no job.
function applyEndpointVisibility() {
const s3Endpoint = document.getElementById('endpoint-select').value.trim();
const iamEndpoint = document.getElementById('iam-endpoint-select').value.trim();
const showS3Options = !!s3Endpoint || !iamEndpoint;
const adminField = document.getElementById('admin-endpoint-field');
if (adminField) adminField.style.display = iamEndpoint ? 'none' : '';
['region-field', 'addressing-style-field'].forEach(id => {
const field = document.getElementById(id);
if (field) field.style.display = showS3Options ? '' : 'none';
});
}
// Keep behavior consistent if user types an endpoint manually
document.getElementById('endpoint-select').addEventListener('input', (e) => {
onEndpointInput(e.target.value);
applyEndpointVisibility();
});
document.getElementById('admin-endpoint-select').addEventListener('input', (e) => {
onAdminEndpointInput(e.target.value);
});
document.getElementById('iam-endpoint-select').addEventListener('input', (e) => {
onIamEndpointInput(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 iamEndpoint = document.getElementById('iam-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. Either endpoint alone is a complete sign-in: this UI
// serves an S3-only deployment, an IAM-only one, and one running both.
if (!s3Endpoint && !iamEndpoint) {
showError('Please enter an S3 API endpoint, an IAM API endpoint, or both.');
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 (s3Endpoint && 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 {
// An empty field means "same place", not "no admin API": the Admin
// API shares the S3 gateway's port unless given one of its own, which
// is what the server assumes when --admin-port is unset.
//
// With an IAM endpoint in play the admin API is ignored outright, so
// the session gets no admin endpoint at all - which is what keeps
// every admin-only surface off screen. Its field is hidden but may
// still hold a server-prefilled value.
api.setCredentials(iamEndpoint ? '' : (adminEndpoint || s3Endpoint), accessKey, secretKey, region);
api.setS3Endpoint(s3Endpoint);
api.setAddressingStyle(addressingStyle);
// Optional third endpoint: only configured when the field is filled in
if (iamEndpoint) {
api.setIAMEndpoint(iamEndpoint);
}
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. The history is keyed by S3 endpoint, so
// an IAM-only sign-in has nothing to record.
if (s3Endpoint) {
const rememberKey = document.getElementById('remember-access-key').checked;
saveRecentGateway(s3Endpoint, region, accessKey, rememberKey);
}
// Navigate based on role
window.location.href = defaultLandingPage();
} catch (error) {
api.logout();
console.error('Login error:', error);
if (error.message.startsWith('Network error:')) {
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>