Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61324ba195 | |||
| dface47942 | |||
| 93ae998e3f | |||
| 5d9cd3ad85 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,9 @@ dist/
|
|||||||
.angular/
|
.angular/
|
||||||
out-tsc/
|
out-tsc/
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
ts/embedded-ui.generated.ts
|
||||||
|
|
||||||
# Deno
|
# Deno
|
||||||
.deno/
|
.deno/
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ coverage/
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
stories/
|
||||||
|
|
||||||
# Package manager locks (keep pnpm-lock.yaml)
|
# Package manager locks (keep pnpm-lock.yaml)
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|||||||
24
changelog.md
24
changelog.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-28 - 1.2.0 - feat(tokens)
|
||||||
|
Add support for organization-owned API tokens and org-level token management
|
||||||
|
|
||||||
|
- ApiToken model: added optional organizationId and createdById fields (persisted and indexed) and new static getOrgTokens method
|
||||||
|
- auth.interfaces: IApiToken and ICreateTokenDto updated to include organizationId and createdById where appropriate
|
||||||
|
- TokenService: create token options now accept organizationId and createdById; tokens store org and creator info; added getOrgTokens and revokeAllOrgTokens (with audit logging)
|
||||||
|
- API: TokenApi now integrates PermissionService to allow organization managers to list/revoke org-owned tokens; GET /api/v1/tokens accepts organizationId query param and token lookup checks org management permissions
|
||||||
|
- Router: PermissionService instantiated and passed to TokenApi
|
||||||
|
- UI: api.service types and methods updated — IToken and ITokenScope include organizationId/createdById; getTokens and createToken now support an organizationId parameter and scoped scopes
|
||||||
|
- .gitignore: added stories/ to ignore
|
||||||
|
|
||||||
|
## 2025-11-28 - 1.1.0 - feat(registry)
|
||||||
|
Add hot-reload websocket, embedded UI bundling, and multi-platform Deno build tasks
|
||||||
|
|
||||||
|
Introduce a ReloadSocketManager and client ReloadService for automatic page reloads when the server restarts. Serve UI assets from an embedded generated file and add Deno tasks to bundle the UI and compile native binaries for multiple platforms. Also update dev watch workflow and ignore generated embedded UI file.
|
||||||
|
|
||||||
|
- Add ReloadSocketManager (ts/reload-socket.ts) to broadcast a server instance ID to connected clients for hot-reload.
|
||||||
|
- Integrate reload socket into StackGalleryRegistry and expose WebSocket upgrade endpoint at /ws/reload.
|
||||||
|
- Add Angular ReloadService (ui/src/app/core/services/reload.service.ts) to connect to the reload WS and trigger page reloads with exponential reconnect.
|
||||||
|
- Serve static UI files from an embedded generated module (getEmbeddedFile) and add SPA fallback to index.html.
|
||||||
|
- Ignore generated embedded UI file (ts/embedded-ui.generated.ts) in .gitignore.
|
||||||
|
- Add Deno tasks in deno.json: bundle-ui, bundle-ui:watch, compile targets (linux/mac x64/arm64) and a release task to bundle + compile.
|
||||||
|
- Update package.json watch script to run BACKEND, UI and BUNDLER concurrently (deno task bundle-ui:watch).
|
||||||
|
|
||||||
## 2025-11-28 - 1.0.1 - fix(smartdata)
|
## 2025-11-28 - 1.0.1 - fix(smartdata)
|
||||||
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
|
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
|
||||||
|
|
||||||
|
|||||||
12
deno.json
12
deno.json
@@ -1,13 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@stack.gallery/registry",
|
"name": "@stack.gallery/registry",
|
||||||
"version": "1.0.1",
|
"version": "1.2.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"start": "deno run --allow-all mod.ts server",
|
"start": "deno run --allow-all mod.ts server",
|
||||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||||
"test": "deno test --allow-all",
|
"test": "deno test --allow-all",
|
||||||
"build": "cd ui && pnpm run build"
|
"build": "cd ui && pnpm run build",
|
||||||
|
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
|
||||||
|
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",
|
||||||
|
"compile": "deno compile --allow-all --output dist/stack-gallery-registry mod.ts",
|
||||||
|
"compile:linux-x64": "deno compile --allow-all --target x86_64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-x64 mod.ts",
|
||||||
|
"compile:linux-arm64": "deno compile --allow-all --target aarch64-unknown-linux-gnu --output dist/stack-gallery-registry-linux-arm64 mod.ts",
|
||||||
|
"compile:macos-x64": "deno compile --allow-all --target x86_64-apple-darwin --output dist/stack-gallery-registry-macos-x64 mod.ts",
|
||||||
|
"compile:macos-arm64": "deno compile --allow-all --target aarch64-apple-darwin --output dist/stack-gallery-registry-macos-arm64 mod.ts",
|
||||||
|
"release": "deno task bundle-ui && deno task compile:linux-x64 && deno task compile:linux-arm64 && deno task compile:macos-x64 && deno task compile:macos-arm64"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
|
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
|
||||||
|
|||||||
3
deno.lock
generated
3
deno.lock
generated
@@ -2,6 +2,7 @@
|
|||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"jsr:@std/cli@^1.0.24": "1.0.24",
|
"jsr:@std/cli@^1.0.24": "1.0.24",
|
||||||
|
"jsr:@std/encoding@1": "1.0.10",
|
||||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
"jsr:@std/fmt@^1.0.8": "1.0.8",
|
"jsr:@std/fmt@^1.0.8": "1.0.8",
|
||||||
"jsr:@std/fs@1": "1.0.20",
|
"jsr:@std/fs@1": "1.0.20",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
|
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/cli",
|
"jsr:@std/cli",
|
||||||
"jsr:@std/encoding",
|
"jsr:@std/encoding@^1.0.10",
|
||||||
"jsr:@std/fmt",
|
"jsr:@std/fmt",
|
||||||
"jsr:@std/fs@^1.0.20",
|
"jsr:@std/fs@^1.0.20",
|
||||||
"jsr:@std/html",
|
"jsr:@std/html",
|
||||||
|
|||||||
361
install.sh
Executable file
361
install.sh
Executable file
@@ -0,0 +1,361 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Stack.Gallery Registry Installer Script
|
||||||
|
# Downloads and installs pre-compiled Stack.Gallery Registry binary from Gitea releases
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# Direct piped installation (recommended):
|
||||||
|
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash
|
||||||
|
#
|
||||||
|
# With version specification:
|
||||||
|
# curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# -h, --help Show this help message
|
||||||
|
# --version VERSION Install specific version (e.g., v1.0.0)
|
||||||
|
# --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)
|
||||||
|
# --setup-service Install and enable systemd service
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
SHOW_HELP=0
|
||||||
|
SPECIFIED_VERSION=""
|
||||||
|
INSTALL_DIR="/opt/stack-gallery-registry"
|
||||||
|
SETUP_SERVICE=0
|
||||||
|
GITEA_BASE_URL="https://code.foss.global"
|
||||||
|
GITEA_REPO="stack.gallery/registry"
|
||||||
|
BINARY_NAME="stack-gallery-registry"
|
||||||
|
SERVICE_NAME="stack-gallery-registry"
|
||||||
|
CONFIG_DIR="/etc/stack-gallery-registry"
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help)
|
||||||
|
SHOW_HELP=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--version)
|
||||||
|
SPECIFIED_VERSION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--install-dir)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--setup-service)
|
||||||
|
SETUP_SERVICE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Use -h or --help for usage information"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $SHOW_HELP -eq 1 ]; then
|
||||||
|
echo "Stack.Gallery Registry Installer Script"
|
||||||
|
echo "Downloads and installs pre-compiled Stack.Gallery Registry binary"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -h, --help Show this help message"
|
||||||
|
echo " --version VERSION Install specific version (e.g., v1.0.0)"
|
||||||
|
echo " --install-dir DIR Installation directory (default: /opt/stack-gallery-registry)"
|
||||||
|
echo " --setup-service Install and enable systemd service"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " # Install latest version"
|
||||||
|
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash"
|
||||||
|
echo ""
|
||||||
|
echo " # Install specific version with systemd service"
|
||||||
|
echo " curl -sSL https://code.foss.global/stack.gallery/registry/raw/branch/main/install.sh | sudo bash -s -- --version v1.0.0 --setup-service"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Helper function to detect OS and architecture
|
||||||
|
detect_platform() {
|
||||||
|
local os=$(uname -s)
|
||||||
|
local arch=$(uname -m)
|
||||||
|
|
||||||
|
# Map OS
|
||||||
|
case "$os" in
|
||||||
|
Linux)
|
||||||
|
os_name="linux"
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
os_name="macos"
|
||||||
|
;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
os_name="windows"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported operating system: $os"
|
||||||
|
echo "Supported: Linux, macOS"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Map architecture
|
||||||
|
case "$arch" in
|
||||||
|
x86_64|amd64)
|
||||||
|
arch_name="x64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
arch_name="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported architecture: $arch"
|
||||||
|
echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Construct binary name
|
||||||
|
echo "${BINARY_NAME}-${os_name}-${arch_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get latest release version from Gitea API
|
||||||
|
get_latest_version() {
|
||||||
|
echo "Fetching latest release version from Gitea..." >&2
|
||||||
|
|
||||||
|
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
|
||||||
|
local response=$(curl -sSL "$api_url" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ $? -ne 0 ] || [ -z "$response" ]; then
|
||||||
|
echo "Error: Failed to fetch latest release information from Gitea API" >&2
|
||||||
|
echo "URL: $api_url" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract tag_name from JSON response
|
||||||
|
local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "Error: Could not determine latest version from API response" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
create_service_file() {
|
||||||
|
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stack.Gallery Registry
|
||||||
|
Documentation=https://code.foss.global/stack.gallery/registry
|
||||||
|
After=network.target mongodb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
ExecStart=${INSTALL_DIR}/${BINARY_NAME} server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=${SERVICE_NAME}
|
||||||
|
|
||||||
|
# Environment variables (customize these)
|
||||||
|
Environment=PORT=3000
|
||||||
|
Environment=HOST=0.0.0.0
|
||||||
|
# Environment=MONGODB_URL=mongodb://localhost:27017/stackgallery
|
||||||
|
# Environment=S3_ENDPOINT=http://localhost:9000
|
||||||
|
# Environment=S3_ACCESS_KEY=minioadmin
|
||||||
|
# Environment=S3_SECRET_KEY=minioadmin
|
||||||
|
# Environment=S3_BUCKET=registry
|
||||||
|
# Environment=JWT_SECRET=your-secret-here
|
||||||
|
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Created systemd service file: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main installation process
|
||||||
|
echo "================================================"
|
||||||
|
echo " Stack.Gallery Registry Installation Script"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detect platform
|
||||||
|
PLATFORM_BINARY=$(detect_platform)
|
||||||
|
echo "Detected platform: $PLATFORM_BINARY"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Determine version to install
|
||||||
|
if [ -n "$SPECIFIED_VERSION" ]; then
|
||||||
|
VERSION="$SPECIFIED_VERSION"
|
||||||
|
echo "Installing specified version: $VERSION"
|
||||||
|
else
|
||||||
|
VERSION=$(get_latest_version)
|
||||||
|
echo "Installing latest version: $VERSION"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Construct download URL
|
||||||
|
DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${PLATFORM_BINARY}"
|
||||||
|
echo "Download URL: $DOWNLOAD_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if service is running and stop it
|
||||||
|
SERVICE_WAS_RUNNING=0
|
||||||
|
if systemctl is-enabled --quiet ${SERVICE_NAME} 2>/dev/null || systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||||
|
SERVICE_WAS_RUNNING=1
|
||||||
|
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||||
|
echo "Stopping ${SERVICE_NAME} service..."
|
||||||
|
systemctl stop ${SERVICE_NAME}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean installation directory - ensure only binary exists
|
||||||
|
if [ -d "$INSTALL_DIR" ]; then
|
||||||
|
echo "Cleaning installation directory: $INSTALL_DIR"
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create fresh installation directory
|
||||||
|
echo "Creating installation directory: $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# Create config directory if it doesn't exist
|
||||||
|
if [ ! -d "$CONFIG_DIR" ]; then
|
||||||
|
echo "Creating config directory: $CONFIG_DIR"
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download binary
|
||||||
|
echo "Downloading Stack.Gallery Registry binary..."
|
||||||
|
TEMP_FILE="$INSTALL_DIR/${BINARY_NAME}.download"
|
||||||
|
curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to download binary from $DOWNLOAD_URL"
|
||||||
|
echo ""
|
||||||
|
echo "Please check:"
|
||||||
|
echo " 1. Your internet connection"
|
||||||
|
echo " 2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases"
|
||||||
|
echo " 3. The platform binary is available for this release"
|
||||||
|
rm -f "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if download was successful (file exists and not empty)
|
||||||
|
if [ ! -s "$TEMP_FILE" ]; then
|
||||||
|
echo "Error: Downloaded file is empty or does not exist"
|
||||||
|
rm -f "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move to final location
|
||||||
|
BINARY_PATH="$INSTALL_DIR/${BINARY_NAME}"
|
||||||
|
mv "$TEMP_FILE" "$BINARY_PATH"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then
|
||||||
|
echo "Error: Failed to move binary to $BINARY_PATH"
|
||||||
|
rm -f "$TEMP_FILE" 2>/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make executable
|
||||||
|
chmod +x "$BINARY_PATH"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to make binary executable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Binary installed successfully to: $BINARY_PATH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if /usr/local/bin is in PATH
|
||||||
|
if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
else
|
||||||
|
BIN_DIR="/usr/bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlink for global access
|
||||||
|
ln -sf "$BINARY_PATH" "$BIN_DIR/${BINARY_NAME}"
|
||||||
|
echo "Symlink created: $BIN_DIR/${BINARY_NAME} -> $BINARY_PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Setup systemd service if requested
|
||||||
|
if [ $SETUP_SERVICE -eq 1 ]; then
|
||||||
|
echo "Setting up systemd service..."
|
||||||
|
create_service_file
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ${SERVICE_NAME}
|
||||||
|
echo "Systemd service enabled: ${SERVICE_NAME}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart service if it was running before update
|
||||||
|
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||||
|
echo "Restarting ${SERVICE_NAME} service..."
|
||||||
|
systemctl restart ${SERVICE_NAME}
|
||||||
|
echo "Service restarted successfully."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "================================================"
|
||||||
|
echo " Stack.Gallery Registry Installation Complete!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Installation details:"
|
||||||
|
echo " Binary location: $BINARY_PATH"
|
||||||
|
echo " Symlink location: $BIN_DIR/${BINARY_NAME}"
|
||||||
|
echo " Config directory: $CONFIG_DIR"
|
||||||
|
echo " Version: $VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if configuration exists
|
||||||
|
if [ -f "${CONFIG_DIR}/config.json" ]; then
|
||||||
|
echo "Configuration: ${CONFIG_DIR}/config.json (preserved)"
|
||||||
|
echo ""
|
||||||
|
echo "Your existing configuration has been preserved."
|
||||||
|
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||||
|
echo "The service has been restarted with your current settings."
|
||||||
|
else
|
||||||
|
echo "Start the service with: sudo systemctl start ${SERVICE_NAME}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Get started:"
|
||||||
|
echo " ${BINARY_NAME} --help"
|
||||||
|
echo " ${BINARY_NAME} server # Start the registry server"
|
||||||
|
echo ""
|
||||||
|
echo "Configure environment variables:"
|
||||||
|
echo " - MONGODB_URL: MongoDB connection string"
|
||||||
|
echo " - S3_ENDPOINT: S3-compatible storage endpoint"
|
||||||
|
echo " - S3_ACCESS_KEY: S3 access key"
|
||||||
|
echo " - S3_SECRET_KEY: S3 secret key"
|
||||||
|
echo " - S3_BUCKET: S3 bucket name"
|
||||||
|
echo " - JWT_SECRET: Secret for JWT signing"
|
||||||
|
echo " - PORT: Server port (default: 3000)"
|
||||||
|
echo ""
|
||||||
|
if [ $SETUP_SERVICE -eq 1 ]; then
|
||||||
|
echo "Edit the service file to configure environment:"
|
||||||
|
echo " sudo nano /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
echo " sudo systemctl daemon-reload"
|
||||||
|
echo " sudo systemctl start ${SERVICE_NAME}"
|
||||||
|
else
|
||||||
|
echo "To setup as a systemd service, re-run with --setup-service:"
|
||||||
|
echo " curl -sSL ${GITEA_BASE_URL}/${GITEA_REPO}/raw/branch/main/install.sh | sudo bash -s -- --setup-service"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@stack.gallery/registry",
|
"name": "@stack.gallery/registry",
|
||||||
"version": "1.0.1",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Enterprise-grade multi-protocol package registry",
|
"description": "Enterprise-grade multi-protocol package registry",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "deno run --allow-all mod.ts server",
|
"start": "deno run --allow-all mod.ts server",
|
||||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||||
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\"",
|
"watch": "concurrently --kill-others --names \"BACKEND,UI,BUNDLER\" --prefix-colors \"cyan,magenta,yellow\" \"deno run --allow-all --watch mod.ts server --ephemeral\" \"cd ui && pnpm run watch\" \"deno task bundle-ui:watch\"",
|
||||||
"build": "cd ui && pnpm run build",
|
"build": "cd ui && pnpm run build",
|
||||||
"test": "deno test --allow-all"
|
"test": "deno test --allow-all"
|
||||||
},
|
},
|
||||||
|
|||||||
214
scripts/bundle-ui.ts
Normal file
214
scripts/bundle-ui.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env -S deno run --allow-all
|
||||||
|
/**
|
||||||
|
* UI Bundler Script
|
||||||
|
* Encodes all files from ui/dist/registry-ui/browser/ as base64
|
||||||
|
* and generates ts/embedded-ui.generated.ts
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* deno task bundle-ui # One-time bundle
|
||||||
|
* deno task bundle-ui:watch # Watch mode for development
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { walk } from 'jsr:@std/fs@1/walk';
|
||||||
|
import { extname, relative } from 'jsr:@std/path@1';
|
||||||
|
import { encodeBase64 } from 'jsr:@std/encoding@1/base64';
|
||||||
|
|
||||||
|
const UI_DIST_PATH = './ui/dist/registry-ui/browser';
|
||||||
|
const OUTPUT_PATH = './ts/embedded-ui.generated.ts';
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.eot': 'application/vnd.ms-fontobject',
|
||||||
|
'.otf': 'font/otf',
|
||||||
|
'.map': 'application/json',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.webmanifest': 'application/manifest+json',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IEmbeddedFile {
|
||||||
|
path: string;
|
||||||
|
base64: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundleUI(): Promise<void> {
|
||||||
|
console.log('[bundle-ui] Starting UI bundling...');
|
||||||
|
console.log(`[bundle-ui] Source: ${UI_DIST_PATH}`);
|
||||||
|
console.log(`[bundle-ui] Output: ${OUTPUT_PATH}`);
|
||||||
|
|
||||||
|
// Check if UI dist exists
|
||||||
|
try {
|
||||||
|
await Deno.stat(UI_DIST_PATH);
|
||||||
|
} catch {
|
||||||
|
console.error(`[bundle-ui] ERROR: UI dist not found at ${UI_DIST_PATH}`);
|
||||||
|
console.error('[bundle-ui] Run "deno task build" first to build the UI');
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: IEmbeddedFile[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
// Walk through all files in the dist directory
|
||||||
|
for await (const entry of walk(UI_DIST_PATH, { includeFiles: true, includeDirs: false })) {
|
||||||
|
const relativePath = '/' + relative(UI_DIST_PATH, entry.path).replace(/\\/g, '/');
|
||||||
|
const ext = extname(entry.path).toLowerCase();
|
||||||
|
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Read file and encode as base64
|
||||||
|
const content = await Deno.readFile(entry.path);
|
||||||
|
const base64 = encodeBase64(content);
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: relativePath,
|
||||||
|
base64,
|
||||||
|
contentType,
|
||||||
|
size: content.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalSize += content.length;
|
||||||
|
console.log(`[bundle-ui] Encoded: ${relativePath} (${formatSize(content.length)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort files for consistent output
|
||||||
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
// Generate TypeScript module
|
||||||
|
const tsContent = generateTypeScript(files, totalSize);
|
||||||
|
|
||||||
|
// Write output file
|
||||||
|
await Deno.writeTextFile(OUTPUT_PATH, tsContent);
|
||||||
|
|
||||||
|
console.log(`[bundle-ui] Generated ${OUTPUT_PATH}`);
|
||||||
|
console.log(`[bundle-ui] Total files: ${files.length}`);
|
||||||
|
console.log(`[bundle-ui] Total size: ${formatSize(totalSize)}`);
|
||||||
|
console.log(`[bundle-ui] Bundling complete!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTypeScript(files: IEmbeddedFile[], totalSize: number): string {
|
||||||
|
const fileEntries = files
|
||||||
|
.map(
|
||||||
|
(f) =>
|
||||||
|
` ['${f.path}', { base64: '${f.base64}', contentType: '${f.contentType}' }]`
|
||||||
|
)
|
||||||
|
.join(',\n');
|
||||||
|
|
||||||
|
return `// AUTO-GENERATED FILE - DO NOT EDIT
|
||||||
|
// Generated by scripts/bundle-ui.ts
|
||||||
|
// Total files: ${files.length}
|
||||||
|
// Total size: ${formatSize(totalSize)}
|
||||||
|
// Generated at: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
interface IEmbeddedFile {
|
||||||
|
base64: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMBEDDED_FILES: Map<string, IEmbeddedFile> = new Map([
|
||||||
|
${fileEntries}
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an embedded file by path
|
||||||
|
* @param path - The file path (e.g., '/index.html')
|
||||||
|
* @returns The file data and content type, or null if not found
|
||||||
|
*/
|
||||||
|
export function getEmbeddedFile(path: string): { data: Uint8Array; contentType: string } | null {
|
||||||
|
const file = EMBEDDED_FILES.get(path);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
// Decode base64 to Uint8Array
|
||||||
|
const binaryString = atob(file.base64);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: bytes, contentType: file.contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an embedded file exists
|
||||||
|
* @param path - The file path to check
|
||||||
|
*/
|
||||||
|
export function hasEmbeddedFile(path: string): boolean {
|
||||||
|
return EMBEDDED_FILES.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all embedded file paths
|
||||||
|
*/
|
||||||
|
export function listEmbeddedFiles(): string[] {
|
||||||
|
return Array.from(EMBEDDED_FILES.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total number of embedded files
|
||||||
|
*/
|
||||||
|
export function getEmbeddedFileCount(): number {
|
||||||
|
return EMBEDDED_FILES.size;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watchMode(): Promise<void> {
|
||||||
|
console.log('[bundle-ui] Starting watch mode...');
|
||||||
|
console.log(`[bundle-ui] Watching: ${UI_DIST_PATH}`);
|
||||||
|
console.log('[bundle-ui] Press Ctrl+C to stop');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Initial bundle
|
||||||
|
await bundleUI();
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
const watcher = Deno.watchFs(UI_DIST_PATH);
|
||||||
|
let debounceTimer: number | null = null;
|
||||||
|
|
||||||
|
for await (const event of watcher) {
|
||||||
|
if (event.kind === 'modify' || event.kind === 'create' || event.kind === 'remove') {
|
||||||
|
// Debounce - wait 500ms after last change
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
console.log('');
|
||||||
|
console.log(`[bundle-ui] Change detected: ${event.kind}`);
|
||||||
|
try {
|
||||||
|
await bundleUI();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bundle-ui] Error during rebundle:', error);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main entry point
|
||||||
|
const args = Deno.args;
|
||||||
|
const isWatch = args.includes('--watch') || args.includes('-w');
|
||||||
|
|
||||||
|
if (isWatch) {
|
||||||
|
await watchMode();
|
||||||
|
} else {
|
||||||
|
await bundleUI();
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@stack.gallery/registry',
|
name: '@stack.gallery/registry',
|
||||||
version: '1.0.1',
|
version: '1.2.0',
|
||||||
description: 'Enterprise-grade multi-protocol package registry'
|
description: 'Enterprise-grade multi-protocol package registry'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,22 @@
|
|||||||
|
|
||||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||||
import { TokenService } from '../../services/token.service.ts';
|
import { TokenService } from '../../services/token.service.ts';
|
||||||
|
import { PermissionService } from '../../services/permission.service.ts';
|
||||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||||
|
|
||||||
export class TokenApi {
|
export class TokenApi {
|
||||||
private tokenService: TokenService;
|
private tokenService: TokenService;
|
||||||
|
private permissionService: PermissionService;
|
||||||
|
|
||||||
constructor(tokenService: TokenService) {
|
constructor(tokenService: TokenService, permissionService?: PermissionService) {
|
||||||
this.tokenService = tokenService;
|
this.tokenService = tokenService;
|
||||||
|
this.permissionService = permissionService || new PermissionService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/tokens
|
* GET /api/v1/tokens
|
||||||
|
* Query params:
|
||||||
|
* - organizationId: list org tokens (requires org admin)
|
||||||
*/
|
*/
|
||||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||||
if (!ctx.actor?.userId) {
|
if (!ctx.actor?.userId) {
|
||||||
@@ -22,7 +27,20 @@ export class TokenApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
const url = new URL(ctx.request.url);
|
||||||
|
const organizationId = url.searchParams.get('organizationId');
|
||||||
|
|
||||||
|
let tokens;
|
||||||
|
if (organizationId) {
|
||||||
|
// Check if user can manage org
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||||
|
if (!canManage) {
|
||||||
|
return { status: 403, body: { error: 'Not authorized to view organization tokens' } };
|
||||||
|
}
|
||||||
|
tokens = await this.tokenService.getOrgTokens(organizationId);
|
||||||
|
} else {
|
||||||
|
tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -33,6 +51,8 @@ export class TokenApi {
|
|||||||
tokenPrefix: t.tokenPrefix,
|
tokenPrefix: t.tokenPrefix,
|
||||||
protocols: t.protocols,
|
protocols: t.protocols,
|
||||||
scopes: t.scopes,
|
scopes: t.scopes,
|
||||||
|
organizationId: t.organizationId,
|
||||||
|
createdById: t.createdById,
|
||||||
expiresAt: t.expiresAt,
|
expiresAt: t.expiresAt,
|
||||||
lastUsedAt: t.lastUsedAt,
|
lastUsedAt: t.lastUsedAt,
|
||||||
usageCount: t.usageCount,
|
usageCount: t.usageCount,
|
||||||
@@ -48,6 +68,12 @@ export class TokenApi {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/tokens
|
* POST /api/v1/tokens
|
||||||
|
* Body:
|
||||||
|
* - name: token name
|
||||||
|
* - organizationId: (optional) create org token instead of personal
|
||||||
|
* - protocols: array of protocols
|
||||||
|
* - scopes: array of scope objects
|
||||||
|
* - expiresInDays: (optional) token expiry
|
||||||
*/
|
*/
|
||||||
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
public async create(ctx: IApiContext): Promise<IApiResponse> {
|
||||||
if (!ctx.actor?.userId) {
|
if (!ctx.actor?.userId) {
|
||||||
@@ -56,8 +82,9 @@ export class TokenApi {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await ctx.request.json();
|
const body = await ctx.request.json();
|
||||||
const { name, protocols, scopes, expiresInDays } = body as {
|
const { name, organizationId, protocols, scopes, expiresInDays } = body as {
|
||||||
name: string;
|
name: string;
|
||||||
|
organizationId?: string;
|
||||||
protocols: TRegistryProtocol[];
|
protocols: TRegistryProtocol[];
|
||||||
scopes: ITokenScope[];
|
scopes: ITokenScope[];
|
||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
@@ -90,8 +117,18 @@ export class TokenApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If creating org token, verify permission
|
||||||
|
if (organizationId) {
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, organizationId);
|
||||||
|
if (!canManage) {
|
||||||
|
return { status: 403, body: { error: 'Not authorized to create organization tokens' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.tokenService.createToken({
|
const result = await this.tokenService.createToken({
|
||||||
userId: ctx.actor.userId,
|
userId: ctx.actor.userId,
|
||||||
|
organizationId,
|
||||||
|
createdById: ctx.actor.userId,
|
||||||
name,
|
name,
|
||||||
protocols,
|
protocols,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -108,6 +145,7 @@ export class TokenApi {
|
|||||||
tokenPrefix: result.token.tokenPrefix,
|
tokenPrefix: result.token.tokenPrefix,
|
||||||
protocols: result.token.protocols,
|
protocols: result.token.protocols,
|
||||||
scopes: result.token.scopes,
|
scopes: result.token.scopes,
|
||||||
|
organizationId: result.token.organizationId,
|
||||||
expiresAt: result.token.expiresAt,
|
expiresAt: result.token.expiresAt,
|
||||||
createdAt: result.token.createdAt,
|
createdAt: result.token.createdAt,
|
||||||
warning: 'Store this token securely. It will not be shown again.',
|
warning: 'Store this token securely. It will not be shown again.',
|
||||||
@@ -121,6 +159,7 @@ export class TokenApi {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/v1/tokens/:id
|
* DELETE /api/v1/tokens/:id
|
||||||
|
* Allows revoking personal tokens or org tokens (if org admin)
|
||||||
*/
|
*/
|
||||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||||
if (!ctx.actor?.userId) {
|
if (!ctx.actor?.userId) {
|
||||||
@@ -130,12 +169,27 @@ export class TokenApi {
|
|||||||
const { id } = ctx.params;
|
const { id } = ctx.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the token to verify ownership
|
// First check if it's a personal token
|
||||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||||
const token = tokens.find((t) => t.id === id);
|
let token = userTokens.find((t) => t.id === id);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Check if it's an org token and user can manage org
|
||||||
|
const { ApiToken } = await import('../../models/index.ts');
|
||||||
|
const anyToken = await ApiToken.getInstance({ id, isRevoked: false });
|
||||||
|
|
||||||
|
if (anyToken?.organizationId) {
|
||||||
|
const canManage = await this.permissionService.canManageOrganization(
|
||||||
|
ctx.actor.userId,
|
||||||
|
anyToken.organizationId
|
||||||
|
);
|
||||||
|
if (canManage) {
|
||||||
|
token = anyToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Either doesn't exist or doesn't belong to user
|
|
||||||
return { status: 404, body: { error: 'Token not found' } };
|
return { status: 404, body: { error: 'Token not found' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*';
|
|||||||
export interface IApiToken {
|
export interface IApiToken {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
organizationId?: string; // For org-owned tokens
|
||||||
|
createdById?: string; // Who created the token (for audit)
|
||||||
name: string;
|
name: string;
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
tokenPrefix: string;
|
tokenPrefix: string;
|
||||||
@@ -276,6 +278,7 @@ export interface ICreateRepositoryDto {
|
|||||||
|
|
||||||
export interface ICreateTokenDto {
|
export interface ICreateTokenDto {
|
||||||
name: string;
|
name: string;
|
||||||
|
organizationId?: string; // For org-owned tokens
|
||||||
protocols: TRegistryProtocol[];
|
protocols: TRegistryProtocol[];
|
||||||
scopes: ITokenScope[];
|
scopes: ITokenScope[];
|
||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
|||||||
@plugins.smartdata.index()
|
@plugins.smartdata.index()
|
||||||
public userId: string = '';
|
public userId: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
@plugins.smartdata.index()
|
||||||
|
public organizationId?: string; // For org-owned tokens
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdById?: string; // Who created the token (for audit)
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public name: string = '';
|
public name: string = '';
|
||||||
|
|
||||||
@@ -90,6 +97,16 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tokens for an organization
|
||||||
|
*/
|
||||||
|
public static async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
|
||||||
|
return await ApiToken.getInstances({
|
||||||
|
organizationId,
|
||||||
|
isRevoked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is valid (not expired, not revoked)
|
* Check if token is valid (not expired, not revoked)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { initDb, closeDb, isDbConnected } from './models/db.ts';
|
|||||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||||
import { ApiRouter } from './api/router.ts';
|
import { ApiRouter } from './api/router.ts';
|
||||||
|
import { getEmbeddedFile } from './embedded-ui.generated.ts';
|
||||||
|
import { ReloadSocketManager } from './reload-socket.ts';
|
||||||
|
|
||||||
export interface IRegistryConfig {
|
export interface IRegistryConfig {
|
||||||
// MongoDB configuration
|
// MongoDB configuration
|
||||||
@@ -41,6 +43,7 @@ export class StackGalleryRegistry {
|
|||||||
private authProvider: StackGalleryAuthProvider | null = null;
|
private authProvider: StackGalleryAuthProvider | null = null;
|
||||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||||
private apiRouter: ApiRouter | null = null;
|
private apiRouter: ApiRouter | null = null;
|
||||||
|
private reloadSocket: ReloadSocketManager | null = null;
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
constructor(config: IRegistryConfig) {
|
constructor(config: IRegistryConfig) {
|
||||||
@@ -110,6 +113,9 @@ export class StackGalleryRegistry {
|
|||||||
this.apiRouter = new ApiRouter();
|
this.apiRouter = new ApiRouter();
|
||||||
console.log('[StackGalleryRegistry] API router initialized');
|
console.log('[StackGalleryRegistry] API router initialized');
|
||||||
|
|
||||||
|
// Initialize reload socket for hot reload
|
||||||
|
this.reloadSocket = new ReloadSocketManager();
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
console.log('[StackGalleryRegistry] Initialization complete');
|
console.log('[StackGalleryRegistry] Initialization complete');
|
||||||
}
|
}
|
||||||
@@ -182,56 +188,40 @@ export class StackGalleryRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebSocket upgrade for hot reload
|
||||||
|
if (path === '/ws/reload' && request.headers.get('upgrade') === 'websocket') {
|
||||||
|
return this.reloadSocket!.handleUpgrade(request);
|
||||||
|
}
|
||||||
|
|
||||||
// Serve static UI files
|
// Serve static UI files
|
||||||
return await this.serveStaticFile(path);
|
return this.serveStaticFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve static files from UI dist
|
* Serve static files from embedded UI
|
||||||
*/
|
*/
|
||||||
private async serveStaticFile(path: string): Promise<Response> {
|
private serveStaticFile(path: string): Response {
|
||||||
const uiDistPath = './ui/dist/registry-ui/browser';
|
const filePath = path === '/' ? '/index.html' : path;
|
||||||
|
|
||||||
// Map path to file
|
// Get embedded file
|
||||||
let filePath = path === '/' ? '/index.html' : path;
|
const embeddedFile = getEmbeddedFile(filePath);
|
||||||
|
if (embeddedFile) {
|
||||||
// Content type mapping
|
return new Response(embeddedFile.data, {
|
||||||
const contentTypes: Record<string, string> = {
|
|
||||||
'.html': 'text/html',
|
|
||||||
'.js': 'application/javascript',
|
|
||||||
'.css': 'text/css',
|
|
||||||
'.json': 'application/json',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
'.woff': 'font/woff',
|
|
||||||
'.woff2': 'font/woff2',
|
|
||||||
'.ttf': 'font/ttf',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullPath = `${uiDistPath}${filePath}`;
|
|
||||||
const file = await Deno.readFile(fullPath);
|
|
||||||
const ext = filePath.substring(filePath.lastIndexOf('.'));
|
|
||||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
||||||
|
|
||||||
return new Response(file, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': contentType },
|
headers: { 'Content-Type': embeddedFile.contentType },
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// For SPA routing, serve index.html for unknown paths
|
|
||||||
try {
|
|
||||||
const indexFile = await Deno.readFile(`${uiDistPath}/index.html`);
|
|
||||||
return new Response(indexFile, {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/html' },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html for unknown paths
|
||||||
|
const indexFile = getEmbeddedFile('/index.html');
|
||||||
|
if (indexFile) {
|
||||||
|
return new Response(indexFile.data, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
65
ts/reload-socket.ts
Normal file
65
ts/reload-socket.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket manager for hot reload
|
||||||
|
* Generates a unique instance ID on startup and broadcasts it to connected clients.
|
||||||
|
* When the server restarts, clients detect the new ID and reload the page.
|
||||||
|
*/
|
||||||
|
export class ReloadSocketManager {
|
||||||
|
private instanceId: string;
|
||||||
|
private clients: Set<WebSocket> = new Set();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.instanceId = crypto.randomUUID();
|
||||||
|
console.log(`[ReloadSocket] Instance ID: ${this.instanceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current instance ID
|
||||||
|
*/
|
||||||
|
getInstanceId(): string {
|
||||||
|
return this.instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebSocket upgrade request
|
||||||
|
*/
|
||||||
|
handleUpgrade(request: Request): Response {
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
this.clients.add(socket);
|
||||||
|
console.log(`[ReloadSocket] Client connected (${this.clients.size} total)`);
|
||||||
|
// Send instance ID immediately
|
||||||
|
socket.send(JSON.stringify({ type: 'instance', id: this.instanceId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
this.clients.delete(socket);
|
||||||
|
console.log(`[ReloadSocket] Client disconnected (${this.clients.size} remaining)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
console.error('[ReloadSocket] WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a message to all connected clients
|
||||||
|
*/
|
||||||
|
broadcast(message: object): void {
|
||||||
|
const msg = JSON.stringify(message);
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of connected clients
|
||||||
|
*/
|
||||||
|
getClientCount(): number {
|
||||||
|
return this.clients.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts';
|
|||||||
|
|
||||||
export interface ICreateTokenOptions {
|
export interface ICreateTokenOptions {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
organizationId?: string; // For org-owned tokens
|
||||||
|
createdById?: string; // Who created the token (defaults to userId)
|
||||||
name: string;
|
name: string;
|
||||||
protocols: TRegistryProtocol[];
|
protocols: TRegistryProtocol[];
|
||||||
scopes: ITokenScope[];
|
scopes: ITokenScope[];
|
||||||
@@ -52,6 +54,8 @@ export class TokenService {
|
|||||||
const token = new ApiToken();
|
const token = new ApiToken();
|
||||||
token.id = await ApiToken.getNewId();
|
token.id = await ApiToken.getNewId();
|
||||||
token.userId = options.userId;
|
token.userId = options.userId;
|
||||||
|
token.organizationId = options.organizationId;
|
||||||
|
token.createdById = options.createdById || options.userId;
|
||||||
token.name = options.name;
|
token.name = options.name;
|
||||||
token.tokenHash = tokenHash;
|
token.tokenHash = tokenHash;
|
||||||
token.tokenPrefix = tokenPrefix;
|
token.tokenPrefix = tokenPrefix;
|
||||||
@@ -150,6 +154,13 @@ export class TokenService {
|
|||||||
return await ApiToken.getUserTokens(userId);
|
return await ApiToken.getUserTokens(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tokens for an organization
|
||||||
|
*/
|
||||||
|
public async getOrgTokens(organizationId: string): Promise<ApiToken[]> {
|
||||||
|
return await ApiToken.getOrgTokens(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke a token
|
* Revoke a token
|
||||||
*/
|
*/
|
||||||
@@ -175,6 +186,18 @@ export class TokenService {
|
|||||||
return tokens.length;
|
return tokens.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all tokens for an organization
|
||||||
|
*/
|
||||||
|
public async revokeAllOrgTokens(organizationId: string, reason?: string): Promise<number> {
|
||||||
|
const tokens = await ApiToken.getOrgTokens(organizationId);
|
||||||
|
for (const token of tokens) {
|
||||||
|
await token.revoke(reason);
|
||||||
|
await this.auditService.logTokenRevoked(token.id, token.name);
|
||||||
|
}
|
||||||
|
return tokens.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token has permission for a specific action
|
* Check if token has permission for a specific action
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { ReloadService } from './core/services/reload.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -7,4 +8,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet />`,
|
template: `<router-outlet />`,
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent {
|
||||||
|
// Inject to trigger instantiation for hot reload
|
||||||
|
private reloadService = inject(ReloadService);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
import { ReloadService } from './core/services/reload.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideExperimentalZonelessChangeDetection(),
|
provideExperimentalZonelessChangeDetection(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(withInterceptors([authInterceptor])),
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
ReloadService,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,11 +39,21 @@ export interface IPackage {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITokenScope {
|
||||||
|
protocol: string;
|
||||||
|
organizationId?: string;
|
||||||
|
repositoryId?: string;
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IToken {
|
export interface IToken {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tokenPrefix: string;
|
tokenPrefix: string;
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
|
scopes?: ITokenScope[];
|
||||||
|
organizationId?: string;
|
||||||
|
createdById?: string;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
@@ -179,14 +189,21 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tokens
|
// Tokens
|
||||||
getTokens(): Observable<{ tokens: IToken[] }> {
|
getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> {
|
||||||
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
|
let httpParams = new HttpParams();
|
||||||
|
if (organizationId) {
|
||||||
|
httpParams = httpParams.set('organizationId', organizationId);
|
||||||
|
}
|
||||||
|
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, {
|
||||||
|
params: httpParams,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createToken(data: {
|
createToken(data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
organizationId?: string;
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
scopes: { protocol: string; actions: string[] }[];
|
scopes: ITokenScope[];
|
||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
}): Observable<IToken & { token: string }> {
|
}): Observable<IToken & { token: string }> {
|
||||||
return this.http.post<IToken & { token: string }>(
|
return this.http.post<IToken & { token: string }>(
|
||||||
|
|||||||
86
ui/src/app/core/services/reload.service.ts
Normal file
86
ui/src/app/core/services/reload.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for automatic page reload when server restarts.
|
||||||
|
* Connects to WebSocket endpoint and monitors server instance ID.
|
||||||
|
* When server restarts with new ID, page automatically reloads.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ReloadService {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private instanceId = signal<string | null>(null);
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 10;
|
||||||
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): void {
|
||||||
|
// Clean up any existing connection
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${location.host}/ws/reload`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('[ReloadService] Connected to server');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'instance') {
|
||||||
|
const currentId = this.instanceId();
|
||||||
|
if (currentId !== null && currentId !== data.id) {
|
||||||
|
// Server restarted with new ID - reload page
|
||||||
|
console.log('[ReloadService] Server restarted, reloading...');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
console.log(`[ReloadService] Instance ID: ${data.id}`);
|
||||||
|
}
|
||||||
|
this.instanceId.set(data.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ReloadService] Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log('[ReloadService] Connection closed');
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('[ReloadService] WebSocket error:', error);
|
||||||
|
this.ws?.close();
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ReloadService] Failed to create WebSocket:', e);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`[ReloadService] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||||
|
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
|
||||||
|
} else {
|
||||||
|
console.log('[ReloadService] Max reconnect attempts reached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { NgClass } from '@angular/common';
|
import { NgClass } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService, type IToken } from '../../core/services/api.service';
|
import { ApiService, type IToken, type ITokenScope, type IOrganization } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
|
||||||
|
interface IScopeEntry {
|
||||||
|
protocol: string;
|
||||||
|
actions: string[];
|
||||||
|
organizationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tokens',
|
selector: 'app-tokens',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
@for (token of tokens(); track token.id) {
|
@for (token of tokens(); track token.id) {
|
||||||
<li class="px-6 py-4 hover:bg-muted/30 transition-colors">
|
<li class="px-6 py-4 hover:bg-muted/30 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
|
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
|
||||||
@for (protocol of token.protocols.slice(0, 3); track protocol) {
|
@if (token.organizationId) {
|
||||||
<span class="badge-accent">{{ protocol }}</span>
|
<span class="badge-primary">Org Token</span>
|
||||||
}
|
} @else {
|
||||||
@if (token.protocols.length > 3) {
|
<span class="badge-default">Personal</span>
|
||||||
<span class="badge-default">+{{ token.protocols.length - 3 }}</span>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="font-mono text-sm text-muted-foreground mt-1">
|
<!-- Scope summary -->
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
@for (scope of token.scopes?.slice(0, 4) || []; track $index) {
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-muted border border-border">
|
||||||
|
<span class="font-semibold">{{ scope.protocol === '*' ? 'All' : scope.protocol }}</span>
|
||||||
|
<span class="text-muted-foreground">{{ formatActions(scope.actions) }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if ((token.scopes?.length || 0) > 4) {
|
||||||
|
<span class="badge-default text-xs">+{{ (token.scopes?.length || 0) - 4 }} more</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-sm text-muted-foreground mt-2">
|
||||||
<code>{{ token.tokenPrefix }}...</code>
|
<code>{{ token.tokenPrefix }}...</code>
|
||||||
@if (token.expiresAt) {
|
@if (token.expiresAt) {
|
||||||
<span class="mx-2">·</span>
|
<span class="mx-2">·</span>
|
||||||
@@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
· {{ token.usageCount }} uses
|
· {{ token.usageCount }} uses
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10">
|
<button (click)="revokeToken(token)" class="btn-ghost btn-sm text-destructive hover:text-destructive hover:bg-destructive/10 ml-4">
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
|
|
||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
@if (showCreateModal()) {
|
@if (showCreateModal()) {
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8">
|
||||||
<div class="card w-full max-w-lg mx-4">
|
<div class="card w-full max-w-2xl mx-4">
|
||||||
<div class="card-header flex items-center justify-between">
|
<div class="card-header flex items-center justify-between">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-indicator"></div>
|
<div class="section-indicator"></div>
|
||||||
@@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content space-y-4">
|
<div class="card-content space-y-5">
|
||||||
|
<!-- Token Name -->
|
||||||
<div>
|
<div>
|
||||||
<label class="label block mb-1.5">Token Name</label>
|
<label class="label block mb-1.5">Token Name</label>
|
||||||
<input
|
<input
|
||||||
@@ -108,27 +126,134 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
placeholder="my-ci-token"
|
placeholder="my-ci-token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="label block mb-1.5">Protocols</label>
|
<label class="label block mb-1.5">Token Type</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex gap-3">
|
||||||
@for (protocol of availableProtocols; track protocol) {
|
<label
|
||||||
|
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-primary/10 border-primary': !newToken.organizationId,
|
||||||
|
'border-border': newToken.organizationId
|
||||||
|
}">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tokenType"
|
||||||
|
[checked]="!newToken.organizationId"
|
||||||
|
(change)="newToken.organizationId = undefined"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-sm font-medium text-foreground">Personal</div>
|
||||||
|
<div class="font-mono text-xs text-muted-foreground">For your personal use</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
@if (organizations().length > 0) {
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-2 px-3 py-1.5 border cursor-pointer hover:bg-muted/30 transition-colors font-mono text-sm"
|
class="flex-1 flex items-center gap-3 p-3 border cursor-pointer hover:bg-muted/30 transition-colors"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'bg-primary/10 border-primary text-primary': newToken.protocols.includes(protocol),
|
'bg-primary/10 border-primary': newToken.organizationId,
|
||||||
'border-border text-foreground': !newToken.protocols.includes(protocol)
|
'border-border': !newToken.organizationId
|
||||||
}">
|
}">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="radio"
|
||||||
[checked]="newToken.protocols.includes(protocol)"
|
name="tokenType"
|
||||||
(change)="toggleProtocol(protocol)"
|
[checked]="newToken.organizationId"
|
||||||
|
(change)="newToken.organizationId = organizations()[0]?.id"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
/>
|
/>
|
||||||
<span>{{ protocol }}</span>
|
<div>
|
||||||
|
<div class="font-mono text-sm font-medium text-foreground">Organization</div>
|
||||||
|
<div class="font-mono text-xs text-muted-foreground">Shared with org members</div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (newToken.organizationId) {
|
||||||
|
<select [(ngModel)]="newToken.organizationId" class="input mt-2">
|
||||||
|
@for (org of organizations(); track org.id) {
|
||||||
|
<option [value]="org.id">{{ org.displayName || org.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scopes -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="label">Scopes</label>
|
||||||
|
<button (click)="addScope()" class="btn-ghost btn-sm text-primary">
|
||||||
|
<svg class="w-4 h-4 mr-1" 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>
|
||||||
|
Add Scope
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (newToken.scopes.length === 0) {
|
||||||
|
<div class="p-4 border border-dashed border-border text-center">
|
||||||
|
<p class="font-mono text-sm text-muted-foreground">No scopes defined. Add at least one scope.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="space-y-3">
|
||||||
|
@for (scope of newToken.scopes; track $index; let i = $index) {
|
||||||
|
<div class="p-4 border border-border bg-muted/20">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<!-- Protocol -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="font-mono text-xs text-muted-foreground w-20">Protocol</label>
|
||||||
|
<select [(ngModel)]="scope.protocol" class="input flex-1">
|
||||||
|
<option value="*">All Protocols</option>
|
||||||
|
@for (protocol of availableProtocols; track protocol) {
|
||||||
|
<option [value]="protocol">{{ protocol }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="font-mono text-xs text-muted-foreground w-20">Actions</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
@for (action of availableActions; track action) {
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="scope.actions.includes(action)"
|
||||||
|
(change)="toggleAction(scope, action)"
|
||||||
|
class="form-checkbox"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-foreground capitalize">{{ action }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Limit to Org -->
|
||||||
|
@if (organizations().length > 0 && !newToken.organizationId) {
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="font-mono text-xs text-muted-foreground w-20">Limit to</label>
|
||||||
|
<select [(ngModel)]="scope.organizationId" class="input flex-1">
|
||||||
|
<option [ngValue]="undefined">Any Organization</option>
|
||||||
|
@for (org of organizations(); track org.id) {
|
||||||
|
<option [value]="org.id">{{ org.displayName || org.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button (click)="removeScope(i)" class="btn-ghost btn-sm p-1 text-muted-foreground hover:text-destructive">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration -->
|
||||||
<div>
|
<div>
|
||||||
<label class="label block mb-1.5">Expiration (optional)</label>
|
<label class="label block mb-1.5">Expiration (optional)</label>
|
||||||
<select [(ngModel)]="newToken.expiresInDays" class="input">
|
<select [(ngModel)]="newToken.expiresInDays" class="input">
|
||||||
@@ -142,7 +267,10 @@ import { ToastService } from '../../core/services/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer flex justify-end gap-3">
|
<div class="card-footer flex justify-end gap-3">
|
||||||
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button>
|
<button (click)="closeCreateModal()" class="btn-secondary btn-md">Cancel</button>
|
||||||
<button (click)="createToken()" [disabled]="creating() || !newToken.name || newToken.protocols.length === 0" class="btn-primary btn-md">
|
<button
|
||||||
|
(click)="createToken()"
|
||||||
|
[disabled]="creating() || !newToken.name || newToken.scopes.length === 0 || !hasValidScopes()"
|
||||||
|
class="btn-primary btn-md">
|
||||||
@if (creating()) {
|
@if (creating()) {
|
||||||
Creating...
|
Creating...
|
||||||
} @else {
|
} @else {
|
||||||
@@ -196,54 +324,86 @@ export class TokensComponent implements OnInit {
|
|||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
tokens = signal<IToken[]>([]);
|
tokens = signal<IToken[]>([]);
|
||||||
|
organizations = signal<IOrganization[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
showCreateModal = signal(false);
|
showCreateModal = signal(false);
|
||||||
creating = signal(false);
|
creating = signal(false);
|
||||||
createdToken = signal<string | null>(null);
|
createdToken = signal<string | null>(null);
|
||||||
|
|
||||||
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||||
|
availableActions = ['read', 'write', 'delete'];
|
||||||
|
|
||||||
newToken = {
|
newToken: {
|
||||||
|
name: string;
|
||||||
|
organizationId?: string;
|
||||||
|
scopes: IScopeEntry[];
|
||||||
|
expiresInDays: number | null;
|
||||||
|
} = {
|
||||||
name: '',
|
name: '',
|
||||||
protocols: [] as string[],
|
organizationId: undefined,
|
||||||
expiresInDays: null as number | null,
|
scopes: [],
|
||||||
|
expiresInDays: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadTokens();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadTokens(): Promise<void> {
|
private async loadData(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
try {
|
try {
|
||||||
const response = await this.apiService.getTokens().toPromise();
|
const [tokensRes, orgsRes] = await Promise.all([
|
||||||
this.tokens.set(response?.tokens || []);
|
this.apiService.getTokens().toPromise(),
|
||||||
|
this.apiService.getOrganizations().toPromise(),
|
||||||
|
]);
|
||||||
|
this.tokens.set(tokensRes?.tokens || []);
|
||||||
|
this.organizations.set(orgsRes?.organizations || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.toastService.error('Failed to load tokens');
|
this.toastService.error('Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleProtocol(protocol: string): void {
|
addScope(): void {
|
||||||
if (this.newToken.protocols.includes(protocol)) {
|
this.newToken.scopes = [
|
||||||
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol);
|
...this.newToken.scopes,
|
||||||
|
{ protocol: '*', actions: ['read', 'write'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeScope(index: number): void {
|
||||||
|
this.newToken.scopes = this.newToken.scopes.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAction(scope: IScopeEntry, action: string): void {
|
||||||
|
if (scope.actions.includes(action)) {
|
||||||
|
scope.actions = scope.actions.filter((a) => a !== action);
|
||||||
} else {
|
} else {
|
||||||
this.newToken.protocols = [...this.newToken.protocols, protocol];
|
scope.actions = [...scope.actions, action];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasValidScopes(): boolean {
|
||||||
|
return this.newToken.scopes.every((s) => s.protocol && s.actions.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
async createToken(): Promise<void> {
|
async createToken(): Promise<void> {
|
||||||
if (!this.newToken.name || this.newToken.protocols.length === 0) return;
|
if (!this.newToken.name || this.newToken.scopes.length === 0 || !this.hasValidScopes()) return;
|
||||||
|
|
||||||
this.creating.set(true);
|
this.creating.set(true);
|
||||||
try {
|
try {
|
||||||
|
// Build protocols array from scopes
|
||||||
|
const protocols = [...new Set(this.newToken.scopes.map((s) => s.protocol))];
|
||||||
|
|
||||||
const response = await this.apiService.createToken({
|
const response = await this.apiService.createToken({
|
||||||
name: this.newToken.name,
|
name: this.newToken.name,
|
||||||
protocols: this.newToken.protocols,
|
organizationId: this.newToken.organizationId,
|
||||||
scopes: this.newToken.protocols.map((p) => ({
|
protocols,
|
||||||
protocol: p,
|
scopes: this.newToken.scopes.map((s) => ({
|
||||||
actions: ['read', 'write'],
|
protocol: s.protocol,
|
||||||
|
actions: s.actions,
|
||||||
|
organizationId: s.organizationId,
|
||||||
})),
|
})),
|
||||||
expiresInDays: this.newToken.expiresInDays || undefined,
|
expiresInDays: this.newToken.expiresInDays || undefined,
|
||||||
}).toPromise();
|
}).toPromise();
|
||||||
@@ -252,7 +412,7 @@ export class TokensComponent implements OnInit {
|
|||||||
this.createdToken.set(response.token);
|
this.createdToken.set(response.token);
|
||||||
this.tokens.update((tokens) => [response, ...tokens]);
|
this.tokens.update((tokens) => [response, ...tokens]);
|
||||||
this.showCreateModal.set(false);
|
this.showCreateModal.set(false);
|
||||||
this.newToken = { name: '', protocols: [], expiresInDays: null };
|
this.resetNewToken();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.toastService.error('Failed to create token');
|
this.toastService.error('Failed to create token');
|
||||||
@@ -275,7 +435,16 @@ export class TokensComponent implements OnInit {
|
|||||||
|
|
||||||
closeCreateModal(): void {
|
closeCreateModal(): void {
|
||||||
this.showCreateModal.set(false);
|
this.showCreateModal.set(false);
|
||||||
this.newToken = { name: '', protocols: [], expiresInDays: null };
|
this.resetNewToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetNewToken(): void {
|
||||||
|
this.newToken = {
|
||||||
|
name: '',
|
||||||
|
organizationId: undefined,
|
||||||
|
scopes: [],
|
||||||
|
expiresInDays: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
copyToken(): void {
|
copyToken(): void {
|
||||||
@@ -289,4 +458,9 @@ export class TokensComponent implements OnInit {
|
|||||||
formatDate(dateStr: string): string {
|
formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString();
|
return new Date(dateStr).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatActions(actions: string[]): string {
|
||||||
|
if (actions.includes('*')) return 'full';
|
||||||
|
return actions.join(', ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user