mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
Orgs, Members, SSH and Admin page
This commit is contained in:
1102
docs/docs.go
1102
docs/docs.go
File diff suppressed because it is too large
Load Diff
1102
docs/swagger.json
1102
docs/swagger.json
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
internal/db/models/credential.go
Normal file
14
internal/db/models/credential.go
Normal 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
|
||||
}
|
||||
15
internal/db/models/organization-key.go
Normal file
15
internal/db/models/organization-key.go
Normal 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
|
||||
}
|
||||
16
internal/db/models/ssh-key.go
Normal file
16
internal/db/models/ssh-key.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
25
internal/handlers/ssh/dto.go
Normal file
25
internal/handlers/ssh/dto.go
Normal 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"`
|
||||
}
|
||||
60
internal/handlers/ssh/funcs.go
Normal file
60
internal/handlers/ssh/funcs.go
Normal 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
|
||||
}
|
||||
361
internal/handlers/ssh/ssh.go
Normal file
361
internal/handlers/ssh/ssh.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
156
internal/ui/dist/assets/icons-CHRYRpwL.js
vendored
Normal file
156
internal/ui/dist/assets/icons-CHRYRpwL.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
Normal file
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
Normal file
File diff suppressed because one or more lines are too long
157
internal/ui/dist/assets/index-DrmAfy-p.js
vendored
157
internal/ui/dist/assets/index-DrmAfy-p.js
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-Nf4c5zdA.css
vendored
1
internal/ui/dist/assets/index-Nf4c5zdA.css
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
Normal file
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
internal/ui/dist/assets/radix-DN_DrUzo.js
vendored
Normal file
11
internal/ui/dist/assets/radix-DN_DrUzo.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
internal/ui/dist/assets/router-CyXg69m3.js
vendored
Normal file
12
internal/ui/dist/assets/router-CyXg69m3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
106
internal/ui/dist/assets/vendor-Cnbx_Mrt.js
vendored
Normal file
106
internal/ui/dist/assets/vendor-Cnbx_Mrt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
internal/ui/dist/index.html
vendored
12
internal/ui/dist/index.html
vendored
@@ -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
85
internal/utils/crypto.go
Normal 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
|
||||
}
|
||||
|
||||
// Go’s 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
101
internal/utils/keys.go
Normal 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
|
||||
}
|
||||
71
internal/utils/org-crypto.go
Normal file
71
internal/utils/org-crypto.go
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,18 +28,18 @@ 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 />} />
|
||||
<Route path="node-pools" element={<NodePoolsPage />} />
|
||||
<Route path="servers" element={<ServersPage />} />
|
||||
@@ -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="orgs" element={<OrgManagement />} />
|
||||
<Route path="members" element={<MemberManagement />} />
|
||||
<Route path="me" element={<Me />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
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">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider>
|
||||
<DashboardSidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,82 +1,150 @@
|
||||
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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem
|
||||
} from "@/components/ui/sidebar.tsx";
|
||||
import type {ComponentType, FC} from "react";
|
||||
import {Link, useLocation} from "react-router-dom";
|
||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from "@/components/ui/collapsible.tsx";
|
||||
import {ChevronDown} from "lucide-react";
|
||||
import {items} from "@/components/sidebar/items.ts";
|
||||
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 { 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" : ""}`}
|
||||
if (item.to) {
|
||||
return (
|
||||
<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="mr-4 h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
return (
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
<Icon className="h-4 w-4 mr-4" />
|
||||
{item.label}
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{item.items.map((subitem, index) => (
|
||||
<SidebarMenuItem key={index}>
|
||||
<SidebarMenuButton asChild>
|
||||
<MenuItem item={subitem} />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
return null
|
||||
if (item.items) {
|
||||
return (
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
<Icon className="mr-4 h-4 w-4" />
|
||||
{item.label}
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{item.items.map((subitem, index) => (
|
||||
<SidebarMenuItem key={index}>
|
||||
<SidebarMenuButton asChild>
|
||||
<MenuItem item={subitem} />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const DashboardSidebar = () => {
|
||||
return (
|
||||
<Sidebar>
|
||||
<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} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
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">
|
||||
<h1 className="text-xl font-bold">AutoGlue</h1>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{(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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
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">
|
||||
Built for{" "}
|
||||
<a
|
||||
href="https://www.glueops.dev/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GlueOps
|
||||
</a>
|
||||
. The source code is available on{" "}
|
||||
<a
|
||||
href="https://github.com/GlueOps/autoglue"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a href="https://github.com/GlueOps/autoglue" target="_blank" rel="noreferrer">
|
||||
<FaGithub className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</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-muted-foreground text-center text-sm leading-loose md:text-left">
|
||||
Built for{" "}
|
||||
<a
|
||||
href="https://www.glueops.dev/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GlueOps
|
||||
</a>
|
||||
. The source code is available on{" "}
|
||||
<a
|
||||
href="https://github.com/GlueOps/autoglue"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a href="https://github.com/GlueOps/autoglue" target="_blank" rel="noreferrer">
|
||||
<FaGithub className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
97
ui/src/components/org-switcher.tsx
Normal file
97
ui/src/components/org-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
ui/src/components/require-admin.tsx
Normal file
38
ui/src/components/require-admin.tsx
Normal 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 />
|
||||
}
|
||||
@@ -1,105 +1,128 @@
|
||||
import type { ComponentType } from "react"
|
||||
import {
|
||||
BoxesIcon,
|
||||
BrainCogIcon,
|
||||
Building2Icon,
|
||||
BuildingIcon,
|
||||
ComponentIcon,
|
||||
FileKey2Icon,
|
||||
HomeIcon,
|
||||
KeyIcon,
|
||||
ListTodoIcon,
|
||||
LockKeyholeIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
SprayCanIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { AiOutlineCluster } from "react-icons/ai";
|
||||
BoxesIcon,
|
||||
BrainCogIcon,
|
||||
BuildingIcon,
|
||||
ComponentIcon,
|
||||
FileKey2Icon,
|
||||
HomeIcon,
|
||||
KeyIcon,
|
||||
ListTodoIcon,
|
||||
LockKeyholeIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SprayCanIcon,
|
||||
TagsIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} 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 = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
icon: HomeIcon,
|
||||
to: "/dashboard",
|
||||
},
|
||||
{
|
||||
label: "Core",
|
||||
icon: BrainCogIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Cluster",
|
||||
to: "/core/cluster",
|
||||
icon: AiOutlineCluster,
|
||||
},
|
||||
{
|
||||
label: "Node Pools",
|
||||
icon: BoxesIcon,
|
||||
to: "/core/node-pools",
|
||||
},
|
||||
{
|
||||
label: "Labels",
|
||||
icon: TagsIcon,
|
||||
to: "/core/labels",
|
||||
},
|
||||
{
|
||||
label: "Roles",
|
||||
icon: ComponentIcon,
|
||||
to: "/core/roles",
|
||||
},
|
||||
{
|
||||
label: "Taints",
|
||||
icon: SprayCanIcon,
|
||||
to: "/core/taints",
|
||||
},
|
||||
{
|
||||
label: "Servers",
|
||||
icon: ServerIcon,
|
||||
to: "/core/servers",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
icon: LockKeyholeIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Keys & Tokens",
|
||||
icon: KeyIcon,
|
||||
to: "/security/keys",
|
||||
},
|
||||
{
|
||||
label: "SSH Keys",
|
||||
to: "/security/ssh",
|
||||
icon: FileKey2Icon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tasks",
|
||||
icon: ListTodoIcon,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
icon: SettingsIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Organizations",
|
||||
icon: Building2Icon,
|
||||
items: [
|
||||
{
|
||||
label: "Organizations",
|
||||
to: "/settings/orgs",
|
||||
icon: BuildingIcon,
|
||||
},
|
||||
{
|
||||
label: "Members",
|
||||
to: "/settings/members",
|
||||
icon: UsersIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
{
|
||||
label: "Dashboard",
|
||||
icon: HomeIcon,
|
||||
to: "/dashboard",
|
||||
},
|
||||
{
|
||||
label: "Core",
|
||||
icon: BrainCogIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Cluster",
|
||||
to: "/core/cluster",
|
||||
icon: AiOutlineCluster,
|
||||
},
|
||||
{
|
||||
label: "Node Pools",
|
||||
icon: BoxesIcon,
|
||||
to: "/core/node-pools",
|
||||
},
|
||||
{
|
||||
label: "Annotations",
|
||||
icon: ComponentIcon,
|
||||
to: "/core/annotations",
|
||||
},
|
||||
{
|
||||
label: "Labels",
|
||||
icon: TagsIcon,
|
||||
to: "/core/labels",
|
||||
},
|
||||
{
|
||||
label: "Taints",
|
||||
icon: SprayCanIcon,
|
||||
to: "/core/taints",
|
||||
},
|
||||
{
|
||||
label: "Servers",
|
||||
icon: ServerIcon,
|
||||
to: "/core/servers",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
icon: LockKeyholeIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Keys & Tokens",
|
||||
icon: KeyIcon,
|
||||
to: "/security/keys",
|
||||
},
|
||||
{
|
||||
label: "SSH Keys",
|
||||
to: "/security/ssh",
|
||||
icon: FileKey2Icon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tasks",
|
||||
icon: ListTodoIcon,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
icon: SettingsIcon,
|
||||
items: [
|
||||
{
|
||||
label: "Organizations",
|
||||
to: "/settings/orgs",
|
||||
icon: BuildingIcon,
|
||||
},
|
||||
{
|
||||
label: "Members",
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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({
|
||||
|
||||
37
ui/src/components/ui/badge.tsx
Normal file
37
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
168
ui/src/components/ui/select.tsx
Normal file
168
ui/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
90
ui/src/components/ui/table.tsx
Normal file
90
ui/src/components/ui/table.tsx
Normal 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 }
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
17
ui/src/lib/orgs-sync.ts
Normal 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))
|
||||
}
|
||||
406
ui/src/pages/admin/users.tsx
Normal file
406
ui/src/pages/admin/users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
8
ui/src/pages/error/forbidden.tsx
Normal file
8
ui/src/pages/error/forbidden.tsx
Normal 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 don’t have access to this area.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
428
ui/src/pages/security/ssh.tsx
Normal file
428
ui/src/pages/security/ssh.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
361
ui/src/pages/settings/members.tsx
Normal file
361
ui/src/pages/settings/members.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +1,357 @@
|
||||
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)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
|
||||
});
|
||||
type OrgFormValues = z.infer<typeof OrgSchema>;
|
||||
name: z.string().min(2).max(100),
|
||||
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>
|
||||
|
||||
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);
|
||||
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)
|
||||
|
||||
// initialize active org from localStorage once
|
||||
useEffect(() => {
|
||||
setActiveOrgId(localStorage.getItem("active_org_id"));
|
||||
}, []);
|
||||
const form = useForm<OrgFormValues>({
|
||||
resolver: zodResolver(OrgSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
},
|
||||
})
|
||||
|
||||
// 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 form = useForm<OrgFormValues>({
|
||||
resolver: zodResolver(OrgSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: { name: "", slug: "" },
|
||||
});
|
||||
|
||||
// auto-generate slug from name unless user edited slug manually
|
||||
const nameValue = form.watch("name");
|
||||
useEffect(() => {
|
||||
if (!slugEditedRef.current) {
|
||||
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true });
|
||||
}
|
||||
}, [nameValue, form]);
|
||||
|
||||
// fetch orgs once
|
||||
const getOrgs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getOrgs();
|
||||
}, []);
|
||||
|
||||
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;
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : "Failed to create organization";
|
||||
toast.error(msg);
|
||||
}
|
||||
// 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 })
|
||||
}
|
||||
}, [nameValue, form])
|
||||
|
||||
function handleSelectOrg(org: Organization) {
|
||||
localStorage.setItem("active_org_id", org.id);
|
||||
setActiveOrgId(org.id);
|
||||
toast.success(`Switched to ${org.name}`);
|
||||
// fetch organizations
|
||||
const getOrgs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
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)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
toast.success(`Deleted ${org.name}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : "Failed to delete organization";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
// initial load + sync listeners
|
||||
useEffect(() => {
|
||||
// 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])
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectOrg(org: Organization) {
|
||||
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}`)
|
||||
|
||||
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
|
||||
setActiveOrgIdLS(nextId)
|
||||
setActiveOrgIdState(nextId)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader><Skeleton className="h-5 w-40" /></CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardContent>
|
||||
<CardFooter><Skeleton className="h-9 w-24" /></CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
emitOrgsChanged()
|
||||
toast.success(`Deleted ${org.name}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : "Failed to delete organization"
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<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>
|
||||
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pr-2">
|
||||
{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">
|
||||
<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)}>
|
||||
{org.id === activeOrgId ? "Selected" : "Select"}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="ml-auto">
|
||||
<TrashIcon className="h-5 w-5 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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"}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create organization</DialogTitle>
|
||||
<DialogDescription>Set a name and a URL-friendly slug.</DialogDescription>
|
||||
</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="Acme Inc" autoFocus {...field} /></FormControl>
|
||||
<FormDescription>This is your organization’s display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="acme-inc"
|
||||
{...field}
|
||||
onChange={(e) => { slugEditedRef.current = true; field.onChange(e); }}
|
||||
onBlur={(e) => {
|
||||
const normalized = slugify(e.target.value);
|
||||
form.setValue("slug", normalized, { shouldValidate: true });
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Lowercase, numbers and hyphens only.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { form.reset(); setCreateOpen(false); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!form.formState.isValid || form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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="mb-4 text-2xl font-bold">Organizations</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<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>
|
||||
<CardContent>
|
||||
<Skeleton className="mb-2 h-4 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</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="mb-4 text-2xl font-bold">Organizations</h1>
|
||||
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{organizations.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No organizations yet.</div>
|
||||
) : (
|
||||
<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-muted-foreground text-sm">
|
||||
<div>Slug: {org.slug}</div>
|
||||
<div className="mt-1">ID: {org.id}</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)}>
|
||||
{org.id === activeOrgId ? "Selected" : "Select"}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<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.
|
||||
</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)}>
|
||||
Confirm delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create organization</DialogTitle>
|
||||
<DialogDescription>Set a name and a URL-friendly slug.</DialogDescription>
|
||||
</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="Acme Inc" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This is your organization’s display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="acme-inc"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
slugEditedRef.current = true // user manually edited slug
|
||||
field.onChange(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// normalize on blur
|
||||
const normalized = slugify(e.target.value)
|
||||
form.setValue("slug", normalized, { shouldValidate: true })
|
||||
field.onBlur()
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Lowercase, numbers and hyphens only.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
setCreateOpen(false)
|
||||
slugEditedRef.current = false
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
})
|
||||
|
||||
80
ui/yarn.lock
80
ui/yarn.lock
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user