commit 95bd9615d175be891562a1bb9ff61c7fc8511c3d Author: allanice001 Date: Mon Sep 1 13:34:13 2025 +0100 initial rebuild diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f015e08 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DB_USER=autoglue +DB_PASSWORD=autoglue +DB_NAME=autoglue + +AUTOGLUE_DATABASE_DSN=postgres://autoglue:autoglue@localhost:5432/autoglue +AUTOGLUE_UI_DEV=true +AUTOGLUE_AUTHENTICATION_SECRET=ia7Qsdha7R6CySbXmkVhU1z_Af_n_Na-RSrITi31qxk= + +AUTOGLUE_SMTP_ENABLED=true +AUTOGLUE_SMTP_HOST=localhost +AUTOGLUE_SMTP_PORT=1025 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f5ff99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,react,intellij+all +# Edit at https://www.toptal.com/developers/gitignore?templates=go,react,intellij+all + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### react ### +.DS_* +*.log +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +psd +thumb +sketch + +# End of https://www.toptal.com/developers/gitignore/api/go,react,intellij+all + +.env +config.yaml \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f98c67 --- /dev/null +++ b/Makefile @@ -0,0 +1,96 @@ +GOCMD ?= go +GOINSTALL := $(GOCMD) install +BIN ?= autoglue +MAIN ?= main.go +UI_DIR ?= ui + +SWAG := $(shell command -v swag 2>/dev/null) +GMU := $(shell command -v go-mod-upgrade 2>/dev/null) +YARN := $(shell command -v yarn 2>/dev/null) +NPM := $(shell command -v npm 2>/dev/null) + +.PHONY: all prepare ui-install ui-build ui swagger build clean fmt vet tidy upgrade help + +all: build + +prepare: fmt vet tidy upgrade + +fmt: + @$(GOCMD) fmt ./... + +vet: + @$(GOCMD) vet ./... + +tidy: + @$(GOCMD) mod tidy + +upgrade: + @echo ">> Checking go-mod-upgrade..." + @if [ -z "$(GMU)" ]; then \ + echo "Installing go-mod-upgrade..."; \ + $(GOINSTALL) github.com/oligot/go-mod-upgrade@latest; \ + fi + @go-mod-upgrade -f || true + +ui-install: + @echo ">> Installing UI deps in $(UI_DIR)..." + @if [ -n "$(YARN)" ]; then \ + cd $(UI_DIR) && yarn install --frozen-lockfile; \ + elif [ -n "$(NPM)" ]; then \ + cd $(UI_DIR) && npm ci; \ + else \ + echo "Error: neither yarn nor npm is installed." >&2; exit 1; \ + fi + +ui-build: ui-install + @echo ">> Building UI in $(UI_DIR)..." + @rm -rf $(UI_DIR)/dist + @if [ -n "$(YARN)" ]; then \ + cd $(UI_DIR) && yarn build; \ + else \ + cd $(UI_DIR) && npm run build; \ + fi + +ui: ui-build + +swagger: + @echo ">> Generating Swagger docs..." + @if [ -z "$(SWAG)" ]; then \ + echo "Installing swag..."; \ + $(GOINSTALL) github.com/swaggo/swag/cmd/swag@latest; \ + fi + @rm -rf docs/swagger.* docs/docs.go + @swag init -g $(MAIN) -o docs + +build: prepare ui swagger + @echo ">> Building Go binary: $(BIN)" + @$(GOCMD) build -o $(BIN) $(MAIN) + +clean: + @echo ">> Cleaning artifacts..." + @rm -rf $(BIN) docs/swagger.* docs/docs.go $(UI_DIR)/dist + +dev: swagger ui + @echo ">> Starting Vite (frontend) and Go API (backend)..." + @cd $(UI_DIR) && \ + ( \ + if command -v yarn >/dev/null 2>&1; then \ + yarn dev & \ + elif command -v npm >/dev/null 2>&1; then \ + npm run dev & \ + else \ + echo "Error: neither yarn nor npm is installed." >&2; exit 1; \ + fi; \ + cd .. && \ + UI_DEV=1 $(GOCMD) run . serve & \ + wait \ + ) + +help: + @echo "Targets:" + @echo " build - fmt, vet, tidy, upgrade, build UI, generate Swagger, build Go binary" + @echo " ui - build the Vite UI (auto-detect yarn/npm)" + @echo " swagger - (re)generate Swagger docs using swag" + @echo " clean - remove binary, Swagger outputs, and UI dist" + @echo " prepare - fmt, vet, tidy, upgrade deps" + @echo " dev - run Vite UI dev server + Go API with UI_DEV=1" diff --git a/autoglue b/autoglue new file mode 100755 index 0000000..8871ab4 Binary files /dev/null and b/autoglue differ diff --git a/cmd/cli/init-config.go b/cmd/cli/init-config.go new file mode 100644 index 0000000..d9ce77c --- /dev/null +++ b/cmd/cli/init-config.go @@ -0,0 +1,71 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/glueops/autoglue/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +var initConfigCmd = &cobra.Command{ + Use: "init-config", + Short: "Initialize config", + Long: "Initialize configuration file", + Run: func(cmd *cobra.Command, args []string) { + file := "config.yaml" + if _, err := os.Stat(file); err == nil { + fmt.Println("config.yaml already exists") + return + } + + defaultSecret := config.GenerateSecureSecret() + + defaultConfig := map[string]interface{}{ + "bind_address": "127.0.0.1", + "bind_port": "8080", + "database": map[string]string{ + "dsn": "postgres://user:pass@localhost:5432/db?sslmode=disable", + }, + "authentication": map[string]string{ + "secret": defaultSecret, + }, + "smtp": map[string]interface{}{ + "enabled": false, + "host": "smtp.example.com", + "port": 587, + "username": "", + "password": "", + "from": "no-reply@example.com", + }, + "frontend": map[string]string{ + "base_url": "http://localhost:5173", + }, + "ui": map[string]string{ + "dev": "false", + }, + } + + data, err := yaml.Marshal(defaultConfig) + if err != nil { + fmt.Println("Error marshalling YAML:", err) + return + } + + err = os.WriteFile(file, data, 0644) + if err != nil { + fmt.Println("Error writing config.yaml:", err) + return + } + fmt.Println("config.yaml written") + }, +} + +func init() { + rootCmd.AddCommand(initConfigCmd) + + _ = viper.BindPFlag("database.dsn", rootCmd.PersistentFlags().Lookup("dsn")) + _ = viper.BindPFlag("authentication.secret", rootCmd.PersistentFlags().Lookup("authentication-secret")) +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go new file mode 100644 index 0000000..df32697 --- /dev/null +++ b/cmd/cli/root.go @@ -0,0 +1,34 @@ +package cli + +import ( + "log" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "autoglue [command]", + Short: "autoglue is used to manage the lifecycle of kubernetes clusters on GlueOps supported cloud providers", + Long: "autoglue is used to manage the lifecycle of kubernetes clusters on GlueOps supported cloud providers", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + serveCmd.Run(cmd, []string{}) + } else { + _ = cmd.Help() + } + }, +} + +func Execute() { + rootCmd.CompletionOptions.DisableDefaultCmd = true + err := rootCmd.Execute() + if err != nil { + log.Fatal(err) + } +} + +func checkNilErr(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/cli/serve.go b/cmd/cli/serve.go new file mode 100644 index 0000000..b0e2496 --- /dev/null +++ b/cmd/cli/serve.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/glueops/autoglue/internal/api" + "github.com/glueops/autoglue/internal/db" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + bindPort string + bindAddress string +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the server", + Long: "Start the server", + Run: func(cmd *cobra.Command, args []string) { + db.Connect() + + // Resolve bind address/port from viper (flags/env/config/defaults) + addr := fmt.Sprintf("%s:%s", viper.GetString("bind_address"), viper.GetString("bind_port")) + + // Build server (uses Chi router inside) + srv := api.NewServer(addr) + + // Start server + errCh := make(chan error, 1) + go func() { + log.Printf("HTTP server listening on http://%s (ui.dev=%v)", addr, viper.GetBool("ui.dev")) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + close(errCh) + }() + + // Handle OS signals for graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-stop: + log.Printf("Received signal: %s — shutting down...", sig) + case err := <-errCh: + if err != nil { + log.Fatalf("Server error: %v", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Printf("Graceful shutdown failed: %v; forcing close", err) + _ = srv.Close() + } else { + log.Println("Server stopped cleanly.") + } + }, +} + +func init() { + // Flags to override bind address/port + serveCmd.Flags().StringVar(&bindAddress, "bind-address", "", "Address to bind the HTTP server (default 127.0.0.1)") + serveCmd.Flags().StringVar(&bindPort, "bind-port", "", "Port to bind the HTTP server (default 8080)") + + // Bind flags to viper keys + _ = viper.BindPFlag("bind_address", serveCmd.Flags().Lookup("bind-address")) + _ = viper.BindPFlag("bind_port", serveCmd.Flags().Lookup("bind-port")) + + // Register command + rootCmd.AddCommand(serveCmd) +} diff --git a/cmd/cli/set-admin.go b/cmd/cli/set-admin.go new file mode 100644 index 0000000..dc025ce --- /dev/null +++ b/cmd/cli/set-admin.go @@ -0,0 +1,43 @@ +package cli + +import ( + "fmt" + "log" + + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/spf13/cobra" +) + +var ( + userEmail string +) + +var setAdminCmd = &cobra.Command{ + Use: "set-admin", + Short: "Set an existing user to admin role", + Long: "Set an existing user to admin role, looked up by email address", + Run: func(cmd *cobra.Command, args []string) { + if userEmail == "" { + log.Fatal("email is required (use --email)") + } + + db.Connect() + + var user models.User + if err := db.DB.Where("email = ?", userEmail).First(&user).Error; err != nil { + log.Fatalf("could not find user with email %s: %v", userEmail, err) + } + + if err := db.DB.Model(&user).Update("role", models.RoleAdmin).Error; err != nil { + log.Fatalf("failed to update user role: %v", err) + } + + fmt.Printf("User %s (%s) set to admin role\n", user.Name, user.Email) + }, +} + +func init() { + setAdminCmd.Flags().StringVarP(&userEmail, "email", "e", "", "Email of the user to promote to admin") + rootCmd.AddCommand(setAdminCmd) +} diff --git a/cmd/cli/show-config.go b/cmd/cli/show-config.go new file mode 100644 index 0000000..9bd15fd --- /dev/null +++ b/cmd/cli/show-config.go @@ -0,0 +1,19 @@ +package cli + +import ( + "github.com/glueops/autoglue/internal/config" + "github.com/spf13/cobra" +) + +var showConfigCmd = &cobra.Command{ + Use: "show-config", + Short: "Show the current configuration", + Long: "Show the current configuration", + Run: func(cmd *cobra.Command, args []string) { + config.DebugPrintConfig() + }, +} + +func init() { + rootCmd.AddCommand(showConfigCmd) +} diff --git a/cmd/cli/version.go b/cmd/cli/version.go new file mode 100644 index 0000000..9f93039 --- /dev/null +++ b/cmd/cli/version.go @@ -0,0 +1,24 @@ +package cli + +import ( + "fmt" + + "github.com/earthboundkid/versioninfo/v2" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of the application", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Version:", versioninfo.Version) + fmt.Println("Revision:", versioninfo.Revision) + fmt.Println("DirtyBuild:", versioninfo.DirtyBuild) + fmt.Println("LastCommit:", versioninfo.LastCommit) + fmt.Printf("Version: %s\n", versioninfo.Short()) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8160678 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + # autoglue: + # image: ghcr.io/glueops/autoglue:latest + # build: + # context: . + # dockerfile: Dockerfile + # ports: + # - 8080:8080 + # expose: + # - 8080 + # env_file: .env + # environment: + # AUTOGLUE_DATABASE_DSN: postgres://autoglue:autoglue@postgres:5432/autoglue + # AUTOGLUE_BIND_ADDRESS: 0.0.0.0 + # depends_on: + # - postgres + + redis: + image: redis:latest + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep pong"] + interval: 1s + timeout: 3s + retries: 5 + command: "redis-server" + ports: + - "6379:6379" + + postgres: + build: + context: postgres + env_file: .env + environment: + POSTGRES_USER: $DB_USER + POSTGRES_PASSWORD: $DB_PASSWORD + POSTGRES_DB: $DB_NAME + expose: + - 5432 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + pgweb: + image: sosedoff/pgweb@sha256:8f1ed22e10c9da0912169b98b62ddc54930dc39a5ae07b0f1354d2a93d44c6ed + restart: always + ports: + - "8081:8081" + links: + - postgres:postgres + env_file: .env + environment: + DATABASE_URL: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME + depends_on: + - postgres + + mailpit: + image: axllent/mailpit@sha256:7b687e9fbc26252866580819733f2dce47edde9b6bf4444da3321fdd06932f02 + restart: always + ports: + - "1025:1025" + - "8025:8025" + +volumes: + postgres_data: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..dcba85a --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,846 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/healthz": { + "get": { + "description": "Returns a 200 if the service is up", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Basic health check", + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/introspect": { + "post": { + "description": "Returns whether the token is active and basic metadata", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Introspect a token", + "parameters": [ + { + "description": "token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Authenticates a user and returns a JWT bearer token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Authenticate and return a token", + "parameters": [ + { + "description": "User login input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.LoginInput" + } + } + ], + "responses": { + "200": { + "description": "token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout user", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/logout_all": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revokes all active refresh tokens for the authenticated user", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Logout from all sessions", + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated user's profile and auth context", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get authenticated user info", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/authn.MeResponse" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/change": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Changes the password for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "current_password, new_password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/forgot": { + "post": { + "description": "Sends a reset token to the user's email address", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/reset": { + "post": { + "description": "Resets the password using a valid reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Confirm password reset", + "parameters": [ + { + "description": "token, new_password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Use a refresh token to obtain a new access token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "new access token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/refresh/rotate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Exchanges a valid refresh token for a new access and refresh token, revoking the old one", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Rotate refresh token", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Registers a new user and stores credentials", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "User registration input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.RegisterInput" + } + } + ], + "responses": { + "201": { + "description": "created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/verify": { + "get": { + "description": "Verifies the user's email using a token (often from an emailed link)", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "verification token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/verify/resend": { + "post": { + "description": "Sends a new email verification token if needed", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Resend email verification", + "parameters": [ + { + "description": "email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organizations for user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Organization" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new organization and assigns the authenticated user as an admin member", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Create a new organization", + "parameters": [ + { + "type": "string", + "description": "Optional organization context (ignored for creation)", + "name": "X-Org-ID", + "in": "header" + }, + { + "description": "Organization Input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.OrgInput" + } + } + ], + "responses": { + "200": { + "description": "organization_id", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "invalid input", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal error", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "authn.AuthClaimsDTO": { + "type": "object", + "properties": { + "aud": { + "type": "array", + "items": { + "type": "string" + } + }, + "exp": { + "type": "integer" + }, + "iat": { + "type": "integer" + }, + "iss": { + "type": "string" + }, + "nbf": { + "type": "integer" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "sub": { + "type": "string" + } + } + }, + "authn.LoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "me@here.com" + }, + "password": { + "type": "string", + "example": "123456" + } + } + }, + "authn.MeResponse": { + "type": "object", + "properties": { + "claims": { + "$ref": "#/definitions/authn.AuthClaimsDTO" + }, + "org_role": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "user_id": { + "$ref": "#/definitions/authn.UserDTO" + } + } + }, + "authn.RegisterInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "me@here.com" + }, + "name": { + "type": "string", + "example": "My Name" + }, + "password": { + "type": "string", + "example": "123456" + } + } + }, + "authn.UserDTO": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.Role" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.Organization": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.Role": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "orgs.OrgInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{"http"}, + Title: "AutoGlue API", + Description: "API for managing K3s clusters across cloud providers", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/efs.go b/docs/efs.go new file mode 100644 index 0000000..1de7ee1 --- /dev/null +++ b/docs/efs.go @@ -0,0 +1,9 @@ +package docs + +import _ "embed" + +//go:embed swagger.json +var SwaggerJSON []byte + +//go:embed swagger.yaml +var SwaggerYAML []byte diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..b6f5206 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,824 @@ +{ + "schemes": [ + "http" + ], + "swagger": "2.0", + "info": { + "description": "API for managing K3s clusters across cloud providers", + "title": "AutoGlue API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/healthz": { + "get": { + "description": "Returns a 200 if the service is up", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Basic health check", + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/introspect": { + "post": { + "description": "Returns whether the token is active and basic metadata", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Introspect a token", + "parameters": [ + { + "description": "token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Authenticates a user and returns a JWT bearer token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Authenticate and return a token", + "parameters": [ + { + "description": "User login input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.LoginInput" + } + } + ], + "responses": { + "200": { + "description": "token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout user", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/logout_all": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revokes all active refresh tokens for the authenticated user", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Logout from all sessions", + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the authenticated user's profile and auth context", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get authenticated user info", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/authn.MeResponse" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/change": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Changes the password for the authenticated user", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "current_password, new_password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/forgot": { + "post": { + "description": "Sends a reset token to the user's email address", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/password/reset": { + "post": { + "description": "Resets the password using a valid reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Confirm password reset", + "parameters": [ + { + "description": "token, new_password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Use a refresh token to obtain a new access token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "new access token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/refresh/rotate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Exchanges a valid refresh token for a new access and refresh token, revoking the old one", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Rotate refresh token", + "parameters": [ + { + "description": "refresh_token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "access_token, refresh_token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Registers a new user and stores credentials", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "User registration input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.RegisterInput" + } + } + ], + "responses": { + "201": { + "description": "created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/verify": { + "get": { + "description": "Verifies the user's email using a token (often from an emailed link)", + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "verification token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/verify/resend": { + "post": { + "description": "Sends a new email verification token if needed", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "auth" + ], + "summary": "Resend email verification", + "parameters": [ + { + "description": "email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + ], + "responses": { + "204": { + "description": "no content", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organizations for user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Organization" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new organization and assigns the authenticated user as an admin member", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Create a new organization", + "parameters": [ + { + "type": "string", + "description": "Optional organization context (ignored for creation)", + "name": "X-Org-ID", + "in": "header" + }, + { + "description": "Organization Input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.OrgInput" + } + } + ], + "responses": { + "200": { + "description": "organization_id", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "invalid input", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal error", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "authn.AuthClaimsDTO": { + "type": "object", + "properties": { + "aud": { + "type": "array", + "items": { + "type": "string" + } + }, + "exp": { + "type": "integer" + }, + "iat": { + "type": "integer" + }, + "iss": { + "type": "string" + }, + "nbf": { + "type": "integer" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "sub": { + "type": "string" + } + } + }, + "authn.LoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "me@here.com" + }, + "password": { + "type": "string", + "example": "123456" + } + } + }, + "authn.MeResponse": { + "type": "object", + "properties": { + "claims": { + "$ref": "#/definitions/authn.AuthClaimsDTO" + }, + "org_role": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "user_id": { + "$ref": "#/definitions/authn.UserDTO" + } + } + }, + "authn.RegisterInput": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "me@here.com" + }, + "name": { + "type": "string", + "example": "My Name" + }, + "password": { + "type": "string", + "example": "123456" + } + } + }, + "authn.UserDTO": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.Role" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.Organization": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.Role": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "orgs.OrgInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..40487ba --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,533 @@ +basePath: / +definitions: + authn.AuthClaimsDTO: + properties: + aud: + items: + type: string + type: array + exp: + type: integer + iat: + type: integer + iss: + type: string + nbf: + type: integer + orgs: + items: + type: string + type: array + roles: + items: + type: string + type: array + sub: + type: string + type: object + authn.LoginInput: + properties: + email: + example: me@here.com + type: string + password: + example: "123456" + type: string + type: object + authn.MeResponse: + properties: + claims: + $ref: '#/definitions/authn.AuthClaimsDTO' + org_role: + type: string + organization_id: + type: string + user_id: + $ref: '#/definitions/authn.UserDTO' + type: object + authn.RegisterInput: + properties: + email: + example: me@here.com + type: string + name: + example: My Name + type: string + password: + example: "123456" + type: string + type: object + authn.UserDTO: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + id: + type: string + name: + type: string + role: + $ref: '#/definitions/models.Role' + updated_at: + type: string + type: object + models.Organization: + properties: + created_at: + type: string + id: + type: string + logo: + type: string + metadata: + type: string + name: + type: string + slug: + type: string + updated_at: + type: string + type: object + models.Role: + enum: + - admin + - user + type: string + x-enum-varnames: + - RoleAdmin + - RoleUser + orgs.OrgInput: + properties: + name: + type: string + slug: + type: string + type: object +info: + contact: {} + description: API for managing K3s clusters across cloud providers + title: AutoGlue API + version: "1.0" +paths: + /api/healthz: + get: + consumes: + - application/json + description: Returns a 200 if the service is up + produces: + - text/plain + responses: + "200": + description: ok + schema: + type: string + summary: Basic health check + tags: + - health + /api/v1/auth/introspect: + post: + consumes: + - application/json + description: Returns whether the token is active and basic metadata + parameters: + - description: token + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Introspect a token + tags: + - auth + /api/v1/auth/login: + post: + consumes: + - application/json + description: Authenticates a user and returns a JWT bearer token + parameters: + - description: User login input + in: body + name: body + required: true + schema: + $ref: '#/definitions/authn.LoginInput' + produces: + - application/json + responses: + "200": + description: token + schema: + additionalProperties: + type: string + type: object + "401": + description: unauthorized + schema: + type: string + summary: Authenticate and return a token + tags: + - auth + /api/v1/auth/logout: + post: + consumes: + - application/json + description: Revoke a refresh token + parameters: + - description: refresh_token + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - application/json + responses: + "204": + description: no content + schema: + type: string + security: + - BearerAuth: [] + summary: Logout user + tags: + - auth + /api/v1/auth/logout_all: + post: + description: Revokes all active refresh tokens for the authenticated user + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + security: + - BearerAuth: [] + summary: Logout from all sessions + tags: + - auth + /api/v1/auth/me: + get: + description: Returns the authenticated user's profile and auth context + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/authn.MeResponse' + "401": + description: unauthorized + schema: + type: string + security: + - BearerAuth: [] + summary: Get authenticated user info + tags: + - auth + /api/v1/auth/password/change: + post: + consumes: + - application/json + description: Changes the password for the authenticated user + parameters: + - description: current_password, new_password + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + "400": + description: bad request + schema: + type: string + security: + - BearerAuth: [] + summary: Change password + tags: + - auth + /api/v1/auth/password/forgot: + post: + consumes: + - application/json + description: Sends a reset token to the user's email address + parameters: + - description: email + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + summary: Request password reset + tags: + - auth + /api/v1/auth/password/reset: + post: + consumes: + - application/json + description: Resets the password using a valid reset token + parameters: + - description: token, new_password + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + "400": + description: bad request + schema: + type: string + summary: Confirm password reset + tags: + - auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + description: Use a refresh token to obtain a new access token + parameters: + - description: refresh_token + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - application/json + responses: + "200": + description: new access token + schema: + additionalProperties: + type: string + type: object + "401": + description: unauthorized + schema: + type: string + security: + - BearerAuth: [] + summary: Refresh access token + tags: + - auth + /api/v1/auth/refresh/rotate: + post: + consumes: + - application/json + description: Exchanges a valid refresh token for a new access and refresh token, + revoking the old one + parameters: + - description: refresh_token + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - application/json + responses: + "200": + description: access_token, refresh_token + schema: + additionalProperties: + type: string + type: object + "401": + description: unauthorized + schema: + type: string + security: + - BearerAuth: [] + summary: Rotate refresh token + tags: + - auth + /api/v1/auth/register: + post: + consumes: + - application/json + description: Registers a new user and stores credentials + parameters: + - description: User registration input + in: body + name: body + required: true + schema: + $ref: '#/definitions/authn.RegisterInput' + produces: + - application/json + responses: + "201": + description: created + schema: + type: string + "400": + description: bad request + schema: + type: string + summary: Register a new user + tags: + - auth + /api/v1/auth/verify: + get: + description: Verifies the user's email using a token (often from an emailed + link) + parameters: + - description: verification token + in: query + name: token + required: true + type: string + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + "400": + description: bad request + schema: + type: string + summary: Verify email address + tags: + - auth + /api/v1/auth/verify/resend: + post: + consumes: + - application/json + description: Sends a new email verification token if needed + parameters: + - description: email + in: body + name: body + required: true + schema: + additionalProperties: + type: string + type: object + produces: + - text/plain + responses: + "204": + description: no content + schema: + type: string + summary: Resend email verification + tags: + - auth + /api/v1/orgs: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Organization' + type: array + "401": + description: unauthorized + schema: + type: string + security: + - BearerAuth: [] + summary: List organizations for user + tags: + - organizations + post: + consumes: + - application/json + description: Creates a new organization and assigns the authenticated user as + an admin member + parameters: + - description: Optional organization context (ignored for creation) + in: header + name: X-Org-ID + type: string + - description: Organization Input + in: body + name: body + required: true + schema: + $ref: '#/definitions/orgs.OrgInput' + produces: + - application/json + responses: + "200": + description: organization_id + schema: + additionalProperties: + type: string + type: object + "400": + description: invalid input + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "500": + description: internal error + schema: + type: string + security: + - BearerAuth: [] + summary: Create a new organization + tags: + - organizations +schemes: +- http +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a10cc6 --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module github.com/glueops/autoglue + +go 1.25.0 + +require ( + github.com/earthboundkid/versioninfo/v2 v2.24.1 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + github.com/swaggo/http-swagger/v2 v2.0.2 + github.com/swaggo/swag v1.16.6 + github.com/wneessen/go-mail v0.6.2 + golang.org/x/crypto v0.41.0 + golang.org/x/text v0.28.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.30.2 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/tools v0.35.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9572f0d --- /dev/null +++ b/go.sum @@ -0,0 +1,197 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8= +github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs= +gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..974b9ab --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,76 @@ +package api + +import ( + httpPprof "net/http/pprof" + + "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/handlers/authn" + "github.com/glueops/autoglue/internal/handlers/health" + "github.com/glueops/autoglue/internal/handlers/orgs" + "github.com/glueops/autoglue/internal/middleware" + "github.com/glueops/autoglue/internal/ui" + "github.com/go-chi/chi/v5" + "github.com/spf13/viper" +) + +func RegisterRoutes(r chi.Router) { + r.Route("/api", func(api chi.Router) { + api.Get("/healthz", health.Check) + + api.Route("/v1", func(v1 chi.Router) { + secret := viper.GetString("authentication.jwt_secret") + authMW := middleware.AuthMiddleware(secret) + + v1.Route("/auth", func(a chi.Router) { + a.Post("/login", authn.Login) + a.Post("/register", authn.Register) + a.Post("/introspect", authn.Introspect) + a.Post("/password/forgot", authn.RequestPasswordReset) + a.Post("/password/reset", authn.ConfirmPasswordReset) + a.Get("/verify", authn.VerifyEmail) + a.Post("/verify/resend", authn.ResendVerification) + + a.Group(func(pr chi.Router) { + pr.Use(authMW) + pr.Post("/refresh", authn.Refresh) + pr.Post("/logout", authn.Logout) + pr.Post("/logout_all", authn.LogoutAll) + pr.Get("/me", authn.Me) + pr.Post("/password/change", authn.ChangePassword) + pr.Post("/refresh/rotate", authn.RotateRefreshToken) + }) + }) + + v1.Route("/orgs", func(o chi.Router) { + o.Use(authMW) + o.Post("/", orgs.CreateOrganization) + o.Get("/", orgs.ListOrganizations) + }) + }) + }) + + r.Route("/debug/pprof", func(pr chi.Router) { + pr.Get("/", httpPprof.Index) + pr.Get("/cmdline", httpPprof.Cmdline) + pr.Get("/profile", httpPprof.Profile) + pr.Get("/symbol", httpPprof.Symbol) + pr.Get("/trace", httpPprof.Trace) + + pr.Handle("/allocs", httpPprof.Handler("allocs")) + pr.Handle("/block", httpPprof.Handler("block")) + pr.Handle("/goroutine", httpPprof.Handler("goroutine")) + pr.Handle("/heap", httpPprof.Handler("heap")) + pr.Handle("/mutex", httpPprof.Handler("mutex")) + pr.Handle("/threadcreate", httpPprof.Handler("threadcreate")) + }) + + if config.IsUIDev() { + if h, err := ui.DevProxy("http://localhost:5173"); err == nil { + r.NotFound(h.ServeHTTP) + } + } else { + if h, err := ui.SPAHandler(); err == nil { + r.NotFound(h.ServeHTTP) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..01ca4a4 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + "time" + + "github.com/glueops/autoglue/docs" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + httpSwagger "github.com/swaggo/http-swagger/v2" +) + +func NewRouter() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8080", + "http://127.0.0.1:8080", + }, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "content-type", "Authorization", "authorization", "X-Org-ID", "x-org-id"}, + AllowCredentials: true, + // OptionsPassthrough: false, // default; Chi will auto 200 OPTIONS + // MaxAge: 300, // optional + })) + + RegisterRoutes(r) + + r.Mount("/swagger", httpSwagger.WrapHandler) + r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json")) + r.Get("/swagger/swagger.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml")) + return r +} + +func NewServer(addr string) *http.Server { + return &http.Server{ + Addr: addr, + Handler: NewRouter(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } +} + +func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + } +} diff --git a/internal/assets/efs.go b/internal/assets/efs.go new file mode 100644 index 0000000..7e585ed --- /dev/null +++ b/internal/assets/efs.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed "emails" +var EmbeddedFiles embed.FS diff --git a/internal/assets/emails/error-notification.tmpl b/internal/assets/emails/error-notification.tmpl new file mode 100644 index 0000000..0d28163 --- /dev/null +++ b/internal/assets/emails/error-notification.tmpl @@ -0,0 +1,12 @@ +{{define "subject"}}Runtime error for {{.BaseURL}}{{end}} + +{{define "plainBody"}} +Error message: {{.Message}} + +Request method: {{.RequestMethod}} +Request URL: {{.RequestURL}} + +Stack trace: + +{{.Trace}} +{{end}} \ No newline at end of file diff --git a/internal/assets/emails/example.tmpl b/internal/assets/emails/example.tmpl new file mode 100644 index 0000000..f381541 --- /dev/null +++ b/internal/assets/emails/example.tmpl @@ -0,0 +1,24 @@ +{{define "subject"}}Example subject{{end}} + +{{define "plainBody"}} +Hi {{.Name}}, + +This is an example body + +Sent at: {{now}} +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hi {{.Name}},

+

This is an example body

+

Sent at: {{now}}

+ + +{{end}} \ No newline at end of file diff --git a/internal/assets/emails/password_reset.tmpl b/internal/assets/emails/password_reset.tmpl new file mode 100644 index 0000000..3ced348 --- /dev/null +++ b/internal/assets/emails/password_reset.tmpl @@ -0,0 +1,45 @@ +{{define "subject"}}Reset your Dragon password{{end}} + +{{define "plainBody"}} +Hi {{.Name}}, + +We received a request to reset your password. Use the token below to continue: + +Reset token: +{{.Token}} + +{{if .ResetURL}}Or open this link: +{{.ResetURL}}{{end}} + +If you didn’t request this, you can safely ignore this email. + +Sent at: {{now}} +{{end}} + +{{define "htmlBody"}} + + + + + + Reset your Dragon password + + +

Hi {{.Name}},

+

We received a request to reset your password. Use the token below to continue:

+ +

Reset token:
+ {{.Token}}

+ + {{if .ResetURL}} +

+ Or click here: + Reset my password +

+ {{end}} + +

If you didn’t request this, you can safely ignore this email.

+

Sent at: {{now}}

+ + +{{end}} diff --git a/internal/assets/emails/verify_account.tmpl b/internal/assets/emails/verify_account.tmpl new file mode 100644 index 0000000..82223e6 --- /dev/null +++ b/internal/assets/emails/verify_account.tmpl @@ -0,0 +1,45 @@ +{{define "subject"}}Verify your Dragon account{{end}} + +{{define "plainBody"}} +Hi {{.Name}}, + +Welcome to Dragon! Please verify your email to activate your account. + +Verification token: +{{.Token}} + +{{if .VerificationURL}}You can also click this link: +{{.VerificationURL}}{{end}} + +If you didn’t create an account, you can ignore this message. + +Sent at: {{now}} +{{end}} + +{{define "htmlBody"}} + + + + + + Verify your Dragon account + + +

Hi {{.Name}},

+

Welcome to Dragon! Please verify your email to activate your account.

+ +

Verification token:
+ {{.Token}}

+ + {{if .VerificationURL}} +

+ Or click here: + Verify my email +

+ {{end}} + +

If you didn’t create an account, you can ignore this message.

+

Sent at: {{now}}

+ + +{{end}} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..05851e8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,109 @@ +package config + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "log" + "os" + "strings" + + "github.com/joho/godotenv" + "github.com/spf13/viper" +) + +var File = "config.yaml" +var fileKeys = map[string]bool{} + +func Load() { + _ = godotenv.Load() + + viper.SetDefault("bind_address", "127.0.0.1") + viper.SetDefault("bind_port", "8080") + viper.SetDefault("database.dsn", "postgres://user:pass@localhost:5432/db?sslmode=disable") + + viper.SetDefault("ui.dev", false) + + viper.SetDefault("authentication.secret", GenerateSecureSecret()) + + viper.SetDefault("smtp.enabled", false) + viper.SetDefault("smtp.host", "smtp.example.com") + viper.SetDefault("smtp.port", 587) + viper.SetDefault("smtp.username", "") + viper.SetDefault("smtp.password", "") + viper.SetDefault("smtp.from", "no-reply@example.com") + + viper.SetDefault("frontend.base_url", "http://localhost:5173") + + viper.SetEnvPrefix("AUTOGLUE") + + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + + viper.SetConfigFile(File) + viper.SetConfigType("yaml") + + if _, err := os.Stat(File); err == nil { + err := viper.ReadInConfig() + if err != nil { + log.Fatalf("Failed to read config file: %v", err) + } + for _, k := range viper.AllKeys() { + fileKeys[k] = true + } + fmt.Println("Loaded config from", File) + } +} + +func GenerateSecureSecret() string { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + panic("unable to generate secure secret") + } + return base64.URLEncoding.EncodeToString(b) +} + +func GetAuthSecret() string { + return viper.GetString("authentication.secret") +} + +func DebugPrintConfig() { + all := viper.AllSettings() + fmt.Println("Loaded configuration:") + for k, v := range all { + fmt.Printf("%s: %#v\n", k, v) + } +} + +func IsUIDev() bool { + return viper.GetBool("ui.dev") +} + +func SMTPEnabled() bool { + return viper.GetBool("smtp.enabled") +} + +func SMTPHost() string { + return viper.GetString("smtp.host") +} + +func SMTPPort() int { + return viper.GetInt("smtp.port") +} + +func SMTPUsername() string { + return viper.GetString("smtp.username") +} + +func SMTPPassword() string { + return viper.GetString("smtp.password") +} + +func SMTPFrom() string { + return viper.GetString("smtp.from") +} + +func FrontendBaseURL() string { + return viper.GetString("frontend.base_url") +} diff --git a/internal/ctxutil/context.go b/internal/ctxutil/context.go new file mode 100644 index 0000000..e255f68 --- /dev/null +++ b/internal/ctxutil/context.go @@ -0,0 +1,32 @@ +package ctxutil + +import ( + "context" + + "github.com/google/uuid" +) + +type ctxKey string + +const ( + keyUserID ctxKey = "user_id" + keyOrgID ctxKey = "org_id" +) + +func WithUserID(ctx context.Context, id uuid.UUID) context.Context { + return context.WithValue(ctx, keyUserID, id) +} + +func UserID(ctx context.Context) (uuid.UUID, bool) { + v, ok := ctx.Value(keyUserID).(uuid.UUID) + return v, ok +} + +func WithOrgID(ctx context.Context, orgID uuid.UUID) context.Context { + return context.WithValue(ctx, keyOrgID, orgID) +} + +func OrgID(ctx context.Context) (uuid.UUID, bool) { + v, ok := ctx.Value(keyOrgID).(uuid.UUID) + return v, ok +} diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..41c4243 --- /dev/null +++ b/internal/db/database.go @@ -0,0 +1,42 @@ +package db + +import ( + "fmt" + "log" + + "github.com/glueops/autoglue/internal/db/models" + "github.com/spf13/viper" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func Connect() { + dsn := viper.GetString("database.dsn") + + if dsn == "" { + log.Fatal("DRAGON_DATABASE_DSN is not set") + } + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect to DB: %v", err) + } + + err = DB.AutoMigrate( + &models.EmailVerification{}, + &models.Invitation{}, + &models.Member{}, + &models.Organization{}, + &models.PasswordReset{}, + &models.RefreshToken{}, + &models.User{}, + ) + if err != nil { + log.Fatalf("auto migration failed: %v", err) + } + + fmt.Println("database connected and migrated") +} diff --git a/internal/db/models/common.go b/internal/db/models/common.go new file mode 100644 index 0000000..022ed8e --- /dev/null +++ b/internal/db/models/common.go @@ -0,0 +1,13 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Timestamped struct { + CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/db/models/email-verification.go b/internal/db/models/email-verification.go new file mode 100644 index 0000000..e2717d8 --- /dev/null +++ b/internal/db/models/email-verification.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type EmailVerification struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"` + Token string `gorm:"type:char(43);uniqueIndex;not null" json:"-"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + Used bool `gorm:"not null;default:false;index" json:"used"` + Timestamped +} + +func (e EmailVerification) IsActive(now time.Time) bool { + return !e.Used && now.Before(e.ExpiresAt) +} diff --git a/internal/db/models/invitation.go b/internal/db/models/invitation.go new file mode 100644 index 0000000..f52f0a6 --- /dev/null +++ b/internal/db/models/invitation.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Invitation struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null"` + Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"` + Email string `gorm:"type:text;not null"` + Role string `gorm:"type:text;default:'member';not null"` + Status string `gorm:"type:text;default:'pending';not null"` // pending, accepted, revoked + ExpiresAt time.Time `gorm:"not null"` + InviterID uuid.UUID `gorm:"type:uuid;not null"` + Timestamped +} diff --git a/internal/db/models/member.go b/internal/db/models/member.go new file mode 100644 index 0000000..07a6337 --- /dev/null +++ b/internal/db/models/member.go @@ -0,0 +1,21 @@ +package models + +import "github.com/google/uuid" + +type MemberRole string + +const ( + MemberRoleAdmin MemberRole = "admin" + MemberRoleMember MemberRole = "member" + MemberRoleUser MemberRole = "user" +) + +type Member struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"` + Role MemberRole `gorm:"not null;default:member" json:"role"` // e.g. admin, member + Timestamped +} diff --git a/internal/db/models/organization.go b/internal/db/models/organization.go new file mode 100644 index 0000000..a035288 --- /dev/null +++ b/internal/db/models/organization.go @@ -0,0 +1,12 @@ +package models + +import "github.com/google/uuid" + +type Organization struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"unique" json:"slug"` + Logo string `json:"logo"` + Metadata string `json:"metadata"` + Timestamped +} diff --git a/internal/db/models/password-reset.go b/internal/db/models/password-reset.go new file mode 100644 index 0000000..56e43a9 --- /dev/null +++ b/internal/db/models/password-reset.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type PasswordReset struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"` + Token string `gorm:"type:char(43);uniqueIndex;not null" json:"-"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + Used bool `gorm:"not null;default:false;index" json:"used"` + Timestamped +} + +func (p PasswordReset) IsActive(now time.Time) bool { + return !p.Used && now.Before(p.ExpiresAt) +} diff --git a/internal/db/models/refresh-token.go b/internal/db/models/refresh-token.go new file mode 100644 index 0000000..0bc2186 --- /dev/null +++ b/internal/db/models/refresh-token.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type RefreshToken struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID + Token string `gorm:"uniqueIndex"` + ExpiresAt time.Time + Revoked bool +} diff --git a/internal/db/models/user.go b/internal/db/models/user.go new file mode 100644 index 0000000..c1f0743 --- /dev/null +++ b/internal/db/models/user.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Email string `gorm:"uniqueIndex" json:"email"` + EmailVerified bool `gorm:"default:false" json:"email_verified"` + EmailVerifiedAt time.Time `gorm:"default:null" json:"email_verified_at"` + Password string + Role Role + Timestamped +} + +func (r Role) IsValid() bool { + switch r { + case RoleAdmin, RoleUser: + return true + } + return false +} diff --git a/internal/funcs/funcs.go b/internal/funcs/funcs.go new file mode 100644 index 0000000..dfbd84f --- /dev/null +++ b/internal/funcs/funcs.go @@ -0,0 +1,209 @@ +package funcs + +import ( + "bytes" + "fmt" + "html/template" + "math" + "net/url" + "strconv" + "strings" + "time" + "unicode" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var printer = message.NewPrinter(language.English) + +var TemplateFuncs = template.FuncMap{ + // Time functions + "now": time.Now, + "timeSince": time.Since, + "timeUntil": time.Until, + "formatTime": formatTime, + "approxDuration": approxDuration, + + // String functions + "uppercase": strings.ToUpper, + "lowercase": strings.ToLower, + "pluralize": pluralize, + "slugify": slugify, + "safeHTML": safeHTML, + + // Slice functions + "join": strings.Join, + + // Number functions + "incr": incr, + "decr": decr, + "formatInt": formatInt, + "formatFloat": formatFloat, + + // Boolean functions + "yesno": yesno, + + // URL functions + "urlSetParam": urlSetParam, + "urlDelParam": urlDelParam, +} + +func formatTime(format string, t time.Time) string { + return t.Format(format) +} + +func approxDuration(d time.Duration) string { + const ( + day = 24 * time.Hour + year = 365 * day + ) + + formatUnit := func(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("1 %s", singular) + } + return fmt.Sprintf("%d %s", count, plural) + } + + switch { + case d >= year: + return formatUnit(int(math.Round(float64(d)/float64(year))), "year", "years") + case d >= day: + return formatUnit(int(math.Round(float64(d)/float64(day))), "day", "days") + case d >= time.Hour: + return formatUnit(int(math.Round(d.Hours())), "hour", "hours") + case d >= time.Minute: + return formatUnit(int(math.Round(d.Minutes())), "minute", "minutes") + case d >= time.Second: + return formatUnit(int(math.Round(d.Seconds())), "second", "seconds") + default: + return "less than 1 second" + } +} + +func pluralize(count any, singular string, plural string) (string, error) { + n, err := toInt64(count) + if err != nil { + return "", err + } + + if n == 1 { + return singular, nil + } + + return plural, nil +} + +func slugify(s string) string { + var buf bytes.Buffer + + for _, r := range s { + switch { + case r > unicode.MaxASCII: + continue + case unicode.IsLetter(r): + buf.WriteRune(unicode.ToLower(r)) + case unicode.IsDigit(r), r == '_', r == '-': + buf.WriteRune(r) + case unicode.IsSpace(r): + buf.WriteRune('-') + } + } + + return buf.String() +} + +func safeHTML(s string) template.HTML { + return template.HTML(s) +} + +func incr(i any) (int64, error) { + n, err := toInt64(i) + if err != nil { + return 0, err + } + + n++ + return n, nil +} + +func decr(i any) (int64, error) { + n, err := toInt64(i) + if err != nil { + return 0, err + } + + n-- + return n, nil +} + +func formatInt(i any) (string, error) { + n, err := toInt64(i) + if err != nil { + return "", err + } + + return printer.Sprintf("%d", n), nil +} + +func formatFloat(f float64, dp int) string { + format := "%." + strconv.Itoa(dp) + "f" + return printer.Sprintf(format, f) +} + +func yesno(b bool) string { + if b { + return "Yes" + } + + return "No" +} + +func urlSetParam(u *url.URL, key string, value any) *url.URL { + nu := *u + values := nu.Query() + + values.Set(key, fmt.Sprintf("%v", value)) + + nu.RawQuery = values.Encode() + return &nu +} + +func urlDelParam(u *url.URL, key string) *url.URL { + nu := *u + values := nu.Query() + + values.Del(key) + + nu.RawQuery = values.Encode() + return &nu +} + +func toInt64(i any) (int64, error) { + switch v := i.(type) { + case int: + return int64(v), nil + case int8: + return int64(v), nil + case int16: + return int64(v), nil + case int32: + return int64(v), nil + case int64: + return v, nil + case uint: + return int64(v), nil + case uint8: + return int64(v), nil + case uint16: + return int64(v), nil + case uint32: + return int64(v), nil + // Note: uint64 not supported due to risk of truncation. + case string: + return strconv.ParseInt(v, 10, 64) + } + + return 0, fmt.Errorf("unable to convert type %T to int", i) +} diff --git a/internal/handlers/authn/auth.go b/internal/handlers/authn/auth.go new file mode 100644 index 0000000..b75912d --- /dev/null +++ b/internal/handlers/authn/auth.go @@ -0,0 +1,541 @@ +package authn + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/glueops/autoglue/internal/middleware" + "github.com/glueops/autoglue/internal/response" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// Register godoc +// @Summary Register a new user +// @Description Registers a new user and stores credentials +// @Tags auth +// @Accept json +// @Produce json +// @Param body body RegisterInput true "User registration input" +// @Success 201 {string} string "created" +// @Failure 400 {string} string "bad request" +// @Router /api/v1/auth/register [post] +func Register(w http.ResponseWriter, r *http.Request) { + var input RegisterInput + json.NewDecoder(r.Body).Decode(&input) + + hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + + user := models.User{Email: input.Email, Password: string(hashed), Name: input.Name, Role: "user"} + if err := db.DB.Create(&user).Error; err != nil { + http.Error(w, "registration failed", 400) + return + } + + _ = response.JSON(w, http.StatusCreated, map[string]string{"status": "created"}) +} + +// Login godoc +// @Summary Authenticate and return a token +// @Description Authenticates a user and returns a JWT bearer token +// @Tags auth +// @Accept json +// @Produce json +// @Param body body LoginInput true "User login input" +// @Success 200 {object} map[string]string "token" +// @Failure 401 {string} string "unauthorized" +// @Router /api/v1/auth/login [post] +func Login(w http.ResponseWriter, r *http.Request) { + var input LoginInput + json.NewDecoder(r.Body).Decode(&input) + + var user models.User + if err := db.DB.Where("email = ?", input.Email).First(&user).Error; err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + claims := jwt.MapClaims{ + "sub": user.ID, + "exp": time.Now().Add(time.Hour * 72).Unix(), + } + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessStr, _ := accessToken.SignedString(jwtSecret) + + refreshTokenStr := uuid.NewString() + + _ = db.DB.Create(&models.RefreshToken{ + UserID: user.ID, + Token: refreshTokenStr, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + Revoked: false, + }).Error + + _ = response.JSON(w, http.StatusOK, map[string]string{ + "access_token": accessStr, + "refresh_token": refreshTokenStr, + }) +} + +// Refresh godoc +// @Summary Refresh access token +// @Description Use a refresh token to obtain a new access token +// @Tags auth +// @Accept json +// @Produce json +// @Param body body map[string]string true "refresh_token" +// @Success 200 {object} map[string]string "new access token" +// @Failure 401 {string} string "unauthorized" +// @Security BearerAuth +// @Router /api/v1/auth/refresh [post] +func Refresh(w http.ResponseWriter, r *http.Request) { + var input struct { + RefreshToken string `json:"refresh_token"` + } + json.NewDecoder(r.Body).Decode(&input) + + var token models.RefreshToken + if err := db.DB.Where("token = ? AND revoked = false", input.RefreshToken).First(&token).Error; err != nil || token.ExpiresAt.Before(time.Now()) { + http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized) + return + } + + claims := jwt.MapClaims{ + "sub": token.UserID, + "exp": time.Now().Add(rotatedAccessTTL).Unix(), + } + newAccess := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + newToken, _ := newAccess.SignedString(jwtSecret) + + _ = response.JSON(w, http.StatusOK, map[string]string{ + "access_token": newToken, + }) +} + +// Logout godoc +// @Summary Logout user +// @Description Revoke a refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param body body map[string]string true "refresh_token" +// @Success 204 {string} string "no content" +// @Security BearerAuth +// @Router /api/v1/auth/logout [post] +func Logout(w http.ResponseWriter, r *http.Request) { + var input struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.RefreshToken == "" { + response.Error(w, http.StatusBadRequest, "bad request") + return + } + + db.DB.Model(&models.RefreshToken{}).Where("token = ?", input.RefreshToken).Update("revoked", true) + response.NoContent(w) +} + +// Me godoc +// @Summary Get authenticated user info +// @Description Returns the authenticated user's profile and auth context +// @Tags auth +// @Produce json +// @Success 200 {object} MeResponse +// @Failure 401 {string} string "unauthorized" +// @Security BearerAuth +// @Router /api/v1/auth/me [get] +func Me(w http.ResponseWriter, r *http.Request) { + authCtx := middleware.GetAuthContext(r) + if authCtx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var user models.User + if err := db.DB.First(&user, "id = ?", authCtx.UserID).Error; err != nil { + response.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + + out := MeResponse{ + User: UserDTO{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + EmailVerified: user.EmailVerified, + Role: user.Role, + CreatedAt: user.CreatedAt, // from Timestamped + UpdatedAt: user.UpdatedAt, // from Timestamped + }, + OrgRole: authCtx.OrgRole, + } + + if authCtx.OrganizationID != uuid.Nil { + s := authCtx.OrganizationID.String() + out.OrganizationID = &s + } + + if c := authCtx.Claims; c != nil { + var exp, iat, nbf int64 + if c.ExpiresAt != nil { + exp = c.ExpiresAt.Time.Unix() + } + if c.IssuedAt != nil { + iat = c.IssuedAt.Time.Unix() + } + if c.NotBefore != nil { + nbf = c.NotBefore.Time.Unix() + } + + out.Claims = &AuthClaimsDTO{ + Orgs: c.Orgs, + Roles: c.Roles, + Issuer: c.Issuer, + Subject: c.Subject, + Audience: []string(c.Audience), + ExpiresAt: exp, + IssuedAt: iat, + NotBefore: nbf, + } + } + + _ = response.JSON(w, http.StatusOK, out) +} + +// Introspect godoc +// @Summary Introspect a token +// @Description Returns whether the token is active and basic metadata +// @Tags auth +// @Accept json +// @Produce json +// @Param body body map[string]string true "token" +// @Success 200 {object} map[string]any +// @Router /api/v1/auth/introspect [post] +func Introspect(w http.ResponseWriter, r *http.Request) { + var in struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + tok, err := jwt.Parse(in.Token, func(t *jwt.Token) (any, error) { return jwtSecret, nil }) + if err == nil && tok.Valid { + claims, _ := tok.Claims.(jwt.MapClaims) + _ = response.JSON(w, http.StatusOK, map[string]any{ + "active": true, + "type": "access", + "sub": claims["sub"], + "exp": claims["exp"], + "iat": claims["iat"], + "nbf": claims["nbf"], + }) + return + } + + var rt models.RefreshToken + if err := db.DB.Where("token = ? AND revoked = false", in.Token).First(&rt).Error; err == nil && rt.ExpiresAt.After(time.Now()) { + _ = response.JSON(w, http.StatusOK, map[string]any{ + "active": true, + "type": "refresh", + "sub": rt.UserID, + "exp": rt.ExpiresAt.Unix(), + }) + return + } + + _ = response.JSON(w, http.StatusOK, map[string]any{"active": false}) +} + +// RequestPasswordReset godoc +// @Summary Request password reset +// @Description Sends a reset token to the user's email address +// @Tags auth +// @Accept json +// @Produce plain +// @Param body body map[string]string true "email" +// @Success 204 {string} string "no content" +// @Router /api/v1/auth/password/forgot [post] +func RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + var in struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Always return 204 to avoid user enumeration. + var user models.User + if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil { + _ = db.DB.Model(&models.PasswordReset{}). + Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()). + Update("used", true).Error + + if tok, err := issuePasswordReset(user.ID, user.Email); err == nil { + _ = sendEmail(user.Email, "Password reset", fmt.Sprintf("Your password reset token is: %s", tok)) + err := sendTemplatedEmail(user.Email, "password_reset.tmpl", PasswordResetData{ + Name: user.Name, + Email: user.Email, + Token: tok, + ResetURL: fmt.Sprintf("%s/auth/reset?token=%s", config.FrontendBaseURL(), tok), // e.g. fmt.Sprintf("%s/reset?token=%s", frontendURL, tok) + }) + if err != nil { + fmt.Printf("smtp send error: %v\n", err) + } + } + } + response.NoContent(w) +} + +// ConfirmPasswordReset godoc +// @Summary Confirm password reset +// @Description Resets the password using a valid reset token +// @Tags auth +// @Accept json +// @Produce plain +// @Param body body map[string]string true "token, new_password" +// @Success 204 {string} string "no content" +// @Failure 400 {string} string "bad request" +// @Router /api/v1/auth/password/reset [post] +func ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) { + var in struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" || in.NewPassword == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + var pr models.PasswordReset + if err := db.DB.Where("token = ? AND used = false", in.Token).First(&pr).Error; err != nil || pr.ExpiresAt.Before(time.Now()) { + response.Error(w, http.StatusBadRequest, "invalid or expired token") + return + } + + var user models.User + if err := db.DB.First(&user, "id = ?", pr.UserID).Error; err != nil { + response.Error(w, http.StatusBadRequest, "invalid token") + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to hash") + return + } + if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil { + response.Error(w, http.StatusInternalServerError, "update failed") + return + } + + _ = db.DB.Model(&models.PasswordReset{}).Where("id = ?", pr.ID).Update("used", true).Error + _ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", user.ID).Update("revoked", true).Error + + response.NoContent(w) +} + +// ChangePassword godoc +// @Summary Change password +// @Description Changes the password for the authenticated user +// @Tags auth +// @Accept json +// @Produce plain +// @Param body body map[string]string true "current_password, new_password" +// @Success 204 {string} string "no content" +// @Failure 400 {string} string "bad request" +// @Security BearerAuth +// @Router /api/v1/auth/password/change [post] +func ChangePassword(w http.ResponseWriter, r *http.Request) { + ctx := middleware.GetAuthContext(r) + if ctx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var in struct { + Current string `json:"current_password"` + New string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Current == "" || in.New == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + var user models.User + if err := db.DB.First(&user, "id = ?", ctx.UserID).Error; err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(in.Current)); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + hashed, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "failed to hash", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + + // Optional hardening: revoke all refresh tokens after password change + // _ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ?", user.ID).Update("revoked", true) + + w.WriteHeader(http.StatusNoContent) +} + +// VerifyEmail godoc +// @Summary Verify email address +// @Description Verifies the user's email using a token (often from an emailed link) +// @Tags auth +// @Produce plain +// @Param token query string true "verification token" +// @Success 204 {string} string "no content" +// @Failure 400 {string} string "bad request" +// @Router /api/v1/auth/verify [get] +func VerifyEmail(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + var ev models.EmailVerification + if err := db.DB.Where("token = ? AND used = false", token).First(&ev).Error; err != nil || ev.ExpiresAt.Before(time.Now()) { + response.Error(w, http.StatusBadRequest, "invalid or expired token") + return + } + + _ = db.DB.Model(&models.User{}).Where("id = ?", ev.UserID).Updates(map[string]any{ + "email_verified": true, + "email_verified_at": time.Now(), + }).Error + _ = db.DB.Model(&models.EmailVerification{}).Where("id = ?", ev.ID).Update("used", true).Error + + response.NoContent(w) +} + +// ResendVerification godoc +// @Summary Resend email verification +// @Description Sends a new email verification token if needed +// @Tags auth +// @Accept json +// @Produce plain +// @Param body body map[string]string true "email" +// @Success 204 {string} string "no content" +// @Router /api/v1/auth/verify/resend [post] +func ResendVerification(w http.ResponseWriter, r *http.Request) { + var in struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + var user models.User + if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil { + _ = db.DB.Model(&models.EmailVerification{}). + Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()). + Update("used", true).Error + + if tok, err := issueEmailVerification(user.ID, user.Email); err == nil { + _ = sendEmail(user.Email, "Verify your account", fmt.Sprintf("Your verification token is: %s", tok)) + _ = sendTemplatedEmail(user.Email, "verify_account.tmpl", VerifyEmailData{ + Name: user.Name, + Email: user.Email, + Token: tok, + VerificationURL: "", // e.g. fmt.Sprintf("%s/verify?token=%s", frontendURL, tok) + }) + } + } + + response.NoContent(w) +} + +// LogoutAll godoc +// @Summary Logout from all sessions +// @Description Revokes all active refresh tokens for the authenticated user +// @Tags auth +// @Produce plain +// @Success 204 {string} string "no content" +// @Security BearerAuth +// @Router /api/v1/auth/logout_all [post] +func LogoutAll(w http.ResponseWriter, r *http.Request) { + ctx := middleware.GetAuthContext(r) + if ctx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", ctx.UserID).Update("revoked", true) + response.NoContent(w) +} + +// RotateRefreshToken godoc +// @Summary Rotate refresh token +// @Description Exchanges a valid refresh token for a new access and refresh token, revoking the old one +// @Tags auth +// @Accept json +// @Produce json +// @Param body body map[string]string true "refresh_token" +// @Success 200 {object} map[string]string "access_token, refresh_token" +// @Failure 401 {string} string "unauthorized" +// @Security BearerAuth +// @Router /api/v1/auth/refresh/rotate [post] +func RotateRefreshToken(w http.ResponseWriter, r *http.Request) { + var in struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.RefreshToken == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + var old models.RefreshToken + if err := db.DB.Where("token = ? AND revoked = false", in.RefreshToken).First(&old).Error; err != nil || old.ExpiresAt.Before(time.Now()) { + http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized) + return + } + + _ = db.DB.Model(&models.RefreshToken{}).Where("id = ?", old.ID).Update("revoked", true) + + claims := jwt.MapClaims{ + "sub": old.UserID, + "exp": time.Now().Add(15 * time.Minute).Unix(), + } + access := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessStr, _ := access.SignedString(jwtSecret) + + newRefresh := models.RefreshToken{ + UserID: old.UserID, + Token: uuid.NewString(), + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + Revoked: false, + } + _ = db.DB.Create(&newRefresh).Error + + _ = response.JSON(w, http.StatusOK, map[string]string{ + "access_token": accessStr, + "refresh_token": newRefresh.Token, + }) +} diff --git a/internal/handlers/authn/dto.go b/internal/handlers/authn/dto.go new file mode 100644 index 0000000..3f65ca0 --- /dev/null +++ b/internal/handlers/authn/dto.go @@ -0,0 +1,79 @@ +package authn + +import ( + "sync" + "time" + + "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/db/models" + appsmtp "github.com/glueops/autoglue/internal/smtp" + "github.com/google/uuid" +) + +var jwtSecret = []byte(config.GetAuthSecret()) +var ( + mailerOnce sync.Once + mailer *appsmtp.Mailer + mailerErr error +) + +const ( + resetTTL = 1 * time.Hour // password reset token validity + verifyTTL = 48 * time.Hour // email verification token validity + refreshTTL = 7 * 24 * time.Hour + accessTTL = 72 * time.Hour + rotatedAccessTTL = 15 * time.Minute +) + +type RegisterInput struct { + Email string `json:"email" example:"me@here.com"` + Name string `json:"name" example:"My Name"` + Password string `json:"password" example:"123456"` +} + +type LoginInput struct { + Email string `json:"email" example:"me@here.com"` + Password string `json:"password" example:"123456"` +} + +type UserDTO struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Role models.Role `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AuthClaimsDTO struct { + Orgs []string `json:"orgs,omitempty"` + Roles []string `json:"roles,omitempty"` + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience []string `json:"aud,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` +} + +type MeResponse struct { + User UserDTO `json:"user_id"` + OrganizationID *string `json:"organization_id,omitempty"` + OrgRole string `json:"org_role,omitempty"` + Claims *AuthClaimsDTO `json:"claims,omitempty"` +} + +type VerifyEmailData struct { + Name string + Email string + Token string + VerificationURL string +} + +type PasswordResetData struct { + Name string + Email string + Token string + ResetURL string +} diff --git a/internal/handlers/authn/funcs.go b/internal/handlers/authn/funcs.go new file mode 100644 index 0000000..832da2b --- /dev/null +++ b/internal/handlers/authn/funcs.go @@ -0,0 +1,92 @@ +package authn + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + appsmtp "github.com/glueops/autoglue/internal/smtp" + "github.com/google/uuid" +) + +func randomToken(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func issuePasswordReset(userID uuid.UUID, email string) (string, error) { + tok, err := randomToken(32) + if err != nil { + return "", err + } + pr := models.PasswordReset{ + UserID: userID, + Token: tok, // consider storing hash in prod + ExpiresAt: time.Now().Add(resetTTL), + Used: false, + } + if err := db.DB.Create(&pr).Error; err != nil { + return "", err + } + return tok, nil +} + +func issueEmailVerification(userID uuid.UUID, email string) (string, error) { + tok, err := randomToken(32) + if err != nil { + return "", err + } + ev := models.EmailVerification{ + ID: uuid.New(), + UserID: userID, + Token: tok, // consider storing hash in prod + ExpiresAt: time.Now().Add(verifyTTL), + Used: false, + } + if err := db.DB.Create(&ev).Error; err != nil { + return "", err + } + return tok, nil +} + +func sendEmail(to, subject, body string) error { + // integrate with your provider here + fmt.Printf("Sending email to: %s\n", to) + fmt.Printf("Subject: %s\n", subject) + fmt.Printf("Content-Type: text/html; charset=UTF-8\n") + fmt.Printf("%s\n", body) + return nil +} + +func getMailer() (*appsmtp.Mailer, error) { + mailerOnce.Do(func() { + if !config.SMTPEnabled() { + mailerErr = fmt.Errorf("smtp disabled") + return + } + mailer, mailerErr = appsmtp.NewMailer( + config.SMTPHost(), + config.SMTPPort(), + config.SMTPUsername(), + config.SMTPPassword(), + config.SMTPFrom(), + ) + }) + return mailer, mailerErr +} + +func sendTemplatedEmail(to string, templateFile string, data any) error { + m, err := getMailer() + if err != nil { + // fail soft if smtp is disabled; return nil so API UX isn't blocked + return nil + } + return m.Send(to, data, templateFile) +} diff --git a/internal/handlers/health/health.go b/internal/handlers/health/health.go new file mode 100644 index 0000000..5c4a0c8 --- /dev/null +++ b/internal/handlers/health/health.go @@ -0,0 +1,19 @@ +package health + +import ( + "net/http" + + "github.com/glueops/autoglue/internal/response" +) + +// Check HealthCheck godoc +// @Summary Basic health check +// @Description Returns a 200 if the service is up +// @Tags health +// @Accept json +// @Produce plain +// @Success 200 {string} string "ok" +// @Router /api/healthz [get] +func Check(w http.ResponseWriter, r *http.Request) { + _ = response.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/handlers/orgs/dto.go b/internal/handlers/orgs/dto.go new file mode 100644 index 0000000..50e9635 --- /dev/null +++ b/internal/handlers/orgs/dto.go @@ -0,0 +1,6 @@ +package orgs + +type OrgInput struct { + Name string `json:"name"` + Slug string `json:"slug"` +} diff --git a/internal/handlers/orgs/orgs.go b/internal/handlers/orgs/orgs.go new file mode 100644 index 0000000..d98b3a2 --- /dev/null +++ b/internal/handlers/orgs/orgs.go @@ -0,0 +1,90 @@ +package orgs + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/glueops/autoglue/internal/middleware" + "github.com/glueops/autoglue/internal/response" +) + +// CreateOrganization godoc +// @Summary Create a new organization +// @Description Creates a new organization and assigns the authenticated user as an admin member +// @Tags organizations +// @Accept json +// @Produce json +// @Param X-Org-ID header string false "Optional organization context (ignored for creation)" +// @Param body body OrgInput true "Organization Input" +// @Success 200 {object} map[string]string "organization_id" +// @Failure 400 {string} string "invalid input" +// @Failure 401 {string} string "unauthorized" +// @Failure 500 {string} string "internal error" +// @Security BearerAuth +// @Router /api/v1/orgs [post] +func CreateOrganization(w http.ResponseWriter, r *http.Request) { + authCtx := middleware.GetAuthContext(r) + if authCtx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + userID := authCtx.UserID + + var input OrgInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || strings.TrimSpace(input.Name) == "" { + http.Error(w, "invalid input", http.StatusBadRequest) + return + } + + org := &models.Organization{ + Name: input.Name, + Slug: input.Slug, + } + + if err := db.DB.Create(&org).Error; err != nil { + http.Error(w, "could not create org", http.StatusInternalServerError) + return + } + + member := models.Member{ + UserID: userID, + OrganizationID: org.ID, + Role: "admin", + } + + if err := db.DB.Create(&member).Error; err != nil { + http.Error(w, "could not add member", http.StatusInternalServerError) + return + } + + _ = response.JSON(w, http.StatusCreated, org) +} + +// ListOrganizations godoc +// @Summary List organizations for user +// @Tags organizations +// @Produce json +// @Success 200 {array} models.Organization +// @Failure 401 {string} string "unauthorized" +// @Security BearerAuth +// @Router /api/v1/orgs [get] +func ListOrganizations(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r) + if auth == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var orgs []models.Organization + err := db.DB.Joins("JOIN members m ON m.organization_id = organizations.id"). + Where("m.user_id = ?", auth.UserID).Where("organizations.deleted_at IS NULL").Find(&orgs).Error + if err != nil { + http.Error(w, "failed to fetch orgs", http.StatusInternalServerError) + return + } + + _ = response.JSON(w, http.StatusOK, orgs) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..6284c16 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,85 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type AuthClaims struct { + Orgs []string `json:"orgs"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +type AuthContext struct { + UserID uuid.UUID + OrganizationID uuid.UUID + OrgRole string // Role in the org + Claims *AuthClaims `json:"claims,omitempty" swaggerignore:"true"` +} + +type contextKey struct{} + +var authContextKey = contextKey{} + +func AuthMiddleware(secret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + claims := &AuthClaims{} + + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil || !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + userUUID, err := uuid.Parse(claims.Subject) + if err != nil { + http.Error(w, "invalid user id", http.StatusUnauthorized) + return + } + + authCtx := &AuthContext{ + UserID: userUUID, + Claims: claims, + } + + if orgID := r.Header.Get("X-Org-ID"); orgID != "" { + orgUUID, _ := uuid.Parse(orgID) + + var member models.Member + if err := db.DB.Where("user_id = ? AND organization_id = ?", claims.Subject, orgID).First(&member).Error; err != nil { + http.Error(w, "User not a member of the organization", http.StatusForbidden) + return + } + authCtx.OrganizationID = orgUUID + authCtx.OrgRole = string(member.Role) + } + + ctx := context.WithValue(r.Context(), authContextKey, authCtx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func GetAuthContext(r *http.Request) *AuthContext { + if ac, ok := r.Context().Value(authContextKey).(*AuthContext); ok { + return ac + } + return nil +} diff --git a/internal/response/json.go b/internal/response/json.go new file mode 100644 index 0000000..8e22686 --- /dev/null +++ b/internal/response/json.go @@ -0,0 +1,39 @@ +package response + +import ( + "encoding/json" + "net/http" +) + +func JSON(w http.ResponseWriter, status int, data any) error { + return JSONWithHeaders(w, status, data, nil) +} + +func JSONWithHeaders(w http.ResponseWriter, status int, data any, headers http.Header) error { + js, err := json.MarshalIndent(data, "", "\t") + if err != nil { + return err + } + + js = append(js, '\n') + + for key, value := range headers { + w.Header()[key] = value + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + + return nil +} + +func Error(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/smtp/mailer.go b/internal/smtp/mailer.go new file mode 100644 index 0000000..21ec4ec --- /dev/null +++ b/internal/smtp/mailer.go @@ -0,0 +1,116 @@ +package smtp + +import ( + "bytes" + "time" + + "github.com/glueops/autoglue/internal/assets" + "github.com/glueops/autoglue/internal/funcs" + "github.com/wneessen/go-mail" + + htmlTemplate "html/template" + textTemplate "text/template" +) + +const defaultTimeout = 10 * time.Second + +type Mailer struct { + client *mail.Client + from string +} + +func NewMailer(host string, port int, username, password, from string) (*Mailer, error) { + opts := []mail.Option{ + mail.WithTimeout(defaultTimeout), + mail.WithPort(port), + // IMPORTANT for Mailpit/local dev: no TLS + mail.WithTLSPolicy(mail.NoTLS), + } + + if username != "" { + opts = append(opts, + mail.WithSMTPAuth(mail.SMTPAuthLogin), + mail.WithUsername(username), + mail.WithPassword(password), + ) + } + + client, err := mail.NewClient(host, opts...) + if err != nil { + return nil, err + } + + mailer := &Mailer{ + client: client, + from: from, + } + + return mailer, nil +} + +func (m *Mailer) Send(recipient string, data any, patterns ...string) error { + for i := range patterns { + patterns[i] = "emails/" + patterns[i] + } + msg := mail.NewMsg() + + err := msg.To(recipient) + if err != nil { + return err + } + + err = msg.From(m.from) + if err != nil { + return err + } + + ts, err := textTemplate.New("").Funcs(funcs.TemplateFuncs).ParseFS(assets.EmbeddedFiles, patterns...) + if err != nil { + return err + } + + subject := new(bytes.Buffer) + err = ts.ExecuteTemplate(subject, "subject", data) + if err != nil { + return err + } + + msg.Subject(subject.String()) + + plainBody := new(bytes.Buffer) + err = ts.ExecuteTemplate(plainBody, "plainBody", data) + if err != nil { + return err + } + + msg.SetBodyString(mail.TypeTextPlain, plainBody.String()) + + if ts.Lookup("htmlBody") != nil { + ts, err := htmlTemplate.New("").Funcs(funcs.TemplateFuncs).ParseFS(assets.EmbeddedFiles, patterns...) + if err != nil { + return err + } + + htmlBody := new(bytes.Buffer) + err = ts.ExecuteTemplate(htmlBody, "htmlBody", data) + if err != nil { + return err + } + + msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String()) + } + + for i := 1; i <= 3; i++ { + err = m.client.DialAndSend(msg) + + if nil == err { + return nil + } + + if i != 3 { + time.Sleep(2 * time.Second) + } + } + + return err +} diff --git a/internal/ui/devproxy.go b/internal/ui/devproxy.go new file mode 100644 index 0000000..2555d96 --- /dev/null +++ b/internal/ui/devproxy.go @@ -0,0 +1,16 @@ +package ui + +import ( + "net/http" + "net/http/httputil" + "net/url" +) + +func DevProxy(target string) (http.Handler, error) { + u, err := url.Parse(target) + if err != nil { + return nil, err + } + p := httputil.NewSingleHostReverseProxy(u) + return p, nil +} diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html new file mode 100644 index 0000000..90fdc55 --- /dev/null +++ b/internal/ui/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + React + TS + + + + +
+ + diff --git a/internal/ui/dist/vite.svg b/internal/ui/dist/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/internal/ui/dist/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/ui/static.go b/internal/ui/static.go new file mode 100644 index 0000000..3f12d3d --- /dev/null +++ b/internal/ui/static.go @@ -0,0 +1,106 @@ +package ui + +import ( + "embed" + "io" + "io/fs" + "net/http" + "path" + "path/filepath" + "strings" + "time" +) + +// NOTE: Vite outputs to ui/dist with assets in dist/assets. +// If you add more nested folders in the future, include them here too. + +//go:embed dist/* dist/assets/* +var distFS embed.FS + +// spaFileSystem serves embedded dist/ files with SPA fallback to index.html +type spaFileSystem struct { + fs fs.FS +} + +func (s spaFileSystem) Open(name string) (fs.File, error) { + // Normalize, strip leading slash + if strings.HasPrefix(name, "/") { + name = name[1:] + } + // Try exact file + f, err := s.fs.Open(name) + if err == nil { + return f, nil + } + + // If the requested file doesn't exist, fall back to index.html for SPA routes + // BUT only if it's not obviously a static asset extension + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".woff", ".woff2": + return nil, fs.ErrNotExist + } + + return s.fs.Open("index.html") +} + +func newDistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// SPAHandler returns an http.Handler that serves the embedded UI (with caching) +func SPAHandler() (http.Handler, error) { + sub, err := newDistFS() + if err != nil { + return nil, err + } + + // Wrap with our SPA filesystem and our own file server to control headers. + spa := spaFileSystem{fs: sub} + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Prevent /api, /swagger, /debug/pprof from being eaten by SPA fallback. + if strings.HasPrefix(r.URL.Path, "/api/") || + r.URL.Path == "/api" || + strings.HasPrefix(r.URL.Path, "/swagger") || + strings.HasPrefix(r.URL.Path, "/debug/pprof") { + http.NotFound(w, r) + return + } + + // Open file (or fallback to index.html) + filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if filePath == "" { + filePath = "index.html" + } + f, err := spa.Open(filePath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + // Guess content-type by suffix (let Go detect if possible) + // Serve with gentle caching: long for assets, short for HTML + if strings.HasSuffix(filePath, ".html") { + w.Header().Set("Cache-Control", "no-cache") + } else { + // Vite assets are hashed; safe to cache + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + + // Serve content + http.ServeContent(w, r, filePath, time.Now(), file{f}) + }), nil +} + +// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent) +type file struct{ fs.File } + +func (f file) Seek(offset int64, whence int) (int64, error) { + if s, ok := f.File.(io.Seeker); ok { + return s.Seek(offset, whence) + } + // Fallback: not seekable + return 0, fs.ErrInvalid +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f3f7972 --- /dev/null +++ b/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/glueops/autoglue/cmd/cli" + "github.com/glueops/autoglue/internal/config" +) + +// @title AutoGlue API +// @version 1.0 +// @description API for managing K3s clusters across cloud providers +// @BasePath / +// @schemes http + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +func main() { + config.Load() + cli.Execute() +} diff --git a/postgres/Dockerfile b/postgres/Dockerfile new file mode 100644 index 0000000..debb8bf --- /dev/null +++ b/postgres/Dockerfile @@ -0,0 +1,10 @@ +FROM postgres:latest + +RUN cd /var/lib/postgresql/ && \ + openssl req -new -text -passout pass:abcd -subj /CN=localhost -out server.req -keyout privkey.pem && \ + openssl rsa -in privkey.pem -passin pass:abcd -out server.key && \ + openssl req -x509 -in server.req -text -key server.key -out server.crt && \ + chmod 600 server.key && \ + chown postgres:postgres server.key + +CMD ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/var/lib/postgresql/server.crt", "-c", "ssl_key_file=/var/lib/postgresql/server.key" ] diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json new file mode 100644 index 0000000..74ca29e --- /dev/null +++ b/ui/.prettierrc.json @@ -0,0 +1,37 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "printWidth": 100, + "trailingComma": "es5", + "importOrder": [ + "^(react/(.*)$)|^(react$)", + "^(next/(.*)$)|^(next$)", + "", + "", + "^@workspace/(.*)$", + "", + "^types$", + "^@/types/(.*)$", + "^@/config/(.*)$", + "^@/lib/(.*)$", + "^@/hooks/(.*)$", + "^@/components/ui/(.*)$", + "^@/components/(.*)$", + "^@/pages/(.*)$", + "^@/registry/(.*)$", + "^@/styles/(.*)$", + "^@/app/(.*)$", + "^@/www/(.*)$", + "", + "^[./]" + ], + "importOrderSeparation": false, + "importOrderSortSpecifiers": true, + "importOrderBuiltinModulesToTop": true, + "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"], + "importOrderMergeDuplicateImports": true, + "importOrderCombineTypeAndValueImports": true, + "plugins": ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"] +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..3066b38 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ["./tsconfig.node.json", "./tsconfig.app.json"], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactDom from "eslint-plugin-react-dom" +import reactX from "eslint-plugin-react-x" + +export default tseslint.config([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs["recommended-typescript"], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ["./tsconfig.node.json", "./tsconfig.app.json"], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000..1f2ec01 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..3b3d2ea --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import { globalIgnores } from "eslint/config" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default tseslint.config([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs["recommended-latest"], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]) diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..1fc604a --- /dev/null +++ b/ui/package.json @@ -0,0 +1,56 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.542.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.62.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.8.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.12", + "zod": "^4.1.5" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^9.34.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.6.14", + "shadcn": "^3.0.0", + "tw-animate-css": "^1.3.7", + "typescript": "~5.9.2", + "typescript-eslint": "^8.41.0", + "vite": "^7.1.3" + } +} diff --git a/ui/public/vite.svg b/ui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..050a870 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,61 @@ +import { Navigate, Route, Routes } from "react-router-dom" + +import { DashboardLayout } from "@/components/dashboard-layout.tsx" +import { ProtectedRoute } from "@/components/protected-route.tsx" +import { ForgotPassword } from "@/pages/auth/forgot-password.tsx" +import { Login } from "@/pages/auth/login.tsx" +import { Me } from "@/pages/auth/me.tsx" +import { Register } from "@/pages/auth/register.tsx" +import { ResetPassword } from "@/pages/auth/reset-password.tsx" +import { VerifyEmail } from "@/pages/auth/verify-email.tsx" +import {NotFoundPage} from "@/pages/error/not-found.tsx"; +import {OrgManagement} from "@/pages/settings/orgs.tsx"; + +function App() { + return ( + + } /> + {/* Public/auth branch */} + + } /> + } /> + } /> + } /> + } /> + + }> + }> + } /> + + + + + }> + }> + + {/* + } /> + } /> + } /> + } /> + */} + + + + {/*} />*/} + + + + } /> + {/*} />*/} + + + } /> + + + } /> + + ) +} + +export default App diff --git a/ui/src/assets/react.svg b/ui/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/components/dashboard-layout.tsx b/ui/src/components/dashboard-layout.tsx new file mode 100644 index 0000000..ccf9f72 --- /dev/null +++ b/ui/src/components/dashboard-layout.tsx @@ -0,0 +1,20 @@ +import { SidebarProvider } from "@/components/ui/sidebar.tsx"; +import {Outlet} from "react-router-dom"; +import {Footer} from "@/components/footer.tsx"; +import {DashboardSidebar} from "@/components/dashboard-sidebar.tsx"; + +export function DashboardLayout() { + return ( +
+ + +
+
+ +
+
+
+
+
+ ) +} diff --git a/ui/src/components/dashboard-sidebar.tsx b/ui/src/components/dashboard-sidebar.tsx new file mode 100644 index 0000000..04e851e --- /dev/null +++ b/ui/src/components/dashboard-sidebar.tsx @@ -0,0 +1,82 @@ +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem +} from "@/components/ui/sidebar.tsx"; +import type {ComponentType, FC} from "react"; +import {Link, useLocation} from "react-router-dom"; +import {Collapsible, CollapsibleContent, CollapsibleTrigger} from "@/components/ui/collapsible.tsx"; +import {ChevronDown} from "lucide-react"; +import {items} from "@/components/sidebar/items.ts"; + +interface MenuItemProps { + label: string; + icon: ComponentType<{ className?: string }>; + to?: string; + items?: MenuItemProps[]; +} + +const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => { + const location = useLocation(); + const Icon = item.icon; + + if (item.to) { + return ( + + + {item.label} + + ) + } + + if (item.items) { + return ( + + + + + + {item.label} + + + + + + + {item.items.map((subitem, index) => ( + + + + + + ))} + + + + + + ) + } + return null +} + +export const DashboardSidebar = () => { + return ( + + +

AutoGlue

+
+ + {items.map((item, index) => ( + + ))} + +
+ ) +} \ No newline at end of file diff --git a/ui/src/components/footer.tsx b/ui/src/components/footer.tsx new file mode 100644 index 0000000..1f9fa20 --- /dev/null +++ b/ui/src/components/footer.tsx @@ -0,0 +1,38 @@ +import { FaGithub } from "react-icons/fa"; + +export function Footer() { + return ( + + ); +} diff --git a/ui/src/components/mode-toggle.tsx b/ui/src/components/mode-toggle.tsx new file mode 100644 index 0000000..a1fe626 --- /dev/null +++ b/ui/src/components/mode-toggle.tsx @@ -0,0 +1,34 @@ +import { Laptop, Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { Button } from "@/components/ui/button.tsx" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx" + +export function ModeToggle() { + const { setTheme, theme } = useTheme() + return ( + + + + + + setTheme("light")}>Light + setTheme("dark")}>Dark + setTheme("system")}>System + + + ) +} diff --git a/ui/src/components/protected-route.tsx b/ui/src/components/protected-route.tsx new file mode 100644 index 0000000..5781b2c --- /dev/null +++ b/ui/src/components/protected-route.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from "react" +import { Navigate, Outlet, useLocation } from "react-router-dom" + +import { authStore } from "@/lib/auth.ts" + +export function ProtectedRoute({ children }: { children?: ReactNode }) { + const location = useLocation() + const isAuthed = authStore.isAuthenticated() + if (!isAuthed) { + return + } + return children ? <>{children} : +} diff --git a/ui/src/components/sidebar/items.ts b/ui/src/components/sidebar/items.ts new file mode 100644 index 0000000..78cf43a --- /dev/null +++ b/ui/src/components/sidebar/items.ts @@ -0,0 +1,105 @@ +import { + BoxesIcon, + BrainCogIcon, + Building2Icon, + BuildingIcon, + ComponentIcon, + FileKey2Icon, + HomeIcon, + KeyIcon, + ListTodoIcon, + LockKeyholeIcon, + ServerIcon, + SettingsIcon, + SprayCanIcon, + TagsIcon, + UsersIcon, +} from "lucide-react"; +import { AiOutlineCluster } from "react-icons/ai"; + +export const items = [ + { + label: "Dashboard", + icon: HomeIcon, + to: "/dashboard", + }, + { + label: "Core", + icon: BrainCogIcon, + items: [ + { + label: "Cluster", + to: "/core/cluster", + icon: AiOutlineCluster, + }, + { + label: "Node Pools", + icon: BoxesIcon, + to: "/core/node-pools", + }, + { + label: "Labels", + icon: TagsIcon, + to: "/core/labels", + }, + { + label: "Roles", + icon: ComponentIcon, + to: "/core/roles", + }, + { + label: "Taints", + icon: SprayCanIcon, + to: "/core/taints", + }, + { + label: "Servers", + icon: ServerIcon, + to: "/core/servers", + }, + ], + }, + { + label: "Security", + icon: LockKeyholeIcon, + items: [ + { + label: "Keys & Tokens", + icon: KeyIcon, + to: "/security/keys", + }, + { + label: "SSH Keys", + to: "/security/ssh", + icon: FileKey2Icon, + }, + ], + }, + { + label: "Tasks", + icon: ListTodoIcon, + items: [], + }, + { + label: "Settings", + icon: SettingsIcon, + items: [ + { + label: "Organizations", + icon: Building2Icon, + items: [ + { + label: "Organizations", + to: "/settings/orgs", + icon: BuildingIcon, + }, + { + label: "Members", + to: "/settings/members", + icon: UsersIcon, + }, + ], + }, + ], + }, +]; diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/theme-provider.tsx new file mode 100644 index 0000000..292c634 --- /dev/null +++ b/ui/src/components/theme-provider.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export type Theme = "light" | "dark" | "system" + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", +}: { + children: ReactNode + defaultTheme?: Theme + storageKey?: string +}) { + return ( + + {children} + + ) +} diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/ui/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx new file mode 100644 index 0000000..9771ba0 --- /dev/null +++ b/ui/src/components/ui/card.tsx @@ -0,0 +1,75 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b7956b6 --- /dev/null +++ b/ui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,226 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/ui/src/components/ui/form.tsx b/ui/src/components/ui/form.tsx new file mode 100644 index 0000000..52a3210 --- /dev/null +++ b/ui/src/components/ui/form.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext({} as FormFieldContextValue) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext({} as FormItemContextValue) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +