mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50: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: /
|
basePath: /
|
||||||
definitions:
|
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:
|
authn.AuthClaimsDTO:
|
||||||
properties:
|
properties:
|
||||||
aud:
|
aud:
|
||||||
@@ -25,6 +62,19 @@ definitions:
|
|||||||
sub:
|
sub:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
authn.LoginInput:
|
||||||
properties:
|
properties:
|
||||||
email:
|
email:
|
||||||
@@ -74,6 +124,65 @@ definitions:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
models.Organization:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
@@ -99,6 +208,34 @@ definitions:
|
|||||||
x-enum-varnames:
|
x-enum-varnames:
|
||||||
- RoleAdmin
|
- RoleAdmin
|
||||||
- RoleUser
|
- 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:
|
orgs.OrgInput:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
@@ -106,6 +243,56 @@ definitions:
|
|||||||
slug:
|
slug:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
info:
|
||||||
contact: {}
|
contact: {}
|
||||||
description: API for managing K3s clusters across cloud providers
|
description: API for managing K3s clusters across cloud providers
|
||||||
@@ -127,6 +314,161 @@ paths:
|
|||||||
summary: Basic health check
|
summary: Basic health check
|
||||||
tags:
|
tags:
|
||||||
- health
|
- 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:
|
/api/v1/auth/introspect:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -523,6 +865,382 @@ paths:
|
|||||||
summary: Create a new organization
|
summary: Create a new organization
|
||||||
tags:
|
tags:
|
||||||
- organizations
|
- 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:
|
schemes:
|
||||||
- http
|
- http
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/handlers/authn"
|
"github.com/glueops/autoglue/internal/handlers/authn"
|
||||||
"github.com/glueops/autoglue/internal/handlers/health"
|
"github.com/glueops/autoglue/internal/handlers/health"
|
||||||
"github.com/glueops/autoglue/internal/handlers/orgs"
|
"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/middleware"
|
||||||
"github.com/glueops/autoglue/internal/ui"
|
"github.com/glueops/autoglue/internal/ui"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -21,6 +22,14 @@ func RegisterRoutes(r chi.Router) {
|
|||||||
secret := viper.GetString("authentication.jwt_secret")
|
secret := viper.GetString("authentication.jwt_secret")
|
||||||
authMW := middleware.AuthMiddleware(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) {
|
v1.Route("/auth", func(a chi.Router) {
|
||||||
a.Post("/login", authn.Login)
|
a.Post("/login", authn.Login)
|
||||||
a.Post("/register", authn.Register)
|
a.Post("/register", authn.Register)
|
||||||
@@ -45,6 +54,20 @@ func RegisterRoutes(r chi.Router) {
|
|||||||
o.Use(authMW)
|
o.Use(authMW)
|
||||||
o.Post("/", orgs.CreateOrganization)
|
o.Post("/", orgs.CreateOrganization)
|
||||||
o.Get("/", orgs.ListOrganizations)
|
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(
|
err = DB.AutoMigrate(
|
||||||
|
&models.Credential{},
|
||||||
&models.EmailVerification{},
|
&models.EmailVerification{},
|
||||||
&models.Invitation{},
|
&models.Invitation{},
|
||||||
&models.MasterKey{},
|
&models.MasterKey{},
|
||||||
&models.Member{},
|
&models.Member{},
|
||||||
&models.Organization{},
|
&models.Organization{},
|
||||||
|
&models.OrganizationKey{},
|
||||||
&models.PasswordReset{},
|
&models.PasswordReset{},
|
||||||
&models.RefreshToken{},
|
&models.RefreshToken{},
|
||||||
|
&models.SshKey{},
|
||||||
&models.User{},
|
&models.User{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/config"
|
"github.com/glueops/autoglue/internal/config"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/db/models"
|
"github.com/glueops/autoglue/internal/db/models"
|
||||||
"github.com/glueops/autoglue/internal/middleware"
|
"github.com/glueops/autoglue/internal/middleware"
|
||||||
"github.com/glueops/autoglue/internal/response"
|
"github.com/glueops/autoglue/internal/response"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -539,3 +541,299 @@ func RotateRefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
"refresh_token": newRefresh.Token,
|
"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
|
Token string
|
||||||
ResetURL 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"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/config"
|
"github.com/glueops/autoglue/internal/config"
|
||||||
"github.com/glueops/autoglue/internal/db"
|
"github.com/glueops/autoglue/internal/db"
|
||||||
"github.com/glueops/autoglue/internal/db/models"
|
"github.com/glueops/autoglue/internal/db/models"
|
||||||
|
"github.com/glueops/autoglue/internal/middleware"
|
||||||
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -90,3 +93,54 @@ func sendTemplatedEmail(to string, templateFile string, data any) error {
|
|||||||
}
|
}
|
||||||
return m.Send(to, data, templateFile)
|
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"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InviteInput struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glueops/autoglue/internal/db"
|
"github.com/glueops/autoglue/internal/db"
|
||||||
"github.com/glueops/autoglue/internal/db/models"
|
"github.com/glueops/autoglue/internal/db/models"
|
||||||
"github.com/glueops/autoglue/internal/middleware"
|
"github.com/glueops/autoglue/internal/middleware"
|
||||||
"github.com/glueops/autoglue/internal/response"
|
"github.com/glueops/autoglue/internal/response"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateOrganization godoc
|
// CreateOrganization godoc
|
||||||
@@ -88,3 +91,178 @@ func ListOrganizations(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
_ = response.JSON(w, http.StatusOK, orgs)
|
_ = 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"
|
||||||
"github.com/glueops/autoglue/internal/db/models"
|
"github.com/glueops/autoglue/internal/db/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -59,14 +60,32 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler {
|
|||||||
Claims: claims,
|
Claims: claims,
|
||||||
}
|
}
|
||||||
|
|
||||||
if orgID := r.Header.Get("X-Org-ID"); orgID != "" {
|
orgIDStr := r.Header.Get("X-Org-ID")
|
||||||
orgUUID, _ := uuid.Parse(orgID)
|
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 orgIDStr != "" {
|
||||||
if err := db.DB.Where("user_id = ? AND organization_id = ?", claims.Subject, orgID).First(&member).Error; err != nil {
|
orgUUID, err := uuid.Parse(orgIDStr)
|
||||||
http.Error(w, "User not a member of the organization", http.StatusForbidden)
|
if err != nil {
|
||||||
|
http.Error(w, "invalid organization id", http.StatusBadRequest)
|
||||||
return
|
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.OrganizationID = orgUUID
|
||||||
authCtx.OrgRole = string(member.Role)
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>AutoGlue</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DrmAfy-p.js"></script>
|
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Nf4c5zdA.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>AutoGlue</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
|
"rollup-plugin-visualizer": "^6.0.3",
|
||||||
"shadcn": "^3.0.0",
|
"shadcn": "^3.0.0",
|
||||||
"tw-animate-css": "^1.3.7",
|
"tw-animate-css": "^1.3.7",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import { Navigate, Route, Routes } from "react-router-dom"
|
|||||||
|
|
||||||
import { DashboardLayout } from "@/components/dashboard-layout.tsx"
|
import { DashboardLayout } from "@/components/dashboard-layout.tsx"
|
||||||
import { ProtectedRoute } from "@/components/protected-route.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 { ForgotPassword } from "@/pages/auth/forgot-password.tsx"
|
||||||
import { Login } from "@/pages/auth/login.tsx"
|
import { Login } from "@/pages/auth/login.tsx"
|
||||||
import { Me } from "@/pages/auth/me.tsx"
|
import { Me } from "@/pages/auth/me.tsx"
|
||||||
import { Register } from "@/pages/auth/register.tsx"
|
import { Register } from "@/pages/auth/register.tsx"
|
||||||
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
||||||
import { VerifyEmail } from "@/pages/auth/verify-email.tsx"
|
import { VerifyEmail } from "@/pages/auth/verify-email.tsx"
|
||||||
import {NotFoundPage} from "@/pages/error/not-found.tsx";
|
import { Forbidden } from "@/pages/error/forbidden.tsx"
|
||||||
import {OrgManagement} from "@/pages/settings/orgs.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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/403" element={<Forbidden />} />
|
||||||
<Route path="/" element={<Navigate to="/auth/login" replace />} />
|
<Route path="/" element={<Navigate to="/auth/login" replace />} />
|
||||||
{/* Public/auth branch */}
|
{/* Public/auth branch */}
|
||||||
<Route path="/auth">
|
<Route path="/auth">
|
||||||
@@ -22,16 +28,16 @@ function App() {
|
|||||||
<Route path="forgot" element={<ForgotPassword />} />
|
<Route path="forgot" element={<ForgotPassword />} />
|
||||||
<Route path="reset" element={<ResetPassword />} />
|
<Route path="reset" element={<ResetPassword />} />
|
||||||
<Route path="verify" element={<VerifyEmail />} />
|
<Route path="verify" element={<VerifyEmail />} />
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
|
||||||
<Route element={<DashboardLayout />}>
|
|
||||||
<Route path="me" element={<Me />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<DashboardLayout />}>
|
<Route element={<DashboardLayout />}>
|
||||||
|
<Route element={<RequireAdmin />}>
|
||||||
|
<Route path="/admin">
|
||||||
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/core">
|
<Route path="/core">
|
||||||
{/*
|
{/*
|
||||||
<Route path="cluster" element={<ClusterListPage />} />
|
<Route path="cluster" element={<ClusterListPage />} />
|
||||||
@@ -42,12 +48,13 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/security">
|
<Route path="/security">
|
||||||
{/*<Route path="ssh" element={<SshKeysPage />} />*/}
|
<Route path="ssh" element={<SshKeysPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
<Route path="orgs" element={<OrgManagement />} />
|
<Route path="orgs" element={<OrgManagement />} />
|
||||||
{/*<Route path="members" element={<MemberManagement />} />*/}
|
<Route path="members" element={<MemberManagement />} />
|
||||||
|
<Route path="me" element={<Me />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
|
import { Outlet } from "react-router-dom"
|
||||||
import {Outlet} from "react-router-dom";
|
|
||||||
import {Footer} from "@/components/footer.tsx";
|
import { SidebarProvider } from "@/components/ui/sidebar.tsx"
|
||||||
import {DashboardSidebar} from "@/components/dashboard-sidebar.tsx";
|
import { DashboardSidebar } from "@/components/dashboard-sidebar.tsx"
|
||||||
|
import { Footer } from "@/components/footer.tsx"
|
||||||
|
|
||||||
export function DashboardLayout() {
|
export function DashboardLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-1 flex-col">
|
||||||
<main className="flex-1 p-4 overflow-auto">
|
<main className="flex-1 overflow-auto p-4">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,35 +1,62 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ComponentType, type FC } from "react"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import { Link, useLocation } from "react-router-dom"
|
||||||
|
|
||||||
|
import { authStore, isGlobalAdmin, isOrgAdmin, type MePayload } from "@/lib/auth.ts"
|
||||||
|
import { Button } from "@/components/ui/button.tsx"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible.tsx"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem
|
SidebarHeader,
|
||||||
} from "@/components/ui/sidebar.tsx";
|
SidebarMenu,
|
||||||
import type {ComponentType, FC} from "react";
|
SidebarMenuButton,
|
||||||
import {Link, useLocation} from "react-router-dom";
|
SidebarMenuItem,
|
||||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from "@/components/ui/collapsible.tsx";
|
} from "@/components/ui/sidebar.tsx"
|
||||||
import {ChevronDown} from "lucide-react";
|
import { ModeToggle } from "@/components/mode-toggle.tsx"
|
||||||
import {items} from "@/components/sidebar/items.ts";
|
import { OrgSwitcher } from "@/components/org-switcher.tsx"
|
||||||
|
import { items, type NavItem } from "@/components/sidebar/items.ts"
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
label: string;
|
label: string
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>
|
||||||
to?: string;
|
to?: string
|
||||||
items?: MenuItemProps[];
|
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 MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
|
||||||
const location = useLocation();
|
const location = useLocation()
|
||||||
const Icon = item.icon;
|
const Icon = item.icon
|
||||||
|
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
return (
|
return (
|
||||||
<Link to={item.to}
|
<Link
|
||||||
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" : ""}`}
|
to={item.to}
|
||||||
|
className={`hover:bg-accent hover:text-accent-foreground flex items-center space-x-2 rounded-md px-4 py-2 text-sm ${location.pathname === item.to ? "bg-accent text-accent-foreground" : ""}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 mr-4" />
|
<Icon className="mr-4 h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -41,7 +68,7 @@ const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel asChild>
|
<SidebarGroupLabel asChild>
|
||||||
<CollapsibleTrigger>
|
<CollapsibleTrigger>
|
||||||
<Icon className="h-4 w-4 mr-4" />
|
<Icon className="mr-4 h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
@@ -67,16 +94,57 @@ const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardSidebar = () => {
|
export const DashboardSidebar = () => {
|
||||||
|
const [me, setMe] = useState<MePayload | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const data = await authStore.me()
|
||||||
|
if (!alive) return
|
||||||
|
setMe(data)
|
||||||
|
} catch {
|
||||||
|
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
|
||||||
|
} finally {
|
||||||
|
if (!alive) return
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
const admin = isGlobalAdmin(me)
|
||||||
|
const orgAdmin = isOrgAdmin(me)
|
||||||
|
return filterItems(items, admin, orgAdmin)
|
||||||
|
}, [me])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarHeader className='flex items-center justify-between p-4'>
|
<SidebarHeader className="flex items-center justify-between p-4">
|
||||||
<h1 className="text-xl font-bold">AutoGlue</h1>
|
<h1 className="text-xl font-bold">AutoGlue</h1>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{items.map((item, index) => (
|
{(loading ? items : visibleItems).map((item, i) => (
|
||||||
<MenuItem item={item} key={index} />
|
<MenuItem item={item} key={i} />
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarFooter className="space-y-2 p-4">
|
||||||
|
<OrgSwitcher />
|
||||||
|
<ModeToggle />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.clear()
|
||||||
|
window.location.reload()
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FaGithub } from "react-icons/fa";
|
import { FaGithub } from "react-icons/fa"
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t">
|
<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="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">
|
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
|
||||||
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
|
<p className="text-muted-foreground text-center text-sm leading-loose md:text-left">
|
||||||
Built for{" "}
|
Built for{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.glueops.dev/"
|
href="https://www.glueops.dev/"
|
||||||
@@ -34,5 +34,5 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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 { useTheme } from "next-themes"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button.tsx"
|
import { Button } from "@/components/ui/button.tsx"
|
||||||
@@ -25,9 +25,15 @@ export function ModeToggle() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
{theme === "light" && <CheckIcon />}Light
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
{theme === "dark" && <CheckIcon />}Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
{theme === "system" && <CheckIcon />}System
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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,7 +1,7 @@
|
|||||||
|
import type { ComponentType } from "react"
|
||||||
import {
|
import {
|
||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
BrainCogIcon,
|
BrainCogIcon,
|
||||||
Building2Icon,
|
|
||||||
BuildingIcon,
|
BuildingIcon,
|
||||||
ComponentIcon,
|
ComponentIcon,
|
||||||
FileKey2Icon,
|
FileKey2Icon,
|
||||||
@@ -11,11 +11,22 @@ import {
|
|||||||
LockKeyholeIcon,
|
LockKeyholeIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
SprayCanIcon,
|
SprayCanIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react"
|
||||||
import { AiOutlineCluster } from "react-icons/ai";
|
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 = [
|
export const items = [
|
||||||
{
|
{
|
||||||
@@ -37,16 +48,16 @@ export const items = [
|
|||||||
icon: BoxesIcon,
|
icon: BoxesIcon,
|
||||||
to: "/core/node-pools",
|
to: "/core/node-pools",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Annotations",
|
||||||
|
icon: ComponentIcon,
|
||||||
|
to: "/core/annotations",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Labels",
|
label: "Labels",
|
||||||
icon: TagsIcon,
|
icon: TagsIcon,
|
||||||
to: "/core/labels",
|
to: "/core/labels",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Roles",
|
|
||||||
icon: ComponentIcon,
|
|
||||||
to: "/core/roles",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Taints",
|
label: "Taints",
|
||||||
icon: SprayCanIcon,
|
icon: SprayCanIcon,
|
||||||
@@ -83,10 +94,6 @@ export const items = [
|
|||||||
{
|
{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Organizations",
|
|
||||||
icon: Building2Icon,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Organizations",
|
label: "Organizations",
|
||||||
@@ -98,8 +105,24 @@ export const items = [
|
|||||||
to: "/settings/members",
|
to: "/settings/members",
|
||||||
icon: UsersIcon,
|
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 { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
function AlertDialog({
|
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
function AlertDialogTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
return (
|
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
...props
|
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
function AlertDialogOverlay({
|
||||||
@@ -61,10 +53,7 @@ function AlertDialogContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogHeader({
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
@@ -74,17 +63,11 @@ function AlertDialogHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogFooter({
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
className={cn(
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -120,12 +103,7 @@ function AlertDialogAction({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
return (
|
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
||||||
<AlertDialogPrimitive.Action
|
|
||||||
className={cn(buttonVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogCancel({
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
destructive:
|
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",
|
"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:
|
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",
|
"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:
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
function Collapsible({
|
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
|
||||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
function CollapsibleTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
return (
|
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
|
||||||
<CollapsiblePrimitive.CollapsibleTrigger
|
|
||||||
data-slot="collapsible-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleContent({
|
function CollapsibleContent({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
return (
|
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
|
||||||
<CollapsiblePrimitive.CollapsibleContent
|
|
||||||
data-slot="collapsible-content"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
|
|||||||
@@ -4,27 +4,19 @@ import { XIcon } from "lucide-react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,19 +84,13 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-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} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +95,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTitle({
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
data-slot="sheet-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 { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@@ -18,12 +18,7 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
@@ -96,10 +91,7 @@ function SidebarProvider({
|
|||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
|
||||||
(event.metaKey || event.ctrlKey)
|
|
||||||
) {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
toggleSidebar()
|
toggleSidebar()
|
||||||
}
|
}
|
||||||
@@ -253,11 +245,7 @@ function Sidebar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTrigger({
|
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||||
className,
|
|
||||||
onClick,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Button>) {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -318,10 +306,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInput({
|
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Input>) {
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
data-slot="sidebar-input"
|
data-slot="sidebar-input"
|
||||||
@@ -354,10 +339,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSeparator({
|
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<Separator
|
<Separator
|
||||||
data-slot="sidebar-separator"
|
data-slot="sidebar-separator"
|
||||||
@@ -437,10 +419,7 @@ function SidebarGroupAction({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group-content"
|
data-slot="sidebar-group-content"
|
||||||
@@ -577,10 +556,7 @@ function SidebarMenuAction({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-badge"
|
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)}
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showIcon && (
|
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||||
<Skeleton
|
|
||||||
className="size-4 rounded-md"
|
|
||||||
data-sidebar="menu-skeleton-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Skeleton
|
<Skeleton
|
||||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
data-sidebar="menu-skeleton-text"
|
data-sidebar="menu-skeleton-text"
|
||||||
@@ -652,10 +623,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubItem({
|
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) {
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="sidebar-menu-sub-item"
|
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({
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
@@ -26,9 +24,7 @@ function Tooltip({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ function authHeaders(): Record<string, string> {
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orgContextHeaders(): Record<string, string> {
|
||||||
|
const id = localStorage.getItem("active_org_id")
|
||||||
|
return id ? { "X-Org-ID": id } : {}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
method: Method,
|
method: Method,
|
||||||
@@ -50,6 +55,7 @@ async function request<T>(
|
|||||||
const merged: Record<string, string> = {
|
const merged: Record<string, string> = {
|
||||||
...baseHeaders,
|
...baseHeaders,
|
||||||
...(opts.auth === false ? {} : authHeaders()),
|
...(opts.auth === false ? {} : authHeaders()),
|
||||||
|
...orgContextHeaders(),
|
||||||
...normalizeHeaders(opts.headers),
|
...normalizeHeaders(opts.headers),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +89,8 @@ async function request<T>(
|
|||||||
throw new ApiError(res.status, String(msg), payload)
|
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)
|
return isJSON ? (payload as T) : (undefined as T)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import { api, API_BASE_URL } from "@/lib/api.ts"
|
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 = {
|
export const authStore = {
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return !!localStorage.getItem("access_token")
|
return !!localStorage.getItem("access_token")
|
||||||
@@ -19,9 +47,7 @@ export const authStore = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async me() {
|
async me() {
|
||||||
return await api.get<{ user_id: string; organization_id?: string; org_role?: string }>(
|
return await api.get<MePayload>("/api/v1/auth/me")
|
||||||
"/api/v1/auth/me"
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
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 {
|
try {
|
||||||
await authStore.login(values.email, values.password)
|
await authStore.login(values.email, values.password)
|
||||||
toast.success("Welcome back!")
|
toast.success("Welcome back!")
|
||||||
const to = location.state?.from?.pathname ?? "/auth/me"
|
const to = location.state?.from?.pathname ?? "/settings/me"
|
||||||
navigate(to, { replace: true })
|
navigate(to, { replace: true })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || "Login failed")
|
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 { useNavigate } from "react-router-dom"
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
|
||||||
|
import { Button } from "@/components/ui/button.tsx"
|
||||||
|
|
||||||
export const NotFoundPage = () => {
|
export const NotFoundPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-foreground">
|
<div className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center">
|
||||||
<h1 className="text-6xl font-bold mb-4">404</h1>
|
<h1 className="mb-4 text-6xl font-bold">404</h1>
|
||||||
<p className="text-2xl mb-8">Oops! Page not found</p>
|
<p className="mb-8 text-2xl">Oops! Page not found</p>
|
||||||
<Button onClick={() => navigate("/dashboard")}>
|
<Button onClick={() => navigate("/dashboard")}>Go back to Dashboard</Button>
|
||||||
Go back to Dashboard
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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,183 +1,241 @@
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button";
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { TrashIcon } from "lucide-react"
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useForm } from "react-hook-form"
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { toast } from "sonner"
|
||||||
import { api, ApiError } from "@/lib/api"; // <-- import ApiError
|
import { z } from "zod"
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { api, ApiError } from "@/lib/api.ts"
|
||||||
import { z } from "zod";
|
|
||||||
import { slugify } from "@/lib/utils";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle
|
emitOrgsChanged,
|
||||||
} from "@/components/ui/dialog";
|
EVT_ACTIVE_ORG_CHANGED,
|
||||||
|
EVT_ORGS_CHANGED,
|
||||||
|
getActiveOrgId,
|
||||||
|
setActiveOrgId as setActiveOrgIdLS,
|
||||||
|
} from "@/lib/orgs-sync.ts"
|
||||||
|
import { slugify } from "@/lib/utils.ts"
|
||||||
import {
|
import {
|
||||||
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage
|
AlertDialog,
|
||||||
} from "@/components/ui/form";
|
AlertDialogAction,
|
||||||
import { Input } from "@/components/ui/input";
|
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 {
|
import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
Dialog,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
|
DialogContent,
|
||||||
} from "@/components/ui/alert-dialog";
|
DialogDescription,
|
||||||
import { TrashIcon } from "lucide-react";
|
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 = {
|
type Organization = {
|
||||||
id: string; // confirm with your API; change to number if needed
|
id: string // confirm with your API; change to number if needed
|
||||||
name: string;
|
name: string
|
||||||
slug: string;
|
slug: string
|
||||||
created_at: string;
|
created_at: string
|
||||||
};
|
}
|
||||||
|
|
||||||
const OrgSchema = z.object({
|
const OrgSchema = z.object({
|
||||||
name: z.string().min(2).max(100),
|
name: z.string().min(2).max(100),
|
||||||
slug: z.string()
|
slug: z
|
||||||
.min(2).max(50)
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(50)
|
||||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
|
||||||
});
|
})
|
||||||
type OrgFormValues = z.infer<typeof OrgSchema>;
|
|
||||||
|
type OrgFormValues = z.infer<typeof OrgSchema>
|
||||||
|
|
||||||
export const OrgManagement = () => {
|
export const OrgManagement = () => {
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState<boolean>(false)
|
||||||
const slugEditedRef = useRef(false);
|
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null)
|
||||||
const [activeOrgId, setActiveOrgId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = 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"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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>({
|
const form = useForm<OrgFormValues>({
|
||||||
resolver: zodResolver(OrgSchema),
|
resolver: zodResolver(OrgSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: { name: "", slug: "" },
|
defaultValues: {
|
||||||
});
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// auto-generate slug from name unless user edited slug manually
|
// auto-generate slug from name unless user manually edited the slug
|
||||||
const nameValue = form.watch("name");
|
const nameValue = form.watch("name")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slugEditedRef.current) {
|
if (!slugEditedRef.current) {
|
||||||
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true });
|
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true })
|
||||||
}
|
}
|
||||||
}, [nameValue, form]);
|
}, [nameValue, form])
|
||||||
|
|
||||||
// fetch orgs once
|
// fetch organizations
|
||||||
const getOrgs = async () => {
|
const getOrgs = async () => {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await api.get<Organization[]>("/api/v1/orgs");
|
const data = await api.get<Organization[]>("/api/v1/orgs")
|
||||||
setOrganizations(data);
|
setOrganizations(data)
|
||||||
setCreateOpen(data.length === 0);
|
setCreateOpen(data.length === 0)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof ApiError ? err.message : "Failed to load organizations";
|
const msg = err instanceof ApiError ? err.message : "Failed to load organizations"
|
||||||
toast.error(msg);
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
// initial load + sync listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void getOrgs();
|
// initialize active org from storage
|
||||||
}, []);
|
setActiveOrgIdState(getActiveOrgId())
|
||||||
|
void getOrgs()
|
||||||
|
|
||||||
|
// cross-tab sync for active org
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === "active_org_id") setActiveOrgIdState(e.newValue)
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", onStorage)
|
||||||
|
|
||||||
|
// same-tab sync for active org (custom event)
|
||||||
|
const onActive = (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string | null>).detail ?? null
|
||||||
|
setActiveOrgIdState(id)
|
||||||
|
}
|
||||||
|
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
|
||||||
|
|
||||||
|
// orgs list changes from elsewhere (custom event)
|
||||||
|
const onOrgs = () => void getOrgs()
|
||||||
|
window.addEventListener(EVT_ORGS_CHANGED, onOrgs)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", onStorage)
|
||||||
|
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
|
||||||
|
window.removeEventListener(EVT_ORGS_CHANGED, onOrgs)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function onSubmit(values: OrgFormValues) {
|
async function onSubmit(values: OrgFormValues) {
|
||||||
try {
|
try {
|
||||||
const newOrg = await api.post<Organization>("/api/v1/orgs", values);
|
const newOrg = await api.post<Organization>("/api/v1/orgs", values)
|
||||||
setOrganizations(prev => [newOrg, ...prev]);
|
setOrganizations((prev) => [newOrg, ...prev])
|
||||||
localStorage.setItem("active_org_id", newOrg.id);
|
|
||||||
setActiveOrgId(newOrg.id);
|
// set as current org and broadcast
|
||||||
toast.success(`Created ${newOrg.name}`);
|
setActiveOrgIdLS(newOrg.id)
|
||||||
setCreateOpen(false);
|
setActiveOrgIdState(newOrg.id)
|
||||||
form.reset({ name: "", slug: "" });
|
emitOrgsChanged()
|
||||||
slugEditedRef.current = false;
|
|
||||||
|
toast.success(`Created ${newOrg.name}`)
|
||||||
|
setCreateOpen(false)
|
||||||
|
form.reset({ name: "", slug: "" })
|
||||||
|
slugEditedRef.current = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof ApiError ? err.message : "Failed to create organization";
|
const msg = err instanceof ApiError ? err.message : "Failed to create organization"
|
||||||
toast.error(msg);
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectOrg(org: Organization) {
|
function handleSelectOrg(org: Organization) {
|
||||||
localStorage.setItem("active_org_id", org.id);
|
setActiveOrgIdLS(org.id) // updates localStorage + emits event
|
||||||
setActiveOrgId(org.id);
|
setActiveOrgIdState(org.id)
|
||||||
toast.success(`Switched to ${org.name}`);
|
toast.success(`Switched to ${org.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteOrg(org: Organization) {
|
async function handleDeleteOrg(org: Organization) {
|
||||||
try {
|
try {
|
||||||
setDeletingId(org.id);
|
setDeletingId(org.id)
|
||||||
await api.delete<void>(`/api/v1/orgs/${org.id}`); // <-- correct path
|
await api.delete<void>(`/api/v1/orgs/${org.id}`)
|
||||||
setOrganizations(prev => {
|
|
||||||
const next = prev.filter(o => o.id !== org.id); // <-- fix shadow bug
|
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) {
|
if (activeOrgId === org.id) {
|
||||||
const nextId = next[0]?.id ?? null;
|
const nextId = next[0]?.id ?? null
|
||||||
if (nextId) localStorage.setItem("active_org_id", nextId);
|
setActiveOrgIdLS(nextId)
|
||||||
else localStorage.removeItem("active_org_id");
|
setActiveOrgIdState(nextId)
|
||||||
setActiveOrgId(nextId);
|
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
});
|
return next
|
||||||
toast.success(`Deleted ${org.name}`);
|
})
|
||||||
|
|
||||||
|
emitOrgsChanged()
|
||||||
|
toast.success(`Deleted ${org.name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof ApiError ? err.message : "Failed to delete organization";
|
const msg = err instanceof ApiError ? err.message : "Failed to delete organization"
|
||||||
toast.error(msg);
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
|
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardHeader><Skeleton className="h-5 w-40" /></CardHeader>
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Skeleton className="h-4 w-24 mb-2" />
|
<Skeleton className="mb-2 h-4 w-24" />
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter><Skeleton className="h-9 w-24" /></CardFooter>
|
<CardFooter>
|
||||||
|
<Skeleton className="h-9 w-24" />
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
|
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
|
||||||
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
|
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{organizations.length === 0 ? (
|
{organizations.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">No organizations yet.</div>
|
<div className="text-muted-foreground text-sm">No organizations yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pr-2">
|
<div className="grid grid-cols-1 gap-4 pr-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{organizations.map(org => (
|
{organizations.map((org) => (
|
||||||
<Card key={org.id} className="flex flex-col">
|
<Card key={org.id} className="flex flex-col">
|
||||||
<CardHeader><CardTitle className="text-base">{org.name}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardTitle className="text-base">{org.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
<div>Slug: {org.slug}</div>
|
<div>Slug: {org.slug}</div>
|
||||||
<div className="mt-1">ID: {org.id}</div>
|
<div className="mt-1">ID: {org.id}</div>
|
||||||
<div className="mt-1">Created: {new Date(org.created_at).toUTCString()}</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button onClick={() => handleSelectOrg(org)}>
|
<Button onClick={() => handleSelectOrg(org)}>
|
||||||
@@ -186,23 +244,28 @@ export const OrgManagement = () => {
|
|||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="ml-auto">
|
<Button
|
||||||
<TrashIcon className="h-5 w-5 mr-2" />
|
variant="destructive"
|
||||||
Delete
|
className="ml-auto"
|
||||||
|
disabled={deletingId === org.id}
|
||||||
|
>
|
||||||
|
<TrashIcon className="mr-2 h-5 w-5" />
|
||||||
|
{deletingId === org.id ? "Deleting…" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
|
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete <b>{org.name}</b>. This action cannot be undone.
|
This will permanently delete <b>{org.name}</b>. This action cannot be
|
||||||
|
undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="sm:justify-between">
|
<AlertDialogFooter className="sm:justify-between">
|
||||||
<AlertDialogCancel disabled={deletingId === org.id}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={deletingId === org.id}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction asChild disabled={deletingId === org.id}>
|
<AlertDialogAction asChild disabled={deletingId === org.id}>
|
||||||
<Button variant="destructive" onClick={() => handleDeleteOrg(org)}>
|
<Button variant="destructive" onClick={() => handleDeleteOrg(org)}>
|
||||||
{deletingId === org.id ? "Deleting…" : "Delete"}
|
Confirm delete
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -229,7 +292,9 @@ export const OrgManagement = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl><Input placeholder="Acme Inc" autoFocus {...field} /></FormControl>
|
<FormControl>
|
||||||
|
<Input placeholder="Acme Inc" autoFocus {...field} />
|
||||||
|
</FormControl>
|
||||||
<FormDescription>This is your organization’s display name.</FormDescription>
|
<FormDescription>This is your organization’s display name.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -246,11 +311,15 @@ export const OrgManagement = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="acme-inc"
|
placeholder="acme-inc"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => { slugEditedRef.current = true; field.onChange(e); }}
|
onChange={(e) => {
|
||||||
|
slugEditedRef.current = true // user manually edited slug
|
||||||
|
field.onChange(e)
|
||||||
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const normalized = slugify(e.target.value);
|
// normalize on blur
|
||||||
form.setValue("slug", normalized, { shouldValidate: true });
|
const normalized = slugify(e.target.value)
|
||||||
field.onBlur();
|
form.setValue("slug", normalized, { shouldValidate: true })
|
||||||
|
field.onBlur()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -264,11 +333,18 @@ export const OrgManagement = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => { form.reset(); setCreateOpen(false); }}
|
onClick={() => {
|
||||||
|
form.reset()
|
||||||
|
setCreateOpen(false)
|
||||||
|
slugEditedRef.current = false
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!form.formState.isValid || form.formState.isSubmitting}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!form.formState.isValid || form.formState.isSubmitting}
|
||||||
|
>
|
||||||
{form.formState.isSubmitting ? "Creating..." : "Create"}
|
{form.formState.isSubmitting ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -277,5 +353,5 @@ export const OrgManagement = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
|
import { visualizer } from "rollup-plugin-visualizer"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
visualizer({
|
||||||
|
filename: "dist/stats.html",
|
||||||
|
template: "treemap",
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
@@ -20,7 +30,26 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
outDir: "../internal/ui/dist",
|
outDir: "../internal/ui/dist",
|
||||||
emptyOutDir: true,
|
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"
|
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
|
||||||
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
|
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":
|
"@radix-ui/primitive@1.1.3":
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
|
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-callback-ref" "1.1.1"
|
||||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
"@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":
|
"@radix-ui/react-separator@^1.1.7":
|
||||||
version "1.1.7"
|
version "1.1.7"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
|
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"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
|
||||||
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
|
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":
|
"@radix-ui/react-use-rect@1.1.1":
|
||||||
version "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"
|
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"
|
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
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:
|
depd@2.0.0, depd@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
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"
|
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
|
||||||
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
|
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:
|
is-extglob@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
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"
|
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
|
||||||
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
|
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:
|
isexe@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
@@ -3032,6 +3086,15 @@ onetime@^7.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function "^5.0.0"
|
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:
|
optionator@^0.9.3:
|
||||||
version "0.9.4"
|
version "0.9.4"
|
||||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
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"
|
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
|
||||||
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
|
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:
|
rollup@^4.43.0:
|
||||||
version "4.50.0"
|
version "4.50.0"
|
||||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.0.tgz#6f237f598b7163ede33ce827af8534c929aaa186"
|
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"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
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:
|
source-map@~0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
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"
|
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
|
||||||
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
|
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
|
||||||
|
|
||||||
yargs@^17.7.2:
|
yargs@^17.5.1, yargs@^17.7.2:
|
||||||
version "17.7.2"
|
version "17.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
|
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
|
||||||
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
|
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
|
||||||
|
|||||||
Reference in New Issue
Block a user