mirror of
https://github.com/versity/versitygw.git
synced 2026-08-22 07:06:19 +00:00
Merge pull request #1763 from versity/ben/webgui
This commit is contained in:
+217
-4
@@ -23,6 +23,7 @@ import (
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3event"
|
||||
"github.com/versity/versitygw/s3log"
|
||||
"github.com/versity/versitygw/webui"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -90,6 +92,9 @@ var (
|
||||
ipaUser, ipaPassword string
|
||||
ipaInsecure bool
|
||||
iamDebug bool
|
||||
webuiAddr string
|
||||
webuiCertFile, webuiKeyFile string
|
||||
webuiNoTLS bool
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -167,6 +172,30 @@ func initFlags() []cli.Flag {
|
||||
Destination: &port,
|
||||
Aliases: []string{"p"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "webui",
|
||||
Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071'; disabled when omitted)",
|
||||
EnvVars: []string{"VGW_WEBUI_PORT"},
|
||||
Destination: &webuiAddr,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "webui-cert",
|
||||
Usage: "TLS cert file for WebUI (defaults to --cert value when WebUI is enabled)",
|
||||
EnvVars: []string{"VGW_WEBUI_CERT"},
|
||||
Destination: &webuiCertFile,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "webui-key",
|
||||
Usage: "TLS key file for WebUI (defaults to --key value when WebUI is enabled)",
|
||||
EnvVars: []string{"VGW_WEBUI_KEY"},
|
||||
Destination: &webuiKeyFile,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "webui-no-tls",
|
||||
Usage: "disable TLS for WebUI even if TLS is configured for the gateway",
|
||||
EnvVars: []string{"VGW_WEBUI_NO_TLS"},
|
||||
Destination: &webuiNoTLS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "access",
|
||||
Usage: "root user access key",
|
||||
@@ -645,6 +674,42 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
return fmt.Errorf("root user access and secret key must be provided")
|
||||
}
|
||||
|
||||
webuiAddr = strings.TrimSpace(webuiAddr)
|
||||
if webuiAddr != "" && isAllDigits(webuiAddr) {
|
||||
webuiAddr = ":" + webuiAddr
|
||||
}
|
||||
|
||||
// WebUI runs in a browser and typically talks to the gateway/admin APIs cross-origin
|
||||
// (different port). If no bucket CORS configuration exists, those API responses need
|
||||
// a default Access-Control-Allow-Origin to be usable from the WebUI.
|
||||
if webuiAddr != "" && strings.TrimSpace(corsAllowOrigin) == "" {
|
||||
// A single Access-Control-Allow-Origin value cannot cover multiple specific
|
||||
// origins. Default to '*' for usability and print a warning so operators can
|
||||
// lock it down explicitly.
|
||||
corsAllowOrigin = "*"
|
||||
webuiScheme := "http"
|
||||
if !webuiNoTLS && (strings.TrimSpace(webuiCertFile) != "" || strings.TrimSpace(certFile) != "") {
|
||||
webuiScheme = "https"
|
||||
}
|
||||
|
||||
// Suggest a more secure explicit origin based on the actual WebUI listening interfaces.
|
||||
// (Browsers require an exact origin match; this is typically one chosen hostname/IP.)
|
||||
var suggestion string
|
||||
ips, ipsErr := getMatchingIPs(webuiAddr)
|
||||
_, webPrt, prtErr := net.SplitHostPort(webuiAddr)
|
||||
if ipsErr == nil && prtErr == nil && len(ips) > 0 {
|
||||
origins := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
origins = append(origins, fmt.Sprintf("%s://%s:%s", webuiScheme, ip, webPrt))
|
||||
}
|
||||
suggestion = fmt.Sprintf("consider setting it to one of: %s (or your public hostname)", strings.Join(origins, ", "))
|
||||
} else {
|
||||
suggestion = fmt.Sprintf("consider setting it to %s://<host>:<port>", webuiScheme)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "WARNING: --webui is enabled but --cors-allow-origin is not set; defaulting to '*'; %s\n", suggestion)
|
||||
}
|
||||
|
||||
utils.SetBucketNameValidationStrict(!disableStrictBucketNames)
|
||||
|
||||
if pprof != "" {
|
||||
@@ -824,15 +889,96 @@ func runGateway(ctx context.Context, be backend.Backend) error {
|
||||
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, admPort, region, iam, loggers.AdminLogger, srv.Router.Ctrl, opts...)
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
printBanner(port, admPort, certFile != "", admCertFile != "")
|
||||
var webSrv *webui.Server
|
||||
webuiSSLEnabled := false
|
||||
if webuiAddr != "" {
|
||||
_, webPrt, err := net.SplitHostPort(webuiAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui listen address must be in the form ':port' or 'host:port': %w", err)
|
||||
}
|
||||
webPortNum, err := strconv.Atoi(webPrt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui port must be a number: %w", err)
|
||||
}
|
||||
if webPortNum < 0 || webPortNum > 65535 {
|
||||
return fmt.Errorf("webui port must be between 0 and 65535")
|
||||
}
|
||||
|
||||
webTLSCert := ""
|
||||
webTLSKey := ""
|
||||
if !webuiNoTLS {
|
||||
// WebUI can either use explicitly provided TLS files or reuse the
|
||||
// gateway's TLS files by default.
|
||||
webTLSCert = webuiCertFile
|
||||
webTLSKey = webuiKeyFile
|
||||
if webTLSCert == "" && webTLSKey == "" {
|
||||
webTLSCert = certFile
|
||||
webTLSKey = keyFile
|
||||
}
|
||||
if webTLSCert != "" || webTLSKey != "" {
|
||||
if webTLSCert == "" {
|
||||
return fmt.Errorf("webui TLS key specified without cert file")
|
||||
}
|
||||
if webTLSKey == "" {
|
||||
return fmt.Errorf("webui TLS cert specified without key file")
|
||||
}
|
||||
webuiSSLEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
sslEnabled := certFile != ""
|
||||
admSSLEnabled := sslEnabled
|
||||
if admPort != "" {
|
||||
admSSLEnabled = admCertFile != ""
|
||||
}
|
||||
|
||||
gateways, err := buildServiceURLs(port, sslEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui: build gateway URLs: %w", err)
|
||||
}
|
||||
|
||||
adminGateways := gateways
|
||||
if admPort != "" {
|
||||
adminGateways, err = buildServiceURLs(admPort, admSSLEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webui: build admin gateway URLs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var webOpts []webui.Option
|
||||
if quiet {
|
||||
webOpts = append(webOpts, webui.WithQuiet())
|
||||
}
|
||||
|
||||
webSrv = webui.NewServer(&webui.ServerConfig{
|
||||
ListenAddr: webuiAddr,
|
||||
Gateways: gateways,
|
||||
AdminGateways: adminGateways,
|
||||
Region: region,
|
||||
TLSCert: webTLSCert,
|
||||
TLSKey: webTLSKey,
|
||||
}, webOpts...)
|
||||
}
|
||||
|
||||
c := make(chan error, 2)
|
||||
if !quiet {
|
||||
printBanner(port, admPort, certFile != "", admCertFile != "", webuiAddr, webuiSSLEnabled)
|
||||
}
|
||||
|
||||
servers := 1
|
||||
if admPort != "" {
|
||||
servers++
|
||||
}
|
||||
if webSrv != nil {
|
||||
servers++
|
||||
}
|
||||
c := make(chan error, servers)
|
||||
go func() { c <- srv.Serve() }()
|
||||
if admPort != "" {
|
||||
go func() { c <- admSrv.Serve() }()
|
||||
}
|
||||
if webSrv != nil {
|
||||
go func() { c <- webSrv.Serve() }()
|
||||
}
|
||||
|
||||
// for/select blocks until shutdown
|
||||
Loop:
|
||||
@@ -875,6 +1021,13 @@ Loop:
|
||||
}
|
||||
}
|
||||
|
||||
if webSrv != nil {
|
||||
err := webSrv.Shutdown()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
be.Shutdown()
|
||||
|
||||
err = iam.Shutdown()
|
||||
@@ -909,7 +1062,7 @@ Loop:
|
||||
return saveErr
|
||||
}
|
||||
|
||||
func printBanner(port, admPort string, ssl, admSsl bool) {
|
||||
func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webuiSsl bool) {
|
||||
interfaces, err := getMatchingIPs(port)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses: %v\n", err)
|
||||
@@ -991,6 +1144,30 @@ func printBanner(port, admPort string, ssl, admSsl bool) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(webuiAddr) != "" {
|
||||
webInterfaces, err := getMatchingIPs(webuiAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to match webui port local IP addresses: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, webPrt, err := net.SplitHostPort(webuiAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to parse webui port: %v\n", err)
|
||||
return
|
||||
}
|
||||
lines = append(lines,
|
||||
centerText(""),
|
||||
leftText("WebUI listening on:"),
|
||||
)
|
||||
for _, ip := range webInterfaces {
|
||||
url := fmt.Sprintf("http://%s:%s", ip, webPrt)
|
||||
if webuiSsl {
|
||||
url = fmt.Sprintf("https://%s:%s", ip, webPrt)
|
||||
}
|
||||
lines = append(lines, leftText(" "+url))
|
||||
}
|
||||
}
|
||||
|
||||
// Print the top border
|
||||
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
|
||||
|
||||
@@ -1066,6 +1243,42 @@ func getMatchingIPs(spec string) ([]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildServiceURLs(spec string, ssl bool) ([]string, error) {
|
||||
interfaces, err := getMatchingIPs(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, prt, err := net.SplitHostPort(spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse address/port: %w", err)
|
||||
}
|
||||
if len(interfaces) == 0 {
|
||||
interfaces = []string{"localhost"}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if ssl {
|
||||
scheme = "https"
|
||||
}
|
||||
urls := make([]string, 0, len(interfaces))
|
||||
for _, ip := range interfaces {
|
||||
urls = append(urls, fmt.Sprintf("%s://%s:%s", scheme, ip, prt))
|
||||
}
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const columnWidth = 70
|
||||
|
||||
func centerText(text string) string {
|
||||
|
||||
@@ -201,6 +201,42 @@ ROOT_SECRET_ACCESS_KEY=
|
||||
# to generate a default rules file "event_config.json" in the current directory.
|
||||
#VGW_EVENT_FILTER=
|
||||
|
||||
###########
|
||||
# Web GUI #
|
||||
###########
|
||||
|
||||
# The VGW_WEBUI_PORT option enables the Web GUI server on the specified
|
||||
# listening address. The Web GUI provides a browser-based interface for managing
|
||||
# users, buckets and objects. The format can be either ':port' to listen on all
|
||||
# interfaces (e.g., ':7071') or 'host:port' to listen on a specific interface
|
||||
# (e.g., '127.0.0.1:7071' or 'localhost:7071'). When omitted, the Web GUI is
|
||||
# disabled.
|
||||
#VGW_WEBUI_PORT=
|
||||
|
||||
# The VGW_WEBUI_CERT and VGW_WEBUI_KEY options specify the TLS certificate and
|
||||
# private key for the Web GUI server. If these are not specified and TLS is
|
||||
# configured for the gateway (VGW_CERT and VGW_KEY), the Web GUI will use the
|
||||
# same certificates as the gateway. If neither are specified, the Web GUI will
|
||||
# run without TLS (HTTP only). These options allow the Web GUI to use different
|
||||
# certificates than the main S3 gateway.
|
||||
#VGW_WEBUI_CERT=
|
||||
#VGW_WEBUI_KEY=
|
||||
|
||||
# The VGW_WEBUI_NO_TLS option disables TLS for the Web GUI even if TLS
|
||||
# certificates are configured for the gateway. Set to true to force the Web GUI
|
||||
# to use HTTP instead of HTTPS. This can be useful when running the Web GUI
|
||||
# behind a reverse proxy that handles TLS termination.
|
||||
#VGW_WEBUI_NO_TLS=false
|
||||
|
||||
# The VGW_CORS_ALLOW_ORIGIN option sets the default CORS (Cross-Origin Resource
|
||||
# Sharing) Access-Control-Allow-Origin header value. This header is applied to
|
||||
# responses when no bucket-specific CORS configuration exists, and for all admin
|
||||
# API responses. When the Web GUI is enabled and this option is not set, it
|
||||
# defaults to '*' (allow all origins) for usability. For production environments,
|
||||
# it is recommended to set this to a specific origin (e.g.,
|
||||
# 'https://webui.example.com') to improve security.
|
||||
#VGW_CORS_ALLOW_ORIGIN=
|
||||
|
||||
#######################
|
||||
# Debug / Diagnostics #
|
||||
#######################
|
||||
|
||||
@@ -71,7 +71,7 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, r
|
||||
// Logging middlewares
|
||||
if !server.quiet {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
Format: "${time} | adm | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
}))
|
||||
}
|
||||
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, nil))
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ func New(
|
||||
// Logging middlewares
|
||||
if !server.quiet {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
}))
|
||||
}
|
||||
// Set up health endpoint if specified
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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.
|
||||
|
||||
package webui
|
||||
|
||||
import "embed"
|
||||
|
||||
// webFiles embeds the admin GUI static files from web/.
|
||||
// The "all:" prefix recursively includes all files and subdirectories.
|
||||
//
|
||||
//go:embed all:web
|
||||
var webFiles embed.FS
|
||||
|
||||
// webFS is an alias for webFiles for consistency with server.go
|
||||
var webFS = webFiles
|
||||
@@ -0,0 +1,71 @@
|
||||
================================================================================
|
||||
TAILWIND CSS
|
||||
================================================================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) Tailwind Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
ROBOTO FONT
|
||||
================================================================================
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2011 Google Inc. All Rights Reserved.
|
||||
|
||||
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.
|
||||
|
||||
================================================================================
|
||||
CRYPTO-JS
|
||||
================================================================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2009-2013 Jeff Mott
|
||||
Copyright (c) 2013-2016 Evan Vosberg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Roboto Font - Regular (400) */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../fonts/roboto-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Roboto Font - Medium (500) */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('../fonts/roboto-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Roboto Font - Semi-Bold (600) */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('../fonts/roboto-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Roboto Font - Bold (700) */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../fonts/roboto-700.woff2') format('woff2');
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,705 @@
|
||||
<!--
|
||||
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 - Buckets</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', 500: '#002A7A', 600: '#002468' },
|
||||
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 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; }
|
||||
.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-backdrop { background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
|
||||
/* 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;
|
||||
}
|
||||
/* Dropup variant - opens upward */
|
||||
.custom-dropdown.dropup {
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface">
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
|
||||
<div class="h-16 flex items-center px-6 border-b border-white/10">
|
||||
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
|
||||
</a>
|
||||
</div>
|
||||
<nav class="flex-1 py-4">
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
|
||||
Admin
|
||||
</div>
|
||||
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</a>
|
||||
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Users</span>
|
||||
</a>
|
||||
<a href="buckets.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
|
||||
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
<span class="font-medium">Buckets</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
|
||||
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Explorer</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10"></div>
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
|
||||
Resources
|
||||
</div>
|
||||
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span class="font-medium">Documentation</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Bug Reports</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<span class="font-medium">Releases</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
<span class="font-medium">GitHub</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
|
||||
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
|
||||
<h1 class="text-xl font-semibold text-charcoal">VersityGW Buckets</h1>
|
||||
<button onclick="loadBuckets()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-charcoal">Buckets</h1>
|
||||
<p class="text-charcoal-300 mt-1">View and manage bucket ownership</p>
|
||||
</div>
|
||||
<button onclick="openCreateBucketDialog()" class="inline-flex items-center gap-2 px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="bg-accent-50 border border-accent/20 rounded-xl p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-accent flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-charcoal">
|
||||
<span class="font-medium">Note:</span> Create buckets, view existing buckets, and transfer ownership between users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-64">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search buckets..."
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
||||
oninput="filterBuckets()"
|
||||
>
|
||||
</div>
|
||||
<div class="relative" id="owner-filter-container">
|
||||
<input
|
||||
type="text"
|
||||
id="owner-filter-display"
|
||||
readonly
|
||||
value="All Owners"
|
||||
onclick="toggleDropdown('owner-filter')"
|
||||
class="bg-white border border-gray-200 rounded-lg px-4 py-2.5 pr-10 text-charcoal cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
||||
>
|
||||
<input type="hidden" id="owner-filter" value="">
|
||||
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 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="owner-filter-dropdown" class="custom-dropdown">
|
||||
<div class="custom-dropdown-item selected" data-value="" onclick="selectOwnerFilter('')">All Owners</div>
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buckets Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<colgroup>
|
||||
<col style="width: 50%;">
|
||||
<col style="width: 30%;">
|
||||
<col style="width: 20%;">
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Bucket Name</th>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Owner</th>
|
||||
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="buckets-table-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Owner Modal -->
|
||||
<div id="owner-modal" class="hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('owner-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<h2 class="text-xl font-semibold text-charcoal">Change Bucket Owner</h2>
|
||||
<button onclick="closeModal('owner-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Bucket</label>
|
||||
<input type="text" id="modal-bucket" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal bg-gray-50 font-mono">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Current Owner</label>
|
||||
<input type="text" id="modal-current-owner" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal-400 bg-gray-50 font-mono">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">New Owner <span class="text-red-500">*</span></label>
|
||||
<div class="relative" id="new-owner-container">
|
||||
<input
|
||||
type="text"
|
||||
id="modal-new-owner-display"
|
||||
readonly
|
||||
value="Select a user..."
|
||||
onclick="toggleDropdown('new-owner')"
|
||||
class="w-full px-4 py-2.5 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="modal-new-owner" value="">
|
||||
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 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="new-owner-dropdown" class="custom-dropdown">
|
||||
<div class="custom-dropdown-item" data-value="" onclick="selectNewOwner('')">Select a user...</div>
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<p class="text-sm text-yellow-800">Transferring ownership will give the new owner full control over this bucket and its contents.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||||
<button onclick="closeModal('owner-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||||
<button id="transfer-btn" onclick="transferOwnership()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Transfer Ownership</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Bucket Modal -->
|
||||
<div id="create-bucket-modal" class="hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-bucket-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<h2 class="text-xl font-semibold text-charcoal">Create Bucket</h2>
|
||||
<button onclick="closeModal('create-bucket-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Bucket Name <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="new-bucket-name" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all" placeholder="my-bucket-name">
|
||||
<p class="text-xs text-charcoal-300 mt-2">Bucket names must be lowercase, 3-63 characters, and can contain letters, numbers, and hyphens.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Owner <span class="text-red-500">*</span></label>
|
||||
<div class="relative" id="bucket-owner-container">
|
||||
<input
|
||||
type="text"
|
||||
id="bucket-owner-display"
|
||||
readonly
|
||||
value="Select owner..."
|
||||
onclick="toggleDropdown('bucket-owner')"
|
||||
class="w-full px-4 py-2.5 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="bucket-owner" value="">
|
||||
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 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="bucket-owner-dropdown" class="custom-dropdown">
|
||||
<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner('', '')">Select owner...</div>
|
||||
<!-- Populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-3 cursor-pointer group">
|
||||
<input type="checkbox" id="enable-versioning" class="mt-1 w-4 h-4 text-accent border-2 border-gray-200 rounded focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-charcoal group-hover:text-accent transition-colors">Enable Versioning</span>
|
||||
<span class="block text-xs text-charcoal-300 mt-0.5">Keep multiple versions of objects in the bucket</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer group">
|
||||
<input type="checkbox" id="enable-object-lock" class="mt-1 w-4 h-4 text-accent border-2 border-gray-200 rounded focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-charcoal group-hover:text-accent transition-colors">Enable Object Lock</span>
|
||||
<span class="block text-xs text-charcoal-300 mt-0.5">Prevent object deletion for compliance (enables versioning)</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||||
<button onclick="closeModal('create-bucket-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="create-bucket-btn" onclick="createBucket()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allBuckets = [];
|
||||
let allUsers = [];
|
||||
let selectedBucket = null;
|
||||
|
||||
// ============================================
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Close all dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const containers = ['owner-filter-container', 'new-owner-container', 'bucket-owner-container'];
|
||||
if (!containers.some(id => e.target.closest('#' + id))) {
|
||||
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
|
||||
}
|
||||
});
|
||||
|
||||
// Owner filter dropdown
|
||||
function selectOwnerFilter(value) {
|
||||
const display = document.getElementById('owner-filter-display');
|
||||
const hidden = document.getElementById('owner-filter');
|
||||
const dropdown = document.getElementById('owner-filter-dropdown');
|
||||
|
||||
display.value = value || 'All Owners';
|
||||
hidden.value = value;
|
||||
|
||||
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.dataset.value === value);
|
||||
});
|
||||
|
||||
dropdown.classList.remove('show');
|
||||
filterBuckets();
|
||||
}
|
||||
|
||||
// Populate owner filter dropdown
|
||||
function populateOwnerFilterDropdown(owners) {
|
||||
const dropdown = document.getElementById('owner-filter-dropdown');
|
||||
dropdown.innerHTML = '<div class="custom-dropdown-item selected" data-value="" onclick="selectOwnerFilter(\'\')">All Owners</div>';
|
||||
owners.forEach(owner => {
|
||||
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(owner)}" onclick="selectOwnerFilter('${escapeHtml(owner)}')">${escapeHtml(owner)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// New owner dropdown (for modal)
|
||||
function selectNewOwner(value, displayText) {
|
||||
const display = document.getElementById('modal-new-owner-display');
|
||||
const hidden = document.getElementById('modal-new-owner');
|
||||
const dropdown = document.getElementById('new-owner-dropdown');
|
||||
|
||||
display.value = displayText || value || 'Select a user...';
|
||||
hidden.value = value;
|
||||
|
||||
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.dataset.value === value);
|
||||
});
|
||||
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
// Bucket owner dropdown (for create bucket modal)
|
||||
function selectBucketOwner(value, displayText) {
|
||||
const display = document.getElementById('bucket-owner-display');
|
||||
const hidden = document.getElementById('bucket-owner');
|
||||
const dropdown = document.getElementById('bucket-owner-dropdown');
|
||||
|
||||
display.value = displayText || value || 'Select owner...';
|
||||
hidden.value = value;
|
||||
|
||||
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.dataset.value === value);
|
||||
});
|
||||
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
// Populate new owner dropdown
|
||||
function populateNewOwnerDropdown(users, currentOwner) {
|
||||
const dropdown = document.getElementById('new-owner-dropdown');
|
||||
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectNewOwner(\'\')">Select a user...</div>';
|
||||
users.forEach(user => {
|
||||
if (user.access !== currentOwner) {
|
||||
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
|
||||
const displayText = `${user.access}${roleLabel}`;
|
||||
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectNewOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Populate bucket owner dropdown (for create bucket modal)
|
||||
function populateBucketOwnerDropdown(users) {
|
||||
const dropdown = document.getElementById('bucket-owner-dropdown');
|
||||
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner(\'\', \'\'">Select owner...</div>';
|
||||
users.forEach(user => {
|
||||
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
|
||||
const displayText = `${user.access}${roleLabel}`;
|
||||
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectBucketOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
|
||||
});
|
||||
}
|
||||
if (!requireAdmin()) {
|
||||
// Redirected
|
||||
} else {
|
||||
initSidebarWithRole();
|
||||
updateUserInfo();
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
// Load both users and buckets
|
||||
allUsers = await api.listUsers();
|
||||
await loadBuckets();
|
||||
|
||||
// Populate owner filter dropdown
|
||||
const uniqueOwners = [...new Set(allBuckets.map(b => b.owner).filter(Boolean))];
|
||||
populateOwnerFilterDropdown(uniqueOwners);
|
||||
|
||||
filterBuckets();
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
showToast('Error loading data: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBuckets() {
|
||||
showTableLoading('buckets-table-body', 4);
|
||||
try {
|
||||
allBuckets = await api.listBuckets();
|
||||
filterBuckets();
|
||||
} catch (error) {
|
||||
console.error('Error loading buckets:', error);
|
||||
showToast('Error loading buckets: ' + error.message, 'error');
|
||||
showEmptyState('buckets-table-body', 4, 'Error loading buckets');
|
||||
}
|
||||
}
|
||||
|
||||
function filterBuckets() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const ownerFilter = document.getElementById('owner-filter').value;
|
||||
|
||||
let filtered = allBuckets;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(b => b.name && b.name.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
|
||||
if (ownerFilter) {
|
||||
filtered = filtered.filter(b => b.owner === ownerFilter);
|
||||
}
|
||||
|
||||
renderBuckets(filtered);
|
||||
}
|
||||
|
||||
function renderBuckets(buckets) {
|
||||
const tbody = document.getElementById('buckets-table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (buckets.length === 0) {
|
||||
showEmptyState('buckets-table-body', 4, 'No buckets found');
|
||||
return;
|
||||
}
|
||||
|
||||
buckets.forEach(bucket => {
|
||||
const explorerHref = `explorer.html#${encodeURIComponent(bucket.name)}`;
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
|
||||
row.innerHTML = `
|
||||
<td class="py-4 px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="${explorerHref}" class="font-mono text-sm text-accent hover:underline">${escapeHtml(bucket.name)}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-6">
|
||||
<span class="font-mono text-sm text-charcoal-400">${escapeHtml(bucket.owner || 'Unknown')}</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button onclick="openChangeOwnerModal('${escapeHtml(bucket.name)}', '${escapeHtml(bucket.owner || '')}')" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
|
||||
</svg>
|
||||
Owner
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openChangeOwnerModal(bucket, currentOwner) {
|
||||
selectedBucket = bucket;
|
||||
document.getElementById('modal-bucket').value = bucket;
|
||||
document.getElementById('modal-current-owner').value = currentOwner || 'Unknown';
|
||||
|
||||
// Reset and populate new owner dropdown
|
||||
document.getElementById('modal-new-owner-display').value = 'Select a user...';
|
||||
document.getElementById('modal-new-owner').value = '';
|
||||
populateNewOwnerDropdown(allUsers, currentOwner);
|
||||
|
||||
openModal('owner-modal');
|
||||
}
|
||||
|
||||
async function transferOwnership() {
|
||||
const newOwner = document.getElementById('modal-new-owner').value;
|
||||
|
||||
if (!newOwner) {
|
||||
showToast('Please select a new owner', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('transfer-btn');
|
||||
setLoading(btn, true);
|
||||
|
||||
try {
|
||||
await api.changeBucketOwner(selectedBucket, newOwner);
|
||||
showToast('Bucket ownership transferred successfully', 'success');
|
||||
closeModal('owner-modal');
|
||||
loadBuckets();
|
||||
} catch (error) {
|
||||
console.error('Error transferring ownership:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Create Bucket
|
||||
// ============================================
|
||||
|
||||
function openCreateBucketDialog() {
|
||||
document.getElementById('new-bucket-name').value = '';
|
||||
document.getElementById('bucket-owner-display').value = 'Select owner...';
|
||||
document.getElementById('bucket-owner').value = '';
|
||||
document.getElementById('enable-versioning').checked = false;
|
||||
document.getElementById('enable-object-lock').checked = false;
|
||||
populateBucketOwnerDropdown(allUsers);
|
||||
openModal('create-bucket-modal');
|
||||
}
|
||||
|
||||
async function createBucket() {
|
||||
const bucketName = document.getElementById('new-bucket-name').value.trim().toLowerCase();
|
||||
const owner = document.getElementById('bucket-owner').value;
|
||||
const enableVersioning = document.getElementById('enable-versioning').checked;
|
||||
const enableObjectLock = document.getElementById('enable-object-lock').checked;
|
||||
|
||||
if (!bucketName) {
|
||||
showToast('Please enter a bucket name', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!owner) {
|
||||
showToast('Please select an owner', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic bucket name validation
|
||||
if (bucketName.length < 3 || bucketName.length > 63) {
|
||||
showToast('Bucket name must be between 3 and 63 characters', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(bucketName) && bucketName.length > 2) {
|
||||
showToast('Bucket name must start and end with a letter or number', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (/[^a-z0-9.-]/.test(bucketName)) {
|
||||
showToast('Bucket name can only contain lowercase letters, numbers, hyphens, and periods', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('create-bucket-btn');
|
||||
setLoading(btn, true);
|
||||
|
||||
try {
|
||||
await api.createBucketWithOwner(bucketName, owner, enableVersioning, enableObjectLock);
|
||||
showToast(`Bucket "${bucketName}" created successfully`, 'success');
|
||||
closeModal('create-bucket-modal');
|
||||
// Reload buckets list
|
||||
await loadBuckets();
|
||||
} catch (error) {
|
||||
console.error('Create bucket error:', error);
|
||||
showToast(error.message || 'Failed to create bucket', 'error');
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!--
|
||||
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 - Dashboard</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', 500: '#002A7A', 600: '#002468' },
|
||||
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 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; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface">
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
|
||||
<div class="h-16 flex items-center px-6 border-b border-white/10">
|
||||
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
|
||||
</a>
|
||||
</div>
|
||||
<nav class="flex-1 py-4">
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
|
||||
Admin
|
||||
</div>
|
||||
<a href="dashboard.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
|
||||
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</a>
|
||||
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Users</span>
|
||||
</a>
|
||||
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
<span class="font-medium">Buckets</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
|
||||
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Explorer</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10"></div>
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
|
||||
Resources
|
||||
</div>
|
||||
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span class="font-medium">Documentation</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Bug Reports</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<span class="font-medium">Releases</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
<span class="font-medium">GitHub</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div id="user-info" class="flex items-center gap-3 mb-3">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
|
||||
<h1 class="text-xl font-semibold text-charcoal">VersityGW Dashboard</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<button onclick="loadDashboard()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Metric Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Total Users -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-charcoal-300 text-sm font-medium">Total Users</p>
|
||||
<p id="user-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-primary-50 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Buckets -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-charcoal-300 text-sm font-medium">Total Buckets</p>
|
||||
<p id="bucket-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-accent-50 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-charcoal-300 text-sm font-medium">System Status</p>
|
||||
<div id="system-status" class="flex items-center gap-2 mt-2">
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<p class="text-xl font-bold text-green-600">Connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-14 h-14 bg-green-50 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-charcoal mb-4">Quick Actions</h3>
|
||||
<div class="space-y-3">
|
||||
<a href="users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
|
||||
<div class="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center group-hover:bg-primary-100 transition-colors">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-charcoal">Manage Users</p>
|
||||
<p class="text-sm text-charcoal-300">Create, edit, and delete user accounts</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-charcoal-300 ml-auto" 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>
|
||||
</a>
|
||||
<a href="buckets.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
|
||||
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center group-hover:bg-accent-100 transition-colors">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-charcoal">Manage Buckets</p>
|
||||
<p class="text-sm text-charcoal-300">View and manage bucket ownership</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-charcoal-300 ml-auto" 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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Info -->
|
||||
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<h3 class="text-lg font-semibold text-charcoal mb-4">Connection Info</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<span class="text-charcoal-300">Endpoint</span>
|
||||
<span id="endpoint-display" class="text-charcoal font-mono text-sm">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<span class="text-charcoal-300">Region</span>
|
||||
<span id="region-display" class="text-charcoal text-sm">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<span class="text-charcoal-300">Access Key</span>
|
||||
<span id="access-key-display" class="text-charcoal font-mono text-sm">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<span class="text-charcoal-300">Status</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
<span class="text-green-600 text-sm font-medium">Authenticated</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Users Table -->
|
||||
<div class="mt-6 bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-charcoal">Recent Users</h3>
|
||||
<a href="users.html" class="text-accent hover:text-accent-600 text-sm font-medium">View all</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100">
|
||||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Access Key</th>
|
||||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Role</th>
|
||||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Project ID</th>
|
||||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">User ID</th>
|
||||
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Group ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-users">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auth guard - require admin role
|
||||
if (!requireAdmin()) {
|
||||
// Will redirect to login or explorer
|
||||
} else {
|
||||
initSidebarWithRole();
|
||||
updateUserInfo();
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const info = api.getCredentialsInfo();
|
||||
|
||||
// Display connection info
|
||||
document.getElementById('endpoint-display').textContent = info.endpoint || '-';
|
||||
document.getElementById('region-display').textContent = info.region || '-';
|
||||
document.getElementById('access-key-display').textContent = info.accessKey || '-';
|
||||
|
||||
try {
|
||||
// Load users
|
||||
const users = await api.listUsers();
|
||||
document.getElementById('user-count').textContent = users.length;
|
||||
|
||||
// Load buckets
|
||||
const buckets = await api.listBuckets();
|
||||
document.getElementById('bucket-count').textContent = buckets.length;
|
||||
|
||||
// Display recent users (max 5)
|
||||
const recentUsers = users.slice(0, 5);
|
||||
const tbody = document.getElementById('recent-users');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (recentUsers.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="py-8 text-center text-charcoal-300">No users found</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
recentUsers.forEach(user => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
|
||||
row.innerHTML = `
|
||||
<td class="py-3 px-4 font-mono text-sm text-charcoal">${escapeHtml(user.access)}</td>
|
||||
<td class="py-3 px-4">${formatRole(user.role)}</td>
|
||||
<td class="py-3 px-4 text-sm text-charcoal">${user.projectid || '-'}</td>
|
||||
<td class="py-3 px-4 text-sm text-charcoal">${user.userid || '0'}</td>
|
||||
<td class="py-3 px-4 text-sm text-charcoal">${user.groupid || '0'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Update status
|
||||
document.getElementById('system-status').innerHTML = `
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<p class="text-xl font-bold text-green-600">Connected</p>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
showToast('Error loading dashboard data: ' + error.message, 'error');
|
||||
|
||||
document.getElementById('system-status').innerHTML = `
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<p class="text-xl font-bold text-red-600">Error</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,836 @@
|
||||
<!--
|
||||
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>
|
||||
+2072
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* VersityGW Admin - Application Utilities
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Navigation & Auth Guards
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if user is authenticated, redirect to login if not
|
||||
* Also loads user context (user type and accessible gateways)
|
||||
*/
|
||||
function requireAuth() {
|
||||
if (!api.loadCredentials()) {
|
||||
window.location.href = 'index.html';
|
||||
return false;
|
||||
}
|
||||
api.loadUserContext();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role, redirect non-admins to explorer
|
||||
* Call this on admin-only pages (dashboard, users, buckets, settings)
|
||||
* Also loads user context (user type and accessible gateways)
|
||||
*/
|
||||
function requireAdmin() {
|
||||
if (!api.loadCredentials()) {
|
||||
window.location.href = 'index.html';
|
||||
return false;
|
||||
}
|
||||
api.loadUserContext();
|
||||
if (!api.isAdmin()) {
|
||||
window.location.href = 'explorer.html';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to appropriate page if already authenticated
|
||||
* Admin users go to dashboard, regular users go to explorer
|
||||
*/
|
||||
function redirectIfAuthenticated() {
|
||||
if (api.loadCredentials()) {
|
||||
if (api.isAdmin()) {
|
||||
window.location.href = 'dashboard.html';
|
||||
} else {
|
||||
window.location.href = 'explorer.html';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Toast Notifications
|
||||
// ============================================
|
||||
|
||||
let toastContainer = null;
|
||||
|
||||
function initToasts() {
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'toast-container';
|
||||
toastContainer.className = 'fixed top-4 right-4 z-50 flex flex-col gap-2';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
initToasts();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
const bgColors = {
|
||||
success: 'bg-green-50 border-green-500 text-green-800',
|
||||
error: 'bg-red-50 border-red-500 text-red-800',
|
||||
warning: 'bg-yellow-50 border-yellow-500 text-yellow-800',
|
||||
info: 'bg-blue-50 border-blue-500 text-blue-800'
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
||||
error: `<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
|
||||
warning: `<svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>`,
|
||||
info: `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`
|
||||
};
|
||||
|
||||
toast.className = `flex items-center gap-3 px-4 py-3 rounded-lg border-l-4 shadow-lg max-w-sm animate-slide-in ${bgColors[type]}`;
|
||||
toast.innerHTML = `
|
||||
${icons[type]}
|
||||
<p class="text-sm font-medium flex-1">${escapeHtml(message)}</p>
|
||||
<button onclick="this.parentElement.remove()" class="text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-fade-out');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Modal Utilities
|
||||
// ============================================
|
||||
|
||||
function openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
// Focus first input
|
||||
const firstInput = modal.querySelector('input:not([readonly]), select');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
document.querySelectorAll('[id$="-modal"]').forEach(modal => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Close modals on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllModals();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Loading States
|
||||
// ============================================
|
||||
|
||||
function setLoading(element, loading) {
|
||||
if (loading) {
|
||||
element.disabled = true;
|
||||
element.dataset.originalText = element.innerHTML;
|
||||
element.innerHTML = `
|
||||
<svg class="animate-spin h-5 w-5 mx-auto" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
element.disabled = false;
|
||||
if (element.dataset.originalText) {
|
||||
element.innerHTML = element.dataset.originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showTableLoading(tableBodyId, columns) {
|
||||
const tbody = document.getElementById(tableBodyId);
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-50';
|
||||
for (let j = 0; j < columns; j++) {
|
||||
row.innerHTML += `
|
||||
<td class="py-4 px-6">
|
||||
<div class="h-4 bg-gray-200 rounded animate-pulse" style="width: ${60 + Math.random() * 40}%"></div>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function showEmptyState(tableBodyId, columns, message = 'No data found') {
|
||||
const tbody = document.getElementById(tableBodyId);
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="${columns}" class="py-12 px-6 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">${escapeHtml(message)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatRole(role) {
|
||||
const roleConfig = {
|
||||
admin: { label: 'Admin', class: 'bg-primary-50 text-primary' },
|
||||
user: { label: 'User', class: 'bg-gray-100 text-charcoal' },
|
||||
userplus: { label: 'User+', class: 'bg-accent-50 text-accent' }
|
||||
};
|
||||
const config = roleConfig[role] || roleConfig.user;
|
||||
return `<span class="px-2.5 py-1 ${config.class} text-xs font-medium rounded-md">${config.label}</span>`;
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sidebar Active State
|
||||
// ============================================
|
||||
|
||||
function initSidebar() {
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
const href = item.getAttribute('href');
|
||||
if (href === currentPage) {
|
||||
item.classList.add('active');
|
||||
item.classList.remove('text-white/70');
|
||||
item.classList.add('text-white');
|
||||
} else {
|
||||
item.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update User Info in Sidebar
|
||||
// ============================================
|
||||
|
||||
function updateUserInfo() {
|
||||
const info = api.getCredentialsInfo();
|
||||
if (!info) return;
|
||||
|
||||
const accessKeyShort = info.accessKey.length > 12
|
||||
? info.accessKey.substring(0, 12) + '...'
|
||||
: info.accessKey;
|
||||
|
||||
const roleLabel = info.isAdmin ? 'Admin' : 'User';
|
||||
|
||||
const userInfoEl = document.getElementById('user-info');
|
||||
if (userInfoEl) {
|
||||
userInfoEl.innerHTML = `
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white text-sm font-medium truncate">${escapeHtml(accessKeyShort)}</p>
|
||||
<p class="text-white/50 text-xs">${roleLabel}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sidebar with role-based navigation
|
||||
* Hides admin-only nav items for non-admin users
|
||||
*/
|
||||
function initSidebarWithRole() {
|
||||
initSidebar();
|
||||
|
||||
// Hide admin-only nav items for non-admin users
|
||||
if (!api.isAdmin()) {
|
||||
document.querySelectorAll('[data-admin-only]').forEach(item => {
|
||||
item.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Confirm Dialog
|
||||
// ============================================
|
||||
|
||||
function confirm(message, onConfirm, onCancel) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-backdrop absolute inset-0" style="background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
<div class="p-6">
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Confirm Action</h3>
|
||||
<p class="text-charcoal-300 text-center mb-6">${escapeHtml(message)}</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button id="confirm-cancel" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="confirm-ok" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelector('#confirm-cancel').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
if (onCancel) onCancel();
|
||||
});
|
||||
|
||||
modal.querySelector('#confirm-ok').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
if (onConfirm) onConfirm();
|
||||
});
|
||||
|
||||
modal.querySelector('.modal-backdrop').addEventListener('click', () => {
|
||||
modal.remove();
|
||||
if (onCancel) onCancel();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// CSS Animations (inject once)
|
||||
// ============================================
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
.animate-slide-in { animation: slide-in 0.3s ease-out; }
|
||||
.animate-fade-out { animation: fade-out 0.3s ease-out; }
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
@@ -0,0 +1,621 @@
|
||||
<!--
|
||||
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 - Users</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', 500: '#002A7A', 600: '#002468' },
|
||||
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 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; }
|
||||
.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-backdrop { background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
|
||||
/* 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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface">
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
|
||||
<div class="h-16 flex items-center px-6 border-b border-white/10">
|
||||
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
|
||||
</a>
|
||||
</div>
|
||||
<nav class="flex-1 py-4">
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
|
||||
Admin
|
||||
</div>
|
||||
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</a>
|
||||
<a href="users.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
|
||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Users</span>
|
||||
</a>
|
||||
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
|
||||
</svg>
|
||||
<span class="font-medium">Buckets</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
|
||||
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Explorer</span>
|
||||
</a>
|
||||
<div class="mx-6 my-2 border-t border-white/10"></div>
|
||||
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
|
||||
Resources
|
||||
</div>
|
||||
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span class="font-medium">Documentation</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<span class="font-medium">Bug Reports</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
|
||||
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<span class="font-medium">Releases</span>
|
||||
</a>
|
||||
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
|
||||
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
<span class="font-medium">GitHub</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
|
||||
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
|
||||
<h1 class="text-xl font-semibold text-charcoal">VersityGW Users</h1>
|
||||
<button onclick="loadUsers()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-charcoal">Users</h1>
|
||||
<p class="text-charcoal-300 mt-1">Manage gateway user accounts</p>
|
||||
</div>
|
||||
<button onclick="openCreateModal()" class="flex items-center gap-2 bg-primary hover:bg-primary-600 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||
</svg>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="relative flex-1 min-w-64">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search by access key..."
|
||||
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
||||
oninput="filterUsers()"
|
||||
>
|
||||
</div>
|
||||
<div class="relative" id="role-filter-container">
|
||||
<input
|
||||
type="text"
|
||||
id="role-filter-display"
|
||||
readonly
|
||||
value="All Roles"
|
||||
onclick="toggleDropdown('role-filter')"
|
||||
class="bg-white border border-gray-200 rounded-lg px-4 py-2.5 pr-10 text-charcoal cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
|
||||
>
|
||||
<input type="hidden" id="role-filter" value="">
|
||||
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 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="role-filter-dropdown" class="custom-dropdown">
|
||||
<div class="custom-dropdown-item selected" data-value="" onclick="selectRoleFilter('')">All Roles</div>
|
||||
<div class="custom-dropdown-item" data-value="admin" onclick="selectRoleFilter('admin')">Admin</div>
|
||||
<div class="custom-dropdown-item" data-value="user" onclick="selectRoleFilter('user')">User</div>
|
||||
<div class="custom-dropdown-item" data-value="userplus" onclick="selectRoleFilter('userplus')">User+</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Access Key</th>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Role</th>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">User ID</th>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Group ID</th>
|
||||
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Project ID</th>
|
||||
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div id="user-modal" class="hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('user-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-100">
|
||||
<h2 id="modal-title" class="text-xl font-semibold text-charcoal">Create New User</h2>
|
||||
<button onclick="closeModal('user-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="user-form" class="p-6 space-y-5">
|
||||
<input type="hidden" id="edit-mode" value="create">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Access Key <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="form-access" required placeholder="e.g., AKIAXXXXXXXXXX" class="flex-1 px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:text-charcoal-300 placeholder:font-sans focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
<button type="button" id="generate-access-btn" onclick="generateAccessKey()" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Secret Key <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="form-secret" required placeholder="Click Generate or enter manually" class="flex-1 px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:text-charcoal-300 placeholder:font-sans focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
<button type="button" onclick="generateSecret()" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Role <span class="text-red-500">*</span></label>
|
||||
<div class="relative" id="form-role-container">
|
||||
<input
|
||||
type="text"
|
||||
id="form-role-display"
|
||||
readonly
|
||||
value="Select a role..."
|
||||
onclick="toggleDropdown('form-role')"
|
||||
class="w-full px-4 py-2.5 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="form-role" value="">
|
||||
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 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="form-role-dropdown" class="custom-dropdown">
|
||||
<div class="custom-dropdown-item" data-value="" onclick="selectFormRole('')">Select a role...</div>
|
||||
<div class="custom-dropdown-item" data-value="admin" onclick="selectFormRole('admin')">Admin - Full administrative access</div>
|
||||
<div class="custom-dropdown-item" data-value="user" onclick="selectFormRole('user')">User - Standard user access</div>
|
||||
<div class="custom-dropdown-item" data-value="userplus" onclick="selectFormRole('userplus')">User+ - Enhanced user permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
|
||||
<svg class="w-4 h-4 transition-transform group-open:rotate-90" 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>
|
||||
Advanced Options
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">User ID</label>
|
||||
<input type="number" id="form-userid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Group ID</label>
|
||||
<input type="number" id="form-groupid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-charcoal mb-2">Project ID</label>
|
||||
<input type="number" id="form-projectid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
|
||||
<button onclick="closeModal('user-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||||
<button id="submit-btn" onclick="submitUserForm()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Create User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="delete-modal" class="hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
<div class="p-6">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete User</h3>
|
||||
<p class="text-charcoal-300 text-center mb-6">
|
||||
Are you sure you want to delete <span id="delete-user-name" class="font-mono text-charcoal"></span>? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button onclick="closeModal('delete-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
|
||||
<button id="confirm-delete-btn" onclick="confirmDelete()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">Delete User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allUsers = [];
|
||||
let userToDelete = null;
|
||||
|
||||
// ============================================
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Close all dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const containers = ['role-filter-container', 'form-role-container'];
|
||||
if (!containers.some(id => e.target.closest('#' + id))) {
|
||||
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
|
||||
}
|
||||
});
|
||||
|
||||
// Role filter dropdown
|
||||
function selectRoleFilter(value) {
|
||||
const display = document.getElementById('role-filter-display');
|
||||
const hidden = document.getElementById('role-filter');
|
||||
const dropdown = document.getElementById('role-filter-dropdown');
|
||||
|
||||
const labels = { '': 'All Roles', 'admin': 'Admin', 'user': 'User', 'userplus': 'User+' };
|
||||
display.value = labels[value] || value;
|
||||
hidden.value = value;
|
||||
|
||||
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.dataset.value === value);
|
||||
});
|
||||
|
||||
dropdown.classList.remove('show');
|
||||
filterUsers();
|
||||
}
|
||||
|
||||
// Form role dropdown (for modal)
|
||||
function selectFormRole(value) {
|
||||
const display = document.getElementById('form-role-display');
|
||||
const hidden = document.getElementById('form-role');
|
||||
const dropdown = document.getElementById('form-role-dropdown');
|
||||
|
||||
const labels = {
|
||||
'': 'Select a role...',
|
||||
'admin': 'Admin - Full administrative access',
|
||||
'user': 'User - Standard user access',
|
||||
'userplus': 'User+ - Enhanced user permissions'
|
||||
};
|
||||
display.value = labels[value] || value;
|
||||
hidden.value = value;
|
||||
|
||||
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
item.classList.toggle('selected', item.dataset.value === value);
|
||||
});
|
||||
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
if (!requireAdmin()) {
|
||||
// Redirected
|
||||
} else {
|
||||
initSidebarWithRole();
|
||||
updateUserInfo();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
showTableLoading('users-table-body', 6);
|
||||
try {
|
||||
allUsers = await api.listUsers();
|
||||
filterUsers();
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
showToast('Error loading users: ' + error.message, 'error');
|
||||
showEmptyState('users-table-body', 6, 'Error loading users');
|
||||
}
|
||||
}
|
||||
|
||||
function filterUsers() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
||||
const roleFilter = document.getElementById('role-filter').value;
|
||||
|
||||
let filtered = allUsers;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(u => u.access && u.access.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
|
||||
if (roleFilter) {
|
||||
filtered = filtered.filter(u => u.role === roleFilter);
|
||||
}
|
||||
|
||||
renderUsers(filtered);
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const tbody = document.getElementById('users-table-body');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (users.length === 0) {
|
||||
showEmptyState('users-table-body', 6, 'No users found');
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(user => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
|
||||
row.innerHTML = `
|
||||
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(user.access)}</span></td>
|
||||
<td class="py-4 px-6">${formatRole(user.role)}</td>
|
||||
<td class="py-4 px-6 text-sm text-charcoal">${user.userid || '0'}</td>
|
||||
<td class="py-4 px-6 text-sm text-charcoal">${user.groupid || '0'}</td>
|
||||
<td class="py-4 px-6 text-sm text-charcoal">${user.projectid || '0'}</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button onclick="openEditModal('${escapeHtml(user.access)}')" class="p-2 text-charcoal-300 hover:text-accent hover:bg-accent-50 rounded-lg transition-colors" title="Edit">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="openDeleteModal('${escapeHtml(user.access)}')" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('edit-mode').value = 'create';
|
||||
document.getElementById('modal-title').textContent = 'Create New User';
|
||||
document.getElementById('submit-btn').textContent = 'Create User';
|
||||
document.getElementById('form-access').value = '';
|
||||
document.getElementById('form-access').readOnly = false;
|
||||
document.getElementById('form-access').classList.remove('bg-gray-50');
|
||||
document.getElementById('generate-access-btn').classList.remove('hidden');
|
||||
document.getElementById('form-secret').value = '';
|
||||
document.getElementById('form-secret').required = true;
|
||||
document.getElementById('form-secret').placeholder = 'Click Generate or enter manually';
|
||||
selectFormRole('');
|
||||
document.getElementById('form-userid').value = '0';
|
||||
document.getElementById('form-groupid').value = '0';
|
||||
document.getElementById('form-projectid').value = '0';
|
||||
openModal('user-modal');
|
||||
}
|
||||
|
||||
function openEditModal(accessKey) {
|
||||
const user = allUsers.find(u => u.access === accessKey);
|
||||
if (!user) return;
|
||||
|
||||
document.getElementById('edit-mode').value = 'edit';
|
||||
document.getElementById('modal-title').textContent = 'Edit User';
|
||||
document.getElementById('submit-btn').textContent = 'Save Changes';
|
||||
document.getElementById('form-access').value = user.access;
|
||||
document.getElementById('form-access').readOnly = true;
|
||||
document.getElementById('form-access').classList.add('bg-gray-50');
|
||||
document.getElementById('generate-access-btn').classList.add('hidden');
|
||||
document.getElementById('form-secret').value = '';
|
||||
document.getElementById('form-secret').required = false;
|
||||
document.getElementById('form-secret').placeholder = 'Leave blank to keep current';
|
||||
selectFormRole(user.role || 'user');
|
||||
document.getElementById('form-userid').value = user.userid || '0';
|
||||
document.getElementById('form-groupid').value = user.groupid || '0';
|
||||
document.getElementById('form-projectid').value = user.projectid || '0';
|
||||
openModal('user-modal');
|
||||
}
|
||||
|
||||
function openDeleteModal(accessKey) {
|
||||
userToDelete = accessKey;
|
||||
document.getElementById('delete-user-name').textContent = accessKey;
|
||||
openModal('delete-modal');
|
||||
}
|
||||
|
||||
function generateAccessKey() {
|
||||
document.getElementById('form-access').value = api.generateAccessKey();
|
||||
}
|
||||
|
||||
function generateSecret() {
|
||||
document.getElementById('form-secret').value = api.generateSecretKey();
|
||||
}
|
||||
|
||||
async function submitUserForm() {
|
||||
const mode = document.getElementById('edit-mode').value;
|
||||
const access = document.getElementById('form-access').value.trim();
|
||||
const secret = document.getElementById('form-secret').value;
|
||||
const role = document.getElementById('form-role').value;
|
||||
const userid = parseInt(document.getElementById('form-userid').value) || 0;
|
||||
const groupid = parseInt(document.getElementById('form-groupid').value) || 0;
|
||||
const projectid = parseInt(document.getElementById('form-projectid').value) || 0;
|
||||
|
||||
if (!access || !role) {
|
||||
showToast('Please fill in all required fields', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'create' && !secret) {
|
||||
showToast('Secret key is required for new users', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submit-btn');
|
||||
setLoading(btn, true);
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await api.createUser(access, secret, role, userid, groupid, projectid);
|
||||
showToast('User created successfully', 'success');
|
||||
} else {
|
||||
const updates = { role, userID: userid, groupID: groupid, projectID: projectid };
|
||||
if (secret) updates.secret = secret;
|
||||
await api.updateUser(access, updates);
|
||||
showToast('User updated successfully', 'success');
|
||||
}
|
||||
closeModal('user-modal');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!userToDelete) return;
|
||||
|
||||
const btn = document.getElementById('confirm-delete-btn');
|
||||
setLoading(btn, true);
|
||||
|
||||
try {
|
||||
await api.deleteUser(userToDelete);
|
||||
showToast('User deleted successfully', 'success');
|
||||
closeModal('delete-modal');
|
||||
userToDelete = null;
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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.
|
||||
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
// ServerConfig holds the server configuration
|
||||
type ServerConfig struct {
|
||||
ListenAddr string
|
||||
Gateways []string // S3 API gateways
|
||||
AdminGateways []string // Admin API gateways (defaults to Gateways if empty)
|
||||
Region string
|
||||
TLSCert string
|
||||
TLSKey string
|
||||
CORSOrigin string
|
||||
}
|
||||
|
||||
// Server is the main GUI server
|
||||
type Server struct {
|
||||
app *fiber.App
|
||||
config *ServerConfig
|
||||
quiet bool
|
||||
}
|
||||
|
||||
// Option sets various options for NewServer()
|
||||
type Option func(*Server)
|
||||
|
||||
// WithQuiet silences default logging output.
|
||||
func WithQuiet() Option {
|
||||
return func(s *Server) { s.quiet = true }
|
||||
}
|
||||
|
||||
// NewServer creates a new GUI server instance
|
||||
func NewServer(cfg *ServerConfig, opts ...Option) *Server {
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "versitygw",
|
||||
ServerHeader: "VERSITYGW",
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
|
||||
server := &Server{
|
||||
app: app,
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(server)
|
||||
}
|
||||
|
||||
server.setupMiddleware()
|
||||
server.setupRoutes()
|
||||
|
||||
fmt.Printf("initializing web dashboard on %s\n", cfg.ListenAddr)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// setupMiddleware configures middleware
|
||||
func (s *Server) setupMiddleware() {
|
||||
// Panic recovery
|
||||
s.app.Use(recover.New())
|
||||
|
||||
// Request logging
|
||||
if !s.quiet {
|
||||
s.app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | web | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// setupRoutes configures all routes
|
||||
func (s *Server) setupRoutes() {
|
||||
// API endpoint to get configured gateways
|
||||
s.app.Get("/api/gateways", s.handleGetGateways)
|
||||
|
||||
// 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,
|
||||
}))
|
||||
}
|
||||
|
||||
// handleGetGateways returns the configured gateway URLs (both S3 and Admin)
|
||||
func (s *Server) handleGetGateways(c *fiber.Ctx) error {
|
||||
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{
|
||||
"gateways": s.config.Gateways,
|
||||
"adminGateways": adminGateways,
|
||||
"defaultRegion": s.config.Region,
|
||||
})
|
||||
}
|
||||
|
||||
// Serve starts the server
|
||||
func (s *Server) Serve() error {
|
||||
addr := strings.TrimSpace(s.config.ListenAddr)
|
||||
if addr == "" {
|
||||
return fmt.Errorf("webui: listen address is required")
|
||||
}
|
||||
|
||||
// Check if TLS is configured
|
||||
if s.config.TLSCert != "" && s.config.TLSKey != "" {
|
||||
return s.app.ListenTLS(addr, s.config.TLSCert, s.config.TLSKey)
|
||||
}
|
||||
|
||||
return s.app.Listen(addr)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server
|
||||
func (s *Server) Shutdown() error {
|
||||
return s.app.Shutdown()
|
||||
}
|
||||
Reference in New Issue
Block a user