Orgs, Members, SSH and Admin page

This commit is contained in:
allanice001
2025-09-01 21:58:34 +01:00
parent 3f22521f49
commit 5425ed5dcc
61 changed files with 7138 additions and 819 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,42 @@
basePath: /
definitions:
authn.AdminCreateUserRequest:
properties:
email:
example: jane@example.com
type: string
name:
example: Jane Doe
type: string
password:
example: Secret123!
type: string
role:
description: 'Role allowed values: "user" or "admin"'
enum:
- user
- admin
example: user
type: string
type: object
authn.AdminUpdateUserRequest:
properties:
email:
example: jane@example.com
type: string
name:
example: Jane Doe
type: string
password:
example: NewSecret123!
type: string
role:
enum:
- user
- admin
example: admin
type: string
type: object
authn.AuthClaimsDTO:
properties:
aud:
@@ -25,6 +62,19 @@ definitions:
sub:
type: string
type: object
authn.ListUsersOut:
properties:
page:
type: integer
page_size:
type: integer
total:
type: integer
users:
items:
$ref: '#/definitions/authn.UserListItem'
type: array
type: object
authn.LoginInput:
properties:
email:
@@ -74,6 +124,65 @@ definitions:
updated_at:
type: string
type: object
authn.UserListItem:
properties:
created_at: {}
email:
type: string
email_verified:
type: boolean
id: {}
name:
type: string
role:
type: string
updated_at: {}
type: object
authn.userOut:
properties:
created_at: {}
email:
type: string
email_verified:
type: boolean
id: {}
name:
type: string
role:
type: string
updated_at: {}
type: object
models.Member:
properties:
created_at:
type: string
id:
type: string
organization:
$ref: '#/definitions/models.Organization'
organization_id:
type: string
role:
allOf:
- $ref: '#/definitions/models.MemberRole'
description: e.g. admin, member
updated_at:
type: string
user:
$ref: '#/definitions/models.User'
user_id:
type: string
type: object
models.MemberRole:
enum:
- admin
- member
- user
type: string
x-enum-varnames:
- MemberRoleAdmin
- MemberRoleMember
- MemberRoleUser
models.Organization:
properties:
created_at:
@@ -99,6 +208,34 @@ definitions:
x-enum-varnames:
- RoleAdmin
- RoleUser
models.User:
properties:
created_at:
type: string
email:
type: string
email_verified:
type: boolean
email_verified_at:
type: string
id:
type: string
name:
type: string
password:
type: string
role:
$ref: '#/definitions/models.Role'
updated_at:
type: string
type: object
orgs.InviteInput:
properties:
email:
type: string
role:
type: string
type: object
orgs.OrgInput:
properties:
name:
@@ -106,6 +243,56 @@ definitions:
slug:
type: string
type: object
ssh.createSSHRequest:
properties:
bits:
example: 4096
type: integer
comment:
example: deploy@autoglue
type: string
download:
example: both
type: string
name:
type: string
type: object
ssh.sshResponse:
properties:
created_at:
type: string
fingerprint:
type: string
id:
type: string
name:
type: string
organization_id:
type: string
public_keys:
type: string
updated_at:
type: string
type: object
ssh.sshRevealResponse:
properties:
created_at:
type: string
fingerprint:
type: string
id:
type: string
name:
type: string
organization_id:
type: string
private_key:
type: string
public_keys:
type: string
updated_at:
type: string
type: object
info:
contact: {}
description: API for managing K3s clusters across cloud providers
@@ -127,6 +314,161 @@ paths:
summary: Basic health check
tags:
- health
/api/v1/admin/users:
get:
description: Returns paginated list of users (admin only)
parameters:
- description: Page number (1-based)
in: query
name: page
type: integer
- description: Page size (max 200)
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/authn.ListUsersOut'
"401":
description: unauthorized
schema:
type: string
"403":
description: forbidden
schema:
type: string
security:
- BearerAuth: []
summary: 'Admin: list all users'
tags:
- admin
post:
consumes:
- application/json
parameters:
- description: payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/authn.AdminCreateUserRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/authn.userOut'
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"403":
description: forbidden
schema:
type: string
"409":
description: conflict
schema:
type: string
security:
- BearerAuth: []
summary: 'Admin: create user'
tags:
- admin
/api/v1/admin/users/{userId}:
delete:
parameters:
- description: User ID
in: path
name: userId
required: true
type: string
responses:
"204":
description: no content
schema:
type: string
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"403":
description: forbidden
schema:
type: string
"404":
description: not found
schema:
type: string
"409":
description: conflict
schema:
type: string
security:
- BearerAuth: []
summary: 'Admin: delete user'
tags:
- admin
patch:
consumes:
- application/json
parameters:
- description: User ID
in: path
name: userId
required: true
type: string
- description: payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/authn.AdminUpdateUserRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/authn.userOut'
"400":
description: bad request
schema:
type: string
"401":
description: unauthorized
schema:
type: string
"403":
description: forbidden
schema:
type: string
"404":
description: not found
schema:
type: string
"409":
description: conflict
schema:
type: string
security:
- BearerAuth: []
summary: 'Admin: update user'
tags:
- admin
/api/v1/auth/introspect:
post:
consumes:
@@ -523,6 +865,382 @@ paths:
summary: Create a new organization
tags:
- organizations
/api/v1/orgs/{orgId}:
delete:
parameters:
- description: Organization ID
in: path
name: orgId
required: true
type: string
responses:
"204":
description: deleted
schema:
type: string
"403":
description: forbidden
schema:
type: string
security:
- BearerAuth: []
summary: Delete organization
tags:
- organizations
patch:
consumes:
- application/json
parameters:
- description: Org ID
in: path
name: orgId
required: true
type: string
- description: Organization data
in: body
name: body
required: true
schema:
$ref: '#/definitions/orgs.OrgInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Organization'
"403":
description: forbidden
schema:
type: string
security:
- BearerAuth: []
summary: Update organization metadata
tags:
- organizations
/api/v1/orgs/invite:
post:
consumes:
- application/json
parameters:
- description: Invite input
in: body
name: body
required: true
schema:
$ref: '#/definitions/orgs.InviteInput'
- description: Organization context
in: header
name: X-Org-ID
required: true
type: string
produces:
- text/plain
responses:
"201":
description: invited
schema:
type: string
"400":
description: bad request
schema:
type: string
"403":
description: forbidden
schema:
type: string
security:
- BearerAuth: []
summary: Invite user to organization
tags:
- organizations
/api/v1/orgs/members:
get:
description: Returns a list of all members in the current organization
parameters:
- description: Organization context
in: header
name: X-Org-ID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/models.Member'
type: array
"401":
description: unauthorized
schema:
type: string
security:
- BearerAuth: []
summary: List organization members
tags:
- organizations
/api/v1/orgs/members/{userId}:
delete:
parameters:
- description: User ID
in: path
name: userId
required: true
type: string
responses:
"204":
description: deleted
schema:
type: string
"403":
description: forbidden
schema:
type: string
security:
- BearerAuth: []
summary: Remove member from organization
tags:
- organizations
/api/v1/ssh:
get:
consumes:
- application/json
description: Returns ssh keys for the organization in X-Org-ID.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/ssh.sshResponse'
type: array
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: failed to list keys
schema:
type: string
security:
- BearerAuth: []
summary: List ssh keys (org scoped)
tags:
- ssh
post:
consumes:
- application/json
description: Generates an RSA keypair, saves it, and returns metadata. Optionally
set `download` to "public", "private", or "both" to download files immediately.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Key generation options
in: body
name: body
required: true
schema:
$ref: '#/definitions/ssh.createSSHRequest'
produces:
- application/json
responses:
"201":
description: Created
headers:
Content-Disposition:
description: When download is requested
type: string
schema:
$ref: '#/definitions/ssh.sshResponse'
"400":
description: invalid json / invalid bits
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: generation/create failed
schema:
type: string
security:
- BearerAuth: []
summary: Create ssh keypair (org scoped)
tags:
- ssh
/api/v1/ssh/{id}:
delete:
consumes:
- application/json
description: Permanently deletes a keypair.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: SSH Key ID (UUID)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
schema:
type: string
"400":
description: invalid id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: delete failed
schema:
type: string
security:
- BearerAuth: []
summary: Delete ssh keypair (org scoped)
tags:
- ssh
get:
consumes:
- application/json
description: Returns public key fields. Append `?reveal=true` to include the
private key PEM.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: SSH Key ID (UUID)
in: path
name: id
required: true
type: string
- description: Reveal private key PEM
in: query
name: reveal
type: boolean
produces:
- application/json
responses:
"200":
description: When reveal=true
schema:
$ref: '#/definitions/ssh.sshRevealResponse'
"400":
description: invalid id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: fetch failed
schema:
type: string
security:
- BearerAuth: []
summary: Get ssh key by ID (org scoped)
tags:
- ssh
/api/v1/ssh/{id}/download:
get:
description: Download `part=public|private|both` of the keypair. `both` returns
a zip file.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: SSH Key ID (UUID)
in: path
name: id
required: true
type: string
- description: Which part to download
enum:
- public
- private
- both
in: query
name: part
required: true
type: string
produces:
- text/plain
responses:
"200":
description: file content
schema:
type: string
"400":
description: invalid id / invalid part
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: download failed
schema:
type: string
security:
- BearerAuth: []
summary: Download ssh key files by ID (org scoped)
tags:
- ssh
schemes:
- http
securityDefinitions:

View File

@@ -7,6 +7,7 @@ import (
"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/handlers/ssh"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
"github.com/go-chi/chi/v5"
@@ -21,6 +22,14 @@ func RegisterRoutes(r chi.Router) {
secret := viper.GetString("authentication.jwt_secret")
authMW := middleware.AuthMiddleware(secret)
v1.Route("/admin", func(ad chi.Router) {
ad.Use(authMW)
ad.Get("/users", authn.AdminListUsers)
ad.Post("/users", authn.AdminCreateUser)
ad.Patch("/users/{userId}", authn.AdminUpdateUser)
ad.Delete("/users/{userId}", authn.AdminDeleteUser)
})
v1.Route("/auth", func(a chi.Router) {
a.Post("/login", authn.Login)
a.Post("/register", authn.Register)
@@ -45,6 +54,20 @@ func RegisterRoutes(r chi.Router) {
o.Use(authMW)
o.Post("/", orgs.CreateOrganization)
o.Get("/", orgs.ListOrganizations)
o.Post("/invite", orgs.InviteMember)
o.Get("/members", orgs.ListMembers)
o.Delete("/members/{userId}", orgs.DeleteMember)
o.Patch("/{orgId}", orgs.UpdateOrganization)
o.Delete("/{orgId}", orgs.DeleteOrganization)
})
v1.Route("/ssh", func(s chi.Router) {
s.Use(authMW)
s.Get("/", ssh.ListPublicKeys)
s.Post("/", ssh.CreateSSHKey)
s.Get("/{id}", ssh.GetSSHKey)
s.Delete("/{id}", ssh.DeleteSSHKey)
s.Get("/{id}/download", ssh.DownloadSSHKey)
})
})
})

View File

@@ -26,13 +26,16 @@ func Connect() {
}
err = DB.AutoMigrate(
&models.Credential{},
&models.EmailVerification{},
&models.Invitation{},
&models.MasterKey{},
&models.Member{},
&models.Organization{},
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.SshKey{},
&models.User{},
)
if err != nil {

View File

@@ -0,0 +1,14 @@
package models
import "github.com/google/uuid"
type Credential struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider,where:deleted_at IS NULL" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Provider string `gorm:"type:varchar(50);not null;uniqueIndex:idx_credentials_org_provider,where:deleted_at IS NULL"`
EncryptedData string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
Timestamped
}

View File

@@ -0,0 +1,15 @@
package models
import "github.com/google/uuid"
type OrganizationKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
EncryptedKey string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
Timestamped
}

View File

@@ -0,0 +1,16 @@
package models
import "github.com/google/uuid"
type SshKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
PublicKey string `gorm:"not null"`
EncryptedPrivateKey string `gorm:"not null"`
PrivateIV string `gorm:"not null"`
PrivateTag string `gorm:"not null"`
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
Timestamped
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/config"
@@ -11,6 +12,7 @@ import (
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
@@ -539,3 +541,299 @@ func RotateRefreshToken(w http.ResponseWriter, r *http.Request) {
"refresh_token": newRefresh.Token,
})
}
// AdminListUsers godoc
// @Summary Admin: list all users
// @Description Returns paginated list of users (admin only)
// @Tags admin
// @Produce json
// @Param page query int false "Page number (1-based)"
// @Param page_size query int false "Page size (max 200)"
// @Success 200 {object} ListUsersOut
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Security BearerAuth
// @Router /api/v1/admin/users [get]
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
ctx := middleware.GetAuthContext(r)
if ctx == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Load current user to check global role
var me models.User
if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if me.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Pagination
page := mustInt(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
pageSize := mustInt(r.URL.Query().Get("page_size"), 50)
if pageSize < 1 {
pageSize = 50
}
if pageSize > 200 {
pageSize = 200
}
offset := (page - 1) * pageSize
// Query
var total int64
_ = db.DB.Model(&models.User{}).Count(&total).Error
var users []models.User
if err := db.DB.
Model(&models.User{}).
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&users).Error; err != nil {
http.Error(w, "failed to fetch users", http.StatusInternalServerError)
return
}
out := make([]UserListItem, len(users))
for i, u := range users {
out[i] = UserListItem{
ID: u.ID,
Name: u.Name,
Email: u.Email,
EmailVerified: u.EmailVerified,
Role: string(u.Role),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
_ = response.JSON(w, http.StatusOK, ListUsersOut{
Users: out,
Page: page,
PageSize: pageSize,
Total: total,
})
}
// AdminCreateUser godoc
// @Summary Admin: create user
// @Tags admin
// @Accept json
// @Produce json
// @Param body body AdminCreateUserRequest true "payload"
// @Success 201 {object} userOut
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users [post]
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
if _, ok := requireGlobalAdmin(w, r); !ok {
return
}
var in struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"` // "user" | "admin"
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
in.Role = strings.TrimSpace(in.Role)
if in.Email == "" || in.Password == "" {
http.Error(w, "email and password required", http.StatusBadRequest)
return
}
if in.Role == "" {
in.Role = "user"
}
if in.Role != "user" && in.Role != "admin" {
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
var exists int64
if err := db.DB.Model(&models.User{}).Where("LOWER(email)=?", in.Email).Count(&exists).Error; err == nil && exists > 0 {
http.Error(w, "email already in use", http.StatusConflict)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
u := models.User{
Name: in.Name,
Email: in.Email,
Password: string(hash),
Role: models.Role(in.Role),
}
if err := db.DB.Create(&u).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, asUserOut(u))
}
// AdminUpdateUser godoc
// @Summary Admin: update user
// @Tags admin
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param body body AdminUpdateUserRequest true "payload"
// @Success 200 {object} userOut
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users/{userId} [patch]
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
_, ok := requireGlobalAdmin(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "userId")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "bad user id", http.StatusBadRequest)
return
}
var u models.User
if err := db.DB.First(&u, "id = ?", id).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
var in struct {
Name *string `json:"name"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"` // "user" | "admin"
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
updates := map[string]any{}
if in.Name != nil {
updates["name"] = *in.Name
}
if in.Email != nil {
email := strings.TrimSpace(strings.ToLower(*in.Email))
if email == "" {
http.Error(w, "email required", http.StatusBadRequest)
return
}
var exists int64
_ = db.DB.Model(&models.User{}).Where("LOWER(email)=? AND id <> ?", email, u.ID).Count(&exists).Error
if exists > 0 {
http.Error(w, "email already in use", http.StatusConflict)
return
}
updates["email"] = email
}
if in.Password != nil && *in.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "hash error", http.StatusInternalServerError)
return
}
updates["password"] = string(hash)
}
if in.Role != nil {
role := strings.TrimSpace(*in.Role)
if role != "user" && role != "admin" {
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
// prevent demoting the last admin
if u.Role == "admin" && role == "user" {
n, _ := adminCount(&u.ID)
if n == 0 {
http.Error(w, "cannot demote last admin", http.StatusConflict)
return
}
}
updates["role"] = role
}
if len(updates) == 0 {
_ = response.JSON(w, http.StatusOK, asUserOut(u))
return
}
if err := db.DB.Model(&u).Updates(updates).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, asUserOut(u))
}
// AdminDeleteUser godoc
// @Summary Admin: delete user
// @Tags admin
// @Param userId path string true "User ID"
// @Success 204 {string} string "no content"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Failure 409 {string} string "conflict"
// @Security BearerAuth
// @Router /api/v1/admin/users/{userId} [delete]
func AdminDeleteUser(w http.ResponseWriter, r *http.Request) {
me, ok := requireGlobalAdmin(w, r)
if !ok {
return
}
idStr := chi.URLParam(r, "userId")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "bad user id", http.StatusBadRequest)
return
}
if me.ID == id {
http.Error(w, "cannot delete self", http.StatusBadRequest)
return
}
var u models.User
if err := db.DB.First(&u, "id = ?", id).Error; err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if u.Role == "admin" {
n, _ := adminCount(&u.ID)
if n == 0 {
http.Error(w, "cannot delete last admin", http.StatusConflict)
return
}
}
if err := db.DB.Delete(&models.User{}, "id = ?", id).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -77,3 +77,62 @@ type PasswordResetData struct {
Token string
ResetURL string
}
type UserListItem struct {
ID any `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
type ListUsersOut struct {
Users []UserListItem `json:"users"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type userOut struct {
ID any `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
type AdminCreateUserRequest struct {
Name string `json:"name" example:"Jane Doe"`
Email string `json:"email" example:"jane@example.com"`
Password string `json:"password" example:"Secret123!"`
// Role allowed values: "user" or "admin"
Role string `json:"role" example:"user" enums:"user,admin"`
}
type AdminUpdateUserRequest struct {
Name *string `json:"name,omitempty" example:"Jane Doe"`
Email *string `json:"email,omitempty" example:"jane@example.com"`
Password *string `json:"password,omitempty" example:"NewSecret123!"`
Role *string `json:"role,omitempty" example:"admin" enums:"user,admin"`
}
type AdminUserResponse struct {
ID uuid.UUID `json:"id" example:"6aa012bc-ce8a-4cd9-9971-58f3917037f8"`
Name string `json:"name" example:"Jane Doe"`
Email string `json:"email" example:"jane@example.com"`
EmailVerified bool `json:"email_verified" example:"false"`
Role string `json:"role" example:"user"`
CreatedAt string `json:"created_at" example:"2025-09-01T08:38:12Z"`
UpdatedAt string `json:"updated_at" example:"2025-09-01T17:02:36Z"`
}
type AdminListUsersResponse struct {
Users []AdminUserResponse `json:"users"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"50"`
Total int64 `json:"total" example:"123"`
}

View File

@@ -4,11 +4,14 @@ import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"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"
appsmtp "github.com/glueops/autoglue/internal/smtp"
"github.com/google/uuid"
)
@@ -90,3 +93,54 @@ func sendTemplatedEmail(to string, templateFile string, data any) error {
}
return m.Send(to, data, templateFile)
}
func mustInt(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func adminCount(except *uuid.UUID) (int64, error) {
q := db.DB.Model(&models.User{}).Where(`role = ?`, "admin")
if except != nil {
q = q.Where("id <> ?", *except)
}
var n int64
err := q.Count(&n).Error
return n, err
}
func requireGlobalAdmin(w http.ResponseWriter, r *http.Request) (*models.User, bool) {
ctx := middleware.GetAuthContext(r)
if ctx == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
var me models.User
if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil, false
}
if me.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return nil, false
}
return &me, true
}
func asUserOut(u models.User) userOut {
return userOut{
ID: u.ID,
Name: u.Name,
Email: u.Email,
EmailVerified: u.EmailVerified,
Role: string(u.Role),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}

View File

@@ -4,3 +4,8 @@ type OrgInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
type InviteInput struct {
Email string `json:"email"`
Role string `json:"role"`
}

View File

@@ -4,11 +4,14 @@ import (
"encoding/json"
"net/http"
"strings"
"time"
"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/go-chi/chi/v5"
"github.com/google/uuid"
)
// CreateOrganization godoc
@@ -88,3 +91,178 @@ func ListOrganizations(w http.ResponseWriter, r *http.Request) {
_ = response.JSON(w, http.StatusOK, orgs)
}
// InviteMember godoc
// @Summary Invite user to organization
// @Tags organizations
// @Accept json
// @Produce plain
// @Param body body InviteInput true "Invite input"
// @Success 201 {string} string "invited"
// @Failure 403 {string} string "forbidden"
// @Failure 400 {string} string "bad request"
// @Router /api/v1/orgs/invite [post]
// @Param X-Org-ID header string true "Organization context"
// @Security BearerAuth
func InviteMember(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" || auth.OrganizationID == uuid.Nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var input InviteInput
json.NewDecoder(r.Body).Decode(&input)
var user models.User
err := db.DB.Where("email = ?", input.Email).First(&user).Error
if err != nil {
http.Error(w, "user not found", http.StatusBadRequest)
return
}
invite := models.Invitation{
OrganizationID: auth.OrganizationID,
Email: input.Email,
Role: input.Role,
Status: "pending",
ExpiresAt: time.Now().Add(48 * time.Hour),
InviterID: auth.UserID,
}
if err := db.DB.Create(&invite).Error; err != nil {
http.Error(w, "failed to invite", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, invite)
}
// ListMembers lists all members of the authenticated org
// @Summary List organization members
// @Description Returns a list of all members in the current organization
// @Tags organizations
// @Security BearerAuth
// @Produce json
// @Success 200 {array} models.Member
// @Failure 401 {string} string "unauthorized"
// @Router /api/v1/orgs/members [get]
// @Param X-Org-ID header string true "Organization context"
func ListMembers(w http.ResponseWriter, r *http.Request) {
authCtx := middleware.GetAuthContext(r)
if authCtx == nil || authCtx.OrganizationID == uuid.Nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var members []models.Member
if err := db.DB.Preload("User").Preload("Organization").Where("organization_id = ?", authCtx.OrganizationID).Find(&members).Error; err != nil {
http.Error(w, "failed to fetch members", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, members)
}
// DeleteMember godoc
// @Summary Remove member from organization
// @Tags organizations
// @Param userId path string true "User ID"
// @Success 204 {string} string "deleted"
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/members/{userId} [delete]
// @Security BearerAuth
func DeleteMember(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
userId := chi.URLParam(r, "userId")
if err := db.DB.Where("user_id = ? AND organization_id = ?", userId, auth.OrganizationID).Delete(&models.Member{}).Error; err != nil {
http.Error(w, "failed to delete", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateOrganization godoc
// @Summary Update organization metadata
// @Tags organizations
// @Accept json
// @Produce json
// @Param orgId path string true "Org ID"
// @Param body body OrgInput true "Organization data"
// @Success 200 {object} models.Organization
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/{orgId} [patch]
// @Security BearerAuth
func UpdateOrganization(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil || auth.OrgRole != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
orgId := chi.URLParam(r, "orgId")
var input OrgInput
json.NewDecoder(r.Body).Decode(&input)
var org models.Organization
db.DB.First(&org, "id = ?", orgId)
org.Name = input.Name
org.Slug = input.Slug
db.DB.Save(&org)
_ = response.JSON(w, http.StatusOK, org)
}
// DeleteOrganization godoc
// @Summary Delete organization
// @Tags organizations
// @Param orgId path string true "Organization ID"
// @Success 204 {string} string "deleted"
// @Failure 403 {string} string "forbidden"
// @Router /api/v1/orgs/{orgId} [delete]
// @Security BearerAuth
func DeleteOrganization(w http.ResponseWriter, r *http.Request) {
auth := middleware.GetAuthContext(r)
if auth == nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
orgId := chi.URLParam(r, "orgId")
orgUUID, err := uuid.Parse(orgId)
if err != nil {
http.Error(w, "invalid organization id", http.StatusBadRequest)
return
}
var member models.Member
if err := db.DB.
Where("user_id = ? AND organization_id = ?", auth.UserID, orgUUID).
First(&member).Error; err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if member.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := db.DB.Where("organization_id = ?", orgUUID).Delete(&models.Member{}).Error; err != nil {
http.Error(w, "failed to delete members", http.StatusInternalServerError)
return
}
if err := db.DB.Delete(&models.Organization{}, "id = ?", orgUUID).Error; err != nil {
http.Error(w, "failed to delete org", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

View File

@@ -0,0 +1,25 @@
package ssh
import "github.com/google/uuid"
type createSSHRequest struct {
Bits *int `json:"bits,omitempty" example:"4096"`
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
Download string `json:"download,omitempty" example:"both"`
Name string `json:"name"`
}
type sshResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
PublicKey string `json:"public_keys"`
Fingerprint string `json:"fingerprint"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type sshRevealResponse struct {
sshResponse
PrivateKey string `json:"private_key"`
}

View File

@@ -0,0 +1,60 @@
package ssh
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"strings"
gossh "golang.org/x/crypto/ssh"
)
func allowedBits(b int) bool {
return b == 2048 || b == 3072 || b == 4096
}
func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, bits)
}
func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
// Private (PKCS#1) to PEM
der := x509.MarshalPKCS1PrivateKey(priv)
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err = pem.Encode(&buf, block); err != nil {
return "", "", err
}
// Public to authorized_keys
pub, err := gossh.NewPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}
func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
priv, err := GenerateRSA(bits)
if err != nil {
return "", "", err
}
return RSAPrivateToPEMAndAuthorized(priv, comment)
}
func toZipFile(filename string, content []byte, zw *zip.Writer) error {
f, err := zw.Create(filename)
if err != nil {
return err
}
_, err = f.Write(content)
return err
}

View File

@@ -0,0 +1,361 @@
package ssh
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"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/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ListPublicKeys godoc
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Security BearerAuth
// @Success 200 {array} sshResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list keys"
// @Router /api/v1/ssh [get]
func ListPublicKeys(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var rows []models.SshKey
if err := db.DB.Where("organization_id = ?", ac.OrganizationID).
Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list ssh keys", http.StatusInternalServerError)
return
}
out := make([]sshResponse, 0, len(rows))
for _, s := range rows {
out = append(out, sshResponse{
ID: s.ID,
OrganizationID: s.OrganizationID,
Name: s.Name,
PublicKey: s.PublicKey,
Fingerprint: s.Fingerprint,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
})
}
_ = response.JSON(w, http.StatusOK, out)
}
// CreateSSHKey godoc
// @Summary Create ssh keypair (org scoped)
// @Description Generates an RSA keypair, saves it, and returns metadata. Optionally set `download` to "public", "private", or "both" to download files immediately.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createSSHRequest true "Key generation options"
// @Security BearerAuth
// @Success 201 {object} sshResponse
// @Header 201 {string} Content-Disposition "When download is requested"
// @Failure 400 {string} string "invalid json / invalid bits"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "generation/create failed"
// @Router /api/v1/ssh [post]
func CreateSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createSSHRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
bits := 4096
if req.Bits != nil {
if !allowedBits(*req.Bits) {
http.Error(w, "invalid bits (allowed: 2048, 3072, 4096)", http.StatusBadRequest)
return
}
bits = *req.Bits
}
privPEM, pubAuth, err := GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
if err != nil {
http.Error(w, "key generation failed", http.StatusInternalServerError)
return
}
cipher, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(privPEM))
if err != nil {
http.Error(w, "encryption failed", http.StatusInternalServerError)
return
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
http.Error(w, "failed to parse public key", http.StatusInternalServerError)
return
}
fp := ssh.FingerprintSHA256(parsed)
key := models.SshKey{
OrganizationID: ac.OrganizationID,
Name: req.Name,
PublicKey: pubAuth,
EncryptedPrivateKey: cipher,
PrivateIV: iv,
PrivateTag: tag,
Fingerprint: fp,
}
if err := db.DB.Create(&key).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
// Immediate download if requested
switch strings.ToLower(strings.TrimSpace(req.Download)) {
case "public":
filename := fmt.Sprintf("id_rsa_%s.pub", key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(pubAuth))
return
case "private":
filename := fmt.Sprintf("id_rsa_%s.pem", key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(privPEM))
return
case "both":
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(privPEM), zw)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(pubAuth), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(buf.Bytes())
return
}
_ = response.JSON(w, http.StatusCreated, sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
}
// GetSSHKey godoc
// @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param reveal query bool false "Reveal private key PEM"
// @Security BearerAuth
// @Success 200 {object} sshResponse
// @Success 200 {object} sshRevealResponse "When reveal=true"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/ssh/{id} [get]
func GetSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var key models.SshKey
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("reveal") != "true" {
_ = response.JSON(w, http.StatusOK, sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
})
return
}
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "failed to decrypt", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, sshRevealResponse{
sshResponse: sshResponse{
ID: key.ID,
OrganizationID: key.OrganizationID,
PublicKey: key.PublicKey,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: key.UpdatedAt.UTC().Format(time.RFC3339),
},
PrivateKey: plain,
})
}
// DeleteSSHKey godoc
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/ssh/{id} [delete]
func DeleteSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
idStr := strings.TrimPrefix(r.URL.Path, "/api/v1/ssh/")
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Delete(&models.SshKey{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}
// DownloadSSHKey godoc
// @Summary Download ssh key files by ID (org scoped)
// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
// @Tags ssh
// @Produce text/plain
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param part query string true "Which part to download" Enums(public,private,both)
// @Security BearerAuth
// @Success 200 {string} string "file content"
// @Failure 400 {string} string "invalid id / invalid part"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "download failed"
// @Router /api/v1/ssh/{id}/download [get]
func DownloadSSHKey(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var key models.SshKey
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
switch strings.ToLower(r.URL.Query().Get("part")) {
case "public":
filename := fmt.Sprintf("id_rsa_%s.pub", key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(key.PublicKey))
case "private":
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "decrypt failed", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("id_rsa_%s.pem", key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(plain))
case "both":
plain, err := utils.DecryptForOrg(ac.OrganizationID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag)
if err != nil {
http.Error(w, "decrypt failed", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("id_rsa_%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write(buf.Bytes())
default:
http.Error(w, "invalid part (public|private|both)", http.StatusBadRequest)
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
@@ -59,14 +60,32 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler {
Claims: claims,
}
if orgID := r.Header.Get("X-Org-ID"); orgID != "" {
orgUUID, _ := uuid.Parse(orgID)
orgIDStr := r.Header.Get("X-Org-ID")
if orgIDStr == "" {
if rc := chi.RouteContext(r.Context()); rc != nil {
if v := rc.URLParam("orgId"); v != "" {
orgIDStr = v
} else if v := rc.URLParam("organizationId"); v != "" {
orgIDStr = v
}
}
}
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)
if orgIDStr != "" {
orgUUID, err := uuid.Parse(orgIDStr)
if err != nil {
http.Error(w, "invalid organization id", http.StatusBadRequest)
return
}
var member models.Member
if err := db.DB.
Where("user_id = ? AND organization_id = ?", userUUID, orgUUID).
First(&member).Error; err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
authCtx.OrganizationID = orgUUID
authCtx.OrgRole = string(member.Role)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,16 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/index-DrmAfy-p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Nf4c5zdA.css">
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CyXg69m3.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-Cnbx_Mrt.js">
<link rel="modulepreload" crossorigin href="/assets/radix-DN_DrUzo.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CHRYRpwL.js">
<link rel="stylesheet" crossorigin href="/assets/index-CJrhsj7s.css">
</head>
<body>
<div id="root"></div>

85
internal/utils/crypto.go Normal file
View File

@@ -0,0 +1,85 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrNoActiveMasterKey = errors.New("no active master key found")
ErrInvalidOrgID = errors.New("invalid organization ID")
ErrCredentialNotFound = errors.New("credential not found")
ErrInvalidMasterKeyLen = errors.New("invalid master key length")
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
return b, nil
}
func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != 12 {
return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
}
iv, err = randomBytes(gcm.NonceSize())
if err != nil {
return nil, nil, nil, err
}
// Gos GCM returns ciphertext||tag, with 16-byte tag.
cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
if len(cipherWithTag) < 16 {
return nil, nil, nil, errors.New("ciphertext too short")
}
tagLen := 16
cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
tag = cipherWithTag[len(cipherWithTag)-tagLen:]
return cipherNoTag, iv, tag, nil
}
func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != len(iv) {
return nil, fmt.Errorf("bad nonce size: %d", len(iv))
}
// Reattach tag
cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return plain, nil
}
func encodeB64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func decodeB64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}

101
internal/utils/keys.go Normal file
View File

@@ -0,0 +1,101 @@
package utils
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func getMasterKey() ([]byte, error) {
var mk models.MasterKey
if err := db.DB.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
if err != nil {
return nil, fmt.Errorf("decoding master key: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
}
return keyBytes, nil
}
func getOrCreateTenantKey(orgID string) ([]byte, error) {
var orgKey models.OrganizationKey
err := db.DB.Where("organization_id = ?", orgID).First(&orgKey).Error
if err == nil {
encKeyB64 := orgKey.EncryptedKey
ivB64 := orgKey.IV
tagB64 := orgKey.Tag
encryptedKey, err := decodeB64(encKeyB64)
if err != nil {
return nil, fmt.Errorf("decode enc key: %w", err)
}
iv, err := decodeB64(ivB64)
if err != nil {
return nil, fmt.Errorf("decode iv: %w", err)
}
tag, err := decodeB64(tagB64)
if err != nil {
return nil, fmt.Errorf("decode tag: %w", err)
}
masterKey, err := getMasterKey()
if err != nil {
return nil, err
}
return decryptAESGCM(encryptedKey, masterKey, iv, tag)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Create new tenant key and wrap with the current master key
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
}
tenantKey, err := randomBytes(32)
if err != nil {
return nil, fmt.Errorf("tenant key gen: %w", err)
}
masterKey, err := getMasterKey()
if err != nil {
return nil, err
}
encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
if err != nil {
return nil, fmt.Errorf("wrap tenant key: %w", err)
}
var mk models.MasterKey
if err := db.DB.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
orgKey = models.OrganizationKey{
OrganizationID: orgUUID,
MasterKeyID: mk.ID,
EncryptedKey: encodeB64(encrypted),
IV: encodeB64(iv),
Tag: encodeB64(tag),
}
if err := db.DB.Create(&orgKey).Error; err != nil {
return nil, fmt.Errorf("persist org key: %w", err)
}
return tenantKey, nil
}

View File

@@ -0,0 +1,71 @@
package utils
import (
"fmt"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func EncryptForOrg(orgID uuid.UUID, plaintext []byte) (cipherB64, ivB64, tagB64 string, err error) {
tenantKey, err := getOrCreateTenantKey(orgID.String())
if err != nil {
return "", "", "", err
}
ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
if err != nil {
return "", "", "", err
}
return encodeB64(ct), encodeB64(iv), encodeB64(tag), nil
}
func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string) (string, error) {
tenantKey, err := getOrCreateTenantKey(orgID.String())
if err != nil {
return "", err
}
ct, err := decodeB64(cipherB64)
if err != nil {
return "", fmt.Errorf("decode cipher: %w", err)
}
iv, err := decodeB64(ivB64)
if err != nil {
return "", fmt.Errorf("decode iv: %w", err)
}
tag, err := decodeB64(tagB64)
if err != nil {
return "", fmt.Errorf("decode tag: %w", err)
}
plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
if err != nil {
return "", err
}
return string(plain), nil
}
func EncryptAndUpsertCredential(orgID uuid.UUID, provider, plaintext string) error {
data, iv, tag, err := EncryptForOrg(orgID, []byte(plaintext))
if err != nil {
return err
}
var cred models.Credential
err = db.DB.Where("organization_id = ? AND provider = ?", orgID, provider).
First(&cred).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
cred.OrganizationID = orgID
cred.Provider = provider
cred.EncryptedData = data
cred.IV = iv
cred.Tag = tag
if cred.ID != uuid.Nil {
return db.DB.Save(&cred).Error
}
return db.DB.Create(&cred).Error
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>AutoGlue</title>
</head>
<body>
<div id="root"></div>

View File

@@ -16,6 +16,7 @@
"@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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -47,6 +48,7 @@
"globals": "^16.3.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"rollup-plugin-visualizer": "^6.0.3",
"shadcn": "^3.0.0",
"tw-animate-css": "^1.3.7",
"typescript": "~5.9.2",

View File

@@ -2,18 +2,24 @@ import { Navigate, Route, Routes } from "react-router-dom"
import { DashboardLayout } from "@/components/dashboard-layout.tsx"
import { ProtectedRoute } from "@/components/protected-route.tsx"
import { RequireAdmin } from "@/components/require-admin.tsx"
import { AdminUsersPage } from "@/pages/admin/users.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";
import { Forbidden } from "@/pages/error/forbidden.tsx"
import { NotFoundPage } from "@/pages/error/not-found.tsx"
import { MemberManagement } from "@/pages/settings/members.tsx"
import { OrgManagement } from "@/pages/settings/orgs.tsx"
import {SshKeysPage} from "@/pages/security/ssh.tsx";
function App() {
return (
<Routes>
<Route path="/403" element={<Forbidden />} />
<Route path="/" element={<Navigate to="/auth/login" replace />} />
{/* Public/auth branch */}
<Route path="/auth">
@@ -22,16 +28,16 @@ function App() {
<Route path="forgot" element={<ForgotPassword />} />
<Route path="reset" element={<ResetPassword />} />
<Route path="verify" element={<VerifyEmail />} />
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route path="me" element={<Me />} />
</Route>
</Route>
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route element={<RequireAdmin />}>
<Route path="/admin">
<Route path="users" element={<AdminUsersPage />} />
</Route>
</Route>
<Route path="/core">
{/*
<Route path="cluster" element={<ClusterListPage />} />
@@ -42,12 +48,13 @@ function App() {
</Route>
<Route path="/security">
{/*<Route path="ssh" element={<SshKeysPage />} />*/}
<Route path="ssh" element={<SshKeysPage />} />
</Route>
<Route path="/settings">
<Route path="orgs" element={<OrgManagement />} />
{/*<Route path="members" element={<MemberManagement />} />*/}
<Route path="members" element={<MemberManagement />} />
<Route path="me" element={<Me />} />
</Route>
<Route path="*" element={<NotFoundPage />} />

View File

@@ -1,15 +1,16 @@
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";
import { Outlet } from "react-router-dom"
import { SidebarProvider } from "@/components/ui/sidebar.tsx"
import { DashboardSidebar } from "@/components/dashboard-sidebar.tsx"
import { Footer } from "@/components/footer.tsx"
export function DashboardLayout() {
return (
<div className="flex h-screen">
<SidebarProvider>
<DashboardSidebar />
<div className="flex flex-col flex-1">
<main className="flex-1 p-4 overflow-auto">
<div className="flex flex-1 flex-col">
<main className="flex-1 overflow-auto p-4">
<Outlet />
</main>
<Footer />

View File

@@ -1,35 +1,62 @@
import { useEffect, useMemo, useState, type ComponentType, type FC } from "react"
import { ChevronDown } from "lucide-react"
import { Link, useLocation } from "react-router-dom"
import { authStore, isGlobalAdmin, isOrgAdmin, type MePayload } from "@/lib/auth.ts"
import { Button } from "@/components/ui/button.tsx"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible.tsx"
import {
Sidebar,
SidebarContent,
SidebarFooter,
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";
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar.tsx"
import { ModeToggle } from "@/components/mode-toggle.tsx"
import { OrgSwitcher } from "@/components/org-switcher.tsx"
import { items, type NavItem } from "@/components/sidebar/items.ts"
interface MenuItemProps {
label: string;
icon: ComponentType<{ className?: string }>;
to?: string;
items?: MenuItemProps[];
label: string
icon: ComponentType<{ className?: string }>
to?: string
items?: MenuItemProps[]
}
function filterItems(items: NavItem[], isAdmin: boolean, isOrgAdminFlag: boolean): NavItem[] {
return items
.filter((it) => {
if (it.requiresAdmin && !isAdmin) return false
if (it.requiresOrgAdmin && !isOrgAdminFlag) return false
return true
})
.map((it) => ({
...it,
items: it.items ? filterItems(it.items, isAdmin, isOrgAdminFlag) : undefined,
}))
.filter((it) => !it.items || it.items.length > 0)
}
const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
const location = useLocation();
const Icon = item.icon;
const location = useLocation()
const Icon = item.icon
if (item.to) {
return (
<Link to={item.to}
className={`flex items-center space-x-2 text-sm py-2 px-4 rounded-md hover:bg-accent hover:text-accent-foreground ${location.pathname === item.to ? "bg-accent text-accent-foreground" : ""}`}
<Link
to={item.to}
className={`hover:bg-accent hover:text-accent-foreground flex items-center space-x-2 rounded-md px-4 py-2 text-sm ${location.pathname === item.to ? "bg-accent text-accent-foreground" : ""}`}
>
<Icon className="h-4 w-4 mr-4" />
<Icon className="mr-4 h-4 w-4" />
{item.label}
</Link>
)
@@ -41,7 +68,7 @@ const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
<Icon className="h-4 w-4 mr-4" />
<Icon className="mr-4 h-4 w-4" />
{item.label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
@@ -67,16 +94,57 @@ const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
}
export const DashboardSidebar = () => {
const [me, setMe] = useState<MePayload | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let alive = true
;(async () => {
try {
const data = await authStore.me()
if (!alive) return
setMe(data)
} catch {
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
} finally {
if (!alive) return
setLoading(false)
}
})()
return () => {
alive = false
}
}, [])
const visibleItems = useMemo(() => {
const admin = isGlobalAdmin(me)
const orgAdmin = isOrgAdmin(me)
return filterItems(items, admin, orgAdmin)
}, [me])
return (
<Sidebar>
<SidebarHeader className='flex items-center justify-between p-4'>
<SidebarHeader className="flex items-center justify-between p-4">
<h1 className="text-xl font-bold">AutoGlue</h1>
</SidebarHeader>
<SidebarContent>
{items.map((item, index) => (
<MenuItem item={item} key={index} />
{(loading ? items : visibleItems).map((item, i) => (
<MenuItem item={item} key={i} />
))}
</SidebarContent>
<SidebarFooter className="space-y-2 p-4">
<OrgSwitcher />
<ModeToggle />
<Button
onClick={() => {
localStorage.clear()
window.location.reload()
}}
className="w-full"
>
Logout
</Button>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -1,11 +1,11 @@
import { FaGithub } from "react-icons/fa";
import { FaGithub } from "react-icons/fa"
export function Footer() {
return (
<footer className="border-t">
<div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
<p className="text-muted-foreground text-center text-sm leading-loose md:text-left">
Built for{" "}
<a
href="https://www.glueops.dev/"
@@ -34,5 +34,5 @@ export function Footer() {
</div>
</div>
</footer>
);
)
}

View File

@@ -1,4 +1,4 @@
import { Laptop, Moon, Sun } from "lucide-react"
import { CheckIcon, Laptop, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button.tsx"
@@ -25,9 +25,15 @@ export function ModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
{theme === "light" && <CheckIcon />}Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{theme === "dark" && <CheckIcon />}Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{theme === "system" && <CheckIcon />}System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react"
import { api, ApiError } from "@/lib/api.ts"
import {
EVT_ACTIVE_ORG_CHANGED,
EVT_ORGS_CHANGED,
getActiveOrgId,
setActiveOrgId,
} from "@/lib/orgs-sync.ts"
import { Button } from "@/components/ui/button.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
type OrgLite = { id: string; name: string }
export const OrgSwitcher = () => {
const [orgs, setOrgs] = useState<OrgLite[]>([])
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null)
async function fetchOrgs() {
try {
const data = await api.get<OrgLite[]>("/api/v1/orgs")
setOrgs(data)
if (!getActiveOrgId() && data.length > 0) {
// default to first org if none selected yet
setActiveOrgId(data[0].id)
setActiveOrgIdState(data[0].id)
}
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load organizations"
// optional: toast.error(msg);
console.error(msg)
}
}
useEffect(() => {
// initial load
setActiveOrgIdState(getActiveOrgId())
void fetchOrgs()
// cross-tab sync
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgIdState(e.newValue)
}
window.addEventListener("storage", onStorage)
// same-tab sync: active org + orgs list mutations
const onActive = (e: Event) =>
setActiveOrgIdState((e as CustomEvent<string | null>).detail ?? null)
const onOrgs = () => void fetchOrgs()
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.addEventListener(EVT_ORGS_CHANGED, onOrgs)
return () => {
window.removeEventListener("storage", onStorage)
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.removeEventListener(EVT_ORGS_CHANGED, onOrgs)
}
}, [])
const switchOrg = (orgId: string) => {
setActiveOrgId(orgId)
setActiveOrgIdState(orgId)
}
const currentOrgName = orgs.find((o) => o.id === activeOrgId)?.name ?? "Select Org"
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-start">
{currentOrgName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{orgs.length === 0 ? (
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
) : (
orgs.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => switchOrg(org.id)}
className={org.id === activeOrgId ? "font-semibold" : undefined}
>
{org.name}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState, type ReactNode } from "react"
import { Navigate, Outlet, useLocation } from "react-router-dom"
import { authStore, isGlobalAdmin, type MePayload } from "@/lib/auth.ts"
type Props = { children?: ReactNode }
export function RequireAdmin({ children }: Props) {
const [loading, setLoading] = useState(true)
const [allowed, setAllowed] = useState(false)
const location = useLocation()
useEffect(() => {
let alive = true
;(async () => {
try {
const me: MePayload = await authStore.me()
if (!alive) return
setAllowed(isGlobalAdmin(me))
} catch {
if (!alive) return
setAllowed(false)
} finally {
setLoading(false)
if (!alive) return
}
})()
return () => {
alive = false
}
}, [])
if (loading) return null
if (!allowed) return <Navigate to="/403" replace state={{ from: location }} />
return children ? <>{children}</> : <Outlet />
}

View File

@@ -1,7 +1,7 @@
import type { ComponentType } from "react"
import {
BoxesIcon,
BrainCogIcon,
Building2Icon,
BuildingIcon,
ComponentIcon,
FileKey2Icon,
@@ -11,11 +11,22 @@ import {
LockKeyholeIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
SprayCanIcon,
TagsIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import { AiOutlineCluster } from "react-icons/ai";
} from "lucide-react"
import { AiOutlineCluster } from "react-icons/ai"
export type NavItem = {
label: string
icon: ComponentType<{ className?: string }>
to?: string
items?: NavItem[]
requiresAdmin?: boolean
requiresOrgAdmin?: boolean
}
export const items = [
{
@@ -37,16 +48,16 @@ export const items = [
icon: BoxesIcon,
to: "/core/node-pools",
},
{
label: "Annotations",
icon: ComponentIcon,
to: "/core/annotations",
},
{
label: "Labels",
icon: TagsIcon,
to: "/core/labels",
},
{
label: "Roles",
icon: ComponentIcon,
to: "/core/roles",
},
{
label: "Taints",
icon: SprayCanIcon,
@@ -83,10 +94,6 @@ export const items = [
{
label: "Settings",
icon: SettingsIcon,
items: [
{
label: "Organizations",
icon: Building2Icon,
items: [
{
label: "Organizations",
@@ -98,8 +105,24 @@ export const items = [
to: "/settings/members",
icon: UsersIcon,
},
],
{
label: "Profile",
to: "/settings/me",
icon: UserIcon,
},
],
},
];
{
label: "Admin",
icon: ShieldCheckIcon,
requiresAdmin: true,
items: [
{
label: "Users",
to: "/admin/users",
icon: UsersIcon,
requiresAdmin: true,
},
],
},
]

View File

@@ -4,26 +4,18 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
@@ -61,10 +53,7 @@ function AlertDialogContent({
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
@@ -74,17 +63,11 @@ function AlertDialogHeader({
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
@@ -120,12 +103,7 @@ function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
}
function AlertDialogCancel({

View File

@@ -0,0 +1,37 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -9,16 +9,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
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",
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: {

View File

@@ -1,31 +1,19 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -4,27 +4,19 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
@@ -92,19 +84,13 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -10,21 +10,15 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
@@ -101,10 +95,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"

View File

@@ -5,8 +5,8 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
@@ -18,12 +18,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -96,10 +91,7 @@ function SidebarProvider({
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
@@ -253,11 +245,7 @@ function Sidebar({
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
@@ -318,10 +306,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
@@ -354,10 +339,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
@@ -437,10 +419,7 @@ function SidebarGroupAction({
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
@@ -577,10 +556,7 @@ function SidebarMenuAction({
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
@@ -618,12 +594,7 @@ function SidebarMenuSkeleton({
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -652,10 +623,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"

View File

@@ -0,0 +1,90 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -16,9 +16,7 @@ function TooltipProvider({
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
@@ -26,9 +24,7 @@ function Tooltip({
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}

View File

@@ -37,6 +37,11 @@ function authHeaders(): Record<string, string> {
return headers
}
function orgContextHeaders(): Record<string, string> {
const id = localStorage.getItem("active_org_id")
return id ? { "X-Org-ID": id } : {}
}
async function request<T>(
path: string,
method: Method,
@@ -50,6 +55,7 @@ async function request<T>(
const merged: Record<string, string> = {
...baseHeaders,
...(opts.auth === false ? {} : authHeaders()),
...orgContextHeaders(),
...normalizeHeaders(opts.headers),
}
@@ -83,6 +89,8 @@ async function request<T>(
throw new ApiError(res.status, String(msg), payload)
}
console.debug("API ->", method, `${API_BASE_URL}${path}`, merged)
return isJSON ? (payload as T) : (undefined as T)
}

View File

@@ -1,5 +1,33 @@
import { api, API_BASE_URL } from "@/lib/api.ts"
export type MeUser = {
id: string
name?: string
email?: string
email_verified?: boolean
role: "admin" | "user" | string
created_at?: string
updated_at?: string
}
export type MePayload = {
user?: MeUser // preferred shape
user_id?: MeUser // fallback (older shape)
organization_id?: string | null
org_role?: "admin" | "member" | string | null
claims?: any
}
function getUser(me: MePayload | null | undefined): MeUser | undefined {
return (me && (me.user || me.user_id)) as MeUser | undefined
}
export function isGlobalAdmin(me: MePayload | null | undefined): boolean {
return getUser(me)?.role === "admin"
}
export function isOrgAdmin(me: MePayload | null | undefined): boolean {
return (me?.org_role ?? "") === "admin"
}
export const authStore = {
isAuthenticated(): boolean {
return !!localStorage.getItem("access_token")
@@ -19,9 +47,7 @@ export const authStore = {
},
async me() {
return await api.get<{ user_id: string; organization_id?: string; org_role?: string }>(
"/api/v1/auth/me"
)
return await api.get<MePayload>("/api/v1/auth/me")
},
async logout() {

17
ui/src/lib/orgs-sync.ts Normal file
View File

@@ -0,0 +1,17 @@
export const ACTIVE_ORG_KEY = "active_org_id"
export const EVT_ACTIVE_ORG_CHANGED = "active-org-changed"
export const EVT_ORGS_CHANGED = "orgs-changed"
export function getActiveOrgId(): string | null {
return localStorage.getItem(ACTIVE_ORG_KEY)
}
export function setActiveOrgId(id: string | null) {
if (id) localStorage.setItem(ACTIVE_ORG_KEY, id)
else localStorage.removeItem(ACTIVE_ORG_KEY)
window.dispatchEvent(new CustomEvent<string | null>(EVT_ACTIVE_ORG_CHANGED, { detail: id }))
}
export function emitOrgsChanged() {
window.dispatchEvent(new Event(EVT_ORGS_CHANGED))
}

View File

@@ -0,0 +1,406 @@
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilIcon, PlusIcon, TrashIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter as DialogFooterUI,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
type User = {
id: string
name: string
email: string
role: "admin" | "user" | string
email_verified: boolean
created_at: string
updated_at?: string
}
type ListRes = { users: User[]; page: number; page_size: number; total: number }
const CreateSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.email("Enter a valid email"),
role: z.enum(["user", "admin"]),
password: z.string().min(8, "Min 8 characters"),
})
type CreateValues = z.infer<typeof CreateSchema>
const EditSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.email("Enter a valid email"),
role: z.enum(["user", "admin"]),
password: z.string().min(8, "Min 8 characters").optional().or(z.literal("")),
})
type EditValues = z.infer<typeof EditSchema>
export function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const createForm = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
mode: "onChange",
defaultValues: { name: "", email: "", role: "user", password: "" },
})
const editForm = useForm<EditValues>({
resolver: zodResolver(EditSchema),
mode: "onChange",
defaultValues: { name: "", email: "", role: "user", password: "" },
})
async function fetchUsers() {
setLoading(true)
try {
const res = await api.get<ListRes>("/api/v1/admin/users?page=1&page_size=100")
setUsers(res.users ?? [])
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to load users")
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchUsers()
}, [])
async function onCreate(values: CreateValues) {
try {
const newUser = await api.post<User>("/api/v1/admin/users", values)
setUsers((prev) => [newUser, ...prev])
setCreateOpen(false)
createForm.reset({ name: "", email: "", role: "user", password: "" })
toast.success(`Created ${newUser.email}`)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to create user")
}
}
function openEdit(u: User) {
setEditing(u)
editForm.reset({
name: u.name || "",
email: u.email,
role: (u.role as "user" | "admin") ?? "user",
password: "",
})
setEditOpen(true)
}
async function onEdit(values: EditValues) {
if (!editing) return
const payload: Record<string, unknown> = {
name: values.name,
email: values.email,
role: values.role,
}
if (values.password && values.password.length >= 8) {
payload.password = values.password
}
try {
const updated = await api.patch<User>(`/api/v1/admin/users/${editing.id}`, payload)
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)))
setEditOpen(false)
setEditing(null)
toast.success(`Updated ${updated.email}`)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to update user")
}
}
async function onDelete(id: string) {
try {
setDeletingId(id)
await api.delete<void>(`/api/v1/admin/users/${id}`)
setUsers((prev) => prev.filter((u) => u.id !== id))
toast.success("User deleted")
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to delete user")
} finally {
setDeletingId(null)
}
}
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Users</h1>
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
New user
</Button>
</div>
<Separator />
{loading ? (
<div className="text-muted-foreground text-sm">Loading</div>
) : users.length === 0 ? (
<div className="text-muted-foreground text-sm">No users yet.</div>
) : (
<div className="grid grid-cols-1 gap-4 pr-2 md:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<Card key={u.id} className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{u.name || u.email}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
<div>Email: {u.email}</div>
<div>Role: {u.role}</div>
<div>Verified: {u.email_verified ? "Yes" : "No"}</div>
<div>Joined: {new Date(u.created_at).toLocaleString()}</div>
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" onClick={() => openEdit(u)}>
<PencilIcon className="mr-2 h-4 w-4" /> Edit
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deletingId === u.id}>
<TrashIcon className="mr-2 h-4 w-4" />
{deletingId === u.id ? "Deleting…" : "Delete"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <b>{u.email}</b>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === u.id}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === u.id}>
<Button variant="destructive" onClick={() => onDelete(u.id)}>
Confirm delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Create user</DialogTitle>
<DialogDescription>Add a new user account.</DialogDescription>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(onCreate)} className="grid gap-4 py-2">
<FormField
name="name"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Jane Doe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} placeholder="jane@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="role"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={!createForm.formState.isValid || createForm.formState.isSubmitting}
>
{createForm.formState.isSubmitting ? "Creating…" : "Create"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Edit user</DialogTitle>
<DialogDescription>
Update user details. Leave password blank to keep it unchanged.
</DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(onEdit)} className="grid gap-4 py-2">
<FormField
name="name"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="role"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>New password (optional)</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Leave blank to keep" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={!editForm.formState.isValid || editForm.formState.isSubmitting}
>
{editForm.formState.isSubmitting ? "Saving…" : "Save changes"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -39,7 +39,7 @@ export function Login() {
try {
await authStore.login(values.email, values.password)
toast.success("Welcome back!")
const to = location.state?.from?.pathname ?? "/auth/me"
const to = location.state?.from?.pathname ?? "/settings/me"
navigate(to, { replace: true })
} catch (e: any) {
toast.error(e.message || "Login failed")

View File

@@ -0,0 +1,8 @@
export function Forbidden() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">403 Forbidden</h1>
<p className="text-muted-foreground text-sm">You dont have access to this area.</p>
</div>
)
}

View File

@@ -1,16 +1,15 @@
import {useNavigate} from "react-router-dom";
import {Button} from "@/components/ui/button.tsx";
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button.tsx"
export const NotFoundPage = () => {
const navigate = useNavigate();
const navigate = useNavigate()
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-foreground">
<h1 className="text-6xl font-bold mb-4">404</h1>
<p className="text-2xl mb-8">Oops! Page not found</p>
<Button onClick={() => navigate("/dashboard")}>
Go back to Dashboard
</Button>
<div className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center">
<h1 className="mb-4 text-6xl font-bold">404</h1>
<p className="mb-8 text-2xl">Oops! Page not found</p>
<Button onClick={() => navigate("/dashboard")}>Go back to Dashboard</Button>
</div>
);
};
)
}

View File

@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { CloudDownload, Copy, Plus, Trash } from "lucide-react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { api, API_BASE_URL } from "@/lib/api.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx"
type SshKey = {
id: string
name: string
public_keys: string
fingerprint: string
created_at: string
}
type Part = "public" | "private" | "both"
const CreateKeySchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Max 100 characters"),
comment: z.string().trim().max(100, "Max 100 characters").default(""),
bits: z.enum(["2048", "3072", "4096"]),
})
type CreateKeyInput = z.input<typeof CreateKeySchema>
type CreateKeyOutput = z.output<typeof CreateKeySchema>
function filenameFromDisposition(disposition?: string, fallback = "download.bin") {
if (!disposition) return fallback
const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition)
if (star?.[1]) return decodeURIComponent(star[1])
const basic = /filename="?([^"]+)"?/i.exec(disposition)
return basic?.[1] ?? fallback
}
function truncateMiddle(str: string, keep = 24) {
if (!str || str.length <= keep * 2 + 3) return str
return `${str.slice(0, keep)}${str.slice(-keep)}`
}
function getKeyType(publicKey: string) {
return publicKey?.split(/\s+/)?.[0] ?? "ssh-key"
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
const el = document.createElement("textarea")
el.value = text
el.setAttribute("readonly", "")
el.style.position = "absolute"
el.style.left = "-9999px"
document.body.appendChild(el)
el.select()
document.execCommand("copy")
document.body.removeChild(el)
}
}
export const SshKeysPage = () => {
const [sshKeys, setSSHKeys] = useState<SshKey[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const hasOrg = useMemo(() => !!localStorage.getItem("active_org_id"), [])
async function fetchSshKeys() {
setLoading(true)
setError(null)
try {
if (!hasOrg) {
setSSHKeys([])
setError("Select an organization first.")
return
}
// api wrapper returns the parsed body directly
const data = await api.get<SshKey[]>("/api/v1/ssh")
setSSHKeys(data ?? [])
} catch (err) {
console.error(err)
setError("Failed to fetch SSH keys")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSshKeys()
// re-fetch if active org changes in another tab
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") fetchSshKeys()
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filtered = sshKeys.filter((k) => {
const hay = `${k.name} ${k.public_keys} ${k.fingerprint}`.toLowerCase()
return hay.includes(filter.toLowerCase())
})
// Use raw fetch for download so we can read headers and blob
async function downloadKeyPair(id: string, part: Part = "both") {
const token = localStorage.getItem("access_token")
const orgId = localStorage.getItem("active_org_id")
const url = `${API_BASE_URL}/api/v1/ssh/${encodeURIComponent(id)}/download?part=${encodeURIComponent(part)}`
try {
const res = await fetch(url, {
method: "GET",
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(orgId ? { "X-Org-ID": orgId } : {}),
},
})
if (!res.ok) {
const msg = await res.text().catch(() => "")
throw new Error(msg || `HTTP ${res.status}`)
}
const blob = await res.blob()
const fallback =
part === "both"
? `ssh_key_${id}.zip`
: part === "public"
? `id_rsa_${id}.pub`
: `id_rsa_${id}.pem`
const filename = filenameFromDisposition(
res.headers.get("content-disposition") ?? undefined,
fallback
)
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(objectUrl)
} catch (e) {
console.error(e)
alert(e instanceof Error ? e.message : "Download failed")
}
}
async function deleteKeyPair(id: string) {
try {
await api.delete<void>(`/api/v1/ssh/${encodeURIComponent(id)}`)
await fetchSshKeys()
} catch (e) {
console.error(e)
alert("Failed to delete key")
}
}
const form = useForm<CreateKeyInput, any, CreateKeyOutput>({
resolver: zodResolver(CreateKeySchema),
defaultValues: { name: "", comment: "deploy@autoglue", bits: "4096" },
})
async function onSubmit(values: CreateKeyInput) {
try {
await api.post<SshKey>("/api/v1/ssh", {
bits: Number(values.bits),
comment: values.comment?.trim() ?? "",
name: values.name.trim(),
download: "none",
})
setCreateOpen(false)
form.reset()
await fetchSshKeys()
} catch (e) {
console.error(e)
alert("Failed to create key")
}
}
if (loading) return <div className="p-6">Loading SSH Keys</div>
if (error) return <div className="p-6 text-red-500">{error}</div>
return (
<TooltipProvider>
<div className="space-y-4 p-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-bold">SSH Keys</h1>
<div className="w-full max-w-sm">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search by name, fingerprint or key"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create New Keypair
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create SSH Keypair</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g., CI deploy key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel>Comment</FormLabel>
<FormControl>
<Input placeholder="e.g., deploy@autoglue" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bits"
render={({ field }) => (
<FormItem>
<FormLabel>Key size</FormLabel>
<FormControl>
<select
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
value={field.value}
onChange={field.onChange}
>
<option value="2048">2048</option>
<option value="3072">3072</option>
<option value="4096">4096</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="min-w-[360px]">Public Key</TableHead>
<TableHead>Fingerprint</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[160px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((sshKey) => {
const keyType = getKeyType(sshKey.public_keys)
const truncated = truncateMiddle(sshKey.public_keys, 18)
return (
<TableRow key={sshKey.id}>
<TableCell className="align-top">{sshKey.name}</TableCell>
<TableCell className="align-top">
<div className="flex items-start gap-2">
<Badge variant="secondary" className="whitespace-nowrap">
{keyType}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<code className="font-mono text-sm break-all md:max-w-[48ch] md:truncate md:break-normal">
{truncated}
</code>
</TooltipTrigger>
<TooltipContent className="max-w-[70vw]">
<div className="max-w-full">
<p className="font-mono text-xs break-all">{sshKey.public_keys}</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="align-top">
<code className="font-mono text-sm">{sshKey.fingerprint}</code>
</TableCell>
<TableCell className="align-top">
{new Date(sshKey.created_at).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell className="align-top">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(sshKey.public_keys)}
title="Copy public key"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<CloudDownload className="mr-2 h-4 w-4" />
Download
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => downloadKeyPair(sshKey.id, "both")}>
Public + Private (.zip)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => downloadKeyPair(sshKey.id, "public")}
>
Public only (.pub)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => downloadKeyPair(sshKey.id, "private")}
>
Private only (.pem)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="destructive"
size="sm"
onClick={() => deleteKeyPair(sshKey.id)}
>
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,361 @@
// src/pages/settings/members.tsx
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { TrashIcon, UserPlus2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api.ts"
import { EVT_ACTIVE_ORG_CHANGED, getActiveOrgId } from "@/lib/orgs-sync.ts"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter as DialogFooterUI,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { Skeleton } from "@/components/ui/skeleton.tsx"
type Me = { id: string; email?: string; name?: string }
// Backend shape can vary; normalize to a safe shape for UI.
type MemberDTO = any
type Member = {
userId: string
email?: string
name?: string
role: string
joinedAt?: string
}
function normalizeMember(m: MemberDTO): Member {
const userId = m?.user_id ?? m?.UserID ?? m?.user?.id ?? m?.User?.ID ?? ""
const email = m?.email ?? m?.Email ?? m?.user?.email ?? m?.User?.Email
const name = m?.name ?? m?.Name ?? m?.user?.name ?? m?.User?.Name
const role = m?.role ?? m?.Role ?? "member"
const joinedAt = m?.created_at ?? m?.CreatedAt
return { userId: String(userId), email, name, role: String(role), joinedAt }
}
const InviteSchema = z.object({
email: z.email("Enter a valid email"),
role: z.enum(["member", "admin"]),
})
type InviteValues = z.infer<typeof InviteSchema>
export const MemberManagement = () => {
const [loading, setLoading] = useState(true)
const [members, setMembers] = useState<Member[]>([])
const [me, setMe] = useState<Me | null>(null)
const [inviteOpen, setInviteOpen] = useState(false)
const [inviting, setInviting] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const activeOrgIdInitial = useMemo(() => getActiveOrgId(), [])
const form = useForm<InviteValues>({
resolver: zodResolver(InviteSchema),
defaultValues: { email: "", role: "member" },
mode: "onChange",
})
async function fetchMe() {
try {
const data = await api.get<Me>("/api/v1/auth/me")
setMe(data)
} catch {
// non-blocking
}
}
async function fetchMembers(orgId: string | null) {
if (!orgId) {
setMembers([])
setLoading(false)
return
}
setLoading(true)
try {
const data = await api.get<MemberDTO[]>("/api/v1/orgs/members")
setMembers((data ?? []).map(normalizeMember))
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load members"
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchMe()
void fetchMembers(activeOrgIdInitial)
}, [activeOrgIdInitial])
// Refetch when active org changes (same tab or across tabs)
useEffect(() => {
const onActiveOrg = () => void fetchMembers(getActiveOrgId())
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") onActiveOrg()
}
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActiveOrg as EventListener)
window.addEventListener("storage", onStorage)
return () => {
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActiveOrg as EventListener)
window.removeEventListener("storage", onStorage)
}
}, [])
async function onInvite(values: InviteValues) {
const orgId = getActiveOrgId()
if (!orgId) {
toast.error("Select an organization first")
return
}
try {
setInviting(true)
await api.post("/api/v1/orgs/invite", values)
toast.success(`Invited ${values.email}`)
setInviteOpen(false)
form.reset({ email: "", role: "member" })
// If you later expose pending invites, update that list; for now just refresh members.
void fetchMembers(orgId)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to invite member"
toast.error(msg)
} finally {
setInviting(false)
}
}
async function onRemove(userId: string) {
const orgId = getActiveOrgId()
if (!orgId) {
toast.error("Select an organization first")
return
}
try {
setDeletingId(userId)
await api.delete<void>(`/api/v1/orgs/members/${userId}`, {
headers: { "X-Org-ID": orgId },
})
setMembers((prev) => prev.filter((m) => m.userId !== userId))
toast.success("Member removed")
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to remove member"
toast.error(msg)
} finally {
setDeletingId(null)
}
}
const canManage = true // Server enforces admin; UI stays permissive.
if (loading) {
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Members</h1>
<Button disabled>
<UserPlus2 className="mr-2 h-4 w-4" />
Invite
</Button>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-56" />
<Skeleton className="h-4 w-40" />
</CardContent>
<CardFooter>
<Skeleton className="h-9 w-24" />
</CardFooter>
</Card>
))}
</div>
</div>
)
}
if (!getActiveOrgId()) {
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Members</h1>
</div>
<Separator />
<p className="text-muted-foreground text-sm">
No organization selected. Choose an organization to manage its members.
</p>
</div>
)
}
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold">Members</h1>
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus2 className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Invite member</DialogTitle>
<DialogDescription>Send an invite to join this organization.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onInvite)} className="grid gap-4 py-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="jane@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!form.formState.isValid || inviting}>
{inviting ? "Sending…" : "Send invite"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<Separator />
{members.length === 0 ? (
<div className="text-muted-foreground text-sm">No members yet.</div>
) : (
<div className="grid grid-cols-1 gap-4 pr-2 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const isSelf = me?.id && m.userId === me.id
return (
<Card key={m.userId} className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{m.name || m.email || m.userId}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
{m.email && <div>Email: {m.email}</div>}
<div>Role: {m.role}</div>
{m.joinedAt && <div>Joined: {new Date(m.joinedAt).toLocaleString()}</div>}
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<div />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={!canManage || isSelf || deletingId === m.userId}
className="ml-auto"
>
<TrashIcon className="mr-2 h-5 w-5" />
{deletingId === m.userId ? "Removing…" : "Remove"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
This will remove <b>{m.name || m.email || m.userId}</b> from the
organization.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === m.userId}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === m.userId}>
<Button variant="destructive" onClick={() => onRemove(m.userId)}>
Confirm remove
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,183 +1,241 @@
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { api, ApiError } from "@/lib/api"; // <-- import ApiError
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { slugify } from "@/lib/utils";
import { toast } from "sonner";
import { useEffect, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { TrashIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api.ts"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle
} from "@/components/ui/dialog";
emitOrgsChanged,
EVT_ACTIVE_ORG_CHANGED,
EVT_ORGS_CHANGED,
getActiveOrgId,
setActiveOrgId as setActiveOrgIdLS,
} from "@/lib/orgs-sync.ts"
import { slugify } from "@/lib/utils.ts"
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import { TrashIcon } from "lucide-react";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { Skeleton } from "@/components/ui/skeleton.tsx"
type Organization = {
id: string; // confirm with your API; change to number if needed
name: string;
slug: string;
created_at: string;
};
id: string // confirm with your API; change to number if needed
name: string
slug: string
created_at: string
}
const OrgSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string()
.min(2).max(50)
slug: z
.string()
.min(2)
.max(50)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
});
type OrgFormValues = z.infer<typeof OrgSchema>;
})
type OrgFormValues = z.infer<typeof OrgSchema>
export const OrgManagement = () => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const slugEditedRef = useRef(false);
const [activeOrgId, setActiveOrgId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// initialize active org from localStorage once
useEffect(() => {
setActiveOrgId(localStorage.getItem("active_org_id"));
}, []);
// keep active org in sync across tabs
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgId(e.newValue);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const slugEditedRef = useRef(false)
const form = useForm<OrgFormValues>({
resolver: zodResolver(OrgSchema),
mode: "onChange",
defaultValues: { name: "", slug: "" },
});
defaultValues: {
name: "",
slug: "",
},
})
// auto-generate slug from name unless user edited slug manually
const nameValue = form.watch("name");
// auto-generate slug from name unless user manually edited the slug
const nameValue = form.watch("name")
useEffect(() => {
if (!slugEditedRef.current) {
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true });
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true })
}
}, [nameValue, form]);
}, [nameValue, form])
// fetch orgs once
// fetch organizations
const getOrgs = async () => {
setLoading(true);
setLoading(true)
try {
const data = await api.get<Organization[]>("/api/v1/orgs");
setOrganizations(data);
setCreateOpen(data.length === 0);
const data = await api.get<Organization[]>("/api/v1/orgs")
setOrganizations(data)
setCreateOpen(data.length === 0)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load organizations";
toast.error(msg);
const msg = err instanceof ApiError ? err.message : "Failed to load organizations"
toast.error(msg)
} finally {
setLoading(false);
setLoading(false)
}
}
};
// initial load + sync listeners
useEffect(() => {
void getOrgs();
}, []);
// initialize active org from storage
setActiveOrgIdState(getActiveOrgId())
void getOrgs()
// cross-tab sync for active org
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgIdState(e.newValue)
}
window.addEventListener("storage", onStorage)
// same-tab sync for active org (custom event)
const onActive = (e: Event) => {
const id = (e as CustomEvent<string | null>).detail ?? null
setActiveOrgIdState(id)
}
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
// orgs list changes from elsewhere (custom event)
const onOrgs = () => void getOrgs()
window.addEventListener(EVT_ORGS_CHANGED, onOrgs)
return () => {
window.removeEventListener("storage", onStorage)
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.removeEventListener(EVT_ORGS_CHANGED, onOrgs)
}
}, [])
async function onSubmit(values: OrgFormValues) {
try {
const newOrg = await api.post<Organization>("/api/v1/orgs", values);
setOrganizations(prev => [newOrg, ...prev]);
localStorage.setItem("active_org_id", newOrg.id);
setActiveOrgId(newOrg.id);
toast.success(`Created ${newOrg.name}`);
setCreateOpen(false);
form.reset({ name: "", slug: "" });
slugEditedRef.current = false;
const newOrg = await api.post<Organization>("/api/v1/orgs", values)
setOrganizations((prev) => [newOrg, ...prev])
// set as current org and broadcast
setActiveOrgIdLS(newOrg.id)
setActiveOrgIdState(newOrg.id)
emitOrgsChanged()
toast.success(`Created ${newOrg.name}`)
setCreateOpen(false)
form.reset({ name: "", slug: "" })
slugEditedRef.current = false
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to create organization";
toast.error(msg);
const msg = err instanceof ApiError ? err.message : "Failed to create organization"
toast.error(msg)
}
}
function handleSelectOrg(org: Organization) {
localStorage.setItem("active_org_id", org.id);
setActiveOrgId(org.id);
toast.success(`Switched to ${org.name}`);
setActiveOrgIdLS(org.id) // updates localStorage + emits event
setActiveOrgIdState(org.id)
toast.success(`Switched to ${org.name}`)
}
async function handleDeleteOrg(org: Organization) {
try {
setDeletingId(org.id);
await api.delete<void>(`/api/v1/orgs/${org.id}`); // <-- correct path
setOrganizations(prev => {
const next = prev.filter(o => o.id !== org.id); // <-- fix shadow bug
setDeletingId(org.id)
await api.delete<void>(`/api/v1/orgs/${org.id}`)
setOrganizations((prev) => {
const next = prev.filter((o) => o.id !== org.id)
// if we deleted the active org, move to the first remaining org (or clear)
if (activeOrgId === org.id) {
const nextId = next[0]?.id ?? null;
if (nextId) localStorage.setItem("active_org_id", nextId);
else localStorage.removeItem("active_org_id");
setActiveOrgId(nextId);
const nextId = next[0]?.id ?? null
setActiveOrgIdLS(nextId)
setActiveOrgIdState(nextId)
}
return next;
});
toast.success(`Deleted ${org.name}`);
return next
})
emitOrgsChanged()
toast.success(`Deleted ${org.name}`)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to delete organization";
toast.error(msg);
const msg = err instanceof ApiError ? err.message : "Failed to delete organization"
toast.error(msg)
} finally {
setDeletingId(null);
setDeletingId(null)
}
}
if (loading) {
return (
<div className="p-6 space-y-4">
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader><Skeleton className="h-5 w-40" /></CardHeader>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-4 w-48" />
</CardContent>
<CardFooter><Skeleton className="h-9 w-24" /></CardFooter>
<CardFooter>
<Skeleton className="h-9 w-24" />
</CardFooter>
</Card>
))}
</div>
</div>
);
)
}
return (
<div className="p-6 space-y-4">
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
</div>
<Separator />
{organizations.length === 0 ? (
<div className="text-sm text-muted-foreground">No organizations yet.</div>
<div className="text-muted-foreground text-sm">No organizations yet.</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pr-2">
{organizations.map(org => (
<div className="grid grid-cols-1 gap-4 pr-2 sm:grid-cols-2 lg:grid-cols-3">
{organizations.map((org) => (
<Card key={org.id} className="flex flex-col">
<CardHeader><CardTitle className="text-base">{org.name}</CardTitle></CardHeader>
<CardContent className="text-sm text-muted-foreground">
<CardHeader>
<CardTitle className="text-base">{org.name}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<div>Slug: {org.slug}</div>
<div className="mt-1">ID: {org.id}</div>
<div className="mt-1">Created: {new Date(org.created_at).toUTCString()}</div>
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={() => handleSelectOrg(org)}>
@@ -186,23 +244,28 @@ export const OrgManagement = () => {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="ml-auto">
<TrashIcon className="h-5 w-5 mr-2" />
Delete
<Button
variant="destructive"
className="ml-auto"
disabled={deletingId === org.id}
>
<TrashIcon className="mr-2 h-5 w-5" />
{deletingId === org.id ? "Deleting…" : "Delete"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <b>{org.name}</b>. This action cannot be undone.
This will permanently delete <b>{org.name}</b>. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === org.id}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === org.id}>
<Button variant="destructive" onClick={() => handleDeleteOrg(org)}>
{deletingId === org.id ? "Deleting…" : "Delete"}
Confirm delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
@@ -229,7 +292,9 @@ export const OrgManagement = () => {
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input placeholder="Acme Inc" autoFocus {...field} /></FormControl>
<FormControl>
<Input placeholder="Acme Inc" autoFocus {...field} />
</FormControl>
<FormDescription>This is your organizations display name.</FormDescription>
<FormMessage />
</FormItem>
@@ -246,11 +311,15 @@ export const OrgManagement = () => {
<Input
placeholder="acme-inc"
{...field}
onChange={(e) => { slugEditedRef.current = true; field.onChange(e); }}
onChange={(e) => {
slugEditedRef.current = true // user manually edited slug
field.onChange(e)
}}
onBlur={(e) => {
const normalized = slugify(e.target.value);
form.setValue("slug", normalized, { shouldValidate: true });
field.onBlur();
// normalize on blur
const normalized = slugify(e.target.value)
form.setValue("slug", normalized, { shouldValidate: true })
field.onBlur()
}}
/>
</FormControl>
@@ -264,11 +333,18 @@ export const OrgManagement = () => {
<Button
type="button"
variant="outline"
onClick={() => { form.reset(); setCreateOpen(false); }}
onClick={() => {
form.reset()
setCreateOpen(false)
slugEditedRef.current = false
}}
>
Cancel
</Button>
<Button type="submit" disabled={!form.formState.isValid || form.formState.isSubmitting}>
<Button
type="submit"
disabled={!form.formState.isValid || form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Creating..." : "Create"}
</Button>
</DialogFooter>
@@ -277,5 +353,5 @@ export const OrgManagement = () => {
</DialogContent>
</Dialog>
</div>
);
};
)
}

View File

@@ -1,11 +1,21 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { visualizer } from "rollup-plugin-visualizer"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
visualizer({
filename: "dist/stats.html",
template: "treemap",
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -20,7 +30,26 @@ export default defineConfig({
},
},
build: {
chunkSizeWarningLimit: 1000,
outDir: "../internal/ui/dist",
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return
if (id.includes("react-router")) return "router"
if (id.includes("@radix-ui")) return "radix"
if (id.includes("lucide-react") || id.includes("react-icons")) return "icons"
if (id.includes("recharts") || id.includes("d3")) return "charts"
if (id.includes("date-fns") || id.includes("dayjs")) return "dates"
return "vendor"
},
},
},
},
optimizeDeps: {
include: ["react", "react-dom", "react-router-dom"],
},
})

View File

@@ -753,6 +753,11 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
@@ -966,6 +971,33 @@
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-select@^2.2.6":
version "2.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.2.6.tgz#022cf8dab16bf05d0d1b4df9e53e4bea1b744fd9"
integrity sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==
dependencies:
"@radix-ui/number" "1.1.1"
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-collection" "1.1.7"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-direction" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-popper" "1.2.8"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-visually-hidden" "1.2.3"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
@@ -1030,6 +1062,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@radix-ui/react-use-previous@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5"
integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==
"@radix-ui/react-use-rect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152"
@@ -1832,6 +1869,11 @@ deepmerge@^4.3.1:
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
depd@2.0.0, depd@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@@ -2528,6 +2570,11 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -2600,6 +2647,13 @@ is-unicode-supported@^2.0.0:
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
is-wsl@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
dependencies:
is-docker "^2.0.0"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -3032,6 +3086,15 @@ onetime@^7.0.0:
dependencies:
mimic-function "^5.0.0"
open@^8.0.0:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
@@ -3342,6 +3405,16 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rollup-plugin-visualizer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.3.tgz#d05bd17e358a6d04bf593cf73556219c9c6d8dad"
integrity sha512-ZU41GwrkDcCpVoffviuM9Clwjy5fcUxlz0oMoTXTYsK+tcIFzbdacnrr2n8TXcHxbGKKXtOdjxM2HUS4HjkwIw==
dependencies:
open "^8.0.0"
picomatch "^4.0.2"
source-map "^0.7.4"
yargs "^17.5.1"
rollup@^4.43.0:
version "4.50.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.0.tgz#6f237f598b7163ede33ce827af8534c929aaa186"
@@ -3563,6 +3636,11 @@ source-map-js@^1.2.1:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map@^0.7.4:
version "0.7.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02"
integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==
source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -3941,7 +4019,7 @@ yargs-parser@^21.1.1:
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.7.2:
yargs@^17.5.1, yargs@^17.7.2:
version "17.7.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==