Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d561b3874 | |||
| d3fd40ce2f | |||
| 44e92d48f2 | |||
| 61324ba195 | |||
| dface47942 | |||
| 93ae998e3f | |||
| 5d9cd3ad85 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,6 +7,9 @@ dist/
|
||||
.angular/
|
||||
out-tsc/
|
||||
|
||||
# Generated files
|
||||
ts/embedded-ui.generated.ts
|
||||
|
||||
# Deno
|
||||
.deno/
|
||||
|
||||
@@ -47,6 +50,7 @@ coverage/
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
stories/
|
||||
|
||||
# Package manager locks (keep pnpm-lock.yaml)
|
||||
package-lock.json
|
||||
|
||||
39
changelog.md
39
changelog.md
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-03 - 1.3.0 - feat(auth)
|
||||
Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
|
||||
|
||||
- Introduce external authentication models: AuthProvider, ExternalIdentity, PlatformSettings to store provider configs, links, and platform auth settings
|
||||
- Add AuthProvider admin API (AdminAuthApi) to create/update/delete/test providers and manage platform auth settings
|
||||
- Add public OAuth endpoints (OAuthApi) for listing providers, initiating OAuth flows, handling callbacks, and LDAP login
|
||||
- Implement ExternalAuthService to orchestrate OAuth and LDAP flows, user provisioning, linking, session/token generation, and provider testing
|
||||
- Add pluggable auth strategy pattern with OAuthStrategy and LdapStrategy plus AuthStrategyFactory to select appropriate strategy
|
||||
- Add CryptoService for AES-256-GCM encryption/decryption of provider secrets and helper for key generation
|
||||
- Extend AuthService and session/user handling to support tokens/sessions created by external auth flows and user provisioning flags
|
||||
- Add UI: admin pages for managing auth providers (list, provider form, connection test) and login enhancements (SSO buttons, LDAP form, oauth-callback handler)
|
||||
- Add client-side AdminAuthService for communicating with new admin auth endpoints and an adminGuard for route protection
|
||||
- Register new API routes in ApiRouter and wire server-side handlers into the router
|
||||
- Implement safeguards: mask secrets in admin responses, validate provider configs, and track connection test results and audit logs
|
||||
|
||||
## 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)
|
||||
Bump @push.rocks/smartdata to ^7.0.13 in deno.json
|
||||
|
||||
|
||||
19
deno.json
19
deno.json
@@ -1,13 +1,26 @@
|
||||
{
|
||||
"name": "@stack.gallery/registry",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"dev": "deno run --allow-all --watch mod.ts server --ephemeral",
|
||||
"test": "deno test --allow-all",
|
||||
"build": "cd ui && pnpm run build"
|
||||
"test": "deno test --allow-all --no-check test/",
|
||||
"test:unit": "deno test --allow-all --no-check test/unit/",
|
||||
"test:integration": "deno test --allow-all --no-check test/integration/",
|
||||
"test:e2e": "deno test --allow-all --no-check test/e2e/",
|
||||
"test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait",
|
||||
"test:docker-down": "docker compose -f test/docker-compose.test.yml down -v",
|
||||
"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": {
|
||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.5.0",
|
||||
|
||||
19
deno.lock
generated
19
deno.lock
generated
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.16",
|
||||
"jsr:@std/assert@^1.0.15": "1.0.16",
|
||||
"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/fmt@^1.0.8": "1.0.8",
|
||||
"jsr:@std/fs@1": "1.0.20",
|
||||
@@ -14,6 +17,7 @@
|
||||
"jsr:@std/path@1": "1.1.3",
|
||||
"jsr:@std/path@^1.1.3": "1.1.3",
|
||||
"jsr:@std/streams@^1.0.14": "1.0.14",
|
||||
"jsr:@std/testing@*": "1.0.16",
|
||||
"npm:@push.rocks/smartarchive@5": "5.0.1",
|
||||
"npm:@push.rocks/smartbucket@^4.3.0": "4.3.0",
|
||||
"npm:@push.rocks/smartcli@4": "4.0.19",
|
||||
@@ -33,6 +37,12 @@
|
||||
"npm:concurrently@^9.1.2": "9.2.1"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.16": {
|
||||
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/cli@1.0.24": {
|
||||
"integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e"
|
||||
},
|
||||
@@ -56,7 +66,7 @@
|
||||
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
|
||||
"dependencies": [
|
||||
"jsr:@std/cli",
|
||||
"jsr:@std/encoding",
|
||||
"jsr:@std/encoding@^1.0.10",
|
||||
"jsr:@std/fmt",
|
||||
"jsr:@std/fs@^1.0.20",
|
||||
"jsr:@std/html",
|
||||
@@ -83,6 +93,13 @@
|
||||
},
|
||||
"@std/streams@1.0.14": {
|
||||
"integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411"
|
||||
},
|
||||
"@std/testing@1.0.16": {
|
||||
"integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.15",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
|
||||
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",
|
||||
"version": "1.0.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Enterprise-grade multi-protocol package registry",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "deno run --allow-all mod.ts server",
|
||||
"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",
|
||||
"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();
|
||||
}
|
||||
48
test/docker-compose.test.yml
Normal file
48
test/docker-compose.test.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
mongodb-test:
|
||||
image: mongo:7
|
||||
container_name: stack-gallery-test-mongo
|
||||
ports:
|
||||
- "27117:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: testadmin
|
||||
MONGO_INITDB_ROOT_PASSWORD: testpass
|
||||
tmpfs:
|
||||
- /data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-test:
|
||||
image: minio/minio:latest
|
||||
container_name: stack-gallery-test-minio
|
||||
ports:
|
||||
- "9100:9000"
|
||||
- "9101:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: testadmin
|
||||
MINIO_ROOT_PASSWORD: testpassword
|
||||
command: server /data --console-address ":9001"
|
||||
tmpfs:
|
||||
- /data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-setup:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set testminio http://minio-test:9000 testadmin testpassword;
|
||||
mc mb testminio/test-registry --ignore-existing;
|
||||
exit 0;
|
||||
"
|
||||
290
test/e2e/npm.e2e.test.ts
Normal file
290
test/e2e/npm.e2e.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* NPM Protocol E2E Tests
|
||||
*
|
||||
* Tests the full NPM package lifecycle: publish -> fetch -> delete
|
||||
* Requires: npm CLI, running registry, Docker test infrastructure
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
runCommand,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/npm/@stack-test/demo-package'
|
||||
);
|
||||
|
||||
describe('NPM E2E: Full lifecycle', () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
let registryUrl: string;
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if npm is available
|
||||
shouldSkip = await skipIfMissing('npm');
|
||||
if (shouldSkip) return;
|
||||
|
||||
await setupTestDb();
|
||||
registryUrl = testConfig.registry.url;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (shouldSkip) return;
|
||||
|
||||
await cleanupTestDb();
|
||||
|
||||
// Create test user and org
|
||||
const { user } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
|
||||
const { organization } = await createOrgWithOwner(testUserId, { name: 'npm-test' });
|
||||
testOrgName = organization.name;
|
||||
|
||||
// Create repository for npm packages
|
||||
await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: testUserId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
});
|
||||
|
||||
// Create API token with npm permissions
|
||||
const { rawToken } = await createTestApiToken({
|
||||
userId: testUserId,
|
||||
name: 'npm-publish-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read', 'write', 'delete'] }],
|
||||
});
|
||||
apiToken = rawToken;
|
||||
});
|
||||
|
||||
it('should publish package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure npm to use our registry
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `
|
||||
//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}
|
||||
@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/
|
||||
`;
|
||||
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
const result = await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
assertEquals(result.success, true, `npm publish failed: ${result.stderr}`);
|
||||
} finally {
|
||||
// Cleanup .npmrc
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch package metadata', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Fetch metadata via npm view
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
);
|
||||
|
||||
assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`);
|
||||
assertEquals(viewResult.stdout.includes('@stack-test/demo-package'), true);
|
||||
} finally {
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should install package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp directory for installation
|
||||
const tempDir = await Deno.makeTempDir({ prefix: 'npm-e2e-' });
|
||||
|
||||
try {
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Create package.json in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, 'package.json'),
|
||||
JSON.stringify({ name: 'test-install', version: '1.0.0' })
|
||||
);
|
||||
|
||||
// Create .npmrc in temp dir
|
||||
await Deno.writeTextFile(
|
||||
path.join(tempDir, '.npmrc'),
|
||||
`@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`
|
||||
);
|
||||
|
||||
// Install
|
||||
const installResult = await clients.npm.install(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
tempDir
|
||||
);
|
||||
|
||||
assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`);
|
||||
|
||||
// Verify installed
|
||||
const pkgPath = path.join(tempDir, 'node_modules/@stack-test/demo-package');
|
||||
const stat = await Deno.stat(pkgPath);
|
||||
assertEquals(stat.isDirectory, true);
|
||||
|
||||
// Cleanup fixture .npmrc
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} finally {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should unpublish package', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// First publish
|
||||
const npmrcPath = path.join(FIXTURE_DIR, '.npmrc');
|
||||
const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`;
|
||||
await Deno.writeTextFile(npmrcPath, npmrcContent);
|
||||
|
||||
try {
|
||||
await clients.npm.publish(
|
||||
FIXTURE_DIR,
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
// Unpublish
|
||||
const unpublishResult = await clients.npm.unpublish(
|
||||
'@stack-test/demo-package@1.0.0',
|
||||
`${registryUrl}/-/npm/${testOrgName}/`,
|
||||
apiToken
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
unpublishResult.success,
|
||||
true,
|
||||
`npm unpublish failed: ${unpublishResult.stderr}`
|
||||
);
|
||||
|
||||
// Verify package is gone
|
||||
const viewResult = await runCommand(
|
||||
['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`],
|
||||
{ env: { npm_config__authToken: apiToken } }
|
||||
);
|
||||
|
||||
// Should fail since package was unpublished
|
||||
assertEquals(viewResult.success, false);
|
||||
} finally {
|
||||
try {
|
||||
await Deno.remove(npmrcPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NPM E2E: Edge cases', () => {
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
shouldSkip = await skipIfMissing('npm');
|
||||
});
|
||||
|
||||
it('should handle scoped packages correctly', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Test scoped package name handling
|
||||
const scopedName = '@stack-test/demo-package';
|
||||
assertEquals(scopedName.startsWith('@'), true);
|
||||
assertEquals(scopedName.includes('/'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid package names', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: npm not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// npm has strict naming rules
|
||||
const invalidNames = ['UPPERCASE', '..dots..', 'spaces here', '_underscore'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
// Just verify these are considered invalid by npm standards
|
||||
assertEquals(!/^[a-z0-9][-a-z0-9._]*$/.test(name), true);
|
||||
}
|
||||
});
|
||||
});
|
||||
190
test/e2e/oci.e2e.test.ts
Normal file
190
test/e2e/oci.e2e.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* OCI Protocol E2E Tests
|
||||
*
|
||||
* Tests the full OCI container image lifecycle: push -> pull -> delete
|
||||
* Requires: docker CLI, running registry, Docker test infrastructure
|
||||
*/
|
||||
|
||||
import { assertEquals } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import * as path from '@std/path';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
createTestApiToken,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
testConfig,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
const FIXTURE_DIR = path.join(
|
||||
path.dirname(path.fromFileUrl(import.meta.url)),
|
||||
'../fixtures/oci'
|
||||
);
|
||||
|
||||
describe('OCI E2E: Full lifecycle', () => {
|
||||
let testUserId: string;
|
||||
let testOrgName: string;
|
||||
let apiToken: string;
|
||||
let registryHost: string;
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Check if docker is available
|
||||
shouldSkip = await skipIfMissing('docker');
|
||||
if (shouldSkip) return;
|
||||
|
||||
await setupTestDb();
|
||||
const url = new URL(testConfig.registry.url);
|
||||
registryHost = url.host;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!shouldSkip) {
|
||||
await teardownTestDb();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (shouldSkip) return;
|
||||
|
||||
await cleanupTestDb();
|
||||
|
||||
// Create test user and org
|
||||
const { user } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
|
||||
const { organization } = await createOrgWithOwner(testUserId, { name: 'oci-test' });
|
||||
testOrgName = organization.name;
|
||||
|
||||
// Create repository for OCI images
|
||||
await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: testUserId,
|
||||
name: 'images',
|
||||
protocol: 'oci',
|
||||
});
|
||||
|
||||
// Create API token with OCI permissions
|
||||
const { rawToken } = await createTestApiToken({
|
||||
userId: testUserId,
|
||||
name: 'oci-push-token',
|
||||
protocols: ['oci'],
|
||||
scopes: [{ protocol: 'oci', actions: ['read', 'write', 'delete'] }],
|
||||
});
|
||||
apiToken = rawToken;
|
||||
});
|
||||
|
||||
it('should build and push image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
// Build image
|
||||
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
|
||||
|
||||
// Login to registry
|
||||
const loginResult = await clients.docker.login(registryHost, 'token', apiToken);
|
||||
assertEquals(loginResult.success, true, `docker login failed: ${loginResult.stderr}`);
|
||||
|
||||
// Push image
|
||||
const pushResult = await clients.docker.push(imageName);
|
||||
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
|
||||
} finally {
|
||||
// Cleanup local image
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pull image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple');
|
||||
|
||||
try {
|
||||
// Build and push first
|
||||
await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
await clients.docker.login(registryHost, 'token', apiToken);
|
||||
await clients.docker.push(imageName);
|
||||
|
||||
// Remove local image
|
||||
await clients.docker.rmi(imageName, true);
|
||||
|
||||
// Pull from registry
|
||||
const pullResult = await clients.docker.pull(imageName);
|
||||
assertEquals(pullResult.success, true, `docker pull failed: ${pullResult.stderr}`);
|
||||
} finally {
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multi-layer images', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageName = `${registryHost}/v2/${testOrgName}/multi:1.0.0`;
|
||||
const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.multi-layer');
|
||||
|
||||
try {
|
||||
// Build multi-stage image
|
||||
const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR);
|
||||
assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`);
|
||||
|
||||
// Login and push
|
||||
await clients.docker.login(registryHost, 'token', apiToken);
|
||||
const pushResult = await clients.docker.push(imageName);
|
||||
assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`);
|
||||
} finally {
|
||||
await clients.docker.rmi(imageName, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCI E2E: Tags and versions', () => {
|
||||
let shouldSkip = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
shouldSkip = await skipIfMissing('docker');
|
||||
});
|
||||
|
||||
it('should handle multiple tags for same image', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify tag handling logic
|
||||
const tags = ['1.0.0', '1.0', '1', 'latest'];
|
||||
for (const tag of tags) {
|
||||
assertEquals(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag), true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle SHA256 digests', async function () {
|
||||
if (shouldSkip) {
|
||||
console.log('Skipping: docker not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify digest format
|
||||
const digest = 'sha256:' + 'a'.repeat(64);
|
||||
assertEquals(digest.startsWith('sha256:'), true);
|
||||
assertEquals(digest.length, 71);
|
||||
});
|
||||
});
|
||||
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal file
15
test/fixtures/cargo/demo-crate/Cargo.toml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "demo-crate"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
authors = ["Stack.Gallery Test <test@stack.gallery>"]
|
||||
description = "Demo crate for Stack.Gallery Registry e2e tests"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/stack-gallery/demo-crate"
|
||||
readme = "README.md"
|
||||
keywords = ["demo", "test", "stack-gallery"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
[lib]
|
||||
name = "demo_crate"
|
||||
path = "src/lib.rs"
|
||||
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal file
13
test/fixtures/cargo/demo-crate/README.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# demo-crate
|
||||
|
||||
Demo crate for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use demo_crate::greet;
|
||||
|
||||
fn main() {
|
||||
println!("{}", greet("World")); // Hello, World!
|
||||
}
|
||||
```
|
||||
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal file
16
test/fixtures/cargo/demo-crate/src/lib.rs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Demo crate for Stack.Gallery Registry e2e tests
|
||||
|
||||
/// Greets the given name
|
||||
pub fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_greet() {
|
||||
assert_eq!(greet("World"), "Hello, World!");
|
||||
}
|
||||
}
|
||||
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal file
13
test/fixtures/composer/stacktest/demo-package/README.md
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# stacktest/demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use StackTest\DemoPackage\Demo;
|
||||
|
||||
echo Demo::greet("World"); // Hello, World!
|
||||
```
|
||||
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal file
21
test/fixtures/composer/stacktest/demo-package/composer.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "stacktest/demo-package",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"version": "1.0.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Stack.Gallery Test",
|
||||
"email": "test@stack.gallery"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"StackTest\\DemoPackage\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal file
20
test/fixtures/composer/stacktest/demo-package/src/Demo.php
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace StackTest\DemoPackage;
|
||||
|
||||
/**
|
||||
* Demo class for Stack.Gallery Registry e2e tests.
|
||||
*/
|
||||
class Demo
|
||||
{
|
||||
/**
|
||||
* Greet the given name.
|
||||
*
|
||||
* @param string $name The name to greet
|
||||
* @return string A greeting message
|
||||
*/
|
||||
public static function greet(string $name): string
|
||||
{
|
||||
return "Hello, {$name}!";
|
||||
}
|
||||
}
|
||||
34
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal file
34
test/fixtures/maven/com/stacktest/demo-artifact/pom.xml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.stacktest</groupId>
|
||||
<artifactId>demo-artifact</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>Stack.Gallery Demo Artifact</name>
|
||||
<description>Demo Maven artifact for e2e tests</description>
|
||||
<url>https://github.com/stack-gallery/demo-artifact</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
<url>https://opensource.org/licenses/MIT</url>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<name>Stack.Gallery Test</name>
|
||||
<email>test@stack.gallery</email>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
</project>
|
||||
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal file
19
test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.stacktest;
|
||||
|
||||
/**
|
||||
* Demo class for Stack.Gallery Registry e2e tests.
|
||||
*/
|
||||
public class Demo {
|
||||
/**
|
||||
* Greet the given name.
|
||||
* @param name The name to greet
|
||||
* @return A greeting message
|
||||
*/
|
||||
public static String greet(String name) {
|
||||
return "Hello, " + name + "!";
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println(greet("World"));
|
||||
}
|
||||
}
|
||||
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal file
10
test/fixtures/npm/@stack-test/demo-package/README.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# @stack-test/demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```javascript
|
||||
const demo = require('@stack-test/demo-package');
|
||||
console.log(demo.greet('World')); // Hello, World!
|
||||
```
|
||||
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal file
9
test/fixtures/npm/@stack-test/demo-package/index.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Demo package for Stack.Gallery Registry e2e tests
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
name: 'demo-package',
|
||||
greet: (name) => `Hello, ${name}!`,
|
||||
version: () => require('./package.json').version
|
||||
};
|
||||
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal file
13
test/fixtures/npm/@stack-test/demo-package/package.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@stack-test/demo-package",
|
||||
"version": "1.0.0",
|
||||
"description": "Demo package for Stack.Gallery Registry e2e tests",
|
||||
"main": "index.js",
|
||||
"author": "Stack.Gallery Test <test@stack.gallery>",
|
||||
"license": "MIT",
|
||||
"keywords": ["demo", "test", "stack-gallery"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/stack-gallery/demo-package"
|
||||
}
|
||||
}
|
||||
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal file
9
test/fixtures/oci/Dockerfile.multi-layer
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM alpine:3.19 AS builder
|
||||
RUN echo "Building..." > /build.log
|
||||
|
||||
FROM alpine:3.19
|
||||
LABEL org.opencontainers.image.title="stack-test-demo-multi"
|
||||
LABEL org.opencontainers.image.version="1.0.0"
|
||||
COPY --from=builder /build.log /build.log
|
||||
RUN echo "Stack.Gallery Multi-Layer Demo" > /README.txt
|
||||
CMD ["cat", "/README.txt"]
|
||||
6
test/fixtures/oci/Dockerfile.simple
vendored
Normal file
6
test/fixtures/oci/Dockerfile.simple
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM alpine:3.19
|
||||
LABEL org.opencontainers.image.title="stack-test-demo"
|
||||
LABEL org.opencontainers.image.version="1.0.0"
|
||||
LABEL org.opencontainers.image.description="Demo image for Stack.Gallery Registry e2e tests"
|
||||
RUN echo "Stack.Gallery Demo Image" > /README.txt
|
||||
CMD ["cat", "/README.txt"]
|
||||
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal file
11
test/fixtures/pypi/demo_package/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# stack-test-demo-package
|
||||
|
||||
Demo package for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from demo_package import greet
|
||||
|
||||
print(greet("World")) # Hello, World!
|
||||
```
|
||||
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal file
8
test/fixtures/pypi/demo_package/demo_package/__init__.py
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Demo package for Stack.Gallery Registry e2e tests."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
|
||||
def greet(name: str) -> str:
|
||||
"""Greet the given name."""
|
||||
return f"Hello, {name}!"
|
||||
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal file
23
test/fixtures/pypi/demo_package/pyproject.toml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "stack-test-demo-package"
|
||||
version = "1.0.0"
|
||||
description = "Demo package for Stack.Gallery Registry e2e tests"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Stack.Gallery Test", email = "test@stack.gallery"}
|
||||
]
|
||||
keywords = ["demo", "test", "stack-gallery"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal file
11
test/fixtures/rubygems/demo-gem/README.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# stack-test-demo-gem
|
||||
|
||||
Demo gem for Stack.Gallery Registry e2e tests.
|
||||
|
||||
## Usage
|
||||
|
||||
```ruby
|
||||
require 'demo-gem'
|
||||
|
||||
puts StackTestDemoGem.greet("World") # Hello, World!
|
||||
```
|
||||
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal file
16
test/fixtures/rubygems/demo-gem/demo-gem.gemspec
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = "stack-test-demo-gem"
|
||||
spec.version = "1.0.0"
|
||||
spec.authors = ["Stack.Gallery Test"]
|
||||
spec.email = ["test@stack.gallery"]
|
||||
|
||||
spec.summary = "Demo gem for Stack.Gallery Registry e2e tests"
|
||||
spec.description = "A demonstration gem for testing Stack.Gallery Registry"
|
||||
spec.homepage = "https://github.com/stack-gallery/demo-gem"
|
||||
spec.license = "MIT"
|
||||
|
||||
spec.required_ruby_version = ">= 2.7.0"
|
||||
|
||||
spec.files = Dir["lib/**/*", "README.md"]
|
||||
spec.require_paths = ["lib"]
|
||||
end
|
||||
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal file
13
test/fixtures/rubygems/demo-gem/lib/demo-gem.rb
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Demo gem for Stack.Gallery Registry e2e tests
|
||||
module StackTestDemoGem
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Greet the given name
|
||||
# @param name [String] The name to greet
|
||||
# @return [String] A greeting message
|
||||
def self.greet(name)
|
||||
"Hello, #{name}!"
|
||||
end
|
||||
end
|
||||
141
test/helpers/auth.helper.ts
Normal file
141
test/helpers/auth.helper.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Authentication test helper - creates test users, tokens, and sessions
|
||||
*/
|
||||
|
||||
import { User } from '../../ts/models/user.ts';
|
||||
import { ApiToken } from '../../ts/models/apitoken.ts';
|
||||
import { AuthService } from '../../ts/services/auth.service.ts';
|
||||
import { TokenService } from '../../ts/services/token.service.ts';
|
||||
import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
const TEST_PASSWORD = 'TestPassword123!';
|
||||
|
||||
export interface ICreateTestUserOptions {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
status?: TUserStatus;
|
||||
isPlatformAdmin?: boolean;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user with sensible defaults
|
||||
*/
|
||||
export async function createTestUser(
|
||||
overrides: ICreateTestUserOptions = {}
|
||||
): Promise<{ user: User; password: string }> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
const password = overrides.password || TEST_PASSWORD;
|
||||
const passwordHash = await User.hashPassword(password);
|
||||
|
||||
const user = await User.createUser({
|
||||
email: overrides.email || `test-${uniqueId}@example.com`,
|
||||
username: overrides.username || `testuser-${uniqueId}`,
|
||||
passwordHash,
|
||||
displayName: overrides.displayName || `Test User ${uniqueId}`,
|
||||
});
|
||||
|
||||
// Set additional properties
|
||||
user.status = overrides.status || 'active';
|
||||
user.emailVerified = overrides.emailVerified ?? true;
|
||||
if (overrides.isPlatformAdmin) {
|
||||
user.isPlatformAdmin = true;
|
||||
}
|
||||
await user.save();
|
||||
|
||||
return { user, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create admin user
|
||||
*/
|
||||
export async function createAdminUser(): Promise<{ user: User; password: string }> {
|
||||
return createTestUser({ isPlatformAdmin: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and get tokens
|
||||
*/
|
||||
export async function loginUser(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
||||
const authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
});
|
||||
|
||||
const result = await authService.login(email, password, {
|
||||
userAgent: 'TestAgent/1.0',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Login failed: ${result.errorMessage}`);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken!,
|
||||
sessionId: result.sessionId!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICreateTestApiTokenOptions {
|
||||
userId: string;
|
||||
name?: string;
|
||||
protocols?: TRegistryProtocol[];
|
||||
scopes?: ITokenScope[];
|
||||
organizationId?: string;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test API token
|
||||
*/
|
||||
export async function createTestApiToken(
|
||||
options: ICreateTestApiTokenOptions
|
||||
): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
const tokenService = new TokenService();
|
||||
|
||||
return tokenService.createToken({
|
||||
userId: options.userId,
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-token-${crypto.randomUUID().slice(0, 8)}`,
|
||||
protocols: options.protocols || ['npm', 'oci'],
|
||||
scopes: options.scopes || [
|
||||
{
|
||||
protocol: '*',
|
||||
actions: ['read', 'write', 'delete'],
|
||||
},
|
||||
],
|
||||
expiresInDays: options.expiresInDays,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create auth header for API requests
|
||||
*/
|
||||
export function createAuthHeader(token: string): { Authorization: string } {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create basic auth header (for registry protocols)
|
||||
*/
|
||||
export function createBasicAuthHeader(
|
||||
username: string,
|
||||
password: string
|
||||
): { Authorization: string } {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default test password
|
||||
*/
|
||||
export function getTestPassword(): string {
|
||||
return TEST_PASSWORD;
|
||||
}
|
||||
106
test/helpers/db.helper.ts
Normal file
106
test/helpers/db.helper.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Database test helper - manages test database lifecycle
|
||||
*
|
||||
* NOTE: The smartdata models use a global `db` singleton. This helper
|
||||
* ensures proper initialization and cleanup for tests.
|
||||
*/
|
||||
|
||||
import * as plugins from '../../ts/plugins.ts';
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
// Test database instance - separate from production
|
||||
let testDb: plugins.smartdata.SmartdataDb | null = null;
|
||||
let testDbName: string = '';
|
||||
let isConnected = false;
|
||||
|
||||
// We need to patch the global db export since models reference it
|
||||
// This is done by re-initializing with the test config
|
||||
import { initDb, closeDb } from '../../ts/models/db.ts';
|
||||
|
||||
/**
|
||||
* Initialize test database with unique name per test run
|
||||
*/
|
||||
export async function setupTestDb(config?: {
|
||||
mongoUrl?: string;
|
||||
dbName?: string;
|
||||
}): Promise<void> {
|
||||
// If already connected, reuse the connection
|
||||
if (isConnected && testDb) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mongoUrl = config?.mongoUrl || testConfig.mongodb.url;
|
||||
|
||||
// Generate unique database name for this test session
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
testDbName = config?.dbName || `${testConfig.mongodb.name}-${uniqueId}`;
|
||||
|
||||
// Initialize the global db singleton with test configuration
|
||||
testDb = await initDb(mongoUrl, testDbName);
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test database - deletes all documents from collections
|
||||
* This is safer than dropping collections which causes index rebuild issues
|
||||
*/
|
||||
export async function cleanupTestDb(): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
try {
|
||||
const collections = await testDb.mongoDb.listCollections().toArray();
|
||||
for (const col of collections) {
|
||||
// Delete all documents but preserve indexes
|
||||
await testDb.mongoDb.collection(col.name).deleteMany({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TestHelper] Error cleaning database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown test database - drops database and closes connection
|
||||
*/
|
||||
export async function teardownTestDb(): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
try {
|
||||
// Drop the test database
|
||||
await testDb.mongoDb.dropDatabase();
|
||||
// Close the connection
|
||||
await closeDb();
|
||||
testDb = null;
|
||||
isConnected = false;
|
||||
} catch (error) {
|
||||
console.warn('[TestHelper] Error tearing down database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific collection(s) - deletes all documents
|
||||
*/
|
||||
export async function clearCollections(...collectionNames: string[]): Promise<void> {
|
||||
if (!testDb || !isConnected) return;
|
||||
|
||||
for (const name of collectionNames) {
|
||||
try {
|
||||
await testDb.mongoDb.collection(name).deleteMany({});
|
||||
} catch {
|
||||
// Collection may not exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current test database name
|
||||
*/
|
||||
export function getTestDbName(): string {
|
||||
return testDbName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance for direct access
|
||||
*/
|
||||
export function getTestDb(): plugins.smartdata.SmartdataDb | null {
|
||||
return testDb;
|
||||
}
|
||||
268
test/helpers/factory.helper.ts
Normal file
268
test/helpers/factory.helper.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Factory helper - creates test entities with sensible defaults
|
||||
*/
|
||||
|
||||
import { Organization } from '../../ts/models/organization.ts';
|
||||
import { OrganizationMember } from '../../ts/models/organization.member.ts';
|
||||
import { Repository } from '../../ts/models/repository.ts';
|
||||
import { Team } from '../../ts/models/team.ts';
|
||||
import { TeamMember } from '../../ts/models/team.member.ts';
|
||||
import { Package } from '../../ts/models/package.ts';
|
||||
import { RepositoryPermission } from '../../ts/models/repository.permission.ts';
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRepositoryVisibility,
|
||||
TRegistryProtocol,
|
||||
} from '../../ts/interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface ICreateTestOrganizationOptions {
|
||||
createdById: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test organization
|
||||
*/
|
||||
export async function createTestOrganization(
|
||||
options: ICreateTestOrganizationOptions
|
||||
): Promise<Organization> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
const org = await Organization.createOrganization({
|
||||
name: options.name || `test-org-${uniqueId}`,
|
||||
displayName: options.displayName || `Test Org ${uniqueId}`,
|
||||
description: options.description || 'Test organization',
|
||||
createdById: options.createdById,
|
||||
});
|
||||
|
||||
if (options.isPublic !== undefined) {
|
||||
org.isPublic = options.isPublic;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization with owner membership
|
||||
*/
|
||||
export async function createOrgWithOwner(
|
||||
ownerId: string,
|
||||
orgOptions?: Partial<ICreateTestOrganizationOptions>
|
||||
): Promise<{
|
||||
organization: Organization;
|
||||
membership: OrganizationMember;
|
||||
}> {
|
||||
const organization = await createTestOrganization({
|
||||
createdById: ownerId,
|
||||
...orgOptions,
|
||||
});
|
||||
|
||||
const membership = await OrganizationMember.addMember({
|
||||
organizationId: organization.id,
|
||||
userId: ownerId,
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
organization.memberCount = 1;
|
||||
await organization.save();
|
||||
|
||||
return { organization, membership };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to organization
|
||||
*/
|
||||
export async function addOrgMember(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
role: TOrganizationRole = 'member',
|
||||
invitedBy?: string
|
||||
): Promise<OrganizationMember> {
|
||||
const membership = await OrganizationMember.addMember({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
invitedBy,
|
||||
});
|
||||
|
||||
const org = await Organization.findById(organizationId);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
export interface ICreateTestRepositoryOptions {
|
||||
organizationId: string;
|
||||
createdById: string;
|
||||
name?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
visibility?: TRepositoryVisibility;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test repository
|
||||
*/
|
||||
export async function createTestRepository(
|
||||
options: ICreateTestRepositoryOptions
|
||||
): Promise<Repository> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
return Repository.createRepository({
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-repo-${uniqueId}`,
|
||||
protocol: options.protocol || 'npm',
|
||||
visibility: options.visibility || 'private',
|
||||
description: options.description || 'Test repository',
|
||||
createdById: options.createdById,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ICreateTestTeamOptions {
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test team
|
||||
*/
|
||||
export async function createTestTeam(options: ICreateTestTeamOptions): Promise<Team> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
|
||||
return Team.createTeam({
|
||||
organizationId: options.organizationId,
|
||||
name: options.name || `test-team-${uniqueId}`,
|
||||
description: options.description || 'Test team',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add member to team
|
||||
*/
|
||||
export async function addTeamMember(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: TTeamRole = 'member'
|
||||
): Promise<TeamMember> {
|
||||
const member = new TeamMember();
|
||||
member.id = await TeamMember.getNewId();
|
||||
member.teamId = teamId;
|
||||
member.userId = userId;
|
||||
member.role = role;
|
||||
member.createdAt = new Date();
|
||||
await member.save();
|
||||
return member;
|
||||
}
|
||||
|
||||
export interface IGrantRepoPermissionOptions {
|
||||
repositoryId: string;
|
||||
userId?: string;
|
||||
teamId?: string;
|
||||
role: TRepositoryRole;
|
||||
grantedById: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant repository permission
|
||||
*/
|
||||
export async function grantRepoPermission(
|
||||
options: IGrantRepoPermissionOptions
|
||||
): Promise<RepositoryPermission> {
|
||||
const perm = new RepositoryPermission();
|
||||
perm.id = await RepositoryPermission.getNewId();
|
||||
perm.repositoryId = options.repositoryId;
|
||||
perm.userId = options.userId;
|
||||
perm.teamId = options.teamId;
|
||||
perm.role = options.role;
|
||||
perm.grantedById = options.grantedById;
|
||||
perm.createdAt = new Date();
|
||||
await perm.save();
|
||||
return perm;
|
||||
}
|
||||
|
||||
export interface ICreateTestPackageOptions {
|
||||
organizationId: string;
|
||||
repositoryId: string;
|
||||
createdById: string;
|
||||
name?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
versions?: string[];
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test package
|
||||
*/
|
||||
export async function createTestPackage(options: ICreateTestPackageOptions): Promise<Package> {
|
||||
const uniqueId = crypto.randomUUID().slice(0, 8);
|
||||
const protocol = options.protocol || 'npm';
|
||||
const name = options.name || `test-package-${uniqueId}`;
|
||||
|
||||
const pkg = new Package();
|
||||
pkg.id = Package.generateId(protocol, options.organizationId, name);
|
||||
pkg.organizationId = options.organizationId;
|
||||
pkg.repositoryId = options.repositoryId;
|
||||
pkg.protocol = protocol;
|
||||
pkg.name = name;
|
||||
pkg.isPrivate = options.isPrivate ?? true;
|
||||
pkg.createdById = options.createdById;
|
||||
pkg.createdAt = new Date();
|
||||
pkg.updatedAt = new Date();
|
||||
|
||||
const versions = options.versions || ['1.0.0'];
|
||||
for (const version of versions) {
|
||||
pkg.addVersion({
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedById: options.createdById,
|
||||
size: 1024,
|
||||
digest: `sha256:${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
await pkg.save();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create complete test scenario with org, repo, team, and package
|
||||
*/
|
||||
export async function createFullTestScenario(ownerId: string): Promise<{
|
||||
organization: Organization;
|
||||
repository: Repository;
|
||||
team: Team;
|
||||
package: Package;
|
||||
}> {
|
||||
const { organization } = await createOrgWithOwner(ownerId);
|
||||
|
||||
const repository = await createTestRepository({
|
||||
organizationId: organization.id,
|
||||
createdById: ownerId,
|
||||
protocol: 'npm',
|
||||
});
|
||||
|
||||
const team = await createTestTeam({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
const pkg = await createTestPackage({
|
||||
organizationId: organization.id,
|
||||
repositoryId: repository.id,
|
||||
createdById: ownerId,
|
||||
});
|
||||
|
||||
return { organization, repository, team, package: pkg };
|
||||
}
|
||||
116
test/helpers/http.helper.ts
Normal file
116
test/helpers/http.helper.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* HTTP test helper - utilities for testing API endpoints
|
||||
*/
|
||||
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
export interface ITestRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ITestResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a test request to the registry API
|
||||
*/
|
||||
export async function testRequest(options: ITestRequest): Promise<ITestResponse> {
|
||||
const baseUrl = testConfig.registry.url;
|
||||
let url = `${baseUrl}${options.path}`;
|
||||
|
||||
if (options.query) {
|
||||
const params = new URLSearchParams(options.query);
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
body,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
export const get = (path: string, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'GET', path, headers });
|
||||
|
||||
export const post = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'POST', path, body, headers });
|
||||
|
||||
export const put = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'PUT', path, body, headers });
|
||||
|
||||
export const patch = (path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'PATCH', path, body, headers });
|
||||
|
||||
export const del = (path: string, headers?: Record<string, string>) =>
|
||||
testRequest({ method: 'DELETE', path, headers });
|
||||
|
||||
/**
|
||||
* Assert response status
|
||||
*/
|
||||
export function assertStatus(response: ITestResponse, expected: number): void {
|
||||
if (response.status !== expected) {
|
||||
throw new Error(
|
||||
`Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response body has specific keys
|
||||
*/
|
||||
export function assertBodyHas(response: ITestResponse, keys: string[]): void {
|
||||
const body = response.body as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
if (!(key in body)) {
|
||||
throw new Error(`Expected response to have key "${key}", body: ${JSON.stringify(body)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response is successful (2xx)
|
||||
*/
|
||||
export function assertSuccess(response: ITestResponse): void {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert response is an error (4xx or 5xx)
|
||||
*/
|
||||
export function assertError(response: ITestResponse, expectedStatus?: number): void {
|
||||
if (response.status < 400) {
|
||||
throw new Error(`Expected error response but got ${response.status}`);
|
||||
}
|
||||
if (expectedStatus !== undefined && response.status !== expectedStatus) {
|
||||
throw new Error(`Expected status ${expectedStatus} but got ${response.status}`);
|
||||
}
|
||||
}
|
||||
85
test/helpers/index.ts
Normal file
85
test/helpers/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Test helpers index - re-exports all helper modules
|
||||
*/
|
||||
|
||||
// Database helpers
|
||||
export {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
teardownTestDb,
|
||||
clearCollections,
|
||||
getTestDbName,
|
||||
getTestDb,
|
||||
} from './db.helper.ts';
|
||||
|
||||
// Auth helpers
|
||||
export {
|
||||
createTestUser,
|
||||
createAdminUser,
|
||||
loginUser,
|
||||
createTestApiToken,
|
||||
createAuthHeader,
|
||||
createBasicAuthHeader,
|
||||
getTestPassword,
|
||||
type ICreateTestUserOptions,
|
||||
type ICreateTestApiTokenOptions,
|
||||
} from './auth.helper.ts';
|
||||
|
||||
// Factory helpers
|
||||
export {
|
||||
createTestOrganization,
|
||||
createOrgWithOwner,
|
||||
addOrgMember,
|
||||
createTestRepository,
|
||||
createTestTeam,
|
||||
addTeamMember,
|
||||
grantRepoPermission,
|
||||
createTestPackage,
|
||||
createFullTestScenario,
|
||||
type ICreateTestOrganizationOptions,
|
||||
type ICreateTestRepositoryOptions,
|
||||
type ICreateTestTeamOptions,
|
||||
type IGrantRepoPermissionOptions,
|
||||
type ICreateTestPackageOptions,
|
||||
} from './factory.helper.ts';
|
||||
|
||||
// HTTP helpers
|
||||
export {
|
||||
testRequest,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
patch,
|
||||
del,
|
||||
assertStatus,
|
||||
assertBodyHas,
|
||||
assertSuccess,
|
||||
assertError,
|
||||
type ITestRequest,
|
||||
type ITestResponse,
|
||||
} from './http.helper.ts';
|
||||
|
||||
// Subprocess helpers
|
||||
export {
|
||||
runCommand,
|
||||
commandExists,
|
||||
clients,
|
||||
skipIfMissing,
|
||||
type ICommandResult,
|
||||
type ICommandOptions,
|
||||
} from './subprocess.helper.ts';
|
||||
|
||||
// Storage helpers
|
||||
export {
|
||||
setupTestStorage,
|
||||
checkStorageAvailable,
|
||||
objectExists,
|
||||
listObjects,
|
||||
deleteObject,
|
||||
deletePrefix,
|
||||
cleanupTestStorage,
|
||||
isStorageAvailable,
|
||||
} from './storage.helper.ts';
|
||||
|
||||
// Re-export test config
|
||||
export { testConfig, getTestConfig } from '../test.config.ts';
|
||||
104
test/helpers/storage.helper.ts
Normal file
104
test/helpers/storage.helper.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Storage helper - S3/MinIO verification utilities for tests
|
||||
*
|
||||
* NOTE: These are stub implementations for testing.
|
||||
* The actual smartbucket API should be verified against the real library.
|
||||
*/
|
||||
|
||||
import { testConfig } from '../test.config.ts';
|
||||
|
||||
// Storage is optional for unit/integration tests
|
||||
// E2E tests with actual S3 operations would need proper implementation
|
||||
let storageAvailable = false;
|
||||
|
||||
/**
|
||||
* Check if test storage is available
|
||||
*/
|
||||
export async function checkStorageAvailable(): Promise<boolean> {
|
||||
try {
|
||||
// Try to connect to MinIO
|
||||
const response = await fetch(`${testConfig.s3.endpoint}/minio/health/live`, {
|
||||
method: 'GET',
|
||||
});
|
||||
storageAvailable = response.ok;
|
||||
return storageAvailable;
|
||||
} catch {
|
||||
storageAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test storage connection
|
||||
*/
|
||||
export async function setupTestStorage(): Promise<void> {
|
||||
await checkStorageAvailable();
|
||||
if (storageAvailable) {
|
||||
console.log('[Test Storage] MinIO available at', testConfig.s3.endpoint);
|
||||
} else {
|
||||
console.log('[Test Storage] MinIO not available, storage tests will be skipped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object exists in storage (stub)
|
||||
*/
|
||||
export async function objectExists(_key: string): Promise<boolean> {
|
||||
if (!storageAvailable) return false;
|
||||
// Would implement actual check here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List objects with a given prefix (stub)
|
||||
*/
|
||||
export async function listObjects(_prefix: string): Promise<string[]> {
|
||||
if (!storageAvailable) return [];
|
||||
// Would implement actual list here
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object from storage (stub)
|
||||
*/
|
||||
export async function deleteObject(_key: string): Promise<void> {
|
||||
if (!storageAvailable) return;
|
||||
// Would implement actual delete here
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all objects with a given prefix
|
||||
*/
|
||||
export async function deletePrefix(prefix: string): Promise<void> {
|
||||
const objects = await listObjects(prefix);
|
||||
for (const key of objects) {
|
||||
await deleteObject(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test storage
|
||||
*/
|
||||
export async function cleanupTestStorage(): Promise<void> {
|
||||
if (!storageAvailable) return;
|
||||
|
||||
try {
|
||||
// Delete all test objects
|
||||
await deletePrefix('npm/');
|
||||
await deletePrefix('oci/');
|
||||
await deletePrefix('maven/');
|
||||
await deletePrefix('cargo/');
|
||||
await deletePrefix('pypi/');
|
||||
await deletePrefix('composer/');
|
||||
await deletePrefix('rubygems/');
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage is available
|
||||
*/
|
||||
export function isStorageAvailable(): boolean {
|
||||
return storageAvailable;
|
||||
}
|
||||
208
test/helpers/subprocess.helper.ts
Normal file
208
test/helpers/subprocess.helper.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Subprocess helper - utilities for running protocol clients in tests
|
||||
*/
|
||||
|
||||
export interface ICommandResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
signal?: Deno.Signal;
|
||||
}
|
||||
|
||||
export interface ICommandOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the result
|
||||
*/
|
||||
export async function runCommand(
|
||||
cmd: string[],
|
||||
options: ICommandOptions = {}
|
||||
): Promise<ICommandResult> {
|
||||
const { cwd, env, timeout = 60000, stdin } = options;
|
||||
|
||||
const command = new Deno.Command(cmd[0], {
|
||||
args: cmd.slice(1),
|
||||
cwd,
|
||||
env: { ...Deno.env.toObject(), ...env },
|
||||
stdin: stdin ? 'piped' : 'null',
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const child = command.spawn();
|
||||
|
||||
if (stdin && child.stdin) {
|
||||
const writer = child.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(stdin));
|
||||
await writer.close();
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
const output = await child.output();
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return {
|
||||
success: output.success,
|
||||
stdout: new TextDecoder().decode(output.stdout),
|
||||
stderr: new TextDecoder().decode(output.stderr),
|
||||
code: output.code,
|
||||
signal: output.signal ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is available
|
||||
*/
|
||||
export async function commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await runCommand(['which', cmd], { timeout: 5000 });
|
||||
return result.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol client wrappers
|
||||
*/
|
||||
export const clients = {
|
||||
npm: {
|
||||
check: () => commandExists('npm'),
|
||||
publish: (dir: string, registry: string, token: string) =>
|
||||
runCommand(['npm', 'publish', '--registry', registry], {
|
||||
cwd: dir,
|
||||
env: { NPM_TOKEN: token, npm_config__authToken: token },
|
||||
}),
|
||||
install: (pkg: string, registry: string, dir: string) =>
|
||||
runCommand(['npm', 'install', pkg, '--registry', registry], { cwd: dir }),
|
||||
unpublish: (pkg: string, registry: string, token: string) =>
|
||||
runCommand(['npm', 'unpublish', pkg, '--registry', registry, '--force'], {
|
||||
env: { NPM_TOKEN: token, npm_config__authToken: token },
|
||||
}),
|
||||
pack: (dir: string) => runCommand(['npm', 'pack'], { cwd: dir }),
|
||||
},
|
||||
|
||||
docker: {
|
||||
check: () => commandExists('docker'),
|
||||
build: (dockerfile: string, tag: string, context: string) =>
|
||||
runCommand(['docker', 'build', '-f', dockerfile, '-t', tag, context]),
|
||||
push: (image: string) => runCommand(['docker', 'push', image]),
|
||||
pull: (image: string) => runCommand(['docker', 'pull', image]),
|
||||
rmi: (image: string, force = false) =>
|
||||
runCommand(['docker', 'rmi', ...(force ? ['-f'] : []), image]),
|
||||
login: (registry: string, username: string, password: string) =>
|
||||
runCommand(['docker', 'login', registry, '-u', username, '--password-stdin'], {
|
||||
stdin: password,
|
||||
}),
|
||||
tag: (source: string, target: string) => runCommand(['docker', 'tag', source, target]),
|
||||
},
|
||||
|
||||
cargo: {
|
||||
check: () => commandExists('cargo'),
|
||||
package: (dir: string) => runCommand(['cargo', 'package', '--allow-dirty'], { cwd: dir }),
|
||||
publish: (dir: string, registry: string, token: string) =>
|
||||
runCommand(
|
||||
['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'],
|
||||
{ cwd: dir }
|
||||
),
|
||||
yank: (crate: string, version: string, token: string) =>
|
||||
runCommand([
|
||||
'cargo',
|
||||
'yank',
|
||||
crate,
|
||||
'--version',
|
||||
version,
|
||||
'--registry',
|
||||
'stack-test',
|
||||
'--token',
|
||||
token,
|
||||
]),
|
||||
},
|
||||
|
||||
pip: {
|
||||
check: () => commandExists('pip'),
|
||||
build: (dir: string) => runCommand(['python', '-m', 'build', dir]),
|
||||
upload: (dist: string, repository: string, token: string) =>
|
||||
runCommand([
|
||||
'python',
|
||||
'-m',
|
||||
'twine',
|
||||
'upload',
|
||||
'--repository-url',
|
||||
repository,
|
||||
'-u',
|
||||
'__token__',
|
||||
'-p',
|
||||
token,
|
||||
`${dist}/*`,
|
||||
]),
|
||||
install: (pkg: string, indexUrl: string) =>
|
||||
runCommand(['pip', 'install', pkg, '--index-url', indexUrl]),
|
||||
},
|
||||
|
||||
composer: {
|
||||
check: () => commandExists('composer'),
|
||||
install: (pkg: string, repository: string, dir: string) =>
|
||||
runCommand(
|
||||
[
|
||||
'composer',
|
||||
'require',
|
||||
pkg,
|
||||
'--repository',
|
||||
JSON.stringify({ type: 'composer', url: repository }),
|
||||
],
|
||||
{ cwd: dir }
|
||||
),
|
||||
},
|
||||
|
||||
gem: {
|
||||
check: () => commandExists('gem'),
|
||||
build: (gemspec: string, dir: string) => runCommand(['gem', 'build', gemspec], { cwd: dir }),
|
||||
push: (gemFile: string, host: string, key: string) =>
|
||||
runCommand(['gem', 'push', gemFile, '--host', host, '--key', key]),
|
||||
install: (gemName: string, source: string) =>
|
||||
runCommand(['gem', 'install', gemName, '--source', source]),
|
||||
yank: (gemName: string, version: string, host: string, key: string) =>
|
||||
runCommand(['gem', 'yank', gemName, '-v', version, '--host', host, '--key', key]),
|
||||
},
|
||||
|
||||
maven: {
|
||||
check: () => commandExists('mvn'),
|
||||
deploy: (dir: string, repositoryUrl: string, username: string, password: string) =>
|
||||
runCommand(
|
||||
[
|
||||
'mvn',
|
||||
'deploy',
|
||||
`-DaltDeploymentRepository=stack-test::default::${repositoryUrl}`,
|
||||
`-Dusername=${username}`,
|
||||
`-Dpassword=${password}`,
|
||||
],
|
||||
{ cwd: dir }
|
||||
),
|
||||
package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip test if command is not available
|
||||
*/
|
||||
export async function skipIfMissing(cmd: string): Promise<boolean> {
|
||||
const exists = await commandExists(cmd);
|
||||
if (!exists) {
|
||||
console.warn(`[Skip] ${cmd} not available`);
|
||||
}
|
||||
return !exists;
|
||||
}
|
||||
169
test/integration/auth.test.ts
Normal file
169
test/integration/auth.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Authentication integration tests
|
||||
* Tests the full authentication flow through the API
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
post,
|
||||
get,
|
||||
assertStatus,
|
||||
createAuthHeader,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Auth API Integration', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'api-login@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertExists(body.accessToken);
|
||||
assertExists(body.refreshToken);
|
||||
assertExists(body.user);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid credentials', async () => {
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'wrongpassword',
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should return 401 for inactive user', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'suspended@example.com',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
const response = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.error, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/refresh', () => {
|
||||
it('should refresh access token', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'refresh@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login first
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
|
||||
// Refresh
|
||||
const refreshResponse = await post('/api/v1/auth/refresh', {
|
||||
refreshToken: loginBody.refreshToken,
|
||||
});
|
||||
|
||||
assertStatus(refreshResponse, 200);
|
||||
const refreshBody = refreshResponse.body as Record<string, unknown>;
|
||||
assertExists(refreshBody.accessToken);
|
||||
});
|
||||
|
||||
it('should return 401 for invalid refresh token', async () => {
|
||||
const response = await post('/api/v1/auth/refresh', {
|
||||
refreshToken: 'invalid-token',
|
||||
});
|
||||
|
||||
assertStatus(response, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
it('should return current user info', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'me@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
|
||||
// Get current user
|
||||
const meResponse = await get(
|
||||
'/api/v1/auth/me',
|
||||
createAuthHeader(loginBody.accessToken as string)
|
||||
);
|
||||
|
||||
assertStatus(meResponse, 200);
|
||||
const meBody = meResponse.body as Record<string, unknown>;
|
||||
assertEquals(meBody.email, user.email);
|
||||
});
|
||||
|
||||
it('should return 401 without token', async () => {
|
||||
const response = await get('/api/v1/auth/me');
|
||||
|
||||
assertStatus(response, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/logout', () => {
|
||||
it('should invalidate session', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'logout@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResponse = await post('/api/v1/auth/login', {
|
||||
email: user.email,
|
||||
password: password,
|
||||
});
|
||||
const loginBody = loginResponse.body as Record<string, unknown>;
|
||||
const token = loginBody.accessToken as string;
|
||||
|
||||
// Logout
|
||||
const logoutResponse = await post('/api/v1/auth/logout', {}, createAuthHeader(token));
|
||||
|
||||
assertStatus(logoutResponse, 200);
|
||||
|
||||
// Token should no longer work
|
||||
const meResponse = await get('/api/v1/auth/me', createAuthHeader(token));
|
||||
assertStatus(meResponse, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/integration/organization.test.ts
Normal file
228
test/integration/organization.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Organization integration tests
|
||||
* Tests organization CRUD and member management through the API
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
loginUser,
|
||||
post,
|
||||
get,
|
||||
put,
|
||||
del,
|
||||
assertStatus,
|
||||
createAuthHeader,
|
||||
} from '../helpers/index.ts';
|
||||
|
||||
describe('Organization API Integration', () => {
|
||||
let accessToken: string;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user, password } = await createTestUser({ status: 'active' });
|
||||
testUserId = user.id;
|
||||
const tokens = await loginUser(user.email, password);
|
||||
accessToken = tokens.accessToken;
|
||||
});
|
||||
|
||||
describe('POST /api/v1/organizations', () => {
|
||||
it('should create organization', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{
|
||||
name: 'my-org',
|
||||
displayName: 'My Organization',
|
||||
description: 'A test organization',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'my-org');
|
||||
assertEquals(body.displayName, 'My Organization');
|
||||
});
|
||||
|
||||
it('should create organization with dots in name', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
},
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'push.rocks');
|
||||
});
|
||||
|
||||
it('should reject duplicate org name', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'First' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'duplicate', displayName: 'Second' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 409);
|
||||
});
|
||||
|
||||
it('should reject invalid org name', async () => {
|
||||
const response = await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: '.invalid', displayName: 'Invalid' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/organizations', () => {
|
||||
it('should list user organizations', async () => {
|
||||
// Create some organizations
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org1', displayName: 'Org 1' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'org2', displayName: 'Org 2' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/organizations/:orgName', () => {
|
||||
it('should get organization by name', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'get-me', displayName: 'Get Me' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.name, 'get-me');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent org', async () => {
|
||||
const response = await get(
|
||||
'/api/v1/organizations/non-existent',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/organizations/:orgName', () => {
|
||||
it('should update organization', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'update-me', displayName: 'Original' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await put(
|
||||
'/api/v1/organizations/update-me',
|
||||
{ displayName: 'Updated', description: 'New description' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>;
|
||||
assertEquals(body.displayName, 'Updated');
|
||||
assertEquals(body.description, 'New description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/organizations/:orgName', () => {
|
||||
it('should delete organization', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'delete-me', displayName: 'Delete Me' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken));
|
||||
|
||||
assertStatus(response, 200);
|
||||
|
||||
// Verify deleted
|
||||
const getResponse = await get(
|
||||
'/api/v1/organizations/delete-me',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
assertStatus(getResponse, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Organization Members', () => {
|
||||
it('should list organization members', async () => {
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'members-org', displayName: 'Members Org' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await get(
|
||||
'/api/v1/organizations/members-org/members',
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 200);
|
||||
const body = response.body as Record<string, unknown>[];
|
||||
assertEquals(body.length >= 1, true); // At least the creator
|
||||
});
|
||||
|
||||
it('should add member to organization', async () => {
|
||||
// Create another user
|
||||
const { user: newUser } = await createTestUser({ email: 'newmember@example.com' });
|
||||
|
||||
await post(
|
||||
'/api/v1/organizations',
|
||||
{ name: 'add-member-org', displayName: 'Add Member Org' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
const response = await post(
|
||||
'/api/v1/organizations/add-member-org/members',
|
||||
{ userId: newUser.id, role: 'member' },
|
||||
createAuthHeader(accessToken)
|
||||
);
|
||||
|
||||
assertStatus(response, 201);
|
||||
});
|
||||
});
|
||||
});
|
||||
60
test/test.config.ts
Normal file
60
test/test.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Test configuration for Stack.Gallery Registry tests
|
||||
*/
|
||||
|
||||
export const testConfig = {
|
||||
mongodb: {
|
||||
url: 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin',
|
||||
name: 'test-registry',
|
||||
},
|
||||
s3: {
|
||||
endpoint: 'http://localhost:9100',
|
||||
accessKey: 'testadmin',
|
||||
secretKey: 'testpassword',
|
||||
bucket: 'test-registry',
|
||||
region: 'us-east-1',
|
||||
},
|
||||
jwt: {
|
||||
secret: 'test-jwt-secret-for-testing-only',
|
||||
refreshSecret: 'test-refresh-secret-for-testing-only',
|
||||
},
|
||||
registry: {
|
||||
url: 'http://localhost:3000',
|
||||
port: 3000,
|
||||
},
|
||||
testUser: {
|
||||
email: 'test@stack.gallery',
|
||||
password: 'TestPassword123!',
|
||||
username: 'testuser',
|
||||
},
|
||||
adminUser: {
|
||||
email: 'admin@stack.gallery',
|
||||
password: 'admin',
|
||||
username: 'admin',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get test config with environment variable overrides
|
||||
*/
|
||||
export function getTestConfig() {
|
||||
return {
|
||||
...testConfig,
|
||||
mongodb: {
|
||||
...testConfig.mongodb,
|
||||
url: Deno.env.get('TEST_MONGODB_URL') || testConfig.mongodb.url,
|
||||
name: Deno.env.get('TEST_MONGODB_NAME') || testConfig.mongodb.name,
|
||||
},
|
||||
s3: {
|
||||
...testConfig.s3,
|
||||
endpoint: Deno.env.get('TEST_S3_ENDPOINT') || testConfig.s3.endpoint,
|
||||
accessKey: Deno.env.get('TEST_S3_ACCESS_KEY') || testConfig.s3.accessKey,
|
||||
secretKey: Deno.env.get('TEST_S3_SECRET_KEY') || testConfig.s3.secretKey,
|
||||
bucket: Deno.env.get('TEST_S3_BUCKET') || testConfig.s3.bucket,
|
||||
},
|
||||
registry: {
|
||||
...testConfig.registry,
|
||||
url: Deno.env.get('TEST_REGISTRY_URL') || testConfig.registry.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
232
test/unit/models/apitoken.test.ts
Normal file
232
test/unit/models/apitoken.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ApiToken model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('ApiToken Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
async function createToken(overrides: Partial<ApiToken> = {}): Promise<ApiToken> {
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = overrides.userId || testUserId;
|
||||
token.name = overrides.name || 'test-token';
|
||||
token.tokenHash = overrides.tokenHash || `hash-${crypto.randomUUID()}`;
|
||||
token.tokenPrefix = overrides.tokenPrefix || 'srg_test';
|
||||
token.protocols = overrides.protocols || ['npm', 'oci'];
|
||||
token.scopes = overrides.scopes || [{ protocol: '*', actions: ['read', 'write'] }];
|
||||
token.createdAt = new Date();
|
||||
|
||||
if (overrides.expiresAt) token.expiresAt = overrides.expiresAt;
|
||||
if (overrides.isRevoked) token.isRevoked = overrides.isRevoked;
|
||||
if (overrides.organizationId) token.organizationId = overrides.organizationId;
|
||||
|
||||
await token.save();
|
||||
return token;
|
||||
}
|
||||
|
||||
describe('findByHash', () => {
|
||||
it('should find token by hash', async () => {
|
||||
const created = await createToken({ tokenHash: 'unique-hash-123' });
|
||||
|
||||
const found = await ApiToken.findByHash('unique-hash-123');
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find revoked tokens', async () => {
|
||||
await createToken({
|
||||
tokenHash: 'revoked-hash',
|
||||
isRevoked: true,
|
||||
});
|
||||
|
||||
const found = await ApiToken.findByHash('revoked-hash');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await createToken({ name: 'token1' });
|
||||
await createToken({ name: 'token2' });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
await createToken({ name: 'active' });
|
||||
await createToken({ name: 'revoked', isRevoked: true });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
await createToken({ name: 'org-token', organizationId: orgId });
|
||||
await createToken({ name: 'personal-token' }); // No org
|
||||
|
||||
const tokens = await ApiToken.getOrgTokens(orgId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'org-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return true for valid token', async () => {
|
||||
const token = await createToken();
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
|
||||
it('should return false for revoked token', async () => {
|
||||
const token = await createToken({ isRevoked: true });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return false for expired token', async () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 1);
|
||||
|
||||
const token = await createToken({ expiresAt: pastDate });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return true for non-expired token', async () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
|
||||
const token = await createToken({ expiresAt: futureDate });
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordUsage', () => {
|
||||
it('should update usage stats', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage('192.168.1.1');
|
||||
|
||||
assertExists(token.lastUsedAt);
|
||||
assertEquals(token.lastUsedIp, '192.168.1.1');
|
||||
assertEquals(token.usageCount, 1);
|
||||
});
|
||||
|
||||
it('should increment usage count', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
|
||||
assertEquals(token.usageCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke('Security concern');
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, 'Security concern');
|
||||
});
|
||||
|
||||
it('should revoke token without reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke();
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProtocol', () => {
|
||||
it('should return true for allowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm', 'oci'] });
|
||||
|
||||
assertEquals(token.hasProtocol('npm'), true);
|
||||
assertEquals(token.hasProtocol('oci'), true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm'] });
|
||||
|
||||
assertEquals(token.hasProtocol('maven'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasScope', () => {
|
||||
it('should allow wildcard protocol scope', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), true);
|
||||
assertEquals(token.hasScope('maven'), true);
|
||||
});
|
||||
|
||||
it('should restrict by specific protocol', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), false);
|
||||
});
|
||||
|
||||
it('should restrict by organization', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', organizationId: 'org-123', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', 'org-123'), true);
|
||||
assertEquals(token.hasScope('npm', 'org-456'), false);
|
||||
});
|
||||
|
||||
it('should check action permissions', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), false);
|
||||
});
|
||||
|
||||
it('should allow wildcard action', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['*'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'delete'), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
test/unit/models/organization.test.ts
Normal file
220
test/unit/models/organization.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Organization model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Organization } from '../../../ts/models/organization.ts';
|
||||
|
||||
describe('Organization Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createOrganization', () => {
|
||||
it('should create an organization with valid data', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'test-org',
|
||||
displayName: 'Test Organization',
|
||||
description: 'A test organization',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(org.id);
|
||||
assertEquals(org.name, 'test-org');
|
||||
assertEquals(org.displayName, 'Test Organization');
|
||||
assertEquals(org.description, 'A test organization');
|
||||
assertEquals(org.createdById, testUserId);
|
||||
assertEquals(org.isPublic, false);
|
||||
assertEquals(org.memberCount, 0);
|
||||
assertEquals(org.plan, 'free');
|
||||
});
|
||||
|
||||
it('should allow dots in org name (domain-like)', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'push.rocks');
|
||||
});
|
||||
|
||||
it('should allow hyphens in org name', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'my-awesome-org',
|
||||
displayName: 'My Awesome Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'my-awesome-org');
|
||||
});
|
||||
|
||||
it('should reject uppercase names (must be lowercase)', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'UPPERCASE',
|
||||
displayName: 'Uppercase Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names starting with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: '.invalid',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names ending with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid.',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject names with special characters', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid@org',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set default settings', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'defaults',
|
||||
displayName: 'Defaults Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.settings.requireMfa, false);
|
||||
assertEquals(org.settings.allowPublicRepositories, true);
|
||||
assertEquals(org.settings.defaultRepositoryVisibility, 'private');
|
||||
assertEquals(org.settings.allowedProtocols.length, 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find organization by ID', async () => {
|
||||
const created = await Organization.createOrganization({
|
||||
name: 'findable',
|
||||
displayName: 'Findable Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Organization.findById('non-existent-id');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find organization by name (case-insensitive)', async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'byname',
|
||||
displayName: 'By Name',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findByName('BYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'byname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage quota', () => {
|
||||
it('should have default 5GB quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'quota-test',
|
||||
displayName: 'Quota Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.storageQuotaBytes, 5 * 1024 * 1024 * 1024);
|
||||
assertEquals(org.usedStorageBytes, 0);
|
||||
});
|
||||
|
||||
it('should check available storage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'storage-check',
|
||||
displayName: 'Storage Check',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1024), true);
|
||||
assertEquals(org.hasStorageAvailable(6 * 1024 * 1024 * 1024), false);
|
||||
});
|
||||
|
||||
it('should allow unlimited storage with -1 quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'unlimited',
|
||||
displayName: 'Unlimited',
|
||||
createdById: testUserId,
|
||||
});
|
||||
org.storageQuotaBytes = -1;
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1000 * 1024 * 1024 * 1024), true);
|
||||
});
|
||||
|
||||
it('should update storage usage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'usage-test',
|
||||
displayName: 'Usage Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await org.updateStorageUsage(1000);
|
||||
assertEquals(org.usedStorageBytes, 1000);
|
||||
|
||||
await org.updateStorageUsage(500);
|
||||
assertEquals(org.usedStorageBytes, 1500);
|
||||
|
||||
await org.updateStorageUsage(-2000);
|
||||
assertEquals(org.usedStorageBytes, 0); // Should not go negative
|
||||
});
|
||||
});
|
||||
});
|
||||
240
test/unit/models/package.test.ts
Normal file
240
test/unit/models/package.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Package model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Package } from '../../../ts/models/package.ts';
|
||||
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
||||
|
||||
describe('Package Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
let testRepoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
const repo = await createTestRepository({
|
||||
organizationId: testOrgId,
|
||||
createdById: testUserId,
|
||||
protocol: 'npm',
|
||||
});
|
||||
testRepoId = repo.id;
|
||||
});
|
||||
|
||||
function createVersion(version: string): IPackageVersion {
|
||||
return {
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: testUserId,
|
||||
size: 1024,
|
||||
checksum: `sha256-${crypto.randomUUID()}`,
|
||||
checksumAlgorithm: 'sha256',
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function createPackage(name: string, versions: string[] = ['1.0.0']): Promise<Package> {
|
||||
const pkg = new Package();
|
||||
pkg.id = Package.generateId('npm', testOrgId, name);
|
||||
pkg.organizationId = testOrgId;
|
||||
pkg.repositoryId = testRepoId;
|
||||
pkg.protocol = 'npm';
|
||||
pkg.name = name;
|
||||
pkg.createdById = testUserId;
|
||||
pkg.createdAt = new Date();
|
||||
pkg.updatedAt = new Date();
|
||||
|
||||
for (const v of versions) {
|
||||
pkg.addVersion(createVersion(v));
|
||||
}
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
|
||||
await pkg.save();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe('generateId', () => {
|
||||
it('should generate correct format', () => {
|
||||
const id = Package.generateId('npm', 'my-org', 'my-package');
|
||||
assertEquals(id, 'npm:my-org:my-package');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find package by ID', async () => {
|
||||
const created = await createPackage('findable');
|
||||
|
||||
const found = await Package.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Package.findById('npm:fake:package');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find package by protocol, org, and name', async () => {
|
||||
await createPackage('by-name');
|
||||
|
||||
const found = await Package.findByName('npm', testOrgId, 'by-name');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'by-name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgPackages', () => {
|
||||
it('should return all packages in organization', async () => {
|
||||
await createPackage('pkg1');
|
||||
await createPackage('pkg2');
|
||||
await createPackage('pkg3');
|
||||
|
||||
const packages = await Package.getOrgPackages(testOrgId);
|
||||
assertEquals(packages.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find packages by name', async () => {
|
||||
await createPackage('search-me');
|
||||
await createPackage('find-this');
|
||||
await createPackage('other');
|
||||
|
||||
const results = await Package.search('search');
|
||||
assertEquals(results.length, 1);
|
||||
assertEquals(results[0].name, 'search-me');
|
||||
});
|
||||
|
||||
it('should find packages by description', async () => {
|
||||
const pkg = await createPackage('described');
|
||||
pkg.description = 'A unique description for testing';
|
||||
await pkg.save();
|
||||
|
||||
const results = await Package.search('unique description');
|
||||
assertEquals(results.length, 1);
|
||||
});
|
||||
|
||||
it('should filter by protocol', async () => {
|
||||
await createPackage('npm-pkg');
|
||||
|
||||
const results = await Package.search('npm', { protocol: 'oci' });
|
||||
assertEquals(results.length, 0);
|
||||
});
|
||||
|
||||
it('should apply pagination', async () => {
|
||||
await createPackage('page1');
|
||||
await createPackage('page2');
|
||||
await createPackage('page3');
|
||||
|
||||
const firstPage = await Package.search('page', { limit: 2, offset: 0 });
|
||||
assertEquals(firstPage.length, 2);
|
||||
|
||||
const secondPage = await Package.search('page', { limit: 2, offset: 2 });
|
||||
assertEquals(secondPage.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('versions', () => {
|
||||
it('should add version and update storage', async () => {
|
||||
const pkg = await createPackage('versioned', []);
|
||||
|
||||
pkg.addVersion(createVersion('1.0.0'));
|
||||
|
||||
assertEquals(Object.keys(pkg.versions).length, 1);
|
||||
assertEquals(pkg.storageBytes, 1024);
|
||||
});
|
||||
|
||||
it('should get specific version', async () => {
|
||||
const pkg = await createPackage('multi-version', ['1.0.0', '1.1.0', '2.0.0']);
|
||||
|
||||
const v1 = pkg.getVersion('1.0.0');
|
||||
assertExists(v1);
|
||||
assertEquals(v1.version, '1.0.0');
|
||||
|
||||
const v2 = pkg.getVersion('2.0.0');
|
||||
assertExists(v2);
|
||||
assertEquals(v2.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent version', async () => {
|
||||
const pkg = await createPackage('single', ['1.0.0']);
|
||||
|
||||
const missing = pkg.getVersion('9.9.9');
|
||||
assertEquals(missing, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestVersion', () => {
|
||||
it('should return version from distTags.latest', async () => {
|
||||
const pkg = await createPackage('tagged', ['1.0.0', '2.0.0']);
|
||||
pkg.distTags['latest'] = '1.0.0'; // Set older version as latest
|
||||
await pkg.save();
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '1.0.0');
|
||||
});
|
||||
|
||||
it('should fallback to last version if no latest tag', async () => {
|
||||
const pkg = await createPackage('untagged', ['1.0.0', '2.0.0']);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for empty versions', async () => {
|
||||
const pkg = await createPackage('empty', []);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertEquals(latest, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment total download count', async () => {
|
||||
const pkg = await createPackage('downloads');
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 1);
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 3);
|
||||
});
|
||||
|
||||
it('should increment version-specific downloads', async () => {
|
||||
const pkg = await createPackage('version-downloads', ['1.0.0', '2.0.0']);
|
||||
|
||||
await pkg.incrementDownloads('1.0.0');
|
||||
assertEquals(pkg.versions['1.0.0'].downloads, 1);
|
||||
assertEquals(pkg.versions['2.0.0'].downloads, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
test/unit/models/repository.test.ts
Normal file
285
test/unit/models/repository.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Repository model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Repository } from '../../../ts/models/repository.ts';
|
||||
|
||||
describe('Repository Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
});
|
||||
|
||||
describe('createRepository', () => {
|
||||
it('should create a repository with valid data', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'test-repo',
|
||||
description: 'A test repository',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(repo.id);
|
||||
assertEquals(repo.name, 'test-repo');
|
||||
assertEquals(repo.organizationId, testOrgId);
|
||||
assertEquals(repo.protocol, 'npm');
|
||||
assertEquals(repo.visibility, 'private');
|
||||
assertEquals(repo.downloadCount, 0);
|
||||
assertEquals(repo.starCount, 0);
|
||||
});
|
||||
|
||||
it('should allow dots and underscores in name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my.test_repo',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'my.test_repo');
|
||||
});
|
||||
|
||||
it('should lowercase the name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'UPPERCASE',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'uppercase');
|
||||
});
|
||||
|
||||
it('should set correct storage namespace', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.storageNamespace, `npm/${testOrgId}/packages`);
|
||||
});
|
||||
|
||||
it('should reject duplicate name+protocol in same org', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'already exists'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow same name with different protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const ociRepo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(ociRepo.name, 'packages');
|
||||
assertEquals(ociRepo.protocol, 'oci');
|
||||
});
|
||||
|
||||
it('should reject invalid names', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: '-invalid',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set visibility when provided', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public-repo',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.visibility, 'public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find repository by org, name, and protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'findable',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'FINDABLE', 'npm');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for wrong protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-only',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'npm-only', 'oci');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgRepositories', () => {
|
||||
it('should return all org repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo1',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo2',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo3',
|
||||
protocol: 'maven',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getOrgRepositories(testOrgId);
|
||||
assertEquals(repos.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicRepositories', () => {
|
||||
it('should return only public repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public1',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'private1',
|
||||
protocol: 'npm',
|
||||
visibility: 'private',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories();
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].name, 'public1');
|
||||
});
|
||||
|
||||
it('should filter by protocol when provided', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-public',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'oci-public',
|
||||
protocol: 'oci',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories('npm');
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].protocol, 'npm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment download count', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'downloads',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 1);
|
||||
|
||||
await repo.incrementDownloads();
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullPath', () => {
|
||||
it('should return org/repo path', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my-package',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const path = repo.getFullPath('my-org');
|
||||
assertEquals(path, 'my-org/my-package');
|
||||
});
|
||||
});
|
||||
});
|
||||
142
test/unit/models/session.test.ts
Normal file
142
test/unit/models/session.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Session model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
|
||||
describe('Session Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a session with valid data', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipAddress: '192.168.1.1',
|
||||
});
|
||||
|
||||
assertExists(session.id);
|
||||
assertEquals(session.userId, testUserId);
|
||||
assertEquals(session.userAgent, 'Mozilla/5.0');
|
||||
assertEquals(session.ipAddress, '192.168.1.1');
|
||||
assertEquals(session.isValid, true);
|
||||
assertExists(session.createdAt);
|
||||
assertExists(session.lastActivityAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidSession', () => {
|
||||
it('should find valid session by ID', async () => {
|
||||
const created = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
const found = await Session.findValidSession(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find invalidated session', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
await session.invalidate('Logged out');
|
||||
|
||||
const found = await Session.findValidSession(session.id);
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserSessions', () => {
|
||||
it('should return all valid sessions for user', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 3);
|
||||
});
|
||||
|
||||
it('should not return invalidated sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Valid', ipAddress: '1.1.1.1' });
|
||||
const invalid = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Invalid',
|
||||
ipAddress: '2.2.2.2',
|
||||
});
|
||||
await invalid.invalidate('test');
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('should invalidate session with reason', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
await session.invalidate('User logged out');
|
||||
|
||||
assertEquals(session.isValid, false);
|
||||
assertExists(session.invalidatedAt);
|
||||
assertEquals(session.invalidatedReason, 'User logged out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateAllUserSessions', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
|
||||
assertEquals(count, 3);
|
||||
|
||||
const remaining = await Session.getUserSessions(testUserId);
|
||||
assertEquals(remaining.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchActivity', () => {
|
||||
it('should update lastActivityAt', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
const originalActivity = session.lastActivityAt;
|
||||
|
||||
// Wait a bit to ensure time difference
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
await session.touchActivity();
|
||||
|
||||
assertEquals(session.lastActivityAt > originalActivity, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/unit/models/user.test.ts
Normal file
228
test/unit/models/user.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* User model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
|
||||
import { User } from '../../../ts/models/user.ts';
|
||||
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create a user with valid data', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
passwordHash,
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
assertExists(user.id);
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
assertEquals(user.displayName, 'Test User');
|
||||
assertEquals(user.status, 'pending_verification');
|
||||
assertEquals(user.emailVerified, false);
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
});
|
||||
|
||||
it('should lowercase email and username', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'TEST@EXAMPLE.COM',
|
||||
username: 'TestUser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
});
|
||||
|
||||
it('should use username as displayName if not provided', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test2@example.com',
|
||||
username: 'testuser2',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.displayName, 'testuser2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
it('should find user by email (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'findme@example.com',
|
||||
username: 'findme',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByEmail('FINDME@example.com');
|
||||
assertExists(found);
|
||||
assertEquals(found.email, 'findme@example.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent email', async () => {
|
||||
const found = await User.findByEmail('nonexistent@example.com');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUsername', () => {
|
||||
it('should find user by username (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'user@example.com',
|
||||
username: 'findbyname',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByUsername('FINDBYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.username, 'findbyname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find user by ID', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const created = await User.createUser({
|
||||
email: 'byid@example.com',
|
||||
username: 'byid',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('should hash password with salt', async () => {
|
||||
const hash = await User.hashPassword('mypassword');
|
||||
assertExists(hash);
|
||||
assertEquals(hash.includes(':'), true);
|
||||
|
||||
const [salt, _hashPart] = hash.split(':');
|
||||
assertEquals(salt.length, 32); // 16 bytes = 32 hex chars
|
||||
});
|
||||
|
||||
it('should produce different hashes for same password', async () => {
|
||||
const hash1 = await User.hashPassword('samepassword');
|
||||
const hash2 = await User.hashPassword('samepassword');
|
||||
|
||||
// Different salts should produce different hashes
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyPassword', () => {
|
||||
it('should verify correct password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'verify@example.com',
|
||||
username: 'verifyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('correctpassword');
|
||||
assertEquals(isValid, true);
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'reject@example.com',
|
||||
username: 'rejectuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('wrongpassword');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
|
||||
it('should reject empty password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'empty@example.com',
|
||||
username: 'emptyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'active@example.com',
|
||||
username: 'activeuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'active';
|
||||
await user.save();
|
||||
|
||||
assertEquals(user.isActive, true);
|
||||
});
|
||||
|
||||
it('should return false for suspended status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'suspended@example.com',
|
||||
username: 'suspendeduser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'suspended';
|
||||
|
||||
assertEquals(user.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlatformAdmin', () => {
|
||||
it('should default to false', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'notadmin@example.com',
|
||||
username: 'notadmin',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
assertEquals(user.isSystemAdmin, false);
|
||||
});
|
||||
|
||||
it('should be settable to true', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'admin@example.com',
|
||||
username: 'adminuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.isPlatformAdmin = true;
|
||||
await user.save();
|
||||
|
||||
const found = await User.findById(user.id);
|
||||
assertEquals(found!.isPlatformAdmin, true);
|
||||
assertEquals(found!.isSystemAdmin, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
test/unit/services/auth.service.test.ts
Normal file
224
test/unit/services/auth.service.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* AuthService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { AuthService } from '../../../ts/services/auth.service.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
import { testConfig } from '../../test.config.ts';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
accessTokenExpiresIn: 60, // 1 minute for tests
|
||||
refreshTokenExpiresIn: 300, // 5 minutes for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should successfully login with valid credentials', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'login@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password, {
|
||||
userAgent: 'TestAgent/1.0',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.user);
|
||||
assertEquals(result.user.id, user.id);
|
||||
assertExists(result.accessToken);
|
||||
assertExists(result.refreshToken);
|
||||
assertExists(result.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid email', async () => {
|
||||
const result = await authService.login('nonexistent@example.com', 'password');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail with invalid password', async () => {
|
||||
const { user } = await createTestUser({ email: 'wrongpass@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, 'wrongpassword');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail for inactive user', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'inactive@example.com',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
|
||||
it('should create a session on successful login', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'session@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.sessionId);
|
||||
|
||||
const session = await Session.findValidSession(result.sessionId!);
|
||||
assertExists(session);
|
||||
assertEquals(session.userId, user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh access token with valid refresh token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'refresh@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(loginResult.success, true);
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, true);
|
||||
assertExists(refreshResult.accessToken);
|
||||
assertEquals(refreshResult.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid refresh token', async () => {
|
||||
const result = await authService.refresh('invalid-token');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidsession@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, false);
|
||||
assertEquals(refreshResult.errorCode, 'SESSION_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('should validate valid access token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'validate@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.user.id, user.id);
|
||||
assertEquals(validation.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should reject invalid access token', async () => {
|
||||
const validation = await authService.validateAccessToken('invalid-token');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidated@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should invalidate session', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logout@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const success = await authService.logout(loginResult.sessionId!);
|
||||
|
||||
assertEquals(success, true);
|
||||
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
assertEquals(session, null);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const success = await authService.logout('non-existent-session-id');
|
||||
|
||||
assertEquals(success, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutAll', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logoutall@example.com' });
|
||||
|
||||
// Create multiple sessions
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
|
||||
const count = await authService.logoutAll(user.id);
|
||||
|
||||
assertEquals(count, 3);
|
||||
|
||||
const sessions = await Session.getUserSessions(user.id);
|
||||
assertEquals(sessions.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static password methods', () => {
|
||||
it('should hash and verify password', async () => {
|
||||
const password = 'MySecurePassword123!';
|
||||
const hash = await AuthService.hashPassword(password);
|
||||
|
||||
const isValid = await AuthService.verifyPassword(password, hash);
|
||||
assertEquals(isValid, true);
|
||||
|
||||
const isInvalid = await AuthService.verifyPassword('WrongPassword', hash);
|
||||
assertEquals(isInvalid, false);
|
||||
});
|
||||
|
||||
it('should generate different hashes for same password', async () => {
|
||||
const password = 'SamePassword';
|
||||
const hash1 = await AuthService.hashPassword(password);
|
||||
const hash2 = await AuthService.hashPassword(password);
|
||||
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
|
||||
// But both should verify
|
||||
assertEquals(await AuthService.verifyPassword(password, hash1), true);
|
||||
assertEquals(await AuthService.verifyPassword(password, hash2), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
test/unit/services/token.service.test.ts
Normal file
260
test/unit/services/token.service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* TokenService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { TokenService } from '../../../ts/services/token.service.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let tokenService: TokenService;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
tokenService = new TokenService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createToken', () => {
|
||||
it('should create token with correct format', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'test-token',
|
||||
protocols: ['npm', 'oci'],
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertExists(result.rawToken);
|
||||
assertExists(result.token);
|
||||
|
||||
// Check token format: srg_{prefix}_{random}
|
||||
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
|
||||
assertEquals(result.token.name, 'test-token');
|
||||
assertEquals(result.token.protocols.includes('npm'), true);
|
||||
assertEquals(result.token.protocols.includes('oci'), true);
|
||||
});
|
||||
|
||||
it('should store hashed token', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'hashed-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
// The stored token should be hashed
|
||||
assertEquals(result.token.tokenHash !== result.rawToken, true);
|
||||
assertEquals(result.token.tokenHash.length, 64); // SHA-256 hex
|
||||
});
|
||||
|
||||
it('should set expiration when provided', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expiring-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 30,
|
||||
});
|
||||
|
||||
assertExists(result.token.expiresAt);
|
||||
|
||||
const expectedExpiry = new Date();
|
||||
expectedExpiry.setDate(expectedExpiry.getDate() + 30);
|
||||
|
||||
// Should be within a few seconds of expected
|
||||
const diff = Math.abs(result.token.expiresAt.getTime() - expectedExpiry.getTime());
|
||||
assertEquals(diff < 5000, true);
|
||||
});
|
||||
|
||||
it('should create org-owned token', async () => {
|
||||
const orgId = 'test-org-123';
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', organizationId: orgId, actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(result.token.organizationId, orgId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should validate correct token', async () => {
|
||||
const { rawToken } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'valid-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.userId, testUserId);
|
||||
assertEquals(validation.protocols.includes('npm'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject non-existent token', async () => {
|
||||
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject revoked token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('Test revocation');
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expired-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 1,
|
||||
});
|
||||
|
||||
// Manually set expiry to past
|
||||
token.expiresAt = new Date(Date.now() - 86400000);
|
||||
await token.save();
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should record usage on validation', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'usage-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.validateToken(rawToken, '192.168.1.100');
|
||||
|
||||
// Reload token from DB
|
||||
const updated = await ApiToken.findByHash(token.tokenHash);
|
||||
assertExists(updated);
|
||||
assertExists(updated.lastUsedAt);
|
||||
assertEquals(updated.lastUsedIp, '192.168.1.100');
|
||||
assertEquals(updated.usageCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token1',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token2',
|
||||
protocols: ['oci'],
|
||||
scopes: [{ protocol: 'oci', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'active',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('test');
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'to-revoke',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.revokeToken(token.id, 'Security concern');
|
||||
|
||||
const updated = await ApiToken.findByPrefix(token.tokenPrefix);
|
||||
assertExists(updated);
|
||||
assertEquals(updated.isRevoked, true);
|
||||
assertEquals(updated.revokedReason, 'Security concern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'personal-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getOrgTokens(orgId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].organizationId, orgId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.0.1',
|
||||
version: '1.3.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
|
||||
461
ts/api/handlers/admin.auth.api.ts
Normal file
461
ts/api/handlers/admin.auth.api.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Admin Auth API handlers
|
||||
* Platform admin endpoints for managing authentication providers and settings
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
import { cryptoService } from '../../services/crypto.service.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
import { AuditService } from '../../services/audit.service.ts';
|
||||
import type {
|
||||
ICreateAuthProviderDto,
|
||||
IUpdateAuthProviderDto,
|
||||
} from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class AdminAuthApi {
|
||||
/**
|
||||
* Check if actor is platform admin
|
||||
*/
|
||||
private requirePlatformAdmin(ctx: IApiContext): IApiResponse | null {
|
||||
if (!ctx.actor?.userId || !ctx.actor.user?.isPlatformAdmin) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { error: 'Platform admin access required' },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/providers
|
||||
* List all authentication providers
|
||||
*/
|
||||
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const providers = await AuthProvider.getAllProviders();
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
providers: providers.map((p) => p.toAdminInfo()),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] List providers error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to list providers' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/auth/providers
|
||||
* Create a new authentication provider
|
||||
*/
|
||||
public async createProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = (await ctx.request.json()) as ICreateAuthProviderDto;
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.displayName || !body.type) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'name, displayName, and type are required' },
|
||||
};
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
const existing = await AuthProvider.findByName(body.name);
|
||||
if (existing) {
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: 'Provider name already exists' },
|
||||
};
|
||||
}
|
||||
|
||||
// Validate type-specific config
|
||||
if (body.type === 'oidc' && !body.oauthConfig) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'oauthConfig is required for OIDC provider' },
|
||||
};
|
||||
}
|
||||
if (body.type === 'ldap' && !body.ldapConfig) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'ldapConfig is required for LDAP provider' },
|
||||
};
|
||||
}
|
||||
|
||||
let provider: AuthProvider;
|
||||
|
||||
if (body.type === 'oidc' && body.oauthConfig) {
|
||||
// Encrypt client secret
|
||||
const encryptedSecret = await cryptoService.encrypt(body.oauthConfig.clientSecretEncrypted);
|
||||
|
||||
provider = await AuthProvider.createOAuthProvider({
|
||||
name: body.name,
|
||||
displayName: body.displayName,
|
||||
oauthConfig: {
|
||||
...body.oauthConfig,
|
||||
clientSecretEncrypted: encryptedSecret,
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
});
|
||||
} else if (body.type === 'ldap' && body.ldapConfig) {
|
||||
// Encrypt bind password
|
||||
const encryptedPassword = await cryptoService.encrypt(body.ldapConfig.bindPasswordEncrypted);
|
||||
|
||||
provider = await AuthProvider.createLdapProvider({
|
||||
name: body.name,
|
||||
displayName: body.displayName,
|
||||
ldapConfig: {
|
||||
...body.ldapConfig,
|
||||
bindPasswordEncrypted: encryptedPassword,
|
||||
},
|
||||
attributeMapping: body.attributeMapping,
|
||||
provisioning: body.provisioning,
|
||||
createdById: ctx.actor!.userId,
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Invalid provider type' },
|
||||
};
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_CREATED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_created',
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Create provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to create provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/providers/:id
|
||||
* Get a specific authentication provider
|
||||
*/
|
||||
public async getProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Get provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to get provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/auth/providers/:id
|
||||
* Update an authentication provider
|
||||
*/
|
||||
public async updateProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
const body = (await ctx.request.json()) as IUpdateAuthProviderDto;
|
||||
|
||||
// Update basic fields
|
||||
if (body.displayName !== undefined) provider.displayName = body.displayName;
|
||||
if (body.status !== undefined) provider.status = body.status;
|
||||
if (body.priority !== undefined) provider.priority = body.priority;
|
||||
|
||||
// Update OAuth config
|
||||
if (body.oauthConfig && provider.oauthConfig) {
|
||||
const newOAuthConfig = { ...provider.oauthConfig, ...body.oauthConfig };
|
||||
|
||||
// Encrypt new client secret if provided and not already encrypted
|
||||
if (
|
||||
body.oauthConfig.clientSecretEncrypted &&
|
||||
!cryptoService.isEncrypted(body.oauthConfig.clientSecretEncrypted)
|
||||
) {
|
||||
newOAuthConfig.clientSecretEncrypted = await cryptoService.encrypt(
|
||||
body.oauthConfig.clientSecretEncrypted
|
||||
);
|
||||
}
|
||||
|
||||
provider.oauthConfig = newOAuthConfig;
|
||||
}
|
||||
|
||||
// Update LDAP config
|
||||
if (body.ldapConfig && provider.ldapConfig) {
|
||||
const newLdapConfig = { ...provider.ldapConfig, ...body.ldapConfig };
|
||||
|
||||
// Encrypt new bind password if provided and not already encrypted
|
||||
if (
|
||||
body.ldapConfig.bindPasswordEncrypted &&
|
||||
!cryptoService.isEncrypted(body.ldapConfig.bindPasswordEncrypted)
|
||||
) {
|
||||
newLdapConfig.bindPasswordEncrypted = await cryptoService.encrypt(
|
||||
body.ldapConfig.bindPasswordEncrypted
|
||||
);
|
||||
}
|
||||
|
||||
provider.ldapConfig = newLdapConfig;
|
||||
}
|
||||
|
||||
// Update attribute mapping
|
||||
if (body.attributeMapping) {
|
||||
provider.attributeMapping = { ...provider.attributeMapping, ...body.attributeMapping };
|
||||
}
|
||||
|
||||
// Update provisioning settings
|
||||
if (body.provisioning) {
|
||||
provider.provisioning = { ...provider.provisioning, ...body.provisioning };
|
||||
}
|
||||
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_updated',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: provider.toAdminInfo(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Update provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to update provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/auth/providers/:id
|
||||
* Delete (or disable) an authentication provider
|
||||
*/
|
||||
public async deleteProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const provider = await AuthProvider.findById(id);
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: 'Provider not found' },
|
||||
};
|
||||
}
|
||||
|
||||
// For now, just disable the provider instead of deleting
|
||||
// This preserves audit history and linked identities
|
||||
provider.status = 'disabled';
|
||||
await provider.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_DELETED', 'system', {
|
||||
resourceId: provider.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'auth_provider_disabled',
|
||||
providerName: provider.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { message: 'Provider disabled' },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Delete provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to delete provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/auth/providers/:id/test
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testProvider(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const result = await externalAuthService.testConnection(id);
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: id,
|
||||
success: result.success,
|
||||
metadata: {
|
||||
action: 'auth_provider_tested',
|
||||
result: result.success ? 'success' : 'failure',
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Test provider error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to test provider' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/auth/settings
|
||||
* Get platform settings
|
||||
*/
|
||||
public async getSettings(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Get settings error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to get settings' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/auth/settings
|
||||
* Update platform settings
|
||||
*/
|
||||
public async updateSettings(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const authError = this.requirePlatformAdmin(ctx);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const settings = await PlatformSettings.get();
|
||||
|
||||
if (body.auth) {
|
||||
await settings.updateAuthSettings(body.auth, ctx.actor!.userId);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: ctx.actor!.userId,
|
||||
actorType: 'user',
|
||||
actorIp: ctx.ip,
|
||||
}).log('ORGANIZATION_UPDATED', 'system', {
|
||||
resourceId: 'platform-settings',
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'platform_settings_updated',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: settings.id,
|
||||
auth: settings.auth,
|
||||
updatedAt: settings.updatedAt,
|
||||
updatedById: settings.updatedById,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminAuthApi] Update settings error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to update settings' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
188
ts/api/handlers/oauth.api.ts
Normal file
188
ts/api/handlers/oauth.api.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* OAuth API handlers
|
||||
* Public endpoints for OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { AuthProvider, PlatformSettings } from '../../models/index.ts';
|
||||
import { externalAuthService } from '../../services/external.auth.service.ts';
|
||||
|
||||
export class OAuthApi {
|
||||
/**
|
||||
* GET /api/v1/auth/providers
|
||||
* List active authentication providers (public info only)
|
||||
*/
|
||||
public async listProviders(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const settings = await PlatformSettings.get();
|
||||
const providers = await AuthProvider.getActiveProviders();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
providers: providers.map((p) => p.toPublicInfo()),
|
||||
localAuthEnabled: settings.auth.localAuthEnabled,
|
||||
defaultProviderId: settings.auth.defaultProviderId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] List providers error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'Failed to list providers' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/oauth/:id/authorize
|
||||
* Initiate OAuth flow - redirects to provider
|
||||
*/
|
||||
public async authorize(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const returnUrl = ctx.url.searchParams.get('returnUrl') || undefined;
|
||||
|
||||
const { authUrl } = await externalAuthService.initiateOAuth(id, returnUrl);
|
||||
|
||||
// Return redirect response
|
||||
return {
|
||||
status: 302,
|
||||
headers: { Location: authUrl },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] Authorize error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Authorization failed';
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/oauth/:id/callback
|
||||
* Handle OAuth callback from provider
|
||||
*/
|
||||
public async callback(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const code = ctx.url.searchParams.get('code');
|
||||
const state = ctx.url.searchParams.get('state');
|
||||
const error = ctx.url.searchParams.get('error');
|
||||
const errorDescription = ctx.url.searchParams.get('error_description');
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorDescription || error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/login?error=missing_parameters',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await externalAuthService.handleOAuthCallback(
|
||||
{ code, state },
|
||||
{ ipAddress: ctx.ip, userAgent: ctx.userAgent }
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(result.errorCode || 'auth_failed')}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Redirect to OAuth callback page with tokens
|
||||
const params = new URLSearchParams({
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken!,
|
||||
sessionId: result.sessionId!,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/oauth-callback?${params.toString()}`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] Callback error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Callback failed';
|
||||
return {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/login?error=${encodeURIComponent(errorMessage)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/ldap/:id/login
|
||||
* LDAP authentication with username/password
|
||||
*/
|
||||
public async ldapLogin(ctx: IApiContext): Promise<IApiResponse> {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const body = await ctx.request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
if (!username || !password) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Username and password are required' },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await externalAuthService.authenticateLdap(id, username, password, {
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: result.errorMessage,
|
||||
code: result.errorCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: result.user!.id,
|
||||
email: result.user!.email,
|
||||
username: result.user!.username,
|
||||
displayName: result.user!.displayName,
|
||||
isSystemAdmin: result.user!.isSystemAdmin,
|
||||
},
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionId: result.sessionId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OAuthApi] LDAP login error:', error);
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: 'LDAP login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ export class OrganizationApi {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve organization by ID or name
|
||||
*/
|
||||
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||
return idOrName.startsWith('Organization:')
|
||||
? await Organization.findById(idOrName)
|
||||
: await Organization.findByName(idOrName);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
@@ -56,19 +65,20 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
* Supports lookup by ID (e.g., Organization:abc123) or by name (e.g., push.rocks)
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
@@ -112,11 +122,11 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
// Validate name format (allows dots for domain-like names)
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens and dots' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +186,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -184,18 +195,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission using org.id
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
@@ -232,6 +243,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -240,18 +252,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
@@ -268,6 +280,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -276,14 +289,19 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
@@ -316,6 +334,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -324,13 +343,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
@@ -349,7 +373,7 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
@@ -357,7 +381,7 @@ export class OrganizationApi {
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
@@ -366,11 +390,8 @@ export class OrganizationApi {
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
@@ -388,6 +409,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -396,13 +418,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
@@ -410,14 +437,14 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -442,6 +469,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -450,23 +478,28 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -476,11 +509,8 @@ export class OrganizationApi {
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@@ -4,17 +4,22 @@
|
||||
|
||||
import type { IApiContext, IApiResponse } from '../router.ts';
|
||||
import { TokenService } from '../../services/token.service.ts';
|
||||
import { PermissionService } from '../../services/permission.service.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export class TokenApi {
|
||||
private tokenService: TokenService;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
constructor(tokenService: TokenService) {
|
||||
constructor(tokenService: TokenService, permissionService?: PermissionService) {
|
||||
this.tokenService = tokenService;
|
||||
this.permissionService = permissionService || new PermissionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tokens
|
||||
* Query params:
|
||||
* - organizationId: list org tokens (requires org admin)
|
||||
*/
|
||||
public async list(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -22,7 +27,20 @@ export class TokenApi {
|
||||
}
|
||||
|
||||
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 {
|
||||
status: 200,
|
||||
@@ -33,6 +51,8 @@ export class TokenApi {
|
||||
tokenPrefix: t.tokenPrefix,
|
||||
protocols: t.protocols,
|
||||
scopes: t.scopes,
|
||||
organizationId: t.organizationId,
|
||||
createdById: t.createdById,
|
||||
expiresAt: t.expiresAt,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
usageCount: t.usageCount,
|
||||
@@ -48,6 +68,12 @@ export class TokenApi {
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -56,8 +82,9 @@ export class TokenApi {
|
||||
|
||||
try {
|
||||
const body = await ctx.request.json();
|
||||
const { name, protocols, scopes, expiresInDays } = body as {
|
||||
const { name, organizationId, protocols, scopes, expiresInDays } = body as {
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
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({
|
||||
userId: ctx.actor.userId,
|
||||
organizationId,
|
||||
createdById: ctx.actor.userId,
|
||||
name,
|
||||
protocols,
|
||||
scopes,
|
||||
@@ -108,6 +145,7 @@ export class TokenApi {
|
||||
tokenPrefix: result.token.tokenPrefix,
|
||||
protocols: result.token.protocols,
|
||||
scopes: result.token.scopes,
|
||||
organizationId: result.token.organizationId,
|
||||
expiresAt: result.token.expiresAt,
|
||||
createdAt: result.token.createdAt,
|
||||
warning: 'Store this token securely. It will not be shown again.',
|
||||
@@ -121,6 +159,7 @@ export class TokenApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tokens/:id
|
||||
* Allows revoking personal tokens or org tokens (if org admin)
|
||||
*/
|
||||
public async revoke(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -130,12 +169,27 @@ export class TokenApi {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
// Get the token to verify ownership
|
||||
const tokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
const token = tokens.find((t) => t.id === id);
|
||||
// First check if it's a personal token
|
||||
const userTokens = await this.tokenService.getUserTokens(ctx.actor.userId);
|
||||
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) {
|
||||
// Either doesn't exist or doesn't belong to user
|
||||
return { status: 404, body: { error: 'Token not found' } };
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { RepositoryApi } from './handlers/repository.api.ts';
|
||||
import { PackageApi } from './handlers/package.api.ts';
|
||||
import { TokenApi } from './handlers/token.api.ts';
|
||||
import { AuditApi } from './handlers/audit.api.ts';
|
||||
import { AdminAuthApi } from './handlers/admin.auth.api.ts';
|
||||
import { OAuthApi } from './handlers/oauth.api.ts';
|
||||
|
||||
export interface IApiContext {
|
||||
request: Request;
|
||||
@@ -57,6 +59,8 @@ export class ApiRouter {
|
||||
private packageApi: PackageApi;
|
||||
private tokenApi: TokenApi;
|
||||
private auditApi: AuditApi;
|
||||
private adminAuthApi: AdminAuthApi;
|
||||
private oauthApi: OAuthApi;
|
||||
|
||||
constructor() {
|
||||
this.authService = new AuthService();
|
||||
@@ -71,6 +75,8 @@ export class ApiRouter {
|
||||
this.packageApi = new PackageApi(this.permissionService);
|
||||
this.tokenApi = new TokenApi(this.tokenService);
|
||||
this.auditApi = new AuditApi(this.permissionService);
|
||||
this.adminAuthApi = new AdminAuthApi();
|
||||
this.oauthApi = new OAuthApi();
|
||||
|
||||
this.registerRoutes();
|
||||
}
|
||||
@@ -124,6 +130,22 @@ export class ApiRouter {
|
||||
|
||||
// Audit routes
|
||||
this.addRoute('GET', '/api/v1/audit', (ctx) => this.auditApi.query(ctx));
|
||||
|
||||
// OAuth/External auth routes (public)
|
||||
this.addRoute('GET', '/api/v1/auth/providers', (ctx) => this.oauthApi.listProviders(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/oauth/:id/authorize', (ctx) => this.oauthApi.authorize(ctx));
|
||||
this.addRoute('GET', '/api/v1/auth/oauth/:id/callback', (ctx) => this.oauthApi.callback(ctx));
|
||||
this.addRoute('POST', '/api/v1/auth/ldap/:id/login', (ctx) => this.oauthApi.ldapLogin(ctx));
|
||||
|
||||
// Admin auth routes (platform admin only)
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.listProviders(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers', (ctx) => this.adminAuthApi.createProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.getProvider(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.updateProvider(ctx));
|
||||
this.addRoute('DELETE', '/api/v1/admin/auth/providers/:id', (ctx) => this.adminAuthApi.deleteProvider(ctx));
|
||||
this.addRoute('POST', '/api/v1/admin/auth/providers/:id/test', (ctx) => this.adminAuthApi.testProvider(ctx));
|
||||
this.addRoute('GET', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.getSettings(ctx));
|
||||
this.addRoute('PUT', '/api/v1/admin/auth/settings', (ctx) => this.adminAuthApi.updateSettings(ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface IOrganization {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
website?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
plan: TOrganizationPlan;
|
||||
settings: IOrganizationSettings;
|
||||
billingEmail?: string;
|
||||
@@ -143,6 +146,8 @@ export type TTokenAction = 'read' | 'write' | 'delete' | '*';
|
||||
export interface IApiToken {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
createdById?: string; // Who created the token (for audit)
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
@@ -276,7 +281,145 @@ export interface ICreateRepositoryDto {
|
||||
|
||||
export interface ICreateTokenDto {
|
||||
name: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// External Authentication Types
|
||||
// =============================================================================
|
||||
|
||||
export type TAuthProviderType = 'oidc' | 'ldap';
|
||||
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecretEncrypted: string; // AES-256-GCM encrypted
|
||||
issuer: string; // OIDC issuer URL (used for discovery)
|
||||
authorizationUrl?: string; // Override discovery
|
||||
tokenUrl?: string; // Override discovery
|
||||
userInfoUrl?: string; // Override discovery
|
||||
scopes: string[];
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
serverUrl: string; // ldap:// or ldaps://
|
||||
bindDn: string;
|
||||
bindPasswordEncrypted: string; // AES-256-GCM encrypted
|
||||
baseDn: string;
|
||||
userSearchFilter: string; // e.g., "(uid={{username}})" or "(sAMAccountName={{username}})"
|
||||
tlsEnabled: boolean;
|
||||
tlsCaCert?: string;
|
||||
}
|
||||
|
||||
export interface IAttributeMapping {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface IProvisioningSettings {
|
||||
jitEnabled: boolean; // Create user on first login
|
||||
autoLinkByEmail: boolean; // Link to existing user by email match
|
||||
allowedEmailDomains?: string[]; // Restrict to specific domains
|
||||
}
|
||||
|
||||
export interface IAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
status: TAuthProviderStatus;
|
||||
priority: number;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping: IAttributeMapping;
|
||||
provisioning: IProvisioningSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdById: string;
|
||||
lastTestedAt?: Date;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface IExternalIdentity {
|
||||
id: string;
|
||||
userId: string;
|
||||
providerId: string;
|
||||
externalId: string;
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
lastLoginAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface IPlatformAuthSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformSettings {
|
||||
id: string;
|
||||
auth: IPlatformAuthSettings;
|
||||
updatedAt: Date;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
// External auth flow types
|
||||
export interface IExternalUserInfo {
|
||||
externalId: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string[];
|
||||
rawAttributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IExternalAuthResult {
|
||||
success: boolean;
|
||||
user?: IUser;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
sessionId?: string;
|
||||
isNewUser?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Admin DTOs
|
||||
export interface ICreateAuthProviderDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
}
|
||||
|
||||
export interface IUpdateAuthProviderDto {
|
||||
displayName?: string;
|
||||
status?: TAuthProviderStatus;
|
||||
priority?: number;
|
||||
oauthConfig?: Partial<IOAuthConfig>;
|
||||
ldapConfig?: Partial<ILdapConfig>;
|
||||
attributeMapping?: Partial<IAttributeMapping>;
|
||||
provisioning?: Partial<IProvisioningSettings>;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export class ApiToken extends plugins.smartdata.SmartDataDbDoc<ApiToken, ApiToke
|
||||
@plugins.smartdata.index()
|
||||
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()
|
||||
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)
|
||||
*/
|
||||
|
||||
252
ts/models/auth.provider.ts
Normal file
252
ts/models/auth.provider.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Authentication Provider model for Stack.Gallery Registry
|
||||
* Stores OAuth/OIDC and LDAP provider configurations
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
TAuthProviderType,
|
||||
TAuthProviderStatus,
|
||||
IOAuthConfig,
|
||||
ILdapConfig,
|
||||
IAttributeMapping,
|
||||
IProvisioningSettings,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_ATTRIBUTE_MAPPING: IAttributeMapping = {
|
||||
email: 'email',
|
||||
username: 'preferred_username',
|
||||
displayName: 'name',
|
||||
};
|
||||
|
||||
const DEFAULT_PROVISIONING: IProvisioningSettings = {
|
||||
jitEnabled: true,
|
||||
autoLinkByEmail: true,
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class AuthProvider
|
||||
extends plugins.smartdata.SmartDataDbDoc<AuthProvider, AuthProvider>
|
||||
implements IAuthProvider
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index({ unique: true })
|
||||
public name: string = ''; // URL-safe slug identifier
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.searchable()
|
||||
public displayName: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public type: TAuthProviderType = 'oidc';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public status: TAuthProviderStatus = 'disabled';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 100; // Lower = shown first in UI
|
||||
|
||||
// Type-specific config (only one should be populated based on type)
|
||||
@plugins.smartdata.svDb()
|
||||
public oauthConfig?: IOAuthConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ldapConfig?: ILdapConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public attributeMapping: IAttributeMapping = DEFAULT_ATTRIBUTE_MAPPING;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisioning: IProvisioningSettings = DEFAULT_PROVISIONING;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdById: string = '';
|
||||
|
||||
// Connection test tracking
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestResult?: 'success' | 'failure';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestError?: string;
|
||||
|
||||
/**
|
||||
* Find provider by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<AuthProvider | null> {
|
||||
return await AuthProvider.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find provider by name (slug)
|
||||
*/
|
||||
public static async findByName(name: string): Promise<AuthProvider | null> {
|
||||
return await AuthProvider.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active providers (for login page)
|
||||
*/
|
||||
public static async getActiveProviders(): Promise<AuthProvider[]> {
|
||||
const providers = await AuthProvider.getInstances({ status: 'active' });
|
||||
return providers.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all providers (for admin)
|
||||
*/
|
||||
public static async getAllProviders(): Promise<AuthProvider[]> {
|
||||
const providers = await AuthProvider.getInstances({});
|
||||
return providers.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth/OIDC provider
|
||||
*/
|
||||
public static async createOAuthProvider(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
oauthConfig: IOAuthConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
createdById: string;
|
||||
}): Promise<AuthProvider> {
|
||||
const provider = new AuthProvider();
|
||||
provider.id = await AuthProvider.getNewId();
|
||||
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
provider.displayName = data.displayName;
|
||||
provider.type = 'oidc';
|
||||
provider.status = 'disabled';
|
||||
provider.oauthConfig = data.oauthConfig;
|
||||
provider.attributeMapping = data.attributeMapping || DEFAULT_ATTRIBUTE_MAPPING;
|
||||
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
|
||||
provider.createdById = data.createdById;
|
||||
provider.createdAt = new Date();
|
||||
provider.updatedAt = new Date();
|
||||
await provider.save();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new LDAP provider
|
||||
*/
|
||||
public static async createLdapProvider(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
ldapConfig: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
createdById: string;
|
||||
}): Promise<AuthProvider> {
|
||||
const provider = new AuthProvider();
|
||||
provider.id = await AuthProvider.getNewId();
|
||||
provider.name = data.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
provider.displayName = data.displayName;
|
||||
provider.type = 'ldap';
|
||||
provider.status = 'disabled';
|
||||
provider.ldapConfig = data.ldapConfig;
|
||||
provider.attributeMapping = data.attributeMapping || {
|
||||
email: 'mail',
|
||||
username: 'uid',
|
||||
displayName: 'displayName',
|
||||
};
|
||||
provider.provisioning = data.provisioning || DEFAULT_PROVISIONING;
|
||||
provider.createdById = data.createdById;
|
||||
provider.createdAt = new Date();
|
||||
provider.updatedAt = new Date();
|
||||
await provider.save();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection test result
|
||||
*/
|
||||
public async updateTestResult(success: boolean, error?: string): Promise<void> {
|
||||
this.lastTestedAt = new Date();
|
||||
this.lastTestResult = success ? 'success' : 'failure';
|
||||
this.lastTestError = error;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Update timestamps before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.updatedAt = new Date();
|
||||
if (!this.id) {
|
||||
this.id = await AuthProvider.getNewId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public info (for login page - no secrets)
|
||||
*/
|
||||
public toPublicInfo(): {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
} {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
displayName: this.displayName,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin info (secrets masked)
|
||||
*/
|
||||
public toAdminInfo(): Record<string, unknown> {
|
||||
const info: Record<string, unknown> = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
displayName: this.displayName,
|
||||
type: this.type,
|
||||
status: this.status,
|
||||
priority: this.priority,
|
||||
attributeMapping: this.attributeMapping,
|
||||
provisioning: this.provisioning,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
createdById: this.createdById,
|
||||
lastTestedAt: this.lastTestedAt,
|
||||
lastTestResult: this.lastTestResult,
|
||||
lastTestError: this.lastTestError,
|
||||
};
|
||||
|
||||
// Mask secrets in config
|
||||
if (this.oauthConfig) {
|
||||
info.oauthConfig = {
|
||||
...this.oauthConfig,
|
||||
clientSecretEncrypted: this.oauthConfig.clientSecretEncrypted ? '********' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.ldapConfig) {
|
||||
info.ldapConfig = {
|
||||
...this.ldapConfig,
|
||||
bindPasswordEncrypted: this.ldapConfig.bindPasswordEncrypted ? '********' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
142
ts/models/external.identity.ts
Normal file
142
ts/models/external.identity.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* External Identity model for Stack.Gallery Registry
|
||||
* Links users to external authentication provider accounts
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IExternalIdentity } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class ExternalIdentity
|
||||
extends plugins.smartdata.SmartDataDbDoc<ExternalIdentity, ExternalIdentity>
|
||||
implements IExternalIdentity
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public userId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public providerId: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public externalId: string = ''; // ID from the external provider
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalEmail?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalUsername?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public rawAttributes?: Record<string, unknown>;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastLoginAt?: Date;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Find by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by provider and external ID (unique combination)
|
||||
*/
|
||||
public static async findByExternalId(
|
||||
providerId: string,
|
||||
externalId: string
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ providerId, externalId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all identities for a user
|
||||
*/
|
||||
public static async findByUserId(userId: string): Promise<ExternalIdentity[]> {
|
||||
return await ExternalIdentity.getInstances({ userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find identity by user and provider
|
||||
*/
|
||||
public static async findByUserAndProvider(
|
||||
userId: string,
|
||||
providerId: string
|
||||
): Promise<ExternalIdentity | null> {
|
||||
return await ExternalIdentity.getInstance({ userId, providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new external identity link
|
||||
*/
|
||||
public static async createIdentity(data: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
externalId: string;
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
}): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked
|
||||
const existing = await ExternalIdentity.findByExternalId(data.providerId, data.externalId);
|
||||
if (existing) {
|
||||
throw new Error('This external account is already linked to a user');
|
||||
}
|
||||
|
||||
const identity = new ExternalIdentity();
|
||||
identity.id = await ExternalIdentity.getNewId();
|
||||
identity.userId = data.userId;
|
||||
identity.providerId = data.providerId;
|
||||
identity.externalId = data.externalId;
|
||||
identity.externalEmail = data.externalEmail;
|
||||
identity.externalUsername = data.externalUsername;
|
||||
identity.rawAttributes = data.rawAttributes;
|
||||
identity.lastLoginAt = new Date();
|
||||
identity.createdAt = new Date();
|
||||
await identity.save();
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login time
|
||||
*/
|
||||
public async updateLastLogin(): Promise<void> {
|
||||
this.lastLoginAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update attributes from provider
|
||||
*/
|
||||
public async updateAttributes(data: {
|
||||
externalEmail?: string;
|
||||
externalUsername?: string;
|
||||
rawAttributes?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
if (data.externalEmail !== undefined) this.externalEmail = data.externalEmail;
|
||||
if (data.externalUsername !== undefined) this.externalUsername = data.externalUsername;
|
||||
if (data.rawAttributes !== undefined) this.rawAttributes = data.rawAttributes;
|
||||
this.lastLoginAt = new Date();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Generate ID before save
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
if (!this.id) {
|
||||
this.id = await ExternalIdentity.getNewId();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,8 @@ export { Package } from './package.ts';
|
||||
export { ApiToken } from './apitoken.ts';
|
||||
export { Session } from './session.ts';
|
||||
export { AuditLog } from './auditlog.ts';
|
||||
|
||||
// External authentication models
|
||||
export { AuthProvider } from './auth.provider.ts';
|
||||
export { ExternalIdentity } from './external.identity.ts';
|
||||
export { PlatformSettings } from './platform.settings.ts';
|
||||
|
||||
@@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
@plugins.smartdata.svDb()
|
||||
public avatarUrl?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public website?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public isPublic: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public memberCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public plan: TOrganizationPlan = 'free';
|
||||
@@ -79,11 +88,11 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
description?: string;
|
||||
createdById: string;
|
||||
}): Promise<Organization> {
|
||||
// Validate name (URL-safe)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
// Validate name (URL-safe, allows dots for domain-like names)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens'
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +109,13 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Organization | null> {
|
||||
return await Organization.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by name (slug)
|
||||
*/
|
||||
|
||||
90
ts/models/platform.settings.ts
Normal file
90
ts/models/platform.settings.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Platform Settings model for Stack.Gallery Registry
|
||||
* Singleton model storing global platform configuration
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IPlatformSettings, IPlatformAuthSettings } from '../interfaces/auth.interfaces.ts';
|
||||
import { db } from './db.ts';
|
||||
|
||||
const DEFAULT_AUTH_SETTINGS: IPlatformAuthSettings = {
|
||||
localAuthEnabled: true,
|
||||
allowUserRegistration: true,
|
||||
sessionDurationMinutes: 10080, // 7 days
|
||||
};
|
||||
|
||||
@plugins.smartdata.Collection(() => db)
|
||||
export class PlatformSettings
|
||||
extends plugins.smartdata.SmartDataDbDoc<PlatformSettings, PlatformSettings>
|
||||
implements IPlatformSettings
|
||||
{
|
||||
@plugins.smartdata.unI()
|
||||
public id: string = 'singleton';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public auth: IPlatformAuthSettings = DEFAULT_AUTH_SETTINGS;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedById?: string;
|
||||
|
||||
/**
|
||||
* Get the singleton settings instance (creates if not exists)
|
||||
*/
|
||||
public static async get(): Promise<PlatformSettings> {
|
||||
let settings = await PlatformSettings.getInstance({ id: 'singleton' });
|
||||
if (!settings) {
|
||||
settings = new PlatformSettings();
|
||||
settings.id = 'singleton';
|
||||
settings.auth = DEFAULT_AUTH_SETTINGS;
|
||||
settings.updatedAt = new Date();
|
||||
await settings.save();
|
||||
console.log('[PlatformSettings] Created default settings');
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update auth settings
|
||||
*/
|
||||
public async updateAuthSettings(
|
||||
settings: Partial<IPlatformAuthSettings>,
|
||||
updatedById?: string
|
||||
): Promise<void> {
|
||||
this.auth = { ...this.auth, ...settings };
|
||||
this.updatedAt = new Date();
|
||||
this.updatedById = updatedById;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local auth is enabled
|
||||
*/
|
||||
public isLocalAuthEnabled(): boolean {
|
||||
return this.auth.localAuthEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registration is allowed
|
||||
*/
|
||||
public isRegistrationAllowed(): boolean {
|
||||
return this.auth.allowUserRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default provider ID (for auto-redirect)
|
||||
*/
|
||||
public getDefaultProviderId(): string | undefined {
|
||||
return this.auth.defaultProviderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Ensure singleton ID
|
||||
*/
|
||||
public async beforeSave(): Promise<void> {
|
||||
this.id = 'singleton';
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,16 @@ export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implement
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: Date = new Date();
|
||||
|
||||
// External authentication fields
|
||||
@plugins.smartdata.svDb()
|
||||
public externalIdentityIds: string[] = [];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public canUseLocalAuth: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisionedByProviderId?: string; // Provider that JIT-created this user
|
||||
|
||||
/**
|
||||
* Create a new user instance
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,8 @@ import { initDb, closeDb, isDbConnected } from './models/db.ts';
|
||||
import { StackGalleryAuthProvider } from './providers/auth.provider.ts';
|
||||
import { StackGalleryStorageHooks } from './providers/storage.provider.ts';
|
||||
import { ApiRouter } from './api/router.ts';
|
||||
import { getEmbeddedFile } from './embedded-ui.generated.ts';
|
||||
import { ReloadSocketManager } from './reload-socket.ts';
|
||||
|
||||
export interface IRegistryConfig {
|
||||
// MongoDB configuration
|
||||
@@ -41,6 +43,7 @@ export class StackGalleryRegistry {
|
||||
private authProvider: StackGalleryAuthProvider | null = null;
|
||||
private storageHooks: StackGalleryStorageHooks | null = null;
|
||||
private apiRouter: ApiRouter | null = null;
|
||||
private reloadSocket: ReloadSocketManager | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: IRegistryConfig) {
|
||||
@@ -110,6 +113,9 @@ export class StackGalleryRegistry {
|
||||
this.apiRouter = new ApiRouter();
|
||||
console.log('[StackGalleryRegistry] API router initialized');
|
||||
|
||||
// Initialize reload socket for hot reload
|
||||
this.reloadSocket = new ReloadSocketManager();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[StackGalleryRegistry] Initialization complete');
|
||||
}
|
||||
@@ -182,57 +188,41 @@ 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
|
||||
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> {
|
||||
const uiDistPath = './ui/dist/registry-ui/browser';
|
||||
private serveStaticFile(path: string): Response {
|
||||
const filePath = path === '/' ? '/index.html' : path;
|
||||
|
||||
// Map path to file
|
||||
let filePath = path === '/' ? '/index.html' : path;
|
||||
|
||||
// Content type mapping
|
||||
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, {
|
||||
// Get embedded file
|
||||
const embeddedFile = getEmbeddedFile(filePath);
|
||||
if (embeddedFile) {
|
||||
return new Response(embeddedFile.data, {
|
||||
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, {
|
||||
}
|
||||
|
||||
// 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' },
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Authentication Strategy Interface
|
||||
* Base interface for OAuth/OIDC and LDAP authentication strategies
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
code: string;
|
||||
state: string;
|
||||
error?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
export interface IAuthStrategy {
|
||||
/**
|
||||
* Get the authorization URL for OAuth/OIDC flow
|
||||
* @param state - CSRF state token
|
||||
* @param nonce - Optional nonce for OIDC
|
||||
* @returns Authorization URL to redirect user to
|
||||
*/
|
||||
getAuthorizationUrl?(state: string, nonce?: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Handle OAuth/OIDC callback
|
||||
* @param data - Callback data including code and state
|
||||
* @returns External user info from the provider
|
||||
*/
|
||||
handleCallback?(data: IOAuthCallbackData): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Authenticate with credentials (LDAP)
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @returns External user info if authentication succeeds
|
||||
*/
|
||||
authenticateCredentials?(username: string, password: string): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Test connection to the provider
|
||||
* @returns Connection test result
|
||||
*/
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
}
|
||||
8
ts/services/auth/strategies/index.ts
Normal file
8
ts/services/auth/strategies/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Auth Strategy exports
|
||||
*/
|
||||
|
||||
export type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
export { OAuthStrategy } from './oauth.strategy.ts';
|
||||
export { LdapStrategy } from './ldap.strategy.ts';
|
||||
export { AuthStrategyFactory } from './strategy.factory.ts';
|
||||
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* LDAP Authentication Strategy
|
||||
* Handles LDAP/Active Directory authentication
|
||||
*
|
||||
* Note: This is a basic implementation. For production use with actual LDAP,
|
||||
* you may need to integrate with a Deno-compatible LDAP library.
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
|
||||
// LDAP entry type (simplified)
|
||||
interface ILdapEntry {
|
||||
dn: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Authenticate user with LDAP credentials
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
throw new Error('LDAP config not found');
|
||||
}
|
||||
|
||||
// Escape username to prevent LDAP injection
|
||||
const escapedUsername = this.escapeLdap(username);
|
||||
|
||||
// Build user search filter
|
||||
const userFilter = config.userSearchFilter.replace('{{username}}', escapedUsername);
|
||||
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Perform LDAP authentication
|
||||
// This is a placeholder - actual implementation would use an LDAP library
|
||||
const userEntry = await this.ldapBind(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
return this.mapAttributes(userEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
const start = Date.now();
|
||||
const config = this.provider.ldapConfig;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: 'LDAP config not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Test connection by binding with service account
|
||||
await this.testLdapConnection(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Date.now() - start,
|
||||
serverInfo: {
|
||||
serverUrl: config.serverUrl,
|
||||
baseDn: config.baseDn,
|
||||
tlsEnabled: config.tlsEnabled,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LDAP characters to prevent injection
|
||||
*/
|
||||
private escapeLdap(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\x00/g, '\\00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform LDAP bind and search
|
||||
* This is a placeholder implementation - actual LDAP would require a library
|
||||
*/
|
||||
private async ldapBind(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
// 2. Bind with service account (bindDn/bindPassword)
|
||||
// 3. Search for user with userFilter
|
||||
// 4. Re-bind with user's DN and password to verify
|
||||
// 5. Return user entry if successful
|
||||
|
||||
// For now, we throw an error indicating LDAP needs to be configured
|
||||
// This allows the structure to be in place while the actual LDAP library
|
||||
// integration can be done separately
|
||||
|
||||
console.log('[LdapStrategy] LDAP auth attempt:', {
|
||||
serverUrl,
|
||||
baseDn,
|
||||
userFilter,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
private async testLdapConnection(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
|
||||
console.log('[LdapStrategy] Testing LDAP connection:', {
|
||||
serverUrl,
|
||||
bindDn,
|
||||
baseDn,
|
||||
});
|
||||
|
||||
// For now, check if server URL is valid
|
||||
if (!serverUrl.startsWith('ldap://') && !serverUrl.startsWith('ldaps://')) {
|
||||
throw new Error('Invalid LDAP server URL. Must start with ldap:// or ldaps://');
|
||||
}
|
||||
|
||||
// In a real implementation, we would actually connect here
|
||||
// For now, we just validate the configuration
|
||||
if (!bindDn || !bindPassword || !baseDn) {
|
||||
throw new Error('Missing required LDAP configuration');
|
||||
}
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map LDAP attributes to standard user info
|
||||
*/
|
||||
private mapAttributes(entry: ILdapEntry): IExternalUserInfo {
|
||||
const mapping = this.provider.attributeMapping;
|
||||
|
||||
// Get external ID (typically uid or sAMAccountName)
|
||||
const externalId = String(entry[mapping.username] || entry.dn);
|
||||
|
||||
// Get email
|
||||
const email = entry[mapping.email];
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('Email not found in LDAP entry');
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LDAP group membership
|
||||
*/
|
||||
private parseGroups(memberOf: unknown): string[] {
|
||||
if (!memberOf) return [];
|
||||
|
||||
if (Array.isArray(memberOf)) {
|
||||
return memberOf.map((dn) => this.extractCnFromDn(String(dn)));
|
||||
}
|
||||
|
||||
return [this.extractCnFromDn(String(memberOf))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CN (Common Name) from a DN (Distinguished Name)
|
||||
*/
|
||||
private extractCnFromDn(dn: string): string {
|
||||
const match = dn.match(/^CN=([^,]+)/i);
|
||||
return match ? match[1] : dn;
|
||||
}
|
||||
}
|
||||
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* OAuth/OIDC Authentication Strategy
|
||||
* Handles OAuth 2.0 and OpenID Connect flows
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
|
||||
interface ITokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface IOIDCDiscovery {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri?: string;
|
||||
scopes_supported?: string[];
|
||||
}
|
||||
|
||||
export class OAuthStrategy implements IAuthStrategy {
|
||||
private discoveryCache: IOIDCDiscovery | null = null;
|
||||
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for initiating OAuth flow
|
||||
*/
|
||||
public async getAuthorizationUrl(state: string, nonce?: string): Promise<string> {
|
||||
const config = this.provider.oauthConfig;
|
||||
if (!config) {
|
||||
throw new Error('OAuth config not found');
|
||||
}
|
||||
|
||||
// Get authorization URL from config or discovery
|
||||
let authorizationUrl = config.authorizationUrl;
|
||||
if (!authorizationUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
authorizationUrl = discovery.authorization_endpoint;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: config.scopes.join(' '),
|
||||
state,
|
||||
});
|
||||
|
||||
// Add nonce for OIDC
|
||||
if (nonce) {
|
||||
params.set('nonce', nonce);
|
||||
}
|
||||
|
||||
return `${authorizationUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for tokens and get user info
|
||||
*/
|
||||
public async handleCallback(data: IOAuthCallbackData): Promise<IExternalUserInfo> {
|
||||
if (data.error) {
|
||||
throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`);
|
||||
}
|
||||
|
||||
const config = this.provider.oauthConfig;
|
||||
if (!config) {
|
||||
throw new Error('OAuth config not found');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await this.exchangeCodeForTokens(data.code);
|
||||
|
||||
// Get user info
|
||||
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
||||
|
||||
// Map attributes according to provider config
|
||||
return this.mapAttributes(userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection by fetching OIDC discovery document
|
||||
*/
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
const start = Date.now();
|
||||
const config = this.provider.oauthConfig;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: 'OAuth config not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const discovery = await this.getDiscovery();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Date.now() - start,
|
||||
serverInfo: {
|
||||
issuer: discovery.issuer,
|
||||
scopes_supported: discovery.scopes_supported,
|
||||
has_userinfo: !!discovery.userinfo_endpoint,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
private async exchangeCodeForTokens(code: string): Promise<ITokenResponse> {
|
||||
const config = this.provider.oauthConfig!;
|
||||
|
||||
// Get token URL from config or discovery
|
||||
let tokenUrl = config.tokenUrl;
|
||||
if (!tokenUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
tokenUrl = discovery.token_endpoint;
|
||||
}
|
||||
|
||||
// Decrypt client secret
|
||||
const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted);
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.callbackUrl,
|
||||
client_id: config.clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from the provider
|
||||
*/
|
||||
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown>> {
|
||||
const config = this.provider.oauthConfig!;
|
||||
|
||||
// Get userinfo URL from config or discovery
|
||||
let userInfoUrl = config.userInfoUrl;
|
||||
if (!userInfoUrl) {
|
||||
const discovery = await this.getDiscovery();
|
||||
userInfoUrl = discovery.userinfo_endpoint;
|
||||
}
|
||||
|
||||
if (!userInfoUrl) {
|
||||
throw new Error('UserInfo endpoint not found');
|
||||
}
|
||||
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OIDC discovery document
|
||||
*/
|
||||
private async getDiscovery(): Promise<IOIDCDiscovery> {
|
||||
if (this.discoveryCache) {
|
||||
return this.discoveryCache;
|
||||
}
|
||||
|
||||
const config = this.provider.oauthConfig!;
|
||||
const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`;
|
||||
|
||||
const response = await fetch(discoveryUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC discovery failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this.discoveryCache = await response.json();
|
||||
return this.discoveryCache!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map provider attributes to standard user info
|
||||
*/
|
||||
private mapAttributes(rawInfo: Record<string, unknown>): IExternalUserInfo {
|
||||
const mapping = this.provider.attributeMapping;
|
||||
|
||||
// Get external ID (sub for OIDC, or id for OAuth2)
|
||||
const externalId = String(rawInfo.sub || rawInfo.id || '');
|
||||
if (!externalId) {
|
||||
throw new Error('External ID not found in user info');
|
||||
}
|
||||
|
||||
// Get email
|
||||
const email = rawInfo[mapping.email];
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('Email not found in user info');
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: rawInfo[mapping.username]
|
||||
? String(rawInfo[mapping.username])
|
||||
: undefined,
|
||||
displayName: rawInfo[mapping.displayName]
|
||||
? String(rawInfo[mapping.displayName])
|
||||
: undefined,
|
||||
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||||
? String(rawInfo[mapping.avatarUrl])
|
||||
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||||
groups: mapping.groups && rawInfo[mapping.groups]
|
||||
? (Array.isArray(rawInfo[mapping.groups])
|
||||
? (rawInfo[mapping.groups] as string[])
|
||||
: [String(rawInfo[mapping.groups])])
|
||||
: undefined,
|
||||
rawAttributes: rawInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Auth Strategy Factory
|
||||
* Creates the appropriate authentication strategy based on provider type
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
import { OAuthStrategy } from './oauth.strategy.ts';
|
||||
import { LdapStrategy } from './ldap.strategy.ts';
|
||||
|
||||
export class AuthStrategyFactory {
|
||||
constructor(private cryptoService: CryptoService) {}
|
||||
|
||||
/**
|
||||
* Create the appropriate strategy for a provider
|
||||
*/
|
||||
public create(provider: AuthProvider): IAuthStrategy {
|
||||
switch (provider.type) {
|
||||
case 'oidc':
|
||||
return new OAuthStrategy(provider, this.cryptoService);
|
||||
case 'ldap':
|
||||
return new LdapStrategy(provider, this.cryptoService);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${provider.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
ts/services/crypto.service.ts
Normal file
178
ts/services/crypto.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Crypto Service for Stack.Gallery Registry
|
||||
* Handles AES-256-GCM encryption/decryption of secrets
|
||||
*/
|
||||
|
||||
export class CryptoService {
|
||||
private masterKey: CryptoKey | null = null;
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the crypto service with the master key
|
||||
* The key should be a 64-character hex string (32 bytes = 256 bits)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
} else {
|
||||
if (keyHex.length !== 64) {
|
||||
throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
this.masterKey = await this.importKey(keyHex);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string
|
||||
* Returns format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async encrypt(plaintext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
// Generate random IV (12 bytes for AES-GCM)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Encode plaintext to bytes
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encoded
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
const ivBase64 = this.bytesToBase64(iv);
|
||||
const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted));
|
||||
|
||||
return `${ivBase64}:${ciphertextBase64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string
|
||||
* Expects format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async decrypt(ciphertext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid ciphertext format');
|
||||
}
|
||||
|
||||
const [ivBase64, encryptedBase64] = parts;
|
||||
|
||||
// Decode from base64
|
||||
const iv = this.base64ToBytes(ivBase64);
|
||||
const encrypted = this.base64ToBytes(encryptedBase64);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encrypted
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is already encrypted (contains the iv:ciphertext format)
|
||||
*/
|
||||
public isEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') return false;
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
// Check if both parts look like base64
|
||||
try {
|
||||
this.base64ToBytes(parts[0]);
|
||||
this.base64ToBytes(parts[1]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a hex key as CryptoKey
|
||||
*/
|
||||
private async importKey(keyHex: string): Promise<CryptoKey> {
|
||||
const keyBytes = this.hexToBytes(keyHex);
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to hex string
|
||||
*/
|
||||
private bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to bytes
|
||||
*/
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to base64
|
||||
*/
|
||||
private bytesToBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 to bytes
|
||||
*/
|
||||
private base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new encryption key (for setup)
|
||||
* Returns a 64-character hex string
|
||||
*/
|
||||
public static generateKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cryptoService = new CryptoService();
|
||||
568
ts/services/external.auth.service.ts
Normal file
568
ts/services/external.auth.service.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* External Auth Service for Stack.Gallery Registry
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
returnUrl?: string;
|
||||
nonce: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class ExternalAuthService {
|
||||
private strategyFactory: AuthStrategyFactory;
|
||||
private authService: AuthService;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor() {
|
||||
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||||
this.authService = new AuthService();
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - returns authorization URL and state
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
if (provider.status !== 'active') {
|
||||
throw new Error('Provider is not active');
|
||||
}
|
||||
|
||||
if (provider.type !== 'oidc') {
|
||||
throw new Error('Provider is not an OAuth/OIDC provider');
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.getAuthorizationUrl) {
|
||||
throw new Error('Provider does not support OAuth flow');
|
||||
}
|
||||
|
||||
// Generate state with encoded data
|
||||
const state = await this.generateState(providerId, returnUrl);
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||||
|
||||
return { authUrl, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for user and create session
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
if (!stateData) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_STATE',
|
||||
errorMessage: 'Invalid or expired state',
|
||||
};
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = await AuthProvider.findById(stateData.providerId);
|
||||
if (!provider || provider.status !== 'active') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_INACTIVE',
|
||||
errorMessage: 'Provider not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.handleCallback) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support OAuth callback',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens using the existing AuthService approach
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with LDAP credentials
|
||||
*/
|
||||
public async authenticateLdap(
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Invalid LDAP provider',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.authenticateCredentials) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support credential authentication',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
username,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'AUTH_FAILED',
|
||||
errorMessage: 'Invalid credentials',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'ldap',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an external provider to an existing user
|
||||
*/
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
if (existing) {
|
||||
if (existing.userId === userId) {
|
||||
// Already linked to this user, just update
|
||||
await existing.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
throw new Error('This external account is already linked to another user');
|
||||
}
|
||||
|
||||
// Create new identity link
|
||||
const identity = await ExternalIdentity.createIdentity({
|
||||
userId,
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
|
||||
// Update user's external identity IDs
|
||||
const user = await User.findById(userId);
|
||||
if (user) {
|
||||
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'link_provider',
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an external provider from a user
|
||||
*/
|
||||
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||||
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure user still has another auth method
|
||||
const user = await User.findById(userId);
|
||||
if (!user) return false;
|
||||
|
||||
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||||
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||||
|
||||
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||||
throw new Error('Cannot unlink last authentication method');
|
||||
}
|
||||
|
||||
// Remove identity
|
||||
await identity.delete();
|
||||
|
||||
// Update user's external identity IDs
|
||||
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'unlink_provider',
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
error: 'Provider not found',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
const result = await strategy.testConnection();
|
||||
|
||||
// Update provider test status
|
||||
await provider.updateTestResult(result.success, result.error);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create user from external authentication
|
||||
*/
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
const user = await User.findById(existingIdentity.userId);
|
||||
if (user) {
|
||||
// Update identity with latest info
|
||||
await existingIdentity.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return { user, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to link by email if enabled
|
||||
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||||
const existingUser = await User.findByEmail(externalUser.email);
|
||||
if (existingUser) {
|
||||
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||||
return { user: existingUser, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create new user if JIT is enabled
|
||||
if (!provider.provisioning.jitEnabled) {
|
||||
throw new Error('User not found and JIT provisioning is disabled');
|
||||
}
|
||||
|
||||
// Check domain restrictions
|
||||
if (provider.provisioning.allowedEmailDomains?.length) {
|
||||
const domain = externalUser.email.split('@')[1];
|
||||
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||||
throw new Error(`Email domain ${domain} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique username
|
||||
let username = externalUser.username || externalUser.email.split('@')[0];
|
||||
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Ensure username is unique
|
||||
let counter = 0;
|
||||
let finalUsername = username;
|
||||
while (await User.findByUsername(finalUsername)) {
|
||||
counter++;
|
||||
finalUsername = `${username}${counter}`;
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = externalUser.email.toLowerCase();
|
||||
user.username = finalUsername;
|
||||
user.displayName = externalUser.displayName || finalUsername;
|
||||
user.avatarUrl = externalUser.avatarUrl;
|
||||
user.status = 'active';
|
||||
user.emailVerified = true; // Trust the provider
|
||||
user.canUseLocalAuth = false; // No password set
|
||||
user.provisionedByProviderId = provider.id;
|
||||
user.passwordHash = ''; // No local password
|
||||
user.createdAt = new Date();
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Link external identity
|
||||
await this.linkProvider(user.id, provider.id, externalUser);
|
||||
|
||||
return { user, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state token
|
||||
*/
|
||||
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||||
const stateData: IOAuthState = {
|
||||
providerId,
|
||||
returnUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
|
||||
// Encode as base64
|
||||
return btoa(JSON.stringify(stateData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state token
|
||||
*/
|
||||
private async validateState(state: string): Promise<IOAuthState | null> {
|
||||
try {
|
||||
const stateData: IOAuthState = JSON.parse(atob(state));
|
||||
|
||||
// Check expiration
|
||||
if (stateData.exp < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 15 * 60; // 15 minutes
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign JWT token
|
||||
*/
|
||||
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const externalAuthService = new ExternalAuthService();
|
||||
@@ -9,6 +9,8 @@ import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface ICreateTokenOptions {
|
||||
userId: string;
|
||||
organizationId?: string; // For org-owned tokens
|
||||
createdById?: string; // Who created the token (defaults to userId)
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
@@ -52,6 +54,8 @@ export class TokenService {
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = options.userId;
|
||||
token.organizationId = options.organizationId;
|
||||
token.createdById = options.createdById || options.userId;
|
||||
token.name = options.name;
|
||||
token.tokenHash = tokenHash;
|
||||
token.tokenPrefix = tokenPrefix;
|
||||
@@ -150,6 +154,13 @@ export class TokenService {
|
||||
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
|
||||
*/
|
||||
@@ -175,6 +186,18 @@ export class TokenService {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { ReloadService } from './core/services/reload.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -7,4 +8,7 @@ import { RouterOutlet } from '@angular/router';
|
||||
imports: [RouterOutlet],
|
||||
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 { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { ReloadService } from './core/services/reload.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideExperimentalZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
ReloadService,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { adminGuard } from './core/guards/admin.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -7,6 +8,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'oauth-callback',
|
||||
loadComponent: () =>
|
||||
import('./features/oauth-callback/oauth-callback.component').then(
|
||||
(m) => m.OAuthCallbackComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
@@ -38,14 +46,14 @@ export const routes: Routes = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId',
|
||||
path: ':orgName',
|
||||
loadComponent: () =>
|
||||
import('./features/organizations/organization-detail.component').then(
|
||||
(m) => m.OrganizationDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':orgId/repositories/:repoId',
|
||||
path: ':orgName/repositories/:repoId',
|
||||
loadComponent: () =>
|
||||
import('./features/repositories/repository-detail.component').then(
|
||||
(m) => m.RepositoryDetailComponent
|
||||
@@ -86,6 +94,39 @@ export const routes: Routes = [
|
||||
(m) => m.SettingsComponent
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
canActivate: [adminGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'auth',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/auth-providers.component').then(
|
||||
(m) => m.AuthProvidersComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/new',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/providers/:id',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/auth-providers/provider-form.component').then(
|
||||
(m) => m.ProviderFormComponent
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
27
ui/src/app/core/guards/admin.guard.ts
Normal file
27
ui/src/app/core/guards/admin.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
export const adminGuard: CanActivateFn = async () => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
// First check if authenticated
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Try to refresh the token
|
||||
const refreshed = await authService.refreshAccessToken();
|
||||
if (!refreshed) {
|
||||
router.navigate(['/login']);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if admin
|
||||
if (!authService.isAdmin()) {
|
||||
// Not an admin, redirect to dashboard
|
||||
router.navigate(['/dashboard']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
141
ui/src/app/core/services/admin-auth.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Types
|
||||
export type TAuthProviderType = 'oidc' | 'ldap';
|
||||
export type TAuthProviderStatus = 'active' | 'disabled' | 'testing';
|
||||
|
||||
export interface IOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecretEncrypted: string;
|
||||
issuer: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
scopes: string[];
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface ILdapConfig {
|
||||
serverUrl: string;
|
||||
bindDn: string;
|
||||
bindPasswordEncrypted: string;
|
||||
baseDn: string;
|
||||
userSearchFilter: string;
|
||||
tlsEnabled: boolean;
|
||||
tlsCaCert?: string;
|
||||
}
|
||||
|
||||
export interface IAttributeMapping {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
groups?: string;
|
||||
}
|
||||
|
||||
export interface IProvisioningSettings {
|
||||
jitEnabled: boolean;
|
||||
autoLinkByEmail: boolean;
|
||||
allowedEmailDomains?: string[];
|
||||
}
|
||||
|
||||
export interface IAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
status: TAuthProviderStatus;
|
||||
priority: number;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping: IAttributeMapping;
|
||||
provisioning: IProvisioningSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdById: string;
|
||||
lastTestedAt?: string;
|
||||
lastTestResult?: 'success' | 'failure';
|
||||
lastTestError?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformAuthSettings {
|
||||
localAuthEnabled: boolean;
|
||||
allowUserRegistration: boolean;
|
||||
sessionDurationMinutes: number;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
export interface IPlatformSettings {
|
||||
id: string;
|
||||
auth: IPlatformAuthSettings;
|
||||
updatedAt: string;
|
||||
updatedById?: string;
|
||||
}
|
||||
|
||||
export interface IConnectionTestResult {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
serverInfo?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ICreateAuthProviderDto {
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: TAuthProviderType;
|
||||
oauthConfig?: IOAuthConfig;
|
||||
ldapConfig?: ILdapConfig;
|
||||
attributeMapping?: IAttributeMapping;
|
||||
provisioning?: IProvisioningSettings;
|
||||
}
|
||||
|
||||
export interface IUpdateAuthProviderDto {
|
||||
displayName?: string;
|
||||
status?: TAuthProviderStatus;
|
||||
priority?: number;
|
||||
oauthConfig?: Partial<IOAuthConfig>;
|
||||
ldapConfig?: Partial<ILdapConfig>;
|
||||
attributeMapping?: Partial<IAttributeMapping>;
|
||||
provisioning?: Partial<IProvisioningSettings>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminAuthService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Provider CRUD
|
||||
listProviders(): Observable<{ providers: IAuthProvider[] }> {
|
||||
return this.http.get<{ providers: IAuthProvider[] }>('/api/v1/admin/auth/providers');
|
||||
}
|
||||
|
||||
getProvider(id: string): Observable<IAuthProvider> {
|
||||
return this.http.get<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
createProvider(dto: ICreateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.post<IAuthProvider>('/api/v1/admin/auth/providers', dto);
|
||||
}
|
||||
|
||||
updateProvider(id: string, dto: IUpdateAuthProviderDto): Observable<IAuthProvider> {
|
||||
return this.http.put<IAuthProvider>(`/api/v1/admin/auth/providers/${id}`, dto);
|
||||
}
|
||||
|
||||
deleteProvider(id: string): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`/api/v1/admin/auth/providers/${id}`);
|
||||
}
|
||||
|
||||
testProvider(id: string): Observable<IConnectionTestResult> {
|
||||
return this.http.post<IConnectionTestResult>(`/api/v1/admin/auth/providers/${id}/test`, {});
|
||||
}
|
||||
|
||||
// Platform settings
|
||||
getSettings(): Observable<IPlatformSettings> {
|
||||
return this.http.get<IPlatformSettings>('/api/v1/admin/auth/settings');
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<{ auth: Partial<IPlatformAuthSettings> }>): Observable<IPlatformSettings> {
|
||||
return this.http.put<IPlatformSettings>('/api/v1/admin/auth/settings', settings);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,21 @@ export interface IPackage {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ITokenScope {
|
||||
protocol: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface IToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
protocols: string[];
|
||||
scopes?: ITokenScope[];
|
||||
organizationId?: string;
|
||||
createdById?: string;
|
||||
expiresAt?: string;
|
||||
lastUsedAt?: string;
|
||||
usageCount: number;
|
||||
@@ -179,14 +189,21 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Tokens
|
||||
getTokens(): Observable<{ tokens: IToken[] }> {
|
||||
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`);
|
||||
getTokens(organizationId?: string): Observable<{ tokens: IToken[] }> {
|
||||
let httpParams = new HttpParams();
|
||||
if (organizationId) {
|
||||
httpParams = httpParams.set('organizationId', organizationId);
|
||||
}
|
||||
return this.http.get<{ tokens: IToken[] }>(`${this.baseUrl}/tokens`, {
|
||||
params: httpParams,
|
||||
});
|
||||
}
|
||||
|
||||
createToken(data: {
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
protocols: string[];
|
||||
scopes: { protocol: string; actions: string[] }[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
}): Observable<IToken & { token: string }> {
|
||||
return this.http.post<IToken & { token: string }>(
|
||||
|
||||
@@ -106,6 +106,19 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback tokens from external providers
|
||||
*/
|
||||
handleOAuthCallback(accessToken: string, refreshToken: string, sessionId: string): void {
|
||||
this._accessToken.set(accessToken);
|
||||
this._refreshToken.set(refreshToken);
|
||||
this._sessionId.set(sessionId);
|
||||
this.saveToStorage();
|
||||
|
||||
// Fetch user info asynchronously
|
||||
this.fetchCurrentUser();
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type IPlatformSettings,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-providers',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">Authentication Providers</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mt-1">Configure OAuth and LDAP authentication</p>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
<svg class="w-5 h-5 mr-2" 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 Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform Settings Card -->
|
||||
<div class="card mb-6">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@if (settings()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Local Authentication</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow email/password login</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleLocalAuth()"
|
||||
[class]="settings()!.auth.localAuthEnabled ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.localAuthEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">User Registration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">Allow new account creation</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleRegistration()"
|
||||
[class]="settings()!.auth.allowUserRegistration ? 'badge-accent' : 'badge-secondary'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ settings()!.auth.allowUserRegistration ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-muted/30 border border-border">
|
||||
<div>
|
||||
<p class="font-mono text-sm font-medium text-foreground">Session Duration</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">{{ formatDuration(settings()!.auth.sessionDurationMinutes) }}</p>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(true)" class="btn-ghost btn-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="animate-pulse flex space-x-4">
|
||||
<div class="flex-1 space-y-2 py-1">
|
||||
<div class="h-4 bg-muted"></div>
|
||||
<div class="h-4 bg-muted w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers List -->
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" 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 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else if (providers().length === 0) {
|
||||
<div class="card card-content text-center py-12">
|
||||
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="font-mono text-lg font-medium text-foreground mb-2">No providers configured</h3>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-4">Add an OAuth or LDAP provider to enable single sign-on</p>
|
||||
<button (click)="showCreateModal.set(true)" class="btn-primary btn-md">
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-4">
|
||||
@for (provider of providers(); track provider.id) {
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 flex items-center justify-center flex-shrink-0" [class]="getProviderIconClass(provider.type)">
|
||||
@if (provider.type === 'oidc') {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-mono font-semibold text-foreground">{{ provider.displayName }}</h3>
|
||||
<span [class]="getStatusBadgeClass(provider.status)">{{ provider.status }}</span>
|
||||
@if (settings()?.auth?.defaultProviderId === provider.id) {
|
||||
<span class="badge-primary">Default</span>
|
||||
}
|
||||
</div>
|
||||
<p class="font-mono text-sm text-muted-foreground">{{ provider.name }} · {{ provider.type.toUpperCase() }}</p>
|
||||
@if (provider.type === 'oidc' && provider.oauthConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.oauthConfig.issuer }}</p>
|
||||
}
|
||||
@if (provider.type === 'ldap' && provider.ldapConfig) {
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1 truncate">{{ provider.ldapConfig.serverUrl }}</p>
|
||||
}
|
||||
@if (provider.lastTestedAt) {
|
||||
<div class="flex items-center gap-2 mt-2 font-mono text-xs">
|
||||
@if (provider.lastTestResult === 'success') {
|
||||
<span class="text-accent flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Connection OK
|
||||
</span>
|
||||
} @else {
|
||||
<span class="text-destructive flex items-center gap-1">
|
||||
<svg class="w-3 h-3" 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>
|
||||
Connection Failed
|
||||
</span>
|
||||
}
|
||||
<span class="text-muted-foreground">
|
||||
tested {{ formatDate(provider.lastTestedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="testProvider(provider)"
|
||||
[disabled]="testing() === provider.id"
|
||||
class="btn-ghost btn-sm"
|
||||
>
|
||||
@if (testing() === provider.id) {
|
||||
<svg class="animate-spin w-4 h-4" 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 12h4z"></path>
|
||||
</svg>
|
||||
} @else {
|
||||
Test
|
||||
}
|
||||
</button>
|
||||
<button (click)="editProvider(provider)" class="btn-ghost btn-sm">Edit</button>
|
||||
<button (click)="confirmDelete(provider)" class="btn-ghost btn-sm text-destructive hover:text-destructive">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Select Provider Type</span>
|
||||
</div>
|
||||
<button (click)="showCreateModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<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 class="card-content space-y-3">
|
||||
<button
|
||||
(click)="createProvider('oidc')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">OAuth / OIDC</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Google, Azure AD, Okta, Auth0, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
(click)="createProvider('ldap')"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 text-left transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-accent/10 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 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground">LDAP / Active Directory</h4>
|
||||
<p class="font-mono text-xs text-muted-foreground">Enterprise directory service</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (providerToDelete()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator bg-destructive"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Delete Provider</span>
|
||||
</div>
|
||||
<button (click)="providerToDelete.set(null)" class="btn-ghost btn-sm p-1">
|
||||
<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 class="card-content">
|
||||
<p class="font-mono text-sm text-foreground">
|
||||
Are you sure you want to delete <strong>{{ providerToDelete()!.displayName }}</strong>?
|
||||
</p>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-2">
|
||||
Users who signed in with this provider will no longer be able to authenticate through it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="providerToDelete.set(null)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="deleteProvider()" [disabled]="deleting()" class="btn-destructive btn-md">
|
||||
@if (deleting()) {
|
||||
Deleting...
|
||||
} @else {
|
||||
Delete
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@if (showSettingsModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Platform Settings</span>
|
||||
</div>
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<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 class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Session Duration (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
[value]="editingSettings.sessionDurationMinutes"
|
||||
(input)="editingSettings.sessionDurationMinutes = +($any($event.target).value)"
|
||||
class="input"
|
||||
min="60"
|
||||
max="43200"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">How long user sessions remain valid (60-43200 minutes)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Default Provider</label>
|
||||
<select
|
||||
[value]="editingSettings.defaultProviderId || ''"
|
||||
(change)="editingSettings.defaultProviderId = $any($event.target).value || undefined"
|
||||
class="input"
|
||||
>
|
||||
<option value="">None (show all options)</option>
|
||||
@for (provider of providers(); track provider.id) {
|
||||
@if (provider.status === 'active') {
|
||||
<option [value]="provider.id">{{ provider.displayName }}</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Automatically redirect to this provider on login</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<button (click)="showSettingsModal.set(false)" class="btn-secondary btn-md">Cancel</button>
|
||||
<button (click)="saveSettings()" [disabled]="savingSettings()" class="btn-primary btn-md">
|
||||
@if (savingSettings()) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AuthProvidersComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
providers = signal<IAuthProvider[]>([]);
|
||||
settings = signal<IPlatformSettings | null>(null);
|
||||
loading = signal(true);
|
||||
testing = signal<string | null>(null);
|
||||
deleting = signal(false);
|
||||
savingSettings = signal(false);
|
||||
|
||||
showCreateModal = signal(false);
|
||||
showSettingsModal = signal(false);
|
||||
providerToDelete = signal<IAuthProvider | null>(null);
|
||||
selectedProviderForEdit = signal<IAuthProvider | null>(null);
|
||||
|
||||
editingSettings = {
|
||||
sessionDurationMinutes: 10080,
|
||||
defaultProviderId: undefined as string | undefined,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [providersRes, settingsRes] = await Promise.all([
|
||||
this.adminAuthService.listProviders().toPromise(),
|
||||
this.adminAuthService.getSettings().toPromise(),
|
||||
]);
|
||||
this.providers.set(providersRes?.providers || []);
|
||||
if (settingsRes) {
|
||||
this.settings.set(settingsRes);
|
||||
this.editingSettings = {
|
||||
sessionDurationMinutes: settingsRes.auth.sessionDurationMinutes,
|
||||
defaultProviderId: settingsRes.auth.defaultProviderId,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load authentication settings');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
createProvider(type: 'oidc' | 'ldap'): void {
|
||||
this.showCreateModal.set(false);
|
||||
// Navigate to provider form
|
||||
window.location.href = `/admin/auth/providers/new?type=${type}`;
|
||||
}
|
||||
|
||||
editProvider(provider: IAuthProvider): void {
|
||||
window.location.href = `/admin/auth/providers/${provider.id}`;
|
||||
}
|
||||
|
||||
async testProvider(provider: IAuthProvider): Promise<void> {
|
||||
this.testing.set(provider.id);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(provider.id).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
// Reload to get updated test results
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test provider');
|
||||
} finally {
|
||||
this.testing.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(provider: IAuthProvider): void {
|
||||
this.providerToDelete.set(provider);
|
||||
}
|
||||
|
||||
async deleteProvider(): Promise<void> {
|
||||
const provider = this.providerToDelete();
|
||||
if (!provider) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
try {
|
||||
await this.adminAuthService.deleteProvider(provider.id).toPromise();
|
||||
this.toastService.success('Provider deleted');
|
||||
this.providerToDelete.set(null);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to delete provider');
|
||||
} finally {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLocalAuth(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { localAuthEnabled: !current.auth.localAuthEnabled },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRegistration(): Promise<void> {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: { allowUserRegistration: !current.auth.allowUserRegistration },
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings updated');
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
this.savingSettings.set(true);
|
||||
try {
|
||||
await this.adminAuthService.updateSettings({
|
||||
auth: {
|
||||
sessionDurationMinutes: this.editingSettings.sessionDurationMinutes,
|
||||
defaultProviderId: this.editingSettings.defaultProviderId,
|
||||
},
|
||||
}).toPromise();
|
||||
this.toastService.success('Settings saved');
|
||||
this.showSettingsModal.set(false);
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to save settings');
|
||||
} finally {
|
||||
this.savingSettings.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
getProviderIconClass(type: string): string {
|
||||
return type === 'oidc' ? 'bg-primary/10 text-primary' : 'bg-accent/10 text-accent';
|
||||
}
|
||||
|
||||
getStatusBadgeClass(status: TAuthProviderStatus): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'badge-accent';
|
||||
case 'testing':
|
||||
return 'badge-warning';
|
||||
case 'disabled':
|
||||
return 'badge-secondary';
|
||||
default:
|
||||
return 'badge-secondary';
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} minutes`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
||||
return `${Math.round(minutes / 1440)} days`;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
AdminAuthService,
|
||||
type IAuthProvider,
|
||||
type ICreateAuthProviderDto,
|
||||
type IUpdateAuthProviderDto,
|
||||
type TAuthProviderType,
|
||||
type TAuthProviderStatus,
|
||||
} from '../../../core/services/admin-auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-form',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<div class="section-header mb-2">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Admin / Auth Providers</span>
|
||||
</div>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">
|
||||
{{ isEditMode() ? 'Edit Provider' : 'New ' + (providerType() === 'oidc' ? 'OAuth/OIDC' : 'LDAP') + ' Provider' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" 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 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="saveProvider()" class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Basic Information</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Name (identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="google, azure-ad, corp-ldap"
|
||||
required
|
||||
[disabled]="isEditMode()"
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase, alphanumeric with hyphens</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="Google SSO, Corporate LDAP"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Shown on login page</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (isEditMode()) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Status</label>
|
||||
<select [(ngModel)]="form.status" name="status" class="input">
|
||||
<option value="active">Active</option>
|
||||
<option value="testing">Testing</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="form.priority"
|
||||
name="priority"
|
||||
class="input"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Higher = shown first (0-100)</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Config -->
|
||||
@if (providerType() === 'oidc') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">OAuth / OIDC Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.issuer"
|
||||
name="issuer"
|
||||
class="input"
|
||||
placeholder="https://accounts.google.com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">OIDC discovery endpoint base URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.oauthConfig.clientId"
|
||||
name="clientId"
|
||||
class="input"
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.oauthConfig.clientSecretEncrypted"
|
||||
name="clientSecret"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-client-secret'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing secret
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="scopesInput"
|
||||
name="scopes"
|
||||
class="input"
|
||||
placeholder="openid profile email"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Space-separated OAuth scopes</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Callback URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[value]="getCallbackUrl()"
|
||||
class="input flex-1"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" (click)="copyCallbackUrl()" class="btn-secondary btn-md">Copy</button>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Add this to your OAuth provider's allowed redirect URIs</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced OAuth Settings -->
|
||||
<details class="group">
|
||||
<summary class="font-mono text-sm font-medium text-foreground cursor-pointer hover:text-primary">
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Authorization URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.authorizationUrl"
|
||||
name="authorizationUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Token URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.tokenUrl"
|
||||
name="tokenUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Info URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="form.oauthConfig.userInfoUrl"
|
||||
name="userInfoUrl"
|
||||
class="input"
|
||||
placeholder="Override OIDC discovery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Config -->
|
||||
@if (providerType() === 'ldap') {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">LDAP Configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.serverUrl"
|
||||
name="serverUrl"
|
||||
class="input"
|
||||
placeholder="ldap://ldap.example.com:389"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">LDAP or LDAPS protocol URL</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.bindDn"
|
||||
name="bindDn"
|
||||
class="input"
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Bind Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.ldapConfig.bindPasswordEncrypted"
|
||||
name="bindPassword"
|
||||
class="input"
|
||||
[placeholder]="isEditMode() ? '••••••••' : 'your-bind-password'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
@if (isEditMode()) {
|
||||
Leave empty to keep existing password
|
||||
} @else {
|
||||
Will be encrypted at rest
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Base DN</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.baseDn"
|
||||
name="baseDn"
|
||||
class="input"
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Base DN for user searches</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">User Search Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.ldapConfig.userSearchFilter"
|
||||
name="userSearchFilter"
|
||||
class="input"
|
||||
[placeholder]="'(uid=' + '{{' + 'username' + '}}' + ')'"
|
||||
required
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Use double-brace username placeholder</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.ldapConfig.tlsEnabled"
|
||||
name="tlsEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Enable TLS/StartTLS</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (form.ldapConfig.tlsEnabled) {
|
||||
<div>
|
||||
<label class="label block mb-1.5">CA Certificate (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="form.ldapConfig.tlsCaCert"
|
||||
name="tlsCaCert"
|
||||
class="input min-h-[100px] font-mono text-xs"
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
></textarea>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">PEM-encoded CA certificate for self-signed servers</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Attribute Mapping -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Attribute Mapping</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<p class="font-mono text-xs text-muted-foreground">
|
||||
Map provider attributes to user fields. Use claim names for OAuth or attribute names for LDAP.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label block mb-1.5">Email</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.email"
|
||||
name="mapEmail"
|
||||
class="input"
|
||||
placeholder="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.username"
|
||||
name="mapUsername"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'preferred_username' : 'uid'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.displayName"
|
||||
name="mapDisplayName"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'name' : 'cn'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Avatar URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.avatarUrl"
|
||||
name="mapAvatarUrl"
|
||||
class="input"
|
||||
placeholder="picture"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Groups (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="form.attributeMapping.groups"
|
||||
name="mapGroups"
|
||||
class="input"
|
||||
[placeholder]="providerType() === 'oidc' ? 'groups' : 'memberOf'"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">For future group sync functionality</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provisioning Settings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">User Provisioning</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.jitEnabled"
|
||||
name="jitEnabled"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Just-in-Time Provisioning</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically create user accounts on first login
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.provisioning.autoLinkByEmail"
|
||||
name="autoLinkByEmail"
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="font-mono text-sm text-foreground">Auto-Link by Email</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-muted-foreground pl-6">
|
||||
Automatically link to existing accounts with matching email addresses
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label block mb-1.5">Allowed Email Domains (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="domainsInput"
|
||||
name="allowedDomains"
|
||||
class="input"
|
||||
placeholder="example.com, corp.example.com"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">
|
||||
Comma-separated. Leave empty to allow all domains.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button type="button" (click)="cancel()" class="btn-secondary btn-md">Cancel</button>
|
||||
<div class="flex gap-3">
|
||||
@if (isEditMode()) {
|
||||
<button type="button" (click)="testConnection()" [disabled]="testing()" class="btn-secondary btn-md">
|
||||
@if (testing()) {
|
||||
Testing...
|
||||
} @else {
|
||||
Test Connection
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button type="submit" [disabled]="saving()" class="btn-primary btn-md">
|
||||
@if (saving()) {
|
||||
Saving...
|
||||
} @else {
|
||||
{{ isEditMode() ? 'Save Changes' : 'Create Provider' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ProviderFormComponent implements OnInit {
|
||||
private adminAuthService = inject(AdminAuthService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
testing = signal(false);
|
||||
isEditMode = signal(false);
|
||||
providerType = signal<TAuthProviderType>('oidc');
|
||||
providerId = signal<string | null>(null);
|
||||
|
||||
scopesInput = 'openid profile email';
|
||||
domainsInput = '';
|
||||
|
||||
form = {
|
||||
name: '',
|
||||
displayName: '',
|
||||
status: 'testing' as TAuthProviderStatus,
|
||||
priority: 0,
|
||||
oauthConfig: {
|
||||
clientId: '',
|
||||
clientSecretEncrypted: '',
|
||||
issuer: '',
|
||||
authorizationUrl: '',
|
||||
tokenUrl: '',
|
||||
userInfoUrl: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
callbackUrl: '',
|
||||
},
|
||||
ldapConfig: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPasswordEncrypted: '',
|
||||
baseDn: '',
|
||||
userSearchFilter: '(uid={{username}})',
|
||||
tlsEnabled: false,
|
||||
tlsCaCert: '',
|
||||
},
|
||||
attributeMapping: {
|
||||
email: 'email',
|
||||
username: 'preferred_username',
|
||||
displayName: 'name',
|
||||
avatarUrl: '',
|
||||
groups: '',
|
||||
},
|
||||
provisioning: {
|
||||
jitEnabled: true,
|
||||
autoLinkByEmail: true,
|
||||
allowedEmailDomains: [] as string[],
|
||||
},
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for edit mode
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id && id !== 'new') {
|
||||
this.isEditMode.set(true);
|
||||
this.providerId.set(id);
|
||||
this.loadProvider(id);
|
||||
} else {
|
||||
// New provider mode
|
||||
const type = this.route.snapshot.queryParamMap.get('type') as TAuthProviderType;
|
||||
if (type && (type === 'oidc' || type === 'ldap')) {
|
||||
this.providerType.set(type);
|
||||
this.setDefaultMappings(type);
|
||||
}
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadProvider(id: string): Promise<void> {
|
||||
try {
|
||||
const provider = await this.adminAuthService.getProvider(id).toPromise();
|
||||
if (provider) {
|
||||
this.providerType.set(provider.type);
|
||||
this.form.name = provider.name;
|
||||
this.form.displayName = provider.displayName;
|
||||
this.form.status = provider.status;
|
||||
this.form.priority = provider.priority;
|
||||
|
||||
if (provider.oauthConfig) {
|
||||
this.form.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
...provider.oauthConfig,
|
||||
clientSecretEncrypted: '', // Don't show encrypted secret
|
||||
};
|
||||
this.scopesInput = provider.oauthConfig.scopes.join(' ');
|
||||
}
|
||||
|
||||
if (provider.ldapConfig) {
|
||||
this.form.ldapConfig = {
|
||||
...this.form.ldapConfig,
|
||||
...provider.ldapConfig,
|
||||
bindPasswordEncrypted: '', // Don't show encrypted password
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.attributeMapping) {
|
||||
this.form.attributeMapping = { ...this.form.attributeMapping, ...provider.attributeMapping };
|
||||
}
|
||||
|
||||
if (provider.provisioning) {
|
||||
this.form.provisioning = { ...this.form.provisioning, ...provider.provisioning };
|
||||
this.domainsInput = provider.provisioning.allowedEmailDomains?.join(', ') || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load provider');
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setDefaultMappings(type: TAuthProviderType): void {
|
||||
if (type === 'ldap') {
|
||||
this.form.attributeMapping = {
|
||||
email: 'mail',
|
||||
username: 'uid',
|
||||
displayName: 'cn',
|
||||
avatarUrl: '',
|
||||
groups: 'memberOf',
|
||||
};
|
||||
this.form.ldapConfig.userSearchFilter = '(uid={{username}})';
|
||||
}
|
||||
}
|
||||
|
||||
getCallbackUrl(): string {
|
||||
const baseUrl = window.location.origin;
|
||||
const providerName = this.form.name || '{provider-name}';
|
||||
return `${baseUrl}/api/v1/auth/oauth/${providerName}/callback`;
|
||||
}
|
||||
|
||||
copyCallbackUrl(): void {
|
||||
navigator.clipboard.writeText(this.getCallbackUrl());
|
||||
this.toastService.success('Callback URL copied');
|
||||
}
|
||||
|
||||
async saveProvider(): Promise<void> {
|
||||
// Parse scopes and domains
|
||||
this.form.oauthConfig.scopes = this.scopesInput.split(/\s+/).filter(Boolean);
|
||||
this.form.provisioning.allowedEmailDomains = this.domainsInput
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
this.saving.set(true);
|
||||
|
||||
try {
|
||||
if (this.isEditMode()) {
|
||||
// Update existing provider
|
||||
const dto: IUpdateAuthProviderDto = {
|
||||
displayName: this.form.displayName,
|
||||
status: this.form.status,
|
||||
priority: this.form.priority,
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = { ...this.form.oauthConfig };
|
||||
// Only include secret if changed
|
||||
if (!dto.oauthConfig.clientSecretEncrypted) {
|
||||
delete dto.oauthConfig.clientSecretEncrypted;
|
||||
}
|
||||
} else {
|
||||
dto.ldapConfig = { ...this.form.ldapConfig };
|
||||
// Only include password if changed
|
||||
if (!dto.ldapConfig.bindPasswordEncrypted) {
|
||||
delete dto.ldapConfig.bindPasswordEncrypted;
|
||||
}
|
||||
}
|
||||
|
||||
await this.adminAuthService.updateProvider(this.providerId()!, dto).toPromise();
|
||||
this.toastService.success('Provider updated');
|
||||
} else {
|
||||
// Create new provider
|
||||
const dto: ICreateAuthProviderDto = {
|
||||
name: this.form.name,
|
||||
displayName: this.form.displayName,
|
||||
type: this.providerType(),
|
||||
attributeMapping: this.form.attributeMapping,
|
||||
provisioning: this.form.provisioning,
|
||||
};
|
||||
|
||||
if (this.providerType() === 'oidc') {
|
||||
dto.oauthConfig = {
|
||||
...this.form.oauthConfig,
|
||||
callbackUrl: this.getCallbackUrl(),
|
||||
};
|
||||
} else {
|
||||
dto.ldapConfig = this.form.ldapConfig;
|
||||
}
|
||||
|
||||
await this.adminAuthService.createProvider(dto).toPromise();
|
||||
this.toastService.success('Provider created');
|
||||
}
|
||||
|
||||
this.router.navigate(['/admin/auth']);
|
||||
} catch (error: any) {
|
||||
const message = error?.error?.error || 'Failed to save provider';
|
||||
this.toastService.error(message);
|
||||
} finally {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(): Promise<void> {
|
||||
if (!this.providerId()) return;
|
||||
|
||||
this.testing.set(true);
|
||||
try {
|
||||
const result = await this.adminAuthService.testProvider(this.providerId()!).toPromise();
|
||||
if (result?.success) {
|
||||
this.toastService.success(`Connection successful (${result.latencyMs}ms)`);
|
||||
} else {
|
||||
this.toastService.error(result?.error || 'Connection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to test connection');
|
||||
} finally {
|
||||
this.testing.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/admin/auth']);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface IPublicProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: 'oidc' | 'ldap';
|
||||
}
|
||||
|
||||
interface IProvidersResponse {
|
||||
providers: IPublicProvider[];
|
||||
localAuthEnabled: boolean;
|
||||
defaultProviderId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -22,7 +37,127 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<p class="font-mono text-sm text-muted-foreground mt-2 uppercase tracking-wider">Registry</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
@if (loadingProviders()) {
|
||||
<div class="card p-6 flex items-center justify-center">
|
||||
<svg class="animate-spin h-6 w-6 text-primary" 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 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- SSO Providers -->
|
||||
@if (oauthProviders().length > 0) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of oauthProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="loginWithOAuth(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-primary/10 flex items-center justify-center">
|
||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Continue with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Providers -->
|
||||
@if (ldapProviders().length > 0 && !showLdapForm()) {
|
||||
<div class="space-y-3 mb-6">
|
||||
@for (provider of ldapProviders(); track provider.id) {
|
||||
<button
|
||||
(click)="selectLdapProvider(provider)"
|
||||
class="w-full p-4 border border-border hover:border-primary/50 bg-card flex items-center gap-4 transition-colors"
|
||||
>
|
||||
<div class="w-10 h-10 bg-accent/10 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 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-foreground">Sign in with {{ provider.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LDAP Login Form -->
|
||||
@if (showLdapForm() && selectedLdapProvider()) {
|
||||
<form (ngSubmit)="loginWithLdap()" class="card p-6 space-y-6 mb-6">
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
<div class="terminal-dot dot-red"></div>
|
||||
<div class="terminal-dot dot-orange"></div>
|
||||
<div class="terminal-dot dot-green"></div>
|
||||
<span class="ml-2 font-mono text-xs text-muted-foreground uppercase">{{ selectedLdapProvider()!.displayName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="ldapUsername" class="label block mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ldapUsername"
|
||||
[(ngModel)]="ldapUsername"
|
||||
name="ldapUsername"
|
||||
class="input"
|
||||
placeholder="your.username"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ldapPassword" class="label block mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="ldapPassword"
|
||||
[(ngModel)]="ldapPassword"
|
||||
name="ldapPassword"
|
||||
class="input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (ldapError()) {
|
||||
<div class="p-3 bg-destructive/10 border border-destructive/30">
|
||||
<p class="font-mono text-sm text-destructive">{{ ldapError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" (click)="cancelLdap()" class="btn-secondary btn-md flex-1">
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" [disabled]="ldapLoading()" class="btn-primary btn-md flex-1">
|
||||
@if (ldapLoading()) {
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" 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>
|
||||
Signing in...
|
||||
} @else {
|
||||
Sign in
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Divider -->
|
||||
@if ((oauthProviders().length > 0 || ldapProviders().length > 0) && localAuthEnabled() && !showLdapForm()) {
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
<span class="font-mono text-xs text-muted-foreground uppercase">or</span>
|
||||
<div class="flex-1 border-t border-border"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Local login form -->
|
||||
@if (localAuthEnabled() && !showLdapForm()) {
|
||||
<form (ngSubmit)="login()" class="card p-6 space-y-6">
|
||||
<!-- Terminal header -->
|
||||
<div class="code-header -mx-6 -mt-6 mb-6">
|
||||
@@ -84,6 +219,20 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- No auth available message -->
|
||||
@if (!localAuthEnabled() && oauthProviders().length === 0 && ldapProviders().length === 0 && !showLdapForm()) {
|
||||
<div class="card p-6 text-center">
|
||||
<svg class="w-12 h-12 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<p class="font-mono text-sm text-muted-foreground">
|
||||
No authentication methods available. Please contact your administrator.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<p class="text-center font-mono text-xs text-muted-foreground mt-6 uppercase tracking-wider">
|
||||
Enterprise Package Registry
|
||||
@@ -92,16 +241,73 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Local login
|
||||
email = '';
|
||||
password = '';
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Providers
|
||||
loadingProviders = signal(true);
|
||||
localAuthEnabled = signal(true);
|
||||
oauthProviders = signal<IPublicProvider[]>([]);
|
||||
ldapProviders = signal<IPublicProvider[]>([]);
|
||||
|
||||
// LDAP form
|
||||
showLdapForm = signal(false);
|
||||
selectedLdapProvider = signal<IPublicProvider | null>(null);
|
||||
ldapUsername = '';
|
||||
ldapPassword = '';
|
||||
ldapLoading = signal(false);
|
||||
ldapError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check for error in URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorParam = params.get('error');
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
}
|
||||
|
||||
this.loadProviders();
|
||||
}
|
||||
|
||||
private async loadProviders(): Promise<void> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<IProvidersResponse>('/api/v1/auth/providers')
|
||||
);
|
||||
|
||||
this.localAuthEnabled.set(response.localAuthEnabled);
|
||||
this.oauthProviders.set(response.providers.filter((p) => p.type === 'oidc'));
|
||||
this.ldapProviders.set(response.providers.filter((p) => p.type === 'ldap'));
|
||||
|
||||
// Auto-redirect to default provider if configured
|
||||
if (response.defaultProviderId && !this.error()) {
|
||||
const defaultProvider = response.providers.find((p) => p.id === response.defaultProviderId);
|
||||
if (defaultProvider) {
|
||||
if (defaultProvider.type === 'oidc') {
|
||||
this.loginWithOAuth(defaultProvider);
|
||||
return;
|
||||
} else if (defaultProvider.type === 'ldap') {
|
||||
this.selectLdapProvider(defaultProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If providers endpoint fails, show local auth
|
||||
console.error('Failed to load providers:', error);
|
||||
} finally {
|
||||
this.loadingProviders.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
if (!this.email || !this.password) {
|
||||
this.error.set('Please enter your email and password');
|
||||
@@ -126,4 +332,62 @@ export class LoginComponent {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
loginWithOAuth(provider: IPublicProvider): void {
|
||||
// Redirect to OAuth authorization endpoint
|
||||
const returnUrl = encodeURIComponent(window.location.origin + '/dashboard');
|
||||
window.location.href = `/api/v1/auth/oauth/${provider.id}/authorize?returnUrl=${returnUrl}`;
|
||||
}
|
||||
|
||||
selectLdapProvider(provider: IPublicProvider): void {
|
||||
this.selectedLdapProvider.set(provider);
|
||||
this.showLdapForm.set(true);
|
||||
this.ldapUsername = '';
|
||||
this.ldapPassword = '';
|
||||
this.ldapError.set(null);
|
||||
}
|
||||
|
||||
cancelLdap(): void {
|
||||
this.showLdapForm.set(false);
|
||||
this.selectedLdapProvider.set(null);
|
||||
}
|
||||
|
||||
async loginWithLdap(): Promise<void> {
|
||||
const provider = this.selectedLdapProvider();
|
||||
if (!provider || !this.ldapUsername || !this.ldapPassword) {
|
||||
this.ldapError.set('Please enter your username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ldapLoading.set(true);
|
||||
this.ldapError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<{
|
||||
user: { id: string; email: string; username: string; displayName: string; isSystemAdmin: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
}>(`/api/v1/auth/ldap/${provider.id}/login`, {
|
||||
username: this.ldapUsername,
|
||||
password: this.ldapPassword,
|
||||
})
|
||||
);
|
||||
|
||||
this.authService.handleOAuthCallback(
|
||||
response.accessToken,
|
||||
response.refreshToken,
|
||||
response.sessionId
|
||||
);
|
||||
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
} catch (err: any) {
|
||||
const message = err?.error?.error || 'Authentication failed';
|
||||
this.ldapError.set(message);
|
||||
} finally {
|
||||
this.ldapLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oauth-callback',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="max-w-md w-full text-center">
|
||||
@if (error()) {
|
||||
<div class="w-16 h-16 bg-destructive/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-10 h-10 text-destructive" 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>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Authentication Failed</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground mb-6">{{ error() }}</p>
|
||||
<a href="/login" class="btn-primary btn-md">Back to Login</a>
|
||||
} @else {
|
||||
<div class="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="animate-spin w-10 h-10 text-primary-foreground" 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 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="font-mono text-xl font-bold text-foreground mb-2">Signing you in...</h1>
|
||||
<p class="font-mono text-sm text-muted-foreground">Please wait while we complete authentication</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OAuthCallbackComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.handleCallback();
|
||||
}
|
||||
|
||||
private handleCallback(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('accessToken');
|
||||
const refreshToken = params.get('refreshToken');
|
||||
const sessionId = params.get('sessionId');
|
||||
const errorParam = params.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
this.error.set(decodeURIComponent(errorParam));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken || !refreshToken || !sessionId) {
|
||||
this.error.set('Missing authentication tokens');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the tokens and redirect
|
||||
this.authService.handleOAuthCallback(accessToken, refreshToken, sessionId);
|
||||
this.toastService.success('Welcome!');
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<div class="p-6 max-w-7xl mx-auto">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="animate-spin h-8 w-8 text-primary" 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 12h4z"></path>
|
||||
</svg>
|
||||
@@ -20,33 +20,36 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300">
|
||||
<div class="w-16 h-16 bg-muted flex items-center justify-center">
|
||||
<span class="font-mono text-2xl font-medium text-muted-foreground">
|
||||
{{ organization()!.name.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">@{{ organization()!.name }}</p>
|
||||
<h1 class="font-mono text-2xl font-bold text-foreground">{{ organization()!.displayName }}</h1>
|
||||
<p class="font-mono text-muted-foreground">@{{ organization()!.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if (organization()!.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
<span class="badge-accent">Public</span>
|
||||
} @else {
|
||||
<span class="badge-warning">Private</span>
|
||||
<span class="badge-primary">Private</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (organization()!.description) {
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p>
|
||||
<p class="font-mono text-muted-foreground mb-8">{{ organization()!.description }}</p>
|
||||
}
|
||||
|
||||
<!-- Repositories Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2>
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Repositories</span>
|
||||
</div>
|
||||
<button class="btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-1.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" />
|
||||
@@ -57,26 +60,26 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
@if (repositories().length === 0) {
|
||||
<div class="card card-content text-center py-8">
|
||||
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p>
|
||||
<p class="font-mono text-muted-foreground">No repositories yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (repo of repositories(); track repo.id) {
|
||||
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors">
|
||||
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p>
|
||||
<h3 class="font-mono font-medium text-foreground">{{ repo.displayName }}</h3>
|
||||
<p class="font-mono text-sm text-muted-foreground">{{ repo.name }}</p>
|
||||
</div>
|
||||
@if (repo.isPublic) {
|
||||
<span class="badge-default">Public</span>
|
||||
<span class="badge-accent">Public</span>
|
||||
}
|
||||
</div>
|
||||
@if (repo.description) {
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground mt-2 line-clamp-2">{{ repo.description }}</p>
|
||||
}
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-1 font-mono text-sm text-muted-foreground">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
@@ -95,18 +98,24 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mb-4">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="section-label">Statistics</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Members</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ organization()!.memberCount }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Repositories</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ repositories().length }}</p>
|
||||
</div>
|
||||
<div class="card card-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p>
|
||||
<p class="font-mono text-sm text-muted-foreground">Created</p>
|
||||
<p class="font-mono text-2xl font-bold text-foreground">{{ formatDate(organization()!.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -123,18 +132,18 @@ export class OrganizationDetailComponent implements OnInit {
|
||||
loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const orgId = this.route.snapshot.paramMap.get('orgId');
|
||||
if (orgId) {
|
||||
this.loadData(orgId);
|
||||
const orgName = this.route.snapshot.paramMap.get('orgName');
|
||||
if (orgName) {
|
||||
this.loadData(orgName);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(orgId: string): Promise<void> {
|
||||
private async loadData(orgName: string): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [org, reposResponse] = await Promise.all([
|
||||
this.apiService.getOrganization(orgId).toPromise(),
|
||||
this.apiService.getRepositories(orgId).toPromise(),
|
||||
this.apiService.getOrganization(orgName).toPromise(),
|
||||
this.apiService.getRepositories(orgName).toPromise(),
|
||||
]);
|
||||
this.organization.set(org || null);
|
||||
this.repositories.set(reposResponse?.repositories || []);
|
||||
|
||||
@@ -47,7 +47,7 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@for (org of organizations(); track org.id) {
|
||||
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary/50 transition-colors">
|
||||
<a [routerLink]="['/organizations', org.name]" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="card-content">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 bg-muted flex items-center justify-center flex-shrink-0">
|
||||
@@ -84,8 +84,8 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div class="card w-full max-w-md mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
@@ -105,20 +105,20 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
[(ngModel)]="newOrg.name"
|
||||
name="name"
|
||||
class="input"
|
||||
placeholder="my-organization"
|
||||
placeholder="push.rocks"
|
||||
required
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
pattern="^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$"
|
||||
/>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, hyphens, and dots (e.g., push.rocks)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label block mb-1.5">Display Name</label>
|
||||
<label class="label block mb-1.5">Display Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newOrg.displayName"
|
||||
name="displayName"
|
||||
class="input"
|
||||
placeholder="My Organization"
|
||||
placeholder="Defaults to name if empty"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -139,6 +139,11 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
class="w-4 h-4 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="isPublic" class="font-mono text-sm text-foreground">Make this organization public</label>
|
||||
<button type="button" (click)="showPublicExplainer.set(true)" class="btn-ghost p-0 h-5 w-5 text-muted-foreground hover:text-foreground">
|
||||
<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.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
@@ -154,6 +159,52 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Public/Private Explainer Modal -->
|
||||
@if (showPublicExplainer()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-md mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
<span class="font-mono text-sm font-semibold text-foreground uppercase">Organization Visibility</span>
|
||||
</div>
|
||||
<button (click)="showPublicExplainer.set(false)" class="btn-ghost btn-sm p-1">
|
||||
<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 class="card-content space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-accent/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Public Organization</h4>
|
||||
<p class="font-mono text-sm text-muted-foreground">Anyone can view this organization and its public repositories. Useful for open-source projects or public packages.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Private Organization</h4>
|
||||
<p class="font-mono text-sm text-muted-foreground">Only organization members can see this organization and access its repositories. Best for internal or proprietary packages.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer flex justify-end">
|
||||
<button (click)="showPublicExplainer.set(false)" class="btn-primary btn-md">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -164,6 +215,7 @@ export class OrganizationsComponent implements OnInit {
|
||||
organizations = signal<IOrganization[]>([]);
|
||||
loading = signal(true);
|
||||
showCreateModal = signal(false);
|
||||
showPublicExplainer = signal(false);
|
||||
creating = signal(false);
|
||||
|
||||
newOrg = {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { NgClass } from '@angular/common';
|
||||
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';
|
||||
|
||||
interface IScopeEntry {
|
||||
protocol: string;
|
||||
actions: string[];
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tokens',
|
||||
standalone: true,
|
||||
@@ -48,17 +54,28 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
@for (token of tokens(); track token.id) {
|
||||
<li class="px-6 py-4 hover:bg-muted/30 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h3 class="font-mono font-medium text-foreground">{{ token.name }}</h3>
|
||||
@for (protocol of token.protocols.slice(0, 3); track protocol) {
|
||||
<span class="badge-accent">{{ protocol }}</span>
|
||||
}
|
||||
@if (token.protocols.length > 3) {
|
||||
<span class="badge-default">+{{ token.protocols.length - 3 }}</span>
|
||||
@if (token.organizationId) {
|
||||
<span class="badge-primary">Org Token</span>
|
||||
} @else {
|
||||
<span class="badge-default">Personal</span>
|
||||
}
|
||||
</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>
|
||||
@if (token.expiresAt) {
|
||||
<span class="mx-2">·</span>
|
||||
@@ -73,7 +90,7 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
· {{ token.usageCount }} uses
|
||||
</p>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,8 +102,8 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
<!-- Create Modal -->
|
||||
@if (showCreateModal()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div class="card w-full max-w-lg mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8 modal-backdrop">
|
||||
<div class="card w-full max-w-2xl mx-4 modal-content">
|
||||
<div class="card-header flex items-center justify-between">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator"></div>
|
||||
@@ -98,7 +115,8 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content space-y-4">
|
||||
<div class="card-content space-y-5">
|
||||
<!-- Token Name -->
|
||||
<div>
|
||||
<label class="label block mb-1.5">Token Name</label>
|
||||
<input
|
||||
@@ -108,27 +126,134 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
placeholder="my-ci-token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Token Type -->
|
||||
<div>
|
||||
<label class="label block mb-1.5">Protocols</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (protocol of availableProtocols; track protocol) {
|
||||
<label class="label block mb-1.5">Token Type</label>
|
||||
<div class="flex gap-3">
|
||||
<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]="{
|
||||
'bg-primary/10 border-primary text-primary': newToken.protocols.includes(protocol),
|
||||
'border-border text-foreground': !newToken.protocols.includes(protocol)
|
||||
'bg-primary/10 border-primary': !newToken.organizationId,
|
||||
'border-border': newToken.organizationId
|
||||
}">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="newToken.protocols.includes(protocol)"
|
||||
(change)="toggleProtocol(protocol)"
|
||||
type="radio"
|
||||
name="tokenType"
|
||||
[checked]="!newToken.organizationId"
|
||||
(change)="newToken.organizationId = undefined"
|
||||
class="sr-only"
|
||||
/>
|
||||
<span>{{ protocol }}</span>
|
||||
<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
|
||||
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 = organizations()[0]?.id"
|
||||
class="sr-only"
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<label class="label block mb-1.5">Expiration (optional)</label>
|
||||
<select [(ngModel)]="newToken.expiresInDays" class="input">
|
||||
@@ -142,7 +267,10 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
</div>
|
||||
<div class="card-footer flex justify-end gap-3">
|
||||
<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()) {
|
||||
Creating...
|
||||
} @else {
|
||||
@@ -156,8 +284,8 @@ import { ToastService } from '../../core/services/toast.service';
|
||||
|
||||
<!-- Token Created Modal -->
|
||||
@if (createdToken()) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div class="card w-full max-w-lg mx-4">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
|
||||
<div class="card w-full max-w-lg mx-4 modal-content">
|
||||
<div class="card-header">
|
||||
<div class="section-header">
|
||||
<div class="section-indicator bg-accent"></div>
|
||||
@@ -196,54 +324,86 @@ export class TokensComponent implements OnInit {
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
tokens = signal<IToken[]>([]);
|
||||
organizations = signal<IOrganization[]>([]);
|
||||
loading = signal(true);
|
||||
showCreateModal = signal(false);
|
||||
creating = signal(false);
|
||||
createdToken = signal<string | null>(null);
|
||||
|
||||
availableProtocols = ['npm', 'oci', 'maven', 'cargo', 'composer', 'pypi', 'rubygems'];
|
||||
availableActions = ['read', 'write', 'delete'];
|
||||
|
||||
newToken = {
|
||||
newToken: {
|
||||
name: string;
|
||||
organizationId?: string;
|
||||
scopes: IScopeEntry[];
|
||||
expiresInDays: number | null;
|
||||
} = {
|
||||
name: '',
|
||||
protocols: [] as string[],
|
||||
expiresInDays: null as number | null,
|
||||
organizationId: undefined,
|
||||
scopes: [],
|
||||
expiresInDays: null,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTokens();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadTokens(): Promise<void> {
|
||||
private async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const response = await this.apiService.getTokens().toPromise();
|
||||
this.tokens.set(response?.tokens || []);
|
||||
const [tokensRes, orgsRes] = await Promise.all([
|
||||
this.apiService.getTokens().toPromise(),
|
||||
this.apiService.getOrganizations().toPromise(),
|
||||
]);
|
||||
this.tokens.set(tokensRes?.tokens || []);
|
||||
this.organizations.set(orgsRes?.organizations || []);
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to load tokens');
|
||||
this.toastService.error('Failed to load data');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleProtocol(protocol: string): void {
|
||||
if (this.newToken.protocols.includes(protocol)) {
|
||||
this.newToken.protocols = this.newToken.protocols.filter((p) => p !== protocol);
|
||||
} else {
|
||||
this.newToken.protocols = [...this.newToken.protocols, protocol];
|
||||
addScope(): void {
|
||||
this.newToken.scopes = [
|
||||
...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 {
|
||||
scope.actions = [...scope.actions, action];
|
||||
}
|
||||
}
|
||||
|
||||
hasValidScopes(): boolean {
|
||||
return this.newToken.scopes.every((s) => s.protocol && s.actions.length > 0);
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
// Build protocols array from scopes
|
||||
const protocols = [...new Set(this.newToken.scopes.map((s) => s.protocol))];
|
||||
|
||||
const response = await this.apiService.createToken({
|
||||
name: this.newToken.name,
|
||||
protocols: this.newToken.protocols,
|
||||
scopes: this.newToken.protocols.map((p) => ({
|
||||
protocol: p,
|
||||
actions: ['read', 'write'],
|
||||
organizationId: this.newToken.organizationId,
|
||||
protocols,
|
||||
scopes: this.newToken.scopes.map((s) => ({
|
||||
protocol: s.protocol,
|
||||
actions: s.actions,
|
||||
organizationId: s.organizationId,
|
||||
})),
|
||||
expiresInDays: this.newToken.expiresInDays || undefined,
|
||||
}).toPromise();
|
||||
@@ -252,7 +412,7 @@ export class TokensComponent implements OnInit {
|
||||
this.createdToken.set(response.token);
|
||||
this.tokens.update((tokens) => [response, ...tokens]);
|
||||
this.showCreateModal.set(false);
|
||||
this.newToken = { name: '', protocols: [], expiresInDays: null };
|
||||
this.resetNewToken();
|
||||
}
|
||||
} catch (error) {
|
||||
this.toastService.error('Failed to create token');
|
||||
@@ -275,7 +435,16 @@ export class TokensComponent implements OnInit {
|
||||
|
||||
closeCreateModal(): void {
|
||||
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 {
|
||||
@@ -289,4 +458,9 @@ export class TokensComponent implements OnInit {
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatActions(actions: string[]): string {
|
||||
if (actions.includes('*')) return 'full';
|
||||
return actions.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ToastService } from '../../../core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
@@ -65,6 +64,20 @@ import { ToastService } from '../../../core/services/toast.service';
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<!-- Admin Section -->
|
||||
@if (isAdmin()) {
|
||||
<div class="pt-4 mt-4 border-t border-border">
|
||||
<p class="px-3 mb-2 font-mono text-xs text-muted-foreground uppercase tracking-wider">Administration</p>
|
||||
<a routerLink="/admin/auth" routerLinkActive="bg-primary/10 text-primary"
|
||||
class="nav-link">
|
||||
<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Authentication
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- User section -->
|
||||
@@ -108,6 +121,7 @@ export class LayoutComponent {
|
||||
const name = this.authService.user()?.displayName || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
isAdmin = computed(() => this.authService.isAdmin());
|
||||
|
||||
logout(): void {
|
||||
this.authService.logout();
|
||||
|
||||
@@ -259,4 +259,44 @@
|
||||
.status-error {
|
||||
@apply bg-destructive;
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
.modal-backdrop {
|
||||
@apply animate-fade-in;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply animate-modal-in;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-modal-in {
|
||||
animation: modal-in 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user