diff --git a/webui/web/assets/css/tailwind-config.js b/webui/web/assets/css/tailwind-config.js
new file mode 100644
index 00000000..826e03dd
--- /dev/null
+++ b/webui/web/assets/css/tailwind-config.js
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ */
+
+// Shared Tailwind CSS configuration for all pages.
+// Edit this file to change the global color palette, typography, etc.
+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'],
+ },
+ }
+ }
+}
diff --git a/webui/web/assets/css/theme.css b/webui/web/assets/css/theme.css
new file mode 100644
index 00000000..d024abce
--- /dev/null
+++ b/webui/web/assets/css/theme.css
@@ -0,0 +1,194 @@
+/*
+ * 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.
+ */
+
+/*
+ * theme.css — Global shared styles for all pages.
+ * Page-specific styles remain inline in their respective HTML files.
+ */
+
+body {
+ font-family: 'Roboto', system-ui, sans-serif;
+}
+
+/* Sidebar Navigation */
+.nav-item {
+ transition: all 0.15s ease;
+}
+.nav-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+.nav-item.active {
+ background: rgba(0, 118, 205, 0.2);
+ border-left: 4px solid #0076CD;
+}
+
+/* Modal */
+.modal-backdrop {
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+}
+
+/* Custom Dropdown */
+.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;
+}
+/* Dropup variant — opens upward */
+.custom-dropdown.dropup {
+ bottom: 100%;
+ top: auto;
+ margin-top: 0;
+ margin-bottom: 4px;
+}
+
+/* Toggle Switch */
+.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;
+}
+
+/* Login Page */
+.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;
+}
diff --git a/webui/web/buckets.html b/webui/web/buckets.html
index 565a6f9e..9d19543c 100644
--- a/webui/web/buckets.html
+++ b/webui/web/buckets.html
@@ -21,67 +21,10 @@ under the License.
VersityGW Admin - Buckets
+
+
-
-
diff --git a/webui/web/dashboard.html b/webui/web/dashboard.html
index 855a3431..7a705b14 100644
--- a/webui/web/dashboard.html
+++ b/webui/web/dashboard.html
@@ -21,29 +21,10 @@ under the License.
VersityGW Admin - Dashboard
+
+
-
-
diff --git a/webui/web/explorer.html b/webui/web/explorer.html
index c4615bec..29c65671 100644
--- a/webui/web/explorer.html
+++ b/webui/web/explorer.html
@@ -21,28 +21,11 @@ under the License.
VersityGW - Explorer
+
+
-
@@ -405,7 +224,7 @@ under the License.
}
// ============================================
- // Configured Gateways (from vgwmgr CLI)
+ // Configured Gateways
// ============================================
let configuredGateways = [];
let configuredAdminGateways = [];
@@ -434,24 +253,18 @@ under the License.
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 };
- }
+ function loadConfiguredGateways() {
+ const cfg = window.__VGWCONFIG__ || {};
+ if (!Array.isArray(cfg.gateways)) return { gateways: [], adminGateways: [], defaultRegion: null };
+ return {
+ gateways: cfg.gateways,
+ adminGateways: cfg.adminGateways || cfg.gateways || [],
+ defaultRegion: normalizeRegion(typeof cfg.defaultRegion === 'string' ? cfg.defaultRegion : null),
+ };
}
- async function initConfiguredGateways() {
- const cfg = await loadConfiguredGateways();
+ function initConfiguredGateways() {
+ const cfg = loadConfiguredGateways();
configuredGateways = uniqNonEmpty(cfg.gateways);
configuredAdminGateways = uniqNonEmpty(cfg.adminGateways);
configuredDefaultRegion = cfg.defaultRegion;
diff --git a/webui/web/js/api.js b/webui/web/js/api.js
index 0ec6de2f..0e9fd1de 100644
--- a/webui/web/js/api.js
+++ b/webui/web/js/api.js
@@ -291,15 +291,6 @@ class VersityAPI {
// User Context Methods (ROOT user detection)
// ============================================
- /**
- * Detect ROOT user and get accessible gateways
- * Calls /api/detect-root endpoint to check if credentials match ROOT config
- * @param {string} accessKey - Access key to check
- * @param {string} secretKey - Secret key to check
- * @returns {Object} - { userType: 'root'|'user', matchingGateways: [...] }
- */
- // detectRootUser removed - single gateway mode
-
/**
* Store user type and accessible gateways in session
* @param {string} userType - 'root' | 'admin' | 'user'
@@ -318,26 +309,6 @@ class VersityAPI {
this._userType = sessionStorage.getItem('vgw_user_type') || 'user';
}
- /**
- * Check if current user is ROOT user (has ROOT credentials matching gateway configs)
- * @returns {boolean}
- */
- // isRootUser removed
-
- /**
- * Get list of gateways accessible to current user
- * Only populated for ROOT users
- * @returns {Array} - Array of { name, port, endpoint, region, status }
- */
- // getAccessibleGateways removed
-
- /**
- * Check if authenticated
- */
- isAuthenticated() {
- return this.credentials !== null && this.s3Endpoint !== null;
- }
-
/**
* Check if user has admin privileges
*/
diff --git a/webui/web/js/config.js.tmpl b/webui/web/js/config.js.tmpl
new file mode 100644
index 00000000..b2f73741
--- /dev/null
+++ b/webui/web/js/config.js.tmpl
@@ -0,0 +1 @@
+window.__VGWCONFIG__ = {{.ConfigJSON}};
diff --git a/webui/web/users.html b/webui/web/users.html
index 8d9dc1bf..219e1b68 100644
--- a/webui/web/users.html
+++ b/webui/web/users.html
@@ -21,60 +21,10 @@ under the License.
VersityGW Admin - Users
+
+
-
-
diff --git a/webui/webserver.go b/webui/webserver.go
index 0f63a560..97f9ff2e 100644
--- a/webui/webserver.go
+++ b/webui/webserver.go
@@ -15,9 +15,11 @@
package webui
import (
+ "encoding/json"
"fmt"
"net"
"net/http"
+ "strings"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
@@ -96,32 +98,48 @@ func (s *Server) setupMiddleware() {
// setupRoutes configures all routes
func (s *Server) setupRoutes() {
- // API endpoint to get configured gateways
- s.app.Get("/api/gateways", s.handleGetGateways)
+ // Serve index.html
+ s.app.Get("/", s.handleIndexHTML)
+ s.app.Get("/index.html", s.handleIndexHTML)
// Serve embedded static files from web/
s.app.Use("/", filesystem.New(filesystem.Config{
- Root: http.FS(webFS),
- PathPrefix: "web",
- Index: "index.html",
- NotFoundFile: "index.html", // SPA fallback
- Browse: false,
+ Root: http.FS(webFS),
+ PathPrefix: "web",
+ Browse: false,
}))
}
-// handleGetGateways returns the configured gateway URLs (both S3 and Admin)
-func (s *Server) handleGetGateways(c *fiber.Ctx) error {
+// handleIndexHTML serves index.html with server config injected as an inline script.
+func (s *Server) handleIndexHTML(c *fiber.Ctx) error {
+ data, err := webFiles.ReadFile("web/index.html")
+ if err != nil {
+ return fiber.ErrInternalServerError
+ }
+
adminGateways := s.config.AdminGateways
if len(adminGateways) == 0 {
- // Fallback to S3 gateways if admin gateways not configured
adminGateways = s.config.Gateways
}
- return c.JSON(fiber.Map{
+ configJSON, err := json.Marshal(map[string]interface{}{
"gateways": s.config.Gateways,
"adminGateways": adminGateways,
"defaultRegion": s.config.Region,
})
+ if err != nil {
+ return fiber.ErrInternalServerError
+ }
+
+ html := strings.Replace(
+ string(data),
+ "",
+ "",
+ 1,
+ )
+
+ c.Set("Content-Type", "text/html; charset=utf-8")
+ return c.SendString(html)
}
// ServeMultiPort creates listeners for multiple address specifications and serves