diff --git a/docs/docs.go b/docs/docs.go index dcba85a..94291d7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -38,6 +38,250 @@ const docTemplate = `{ } } }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns paginated list of users (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: list all users", + "parameters": [ + { + "type": "integer", + "description": "Page number (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 200)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/authn.ListUsersOut" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: create user", + "parameters": [ + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.AdminCreateUserRequest" + } + } + ], + "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" + } + } + } + } + }, + "/api/v1/admin/users/{userId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "admin" + ], + "summary": "Admin: delete user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "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" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: update user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.AdminUpdateUserRequest" + } + } + ], + "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" + } + } + } + } + }, "/api/v1/auth/introspect": { "post": { "description": "Returns whether the token is active and basic metadata", @@ -655,9 +899,642 @@ const docTemplate = `{ } } } + }, + "/api/v1/orgs/invite": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "organizations" + ], + "summary": "Invite user to organization", + "parameters": [ + { + "description": "Invite input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.InviteInput" + } + }, + { + "type": "string", + "description": "Organization context", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "201": { + "description": "invited", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/members": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a list of all members in the current organization", + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organization members", + "parameters": [ + { + "type": "string", + "description": "Organization context", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Member" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/members/{userId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "organizations" + ], + "summary": "Remove member from organization", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "deleted", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/{orgId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "organizations" + ], + "summary": "Delete organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "orgId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "deleted", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Update organization metadata", + "parameters": [ + { + "type": "string", + "description": "Org ID", + "name": "orgId", + "in": "path", + "required": true + }, + { + "description": "Organization data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.OrgInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Organization" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/ssh": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns ssh keys for the organization in X-Org-ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "List ssh keys (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ssh.sshResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list keys", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates an RSA keypair, saves it, and returns metadata. Optionally set ` + "`" + `download` + "`" + ` to \"public\", \"private\", or \"both\" to download files immediately.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Create ssh keypair (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "Key generation options", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ssh.createSSHRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/ssh.sshResponse" + }, + "headers": { + "Content-Disposition": { + "type": "string", + "description": "When download is requested" + } + } + }, + "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" + } + } + } + } + }, + "/api/v1/ssh/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns public key fields. Append ` + "`" + `?reveal=true` + "`" + ` to include the private key PEM.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Get ssh key by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Reveal private key PEM", + "name": "reveal", + "in": "query" + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Permanently deletes a keypair.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Delete ssh keypair (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "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" + } + } + } + } + }, + "/api/v1/ssh/{id}/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download ` + "`" + `part=public|private|both` + "`" + ` of the keypair. ` + "`" + `both` + "`" + ` returns a zip file.", + "produces": [ + "text/plain" + ], + "tags": [ + "ssh" + ], + "summary": "Download ssh key files by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "enum": [ + "public", + "private", + "both" + ], + "type": "string", + "description": "Which part to download", + "name": "part", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } } }, "definitions": { + "authn.AdminCreateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "jane@example.com" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "password": { + "type": "string", + "example": "Secret123!" + }, + "role": { + "description": "Role allowed values: \"user\" or \"admin\"", + "type": "string", + "enum": [ + "user", + "admin" + ], + "example": "user" + } + } + }, + "authn.AdminUpdateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "jane@example.com" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "password": { + "type": "string", + "example": "NewSecret123!" + }, + "role": { + "type": "string", + "enum": [ + "user", + "admin" + ], + "example": "admin" + } + } + }, "authn.AuthClaimsDTO": { "type": "object", "properties": { @@ -696,6 +1573,26 @@ const docTemplate = `{ } } }, + "authn.ListUsersOut": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/authn.UserListItem" + } + } + } + }, "authn.LoginInput": { "type": "object", "properties": { @@ -769,6 +1666,93 @@ const docTemplate = `{ } } }, + "authn.UserListItem": { + "type": "object", + "properties": { + "created_at": {}, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": {}, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "updated_at": {} + } + }, + "authn.userOut": { + "type": "object", + "properties": { + "created_at": {}, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": {}, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "updated_at": {} + } + }, + "models.Member": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/models.Organization" + }, + "organization_id": { + "type": "string" + }, + "role": { + "description": "e.g. admin, member", + "allOf": [ + { + "$ref": "#/definitions/models.MemberRole" + } + ] + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/models.User" + }, + "user_id": { + "type": "string" + } + } + }, + "models.MemberRole": { + "type": "string", + "enum": [ + "admin", + "member", + "user" + ], + "x-enum-varnames": [ + "MemberRoleAdmin", + "MemberRoleMember", + "MemberRoleUser" + ] + }, "models.Organization": { "type": "object", "properties": { @@ -806,6 +1790,49 @@ const docTemplate = `{ "RoleUser" ] }, + "models.User": { + "type": "object", + "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" + } + } + }, + "orgs.InviteInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, "orgs.OrgInput": { "type": "object", "properties": { @@ -816,6 +1843,81 @@ const docTemplate = `{ "type": "string" } } + }, + "ssh.createSSHRequest": { + "type": "object", + "properties": { + "bits": { + "type": "integer", + "example": 4096 + }, + "comment": { + "type": "string", + "example": "deploy@autoglue" + }, + "download": { + "type": "string", + "example": "both" + }, + "name": { + "type": "string" + } + } + }, + "ssh.sshResponse": { + "type": "object", + "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" + } + } + }, + "ssh.sshRevealResponse": { + "type": "object", + "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" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index b6f5206..7b98d6b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -34,6 +34,250 @@ } } }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns paginated list of users (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: list all users", + "parameters": [ + { + "type": "integer", + "description": "Page number (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (max 200)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/authn.ListUsersOut" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: create user", + "parameters": [ + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.AdminCreateUserRequest" + } + } + ], + "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" + } + } + } + } + }, + "/api/v1/admin/users/{userId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "admin" + ], + "summary": "Admin: delete user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "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" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Admin: update user", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authn.AdminUpdateUserRequest" + } + } + ], + "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" + } + } + } + } + }, "/api/v1/auth/introspect": { "post": { "description": "Returns whether the token is active and basic metadata", @@ -651,9 +895,642 @@ } } } + }, + "/api/v1/orgs/invite": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "organizations" + ], + "summary": "Invite user to organization", + "parameters": [ + { + "description": "Invite input", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.InviteInput" + } + }, + { + "type": "string", + "description": "Organization context", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "201": { + "description": "invited", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/members": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns a list of all members in the current organization", + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organization members", + "parameters": [ + { + "type": "string", + "description": "Organization context", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Member" + } + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/members/{userId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "organizations" + ], + "summary": "Remove member from organization", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "deleted", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/orgs/{orgId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "organizations" + ], + "summary": "Delete organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "orgId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "deleted", + "schema": { + "type": "string" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Update organization metadata", + "parameters": [ + { + "type": "string", + "description": "Org ID", + "name": "orgId", + "in": "path", + "required": true + }, + { + "description": "Organization data", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/orgs.OrgInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Organization" + } + }, + "403": { + "description": "forbidden", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/ssh": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns ssh keys for the organization in X-Org-ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "List ssh keys (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ssh.sshResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list keys", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Generates an RSA keypair, saves it, and returns metadata. Optionally set `download` to \"public\", \"private\", or \"both\" to download files immediately.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Create ssh keypair (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "Key generation options", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ssh.createSSHRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/ssh.sshResponse" + }, + "headers": { + "Content-Disposition": { + "type": "string", + "description": "When download is requested" + } + } + }, + "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" + } + } + } + } + }, + "/api/v1/ssh/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns public key fields. Append `?reveal=true` to include the private key PEM.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Get ssh key by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Reveal private key PEM", + "name": "reveal", + "in": "query" + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Permanently deletes a keypair.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ssh" + ], + "summary": "Delete ssh keypair (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "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" + } + } + } + } + }, + "/api/v1/ssh/{id}/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download `part=public|private|both` of the keypair. `both` returns a zip file.", + "produces": [ + "text/plain" + ], + "tags": [ + "ssh" + ], + "summary": "Download ssh key files by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "SSH Key ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "enum": [ + "public", + "private", + "both" + ], + "type": "string", + "description": "Which part to download", + "name": "part", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } } }, "definitions": { + "authn.AdminCreateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "jane@example.com" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "password": { + "type": "string", + "example": "Secret123!" + }, + "role": { + "description": "Role allowed values: \"user\" or \"admin\"", + "type": "string", + "enum": [ + "user", + "admin" + ], + "example": "user" + } + } + }, + "authn.AdminUpdateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "jane@example.com" + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "password": { + "type": "string", + "example": "NewSecret123!" + }, + "role": { + "type": "string", + "enum": [ + "user", + "admin" + ], + "example": "admin" + } + } + }, "authn.AuthClaimsDTO": { "type": "object", "properties": { @@ -692,6 +1569,26 @@ } } }, + "authn.ListUsersOut": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/authn.UserListItem" + } + } + } + }, "authn.LoginInput": { "type": "object", "properties": { @@ -765,6 +1662,93 @@ } } }, + "authn.UserListItem": { + "type": "object", + "properties": { + "created_at": {}, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": {}, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "updated_at": {} + } + }, + "authn.userOut": { + "type": "object", + "properties": { + "created_at": {}, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": {}, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "updated_at": {} + } + }, + "models.Member": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/models.Organization" + }, + "organization_id": { + "type": "string" + }, + "role": { + "description": "e.g. admin, member", + "allOf": [ + { + "$ref": "#/definitions/models.MemberRole" + } + ] + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/models.User" + }, + "user_id": { + "type": "string" + } + } + }, + "models.MemberRole": { + "type": "string", + "enum": [ + "admin", + "member", + "user" + ], + "x-enum-varnames": [ + "MemberRoleAdmin", + "MemberRoleMember", + "MemberRoleUser" + ] + }, "models.Organization": { "type": "object", "properties": { @@ -802,6 +1786,49 @@ "RoleUser" ] }, + "models.User": { + "type": "object", + "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" + } + } + }, + "orgs.InviteInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, "orgs.OrgInput": { "type": "object", "properties": { @@ -812,6 +1839,81 @@ "type": "string" } } + }, + "ssh.createSSHRequest": { + "type": "object", + "properties": { + "bits": { + "type": "integer", + "example": 4096 + }, + "comment": { + "type": "string", + "example": "deploy@autoglue" + }, + "download": { + "type": "string", + "example": "both" + }, + "name": { + "type": "string" + } + } + }, + "ssh.sshResponse": { + "type": "object", + "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" + } + } + }, + "ssh.sshRevealResponse": { + "type": "object", + "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" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 40487ba..b76d91b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,42 @@ basePath: / definitions: + authn.AdminCreateUserRequest: + properties: + email: + example: jane@example.com + type: string + name: + example: Jane Doe + type: string + password: + example: Secret123! + type: string + role: + description: 'Role allowed values: "user" or "admin"' + enum: + - user + - admin + example: user + type: string + type: object + authn.AdminUpdateUserRequest: + properties: + email: + example: jane@example.com + type: string + name: + example: Jane Doe + type: string + password: + example: NewSecret123! + type: string + role: + enum: + - user + - admin + example: admin + type: string + type: object authn.AuthClaimsDTO: properties: aud: @@ -25,6 +62,19 @@ definitions: sub: type: string type: object + authn.ListUsersOut: + properties: + page: + type: integer + page_size: + type: integer + total: + type: integer + users: + items: + $ref: '#/definitions/authn.UserListItem' + type: array + type: object authn.LoginInput: properties: email: @@ -74,6 +124,65 @@ definitions: updated_at: type: string type: object + authn.UserListItem: + properties: + created_at: {} + email: + type: string + email_verified: + type: boolean + id: {} + name: + type: string + role: + type: string + updated_at: {} + type: object + authn.userOut: + properties: + created_at: {} + email: + type: string + email_verified: + type: boolean + id: {} + name: + type: string + role: + type: string + updated_at: {} + type: object + models.Member: + properties: + created_at: + type: string + id: + type: string + organization: + $ref: '#/definitions/models.Organization' + organization_id: + type: string + role: + allOf: + - $ref: '#/definitions/models.MemberRole' + description: e.g. admin, member + updated_at: + type: string + user: + $ref: '#/definitions/models.User' + user_id: + type: string + type: object + models.MemberRole: + enum: + - admin + - member + - user + type: string + x-enum-varnames: + - MemberRoleAdmin + - MemberRoleMember + - MemberRoleUser models.Organization: properties: created_at: @@ -99,6 +208,34 @@ definitions: x-enum-varnames: - RoleAdmin - RoleUser + models.User: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + email_verified_at: + type: string + id: + type: string + name: + type: string + password: + type: string + role: + $ref: '#/definitions/models.Role' + updated_at: + type: string + type: object + orgs.InviteInput: + properties: + email: + type: string + role: + type: string + type: object orgs.OrgInput: properties: name: @@ -106,6 +243,56 @@ definitions: slug: type: string type: object + ssh.createSSHRequest: + properties: + bits: + example: 4096 + type: integer + comment: + example: deploy@autoglue + type: string + download: + example: both + type: string + name: + type: string + type: object + ssh.sshResponse: + properties: + created_at: + type: string + fingerprint: + type: string + id: + type: string + name: + type: string + organization_id: + type: string + public_keys: + type: string + updated_at: + type: string + type: object + ssh.sshRevealResponse: + properties: + created_at: + type: string + fingerprint: + type: string + id: + type: string + name: + type: string + organization_id: + type: string + private_key: + type: string + public_keys: + type: string + updated_at: + type: string + type: object info: contact: {} description: API for managing K3s clusters across cloud providers @@ -127,6 +314,161 @@ paths: summary: Basic health check tags: - health + /api/v1/admin/users: + get: + description: Returns paginated list of users (admin only) + parameters: + - description: Page number (1-based) + in: query + name: page + type: integer + - description: Page size (max 200) + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/authn.ListUsersOut' + "401": + description: unauthorized + schema: + type: string + "403": + description: forbidden + schema: + type: string + security: + - BearerAuth: [] + summary: 'Admin: list all users' + tags: + - admin + post: + consumes: + - application/json + parameters: + - description: payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/authn.AdminCreateUserRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/authn.userOut' + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "403": + description: forbidden + schema: + type: string + "409": + description: conflict + schema: + type: string + security: + - BearerAuth: [] + summary: 'Admin: create user' + tags: + - admin + /api/v1/admin/users/{userId}: + delete: + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + responses: + "204": + description: no content + schema: + type: string + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "403": + description: forbidden + schema: + type: string + "404": + description: not found + schema: + type: string + "409": + description: conflict + schema: + type: string + security: + - BearerAuth: [] + summary: 'Admin: delete user' + tags: + - admin + patch: + consumes: + - application/json + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + - description: payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/authn.AdminUpdateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/authn.userOut' + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "403": + description: forbidden + schema: + type: string + "404": + description: not found + schema: + type: string + "409": + description: conflict + schema: + type: string + security: + - BearerAuth: [] + summary: 'Admin: update user' + tags: + - admin /api/v1/auth/introspect: post: consumes: @@ -523,6 +865,382 @@ paths: summary: Create a new organization tags: - organizations + /api/v1/orgs/{orgId}: + delete: + parameters: + - description: Organization ID + in: path + name: orgId + required: true + type: string + responses: + "204": + description: deleted + schema: + type: string + "403": + description: forbidden + schema: + type: string + security: + - BearerAuth: [] + summary: Delete organization + tags: + - organizations + patch: + consumes: + - application/json + parameters: + - description: Org ID + in: path + name: orgId + required: true + type: string + - description: Organization data + in: body + name: body + required: true + schema: + $ref: '#/definitions/orgs.OrgInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Organization' + "403": + description: forbidden + schema: + type: string + security: + - BearerAuth: [] + summary: Update organization metadata + tags: + - organizations + /api/v1/orgs/invite: + post: + consumes: + - application/json + parameters: + - description: Invite input + in: body + name: body + required: true + schema: + $ref: '#/definitions/orgs.InviteInput' + - description: Organization context + in: header + name: X-Org-ID + required: true + type: string + produces: + - text/plain + responses: + "201": + description: invited + schema: + type: string + "400": + description: bad request + schema: + type: string + "403": + description: forbidden + schema: + type: string + security: + - BearerAuth: [] + summary: Invite user to organization + tags: + - organizations + /api/v1/orgs/members: + get: + description: Returns a list of all members in the current organization + parameters: + - description: Organization context + in: header + name: X-Org-ID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Member' + type: array + "401": + description: unauthorized + schema: + type: string + security: + - BearerAuth: [] + summary: List organization members + tags: + - organizations + /api/v1/orgs/members/{userId}: + delete: + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + responses: + "204": + description: deleted + schema: + type: string + "403": + description: forbidden + schema: + type: string + security: + - BearerAuth: [] + summary: Remove member from organization + tags: + - organizations + /api/v1/ssh: + get: + consumes: + - application/json + description: Returns ssh keys for the organization in X-Org-ID. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/ssh.sshResponse' + type: array + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: failed to list keys + schema: + type: string + security: + - BearerAuth: [] + summary: List ssh keys (org scoped) + tags: + - ssh + post: + consumes: + - application/json + description: Generates an RSA keypair, saves it, and returns metadata. Optionally + set `download` to "public", "private", or "both" to download files immediately. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Key generation options + in: body + name: body + required: true + schema: + $ref: '#/definitions/ssh.createSSHRequest' + produces: + - application/json + responses: + "201": + description: Created + headers: + Content-Disposition: + description: When download is requested + type: string + schema: + $ref: '#/definitions/ssh.sshResponse' + "400": + description: invalid json / invalid bits + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: generation/create failed + schema: + type: string + security: + - BearerAuth: [] + summary: Create ssh keypair (org scoped) + tags: + - ssh + /api/v1/ssh/{id}: + delete: + consumes: + - application/json + description: Permanently deletes a keypair. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: SSH Key ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: delete failed + schema: + type: string + security: + - BearerAuth: [] + summary: Delete ssh keypair (org scoped) + tags: + - ssh + get: + consumes: + - application/json + description: Returns public key fields. Append `?reveal=true` to include the + private key PEM. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: SSH Key ID (UUID) + in: path + name: id + required: true + type: string + - description: Reveal private key PEM + in: query + name: reveal + type: boolean + produces: + - application/json + responses: + "200": + description: When reveal=true + schema: + $ref: '#/definitions/ssh.sshRevealResponse' + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: Get ssh key by ID (org scoped) + tags: + - ssh + /api/v1/ssh/{id}/download: + get: + description: Download `part=public|private|both` of the keypair. `both` returns + a zip file. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: SSH Key ID (UUID) + in: path + name: id + required: true + type: string + - description: Which part to download + enum: + - public + - private + - both + in: query + name: part + required: true + type: string + produces: + - text/plain + responses: + "200": + description: file content + schema: + type: string + "400": + description: invalid id / invalid part + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: download failed + schema: + type: string + security: + - BearerAuth: [] + summary: Download ssh key files by ID (org scoped) + tags: + - ssh schemes: - http securityDefinitions: diff --git a/internal/api/routes.go b/internal/api/routes.go index 974b9ab..a755f1d 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -7,6 +7,7 @@ import ( "github.com/glueops/autoglue/internal/handlers/authn" "github.com/glueops/autoglue/internal/handlers/health" "github.com/glueops/autoglue/internal/handlers/orgs" + "github.com/glueops/autoglue/internal/handlers/ssh" "github.com/glueops/autoglue/internal/middleware" "github.com/glueops/autoglue/internal/ui" "github.com/go-chi/chi/v5" @@ -21,6 +22,14 @@ func RegisterRoutes(r chi.Router) { secret := viper.GetString("authentication.jwt_secret") authMW := middleware.AuthMiddleware(secret) + v1.Route("/admin", func(ad chi.Router) { + ad.Use(authMW) + ad.Get("/users", authn.AdminListUsers) + ad.Post("/users", authn.AdminCreateUser) + ad.Patch("/users/{userId}", authn.AdminUpdateUser) + ad.Delete("/users/{userId}", authn.AdminDeleteUser) + }) + v1.Route("/auth", func(a chi.Router) { a.Post("/login", authn.Login) a.Post("/register", authn.Register) @@ -45,6 +54,20 @@ func RegisterRoutes(r chi.Router) { o.Use(authMW) o.Post("/", orgs.CreateOrganization) o.Get("/", orgs.ListOrganizations) + o.Post("/invite", orgs.InviteMember) + o.Get("/members", orgs.ListMembers) + o.Delete("/members/{userId}", orgs.DeleteMember) + o.Patch("/{orgId}", orgs.UpdateOrganization) + o.Delete("/{orgId}", orgs.DeleteOrganization) + }) + + v1.Route("/ssh", func(s chi.Router) { + s.Use(authMW) + s.Get("/", ssh.ListPublicKeys) + s.Post("/", ssh.CreateSSHKey) + s.Get("/{id}", ssh.GetSSHKey) + s.Delete("/{id}", ssh.DeleteSSHKey) + s.Get("/{id}/download", ssh.DownloadSSHKey) }) }) }) diff --git a/internal/db/database.go b/internal/db/database.go index fb94414..c13276a 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -26,13 +26,16 @@ func Connect() { } err = DB.AutoMigrate( + &models.Credential{}, &models.EmailVerification{}, &models.Invitation{}, &models.MasterKey{}, &models.Member{}, &models.Organization{}, + &models.OrganizationKey{}, &models.PasswordReset{}, &models.RefreshToken{}, + &models.SshKey{}, &models.User{}, ) if err != nil { diff --git a/internal/db/models/credential.go b/internal/db/models/credential.go new file mode 100644 index 0000000..86b9008 --- /dev/null +++ b/internal/db/models/credential.go @@ -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 +} diff --git a/internal/db/models/organization-key.go b/internal/db/models/organization-key.go new file mode 100644 index 0000000..e19624e --- /dev/null +++ b/internal/db/models/organization-key.go @@ -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 +} diff --git a/internal/db/models/ssh-key.go b/internal/db/models/ssh-key.go new file mode 100644 index 0000000..e40b32b --- /dev/null +++ b/internal/db/models/ssh-key.go @@ -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 +} diff --git a/internal/handlers/authn/auth.go b/internal/handlers/authn/auth.go index b75912d..1f1d3e4 100644 --- a/internal/handlers/authn/auth.go +++ b/internal/handlers/authn/auth.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/glueops/autoglue/internal/config" @@ -11,6 +12,7 @@ import ( "github.com/glueops/autoglue/internal/db/models" "github.com/glueops/autoglue/internal/middleware" "github.com/glueops/autoglue/internal/response" + "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" @@ -539,3 +541,299 @@ func RotateRefreshToken(w http.ResponseWriter, r *http.Request) { "refresh_token": newRefresh.Token, }) } + +// AdminListUsers godoc +// @Summary Admin: list all users +// @Description Returns paginated list of users (admin only) +// @Tags admin +// @Produce json +// @Param page query int false "Page number (1-based)" +// @Param page_size query int false "Page size (max 200)" +// @Success 200 {object} ListUsersOut +// @Failure 401 {string} string "unauthorized" +// @Failure 403 {string} string "forbidden" +// @Security BearerAuth +// @Router /api/v1/admin/users [get] +func AdminListUsers(w http.ResponseWriter, r *http.Request) { + ctx := middleware.GetAuthContext(r) + if ctx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Load current user to check global role + var me models.User + if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if me.Role != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + // Pagination + page := mustInt(r.URL.Query().Get("page"), 1) + if page < 1 { + page = 1 + } + pageSize := mustInt(r.URL.Query().Get("page_size"), 50) + if pageSize < 1 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + offset := (page - 1) * pageSize + + // Query + var total int64 + _ = db.DB.Model(&models.User{}).Count(&total).Error + + var users []models.User + if err := db.DB. + Model(&models.User{}). + Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&users).Error; err != nil { + http.Error(w, "failed to fetch users", http.StatusInternalServerError) + return + } + + out := make([]UserListItem, len(users)) + for i, u := range users { + out[i] = UserListItem{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + EmailVerified: u.EmailVerified, + Role: string(u.Role), + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + } + + _ = response.JSON(w, http.StatusOK, ListUsersOut{ + Users: out, + Page: page, + PageSize: pageSize, + Total: total, + }) +} + +// AdminCreateUser godoc +// @Summary Admin: create user +// @Tags admin +// @Accept json +// @Produce json +// @Param body body AdminCreateUserRequest true "payload" +// @Success 201 {object} userOut +// @Failure 400 {string} string "bad request" +// @Failure 401 {string} string "unauthorized" +// @Failure 403 {string} string "forbidden" +// @Failure 409 {string} string "conflict" +// @Security BearerAuth +// @Router /api/v1/admin/users [post] +func AdminCreateUser(w http.ResponseWriter, r *http.Request) { + if _, ok := requireGlobalAdmin(w, r); !ok { + return + } + + var in struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` // "user" | "admin" + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + in.Email = strings.TrimSpace(strings.ToLower(in.Email)) + in.Role = strings.TrimSpace(in.Role) + if in.Email == "" || in.Password == "" { + http.Error(w, "email and password required", http.StatusBadRequest) + return + } + if in.Role == "" { + in.Role = "user" + } + if in.Role != "user" && in.Role != "admin" { + http.Error(w, "invalid role", http.StatusBadRequest) + return + } + + var exists int64 + if err := db.DB.Model(&models.User{}).Where("LOWER(email)=?", in.Email).Count(&exists).Error; err == nil && exists > 0 { + http.Error(w, "email already in use", http.StatusConflict) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + + u := models.User{ + Name: in.Name, + Email: in.Email, + Password: string(hash), + Role: models.Role(in.Role), + } + if err := db.DB.Create(&u).Error; err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + _ = response.JSON(w, http.StatusCreated, asUserOut(u)) +} + +// AdminUpdateUser godoc +// @Summary Admin: update user +// @Tags admin +// @Accept json +// @Produce json +// @Param userId path string true "User ID" +// @Param body body AdminUpdateUserRequest true "payload" +// @Success 200 {object} userOut +// @Failure 400 {string} string "bad request" +// @Failure 401 {string} string "unauthorized" +// @Failure 403 {string} string "forbidden" +// @Failure 404 {string} string "not found" +// @Failure 409 {string} string "conflict" +// @Security BearerAuth +// @Router /api/v1/admin/users/{userId} [patch] +func AdminUpdateUser(w http.ResponseWriter, r *http.Request) { + _, ok := requireGlobalAdmin(w, r) + if !ok { + return + } + + idStr := chi.URLParam(r, "userId") + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "bad user id", http.StatusBadRequest) + return + } + + var u models.User + if err := db.DB.First(&u, "id = ?", id).Error; err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + var in struct { + Name *string `json:"name"` + Email *string `json:"email"` + Password *string `json:"password"` + Role *string `json:"role"` // "user" | "admin" + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + updates := map[string]any{} + if in.Name != nil { + updates["name"] = *in.Name + } + if in.Email != nil { + email := strings.TrimSpace(strings.ToLower(*in.Email)) + if email == "" { + http.Error(w, "email required", http.StatusBadRequest) + return + } + var exists int64 + _ = db.DB.Model(&models.User{}).Where("LOWER(email)=? AND id <> ?", email, u.ID).Count(&exists).Error + if exists > 0 { + http.Error(w, "email already in use", http.StatusConflict) + return + } + updates["email"] = email + } + if in.Password != nil && *in.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "hash error", http.StatusInternalServerError) + return + } + updates["password"] = string(hash) + } + if in.Role != nil { + role := strings.TrimSpace(*in.Role) + if role != "user" && role != "admin" { + http.Error(w, "invalid role", http.StatusBadRequest) + return + } + // prevent demoting the last admin + if u.Role == "admin" && role == "user" { + n, _ := adminCount(&u.ID) + if n == 0 { + http.Error(w, "cannot demote last admin", http.StatusConflict) + return + } + } + updates["role"] = role + } + if len(updates) == 0 { + _ = response.JSON(w, http.StatusOK, asUserOut(u)) + return + } + if err := db.DB.Model(&u).Updates(updates).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + _ = response.JSON(w, http.StatusOK, asUserOut(u)) +} + +// AdminDeleteUser godoc +// @Summary Admin: delete user +// @Tags admin +// @Param userId path string true "User ID" +// @Success 204 {string} string "no content" +// @Failure 400 {string} string "bad request" +// @Failure 401 {string} string "unauthorized" +// @Failure 403 {string} string "forbidden" +// @Failure 404 {string} string "not found" +// @Failure 409 {string} string "conflict" +// @Security BearerAuth +// @Router /api/v1/admin/users/{userId} [delete] +func AdminDeleteUser(w http.ResponseWriter, r *http.Request) { + me, ok := requireGlobalAdmin(w, r) + if !ok { + return + } + + idStr := chi.URLParam(r, "userId") + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "bad user id", http.StatusBadRequest) + return + } + + if me.ID == id { + http.Error(w, "cannot delete self", http.StatusBadRequest) + return + } + + var u models.User + if err := db.DB.First(&u, "id = ?", id).Error; err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if u.Role == "admin" { + n, _ := adminCount(&u.ID) + if n == 0 { + http.Error(w, "cannot delete last admin", http.StatusConflict) + return + } + } + + if err := db.DB.Delete(&models.User{}, "id = ?", id).Error; err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} diff --git a/internal/handlers/authn/dto.go b/internal/handlers/authn/dto.go index 3f65ca0..92c76eb 100644 --- a/internal/handlers/authn/dto.go +++ b/internal/handlers/authn/dto.go @@ -77,3 +77,62 @@ type PasswordResetData struct { Token string ResetURL string } + +type UserListItem struct { + ID any `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Role string `json:"role"` + CreatedAt any `json:"created_at"` + UpdatedAt any `json:"updated_at"` +} + +type ListUsersOut struct { + Users []UserListItem `json:"users"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` +} + +type userOut struct { + ID any `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Role string `json:"role"` + CreatedAt any `json:"created_at"` + UpdatedAt any `json:"updated_at"` +} + +type AdminCreateUserRequest struct { + Name string `json:"name" example:"Jane Doe"` + Email string `json:"email" example:"jane@example.com"` + Password string `json:"password" example:"Secret123!"` + // Role allowed values: "user" or "admin" + Role string `json:"role" example:"user" enums:"user,admin"` +} + +type AdminUpdateUserRequest struct { + Name *string `json:"name,omitempty" example:"Jane Doe"` + Email *string `json:"email,omitempty" example:"jane@example.com"` + Password *string `json:"password,omitempty" example:"NewSecret123!"` + Role *string `json:"role,omitempty" example:"admin" enums:"user,admin"` +} + +type AdminUserResponse struct { + ID uuid.UUID `json:"id" example:"6aa012bc-ce8a-4cd9-9971-58f3917037f8"` + Name string `json:"name" example:"Jane Doe"` + Email string `json:"email" example:"jane@example.com"` + EmailVerified bool `json:"email_verified" example:"false"` + Role string `json:"role" example:"user"` + CreatedAt string `json:"created_at" example:"2025-09-01T08:38:12Z"` + UpdatedAt string `json:"updated_at" example:"2025-09-01T17:02:36Z"` +} + +type AdminListUsersResponse struct { + Users []AdminUserResponse `json:"users"` + Page int `json:"page" example:"1"` + PageSize int `json:"page_size" example:"50"` + Total int64 `json:"total" example:"123"` +} diff --git a/internal/handlers/authn/funcs.go b/internal/handlers/authn/funcs.go index 832da2b..2b4892d 100644 --- a/internal/handlers/authn/funcs.go +++ b/internal/handlers/authn/funcs.go @@ -4,11 +4,14 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "net/http" + "strconv" "time" "github.com/glueops/autoglue/internal/config" "github.com/glueops/autoglue/internal/db" "github.com/glueops/autoglue/internal/db/models" + "github.com/glueops/autoglue/internal/middleware" appsmtp "github.com/glueops/autoglue/internal/smtp" "github.com/google/uuid" ) @@ -90,3 +93,54 @@ func sendTemplatedEmail(to string, templateFile string, data any) error { } return m.Send(to, data, templateFile) } + +func mustInt(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +func adminCount(except *uuid.UUID) (int64, error) { + q := db.DB.Model(&models.User{}).Where(`role = ?`, "admin") + if except != nil { + q = q.Where("id <> ?", *except) + } + var n int64 + err := q.Count(&n).Error + return n, err +} + +func requireGlobalAdmin(w http.ResponseWriter, r *http.Request) (*models.User, bool) { + ctx := middleware.GetAuthContext(r) + if ctx == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return nil, false + } + var me models.User + if err := db.DB.Select("id, role").First(&me, "id = ?", ctx.UserID).Error; err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return nil, false + } + if me.Role != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return nil, false + } + return &me, true +} + +func asUserOut(u models.User) userOut { + return userOut{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + EmailVerified: u.EmailVerified, + Role: string(u.Role), + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} diff --git a/internal/handlers/orgs/dto.go b/internal/handlers/orgs/dto.go index 50e9635..148b98a 100644 --- a/internal/handlers/orgs/dto.go +++ b/internal/handlers/orgs/dto.go @@ -4,3 +4,8 @@ type OrgInput struct { Name string `json:"name"` Slug string `json:"slug"` } + +type InviteInput struct { + Email string `json:"email"` + Role string `json:"role"` +} diff --git a/internal/handlers/orgs/orgs.go b/internal/handlers/orgs/orgs.go index d98b3a2..59b97a0 100644 --- a/internal/handlers/orgs/orgs.go +++ b/internal/handlers/orgs/orgs.go @@ -4,11 +4,14 @@ import ( "encoding/json" "net/http" "strings" + "time" "github.com/glueops/autoglue/internal/db" "github.com/glueops/autoglue/internal/db/models" "github.com/glueops/autoglue/internal/middleware" "github.com/glueops/autoglue/internal/response" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) // CreateOrganization godoc @@ -88,3 +91,178 @@ func ListOrganizations(w http.ResponseWriter, r *http.Request) { _ = response.JSON(w, http.StatusOK, orgs) } + +// InviteMember godoc +// @Summary Invite user to organization +// @Tags organizations +// @Accept json +// @Produce plain +// @Param body body InviteInput true "Invite input" +// @Success 201 {string} string "invited" +// @Failure 403 {string} string "forbidden" +// @Failure 400 {string} string "bad request" +// @Router /api/v1/orgs/invite [post] +// @Param X-Org-ID header string true "Organization context" +// @Security BearerAuth +func InviteMember(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r) + if auth == nil || auth.OrgRole != "admin" || auth.OrganizationID == uuid.Nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var input InviteInput + json.NewDecoder(r.Body).Decode(&input) + + var user models.User + err := db.DB.Where("email = ?", input.Email).First(&user).Error + if err != nil { + http.Error(w, "user not found", http.StatusBadRequest) + return + } + + invite := models.Invitation{ + OrganizationID: auth.OrganizationID, + Email: input.Email, + Role: input.Role, + Status: "pending", + ExpiresAt: time.Now().Add(48 * time.Hour), + InviterID: auth.UserID, + } + + if err := db.DB.Create(&invite).Error; err != nil { + http.Error(w, "failed to invite", http.StatusInternalServerError) + return + } + + _ = response.JSON(w, http.StatusCreated, invite) +} + +// ListMembers lists all members of the authenticated org +// @Summary List organization members +// @Description Returns a list of all members in the current organization +// @Tags organizations +// @Security BearerAuth +// @Produce json +// @Success 200 {array} models.Member +// @Failure 401 {string} string "unauthorized" +// @Router /api/v1/orgs/members [get] +// @Param X-Org-ID header string true "Organization context" +func ListMembers(w http.ResponseWriter, r *http.Request) { + authCtx := middleware.GetAuthContext(r) + if authCtx == nil || authCtx.OrganizationID == uuid.Nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var members []models.Member + if err := db.DB.Preload("User").Preload("Organization").Where("organization_id = ?", authCtx.OrganizationID).Find(&members).Error; err != nil { + http.Error(w, "failed to fetch members", http.StatusInternalServerError) + return + } + _ = response.JSON(w, http.StatusOK, members) +} + +// DeleteMember godoc +// @Summary Remove member from organization +// @Tags organizations +// @Param userId path string true "User ID" +// @Success 204 {string} string "deleted" +// @Failure 403 {string} string "forbidden" +// @Router /api/v1/orgs/members/{userId} [delete] +// @Security BearerAuth +func DeleteMember(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r) + if auth == nil || auth.OrgRole != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + userId := chi.URLParam(r, "userId") + + if err := db.DB.Where("user_id = ? AND organization_id = ?", userId, auth.OrganizationID).Delete(&models.Member{}).Error; err != nil { + http.Error(w, "failed to delete", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UpdateOrganization godoc +// @Summary Update organization metadata +// @Tags organizations +// @Accept json +// @Produce json +// @Param orgId path string true "Org ID" +// @Param body body OrgInput true "Organization data" +// @Success 200 {object} models.Organization +// @Failure 403 {string} string "forbidden" +// @Router /api/v1/orgs/{orgId} [patch] +// @Security BearerAuth +func UpdateOrganization(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r) + if auth == nil || auth.OrgRole != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + orgId := chi.URLParam(r, "orgId") + + var input OrgInput + json.NewDecoder(r.Body).Decode(&input) + + var org models.Organization + db.DB.First(&org, "id = ?", orgId) + + org.Name = input.Name + org.Slug = input.Slug + db.DB.Save(&org) + + _ = response.JSON(w, http.StatusOK, org) +} + +// DeleteOrganization godoc +// @Summary Delete organization +// @Tags organizations +// @Param orgId path string true "Organization ID" +// @Success 204 {string} string "deleted" +// @Failure 403 {string} string "forbidden" +// @Router /api/v1/orgs/{orgId} [delete] +// @Security BearerAuth +func DeleteOrganization(w http.ResponseWriter, r *http.Request) { + auth := middleware.GetAuthContext(r) + if auth == nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + orgId := chi.URLParam(r, "orgId") + orgUUID, err := uuid.Parse(orgId) + if err != nil { + http.Error(w, "invalid organization id", http.StatusBadRequest) + return + } + + var member models.Member + if err := db.DB. + Where("user_id = ? AND organization_id = ?", auth.UserID, orgUUID). + First(&member).Error; err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if member.Role != "admin" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + if err := db.DB.Where("organization_id = ?", orgUUID).Delete(&models.Member{}).Error; err != nil { + http.Error(w, "failed to delete members", http.StatusInternalServerError) + return + } + if err := db.DB.Delete(&models.Organization{}, "id = ?", orgUUID).Error; err != nil { + http.Error(w, "failed to delete org", http.StatusInternalServerError) + return + } + + response.NoContent(w) +} diff --git a/internal/handlers/ssh/dto.go b/internal/handlers/ssh/dto.go new file mode 100644 index 0000000..74a36c2 --- /dev/null +++ b/internal/handlers/ssh/dto.go @@ -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"` +} diff --git a/internal/handlers/ssh/funcs.go b/internal/handlers/ssh/funcs.go new file mode 100644 index 0000000..16450f7 --- /dev/null +++ b/internal/handlers/ssh/funcs.go @@ -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 +} diff --git a/internal/handlers/ssh/ssh.go b/internal/handlers/ssh/ssh.go new file mode 100644 index 0000000..1db14ab --- /dev/null +++ b/internal/handlers/ssh/ssh.go @@ -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) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6284c16..56da9b3 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,6 +7,7 @@ import ( "github.com/glueops/autoglue/internal/db" "github.com/glueops/autoglue/internal/db/models" + "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) @@ -59,14 +60,32 @@ func AuthMiddleware(secret string) func(http.Handler) http.Handler { Claims: claims, } - if orgID := r.Header.Get("X-Org-ID"); orgID != "" { - orgUUID, _ := uuid.Parse(orgID) + orgIDStr := r.Header.Get("X-Org-ID") + if orgIDStr == "" { + if rc := chi.RouteContext(r.Context()); rc != nil { + if v := rc.URLParam("orgId"); v != "" { + orgIDStr = v + } else if v := rc.URLParam("organizationId"); v != "" { + orgIDStr = v + } + } + } - var member models.Member - if err := db.DB.Where("user_id = ? AND organization_id = ?", claims.Subject, orgID).First(&member).Error; err != nil { - http.Error(w, "User not a member of the organization", http.StatusForbidden) + if orgIDStr != "" { + orgUUID, err := uuid.Parse(orgIDStr) + if err != nil { + http.Error(w, "invalid organization id", http.StatusBadRequest) return } + + var member models.Member + if err := db.DB. + Where("user_id = ? AND organization_id = ?", userUUID, orgUUID). + First(&member).Error; err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + authCtx.OrganizationID = orgUUID authCtx.OrgRole = string(member.Role) } diff --git a/internal/ui/dist/assets/icons-CHRYRpwL.js b/internal/ui/dist/assets/icons-CHRYRpwL.js new file mode 100644 index 0000000..a477002 --- /dev/null +++ b/internal/ui/dist/assets/icons-CHRYRpwL.js @@ -0,0 +1,156 @@ +import{r as d,R as s}from"./vendor-Cnbx_Mrt.js";/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),x=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(t,a,c)=>c?c.toUpperCase():a.toLowerCase()),k=e=>{const t=x(e);return t.charAt(0).toUpperCase()+t.slice(1)},v=(...e)=>e.filter((t,a,c)=>!!t&&t.trim()!==""&&c.indexOf(t)===a).join(" ").trim(),z=e=>{for(const t in e)if(t.startsWith("aria-")||t==="role"||t==="title")return!0};/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var j={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const N=d.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:a=2,absoluteStrokeWidth:c,className:n="",children:o,iconNode:p,...i},h)=>d.createElement("svg",{ref:h,...j,width:t,height:t,stroke:e,strokeWidth:c?Number(a)*24/Number(t):a,className:v("lucide",n),...!o&&!z(i)&&{"aria-hidden":"true"},...i},[...p.map(([b,w])=>d.createElement(b,w)),...Array.isArray(o)?o:[o]]));/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const r=(e,t)=>{const a=d.forwardRef(({className:c,...n},o)=>d.createElement(N,{ref:o,iconNode:t,className:v(`lucide-${_(k(e))}`,`lucide-${e}`,c),...n}));return a.displayName=k(e),a};/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const C=[["path",{d:"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z",key:"lc1i9w"}],["path",{d:"m7 16.5-4.74-2.85",key:"1o9zyk"}],["path",{d:"m7 16.5 5-3",key:"va8pkn"}],["path",{d:"M7 16.5v5.17",key:"jnp8gn"}],["path",{d:"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z",key:"8zsnat"}],["path",{d:"m17 16.5-5-3",key:"8arw3v"}],["path",{d:"m17 16.5 4.74-2.85",key:"8rfmw"}],["path",{d:"M17 16.5v5.17",key:"k6z78m"}],["path",{d:"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z",key:"1xygjf"}],["path",{d:"M12 8 7.26 5.15",key:"1vbdud"}],["path",{d:"m12 8 4.74-2.85",key:"3rx089"}],["path",{d:"M12 13.5V8",key:"1io7kd"}]],i1=r("boxes",C);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const O=[["path",{d:"m10.852 14.772-.383.923",key:"11vil6"}],["path",{d:"m10.852 9.228-.383-.923",key:"1fjppe"}],["path",{d:"m13.148 14.772.382.924",key:"je3va1"}],["path",{d:"m13.531 8.305-.383.923",key:"18epck"}],["path",{d:"m14.772 10.852.923-.383",key:"k9m8cz"}],["path",{d:"m14.772 13.148.923.383",key:"1xvhww"}],["path",{d:"M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446 3 3 0 0 0-.368 1.571 4 4 0 0 0-2.525 5.771",key:"jcbbz1"}],["path",{d:"M17.998 5.125a4 4 0 0 1 2.525 5.771",key:"1kkn7e"}],["path",{d:"M19.505 10.294a4 4 0 0 1-1.5 7.706",key:"18bmuc"}],["path",{d:"M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516",key:"uozx0d"}],["path",{d:"M4.5 10.291A4 4 0 0 0 6 18",key:"whdemb"}],["path",{d:"M6.002 5.125a3 3 0 0 0 .4 1.375",key:"1kqy2g"}],["path",{d:"m9.228 10.852-.923-.383",key:"1wtb30"}],["path",{d:"m9.228 13.148-.923.383",key:"1a830x"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]],d1=r("brain-cog",O);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $=[["path",{d:"M12 10h.01",key:"1nrarc"}],["path",{d:"M12 14h.01",key:"1etili"}],["path",{d:"M12 6h.01",key:"1vi96p"}],["path",{d:"M16 10h.01",key:"1m94wz"}],["path",{d:"M16 14h.01",key:"1gbofw"}],["path",{d:"M16 6h.01",key:"1x0f13"}],["path",{d:"M8 10h.01",key:"19clt8"}],["path",{d:"M8 14h.01",key:"6423bh"}],["path",{d:"M8 6h.01",key:"1dz90k"}],["path",{d:"M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3",key:"cabbwy"}],["rect",{x:"4",y:"2",width:"16",height:"20",rx:"2",key:"1uxh74"}]],y1=r("building",$);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const P=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],l1=r("check",P);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const q=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],p1=r("chevron-down",q);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const A=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],k1=r("chevron-up",A);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const V=[["path",{d:"M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z",key:"1uwlt4"}],["path",{d:"M2.297 11.293a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0z",key:"10291m"}],["path",{d:"M8.916 17.912a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0z",key:"1tqoq1"}],["path",{d:"M8.916 4.674a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z",key:"1x6lto"}]],u1=r("component",V);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const L=[["path",{d:"M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v6",key:"rc0qvx"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["circle",{cx:"4",cy:"16",r:"2",key:"1ehqvc"}],["path",{d:"m10 10-4.5 4.5",key:"7fwrp6"}],["path",{d:"m9 11 1 1",key:"wa6s5q"}]],m1=r("file-key-2",L);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const E=[["path",{d:"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8",key:"5wwlr5"}],["path",{d:"M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",key:"1d0kgt"}]],v1=r("house",E);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const H=[["path",{d:"m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4",key:"g0fldk"}],["path",{d:"m21 2-9.6 9.6",key:"1j0ho8"}],["circle",{cx:"7.5",cy:"15.5",r:"5.5",key:"yqb3hr"}]],f1=r("key",H);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const S=[["path",{d:"M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z",key:"1pdavp"}],["path",{d:"M20.054 15.987H3.946",key:"14rxg9"}]],M1=r("laptop",S);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const B=[["rect",{x:"3",y:"5",width:"6",height:"6",rx:"1",key:"1defrl"}],["path",{d:"m3 17 2 2 4-4",key:"1jhpwq"}],["path",{d:"M13 6h8",key:"15sg57"}],["path",{d:"M13 12h8",key:"h98zly"}],["path",{d:"M13 18h8",key:"oe0vm4"}]],g1=r("list-todo",B);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const U=[["circle",{cx:"12",cy:"16",r:"1",key:"1au0dj"}],["rect",{x:"3",y:"10",width:"18",height:"12",rx:"2",key:"6s8ecr"}],["path",{d:"M7 10V7a5 5 0 0 1 10 0v3",key:"1pqi11"}]],b1=r("lock-keyhole",U);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const D=[["path",{d:"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401",key:"kfwtm"}]],w1=r("moon",D);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const I=[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}],["path",{d:"m15 5 4 4",key:"1mk7zo"}]],_1=r("pencil",I);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const K=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],x1=r("plus",K);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const T=[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]],z1=r("server",T);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const R=[["path",{d:"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915",key:"1i5ecw"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]],j1=r("settings",R);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const W=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],N1=r("shield-check",W);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Z=[["path",{d:"M3 3h.01",key:"159qn6"}],["path",{d:"M7 5h.01",key:"1hq22a"}],["path",{d:"M11 7h.01",key:"1osv80"}],["path",{d:"M3 7h.01",key:"1xzrh3"}],["path",{d:"M7 9h.01",key:"19b3jx"}],["path",{d:"M3 11h.01",key:"1eifu7"}],["rect",{width:"4",height:"4",x:"15",y:"5",key:"mri9e4"}],["path",{d:"m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2",key:"aib6hk"}],["path",{d:"m13 14 8-2",key:"1d7bmk"}],["path",{d:"m13 19 8-2",key:"1y2vml"}]],C1=r("spray-can",Z);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const F=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],O1=r("sun",F);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const G=[["path",{d:"M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z",key:"16rjxf"}],["path",{d:"M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193",key:"178nd4"}],["circle",{cx:"10.5",cy:"6.5",r:".5",fill:"currentColor",key:"12ikhr"}]],$1=r("tags",G);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const X=[["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],P1=r("trash",X);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const J=[["path",{d:"M2 21a8 8 0 0 1 13.292-6",key:"bjp14o"}],["circle",{cx:"10",cy:"8",r:"5",key:"o932ke"}],["path",{d:"M19 16v6",key:"tddt3s"}],["path",{d:"M22 19h-6",key:"vcuq98"}]],q1=r("user-round-plus",J);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Q=[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]],A1=r("user",Q);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Y=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["path",{d:"M16 3.128a4 4 0 0 1 0 7.744",key:"16gr8j"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}]],V1=r("users",Y);/** + * @license lucide-react v0.542.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const e1=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],L1=r("x",e1);var f={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},u=s.createContext&&s.createContext(f),t1=["attr","size","title"];function a1(e,t){if(e==null)return{};var a=c1(e,t),c,n;if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(n=0;n=0)&&Object.prototype.propertyIsEnumerable.call(e,c)&&(a[c]=e[c])}return a}function c1(e,t){if(e==null)return{};var a={};for(var c in e)if(Object.prototype.hasOwnProperty.call(e,c)){if(t.indexOf(c)>=0)continue;a[c]=e[c]}return a}function y(){return y=Object.assign?Object.assign.bind():function(e){for(var t=1;ts.createElement(t.tag,l({key:a},t.attr),M(t.child)))}function g(e){return t=>s.createElement(h1,y({attr:l({},e.attr)},t),M(e.child))}function h1(e){var t=a=>{var{attr:c,size:n,title:o}=e,p=a1(e,t1),i=n||a.size||"1em",h;return a.className&&(h=a.className),e.className&&(h=(h?h+" ":"")+e.className),s.createElement("svg",y({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},a.attr,c,p,{className:h,style:l(l({color:e.color||a.color},a.style),e.style),height:i,width:i,xmlns:"http://www.w3.org/2000/svg"}),o&&s.createElement("title",null,o),e.children)};return u!==void 0?s.createElement(u.Consumer,null,a=>t(a)):t(f)}function E1(e){return g({attr:{viewBox:"0 0 1024 1024"},child:[{tag:"path",attr:{d:"M888 680h-54V540H546v-92h238c8.8 0 16-7.2 16-16V168c0-8.8-7.2-16-16-16H240c-8.8 0-16 7.2-16 16v264c0 8.8 7.2 16 16 16h238v92H190v140h-54c-4.4 0-8 3.6-8 8v176c0 4.4 3.6 8 8 8h176c4.4 0 8-3.6 8-8V688c0-4.4-3.6-8-8-8h-54v-72h220v72h-54c-4.4 0-8 3.6-8 8v176c0 4.4 3.6 8 8 8h176c4.4 0 8-3.6 8-8V688c0-4.4-3.6-8-8-8h-54v-72h220v72h-54c-4.4 0-8 3.6-8 8v176c0 4.4 3.6 8 8 8h176c4.4 0 8-3.6 8-8V688c0-4.4-3.6-8-8-8zM256 805.3c0 1.5-1.2 2.7-2.7 2.7h-58.7c-1.5 0-2.7-1.2-2.7-2.7v-58.7c0-1.5 1.2-2.7 2.7-2.7h58.7c1.5 0 2.7 1.2 2.7 2.7v58.7zm288 0c0 1.5-1.2 2.7-2.7 2.7h-58.7c-1.5 0-2.7-1.2-2.7-2.7v-58.7c0-1.5 1.2-2.7 2.7-2.7h58.7c1.5 0 2.7 1.2 2.7 2.7v58.7zM288 384V216h448v168H288zm544 421.3c0 1.5-1.2 2.7-2.7 2.7h-58.7c-1.5 0-2.7-1.2-2.7-2.7v-58.7c0-1.5 1.2-2.7 2.7-2.7h58.7c1.5 0 2.7 1.2 2.7 2.7v58.7zM360 300a40 40 0 1 0 80 0 40 40 0 1 0-80 0z"},child:[]}]})(e)}function H1(e){return g({attr:{viewBox:"0 0 496 512"},child:[{tag:"path",attr:{d:"M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"},child:[]}]})(e)}export{E1 as A,i1 as B,l1 as C,m1 as F,v1 as H,f1 as K,M1 as L,w1 as M,x1 as P,O1 as S,$1 as T,V1 as U,L1 as X,u1 as a,C1 as b,z1 as c,d1 as d,b1 as e,g1 as f,y1 as g,A1 as h,j1 as i,N1 as j,p1 as k,H1 as l,k1 as m,q1 as n,P1 as o,_1 as p}; diff --git a/internal/ui/dist/assets/index-CJrhsj7s.css b/internal/ui/dist/assets/index-CJrhsj7s.css new file mode 100644 index 0000000..7676b65 --- /dev/null +++ b/internal/ui/dist/assets/index-CJrhsj7s.css @@ -0,0 +1 @@ +/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-widest:.1em;--leading-loose:2;--radius-xs:.125rem;--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.top-1\.5{top:calc(var(--spacing)*1.5)}.top-3\.5{top:calc(var(--spacing)*3.5)}.top-4{top:calc(var(--spacing)*4)}.top-\[50\%\]{top:50%}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-3{right:calc(var(--spacing)*3)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.left-2{left:calc(var(--spacing)*2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-2{margin-inline:calc(var(--spacing)*2)}.mx-3\.5{margin-inline:calc(var(--spacing)*3.5)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-4{margin-right:calc(var(--spacing)*4)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-2\.5{width:calc(var(--spacing)*2.5);height:calc(var(--spacing)*2.5)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-7{width:calc(var(--spacing)*7);height:calc(var(--spacing)*7)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.h-svh{height:100svh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-screen{min-height:100vh}.min-h-svh{min-height:100svh}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-24{width:calc(var(--spacing)*24)}.w-40{width:calc(var(--spacing)*40)}.w-48{width:calc(var(--spacing)*48)}.w-56{width:calc(var(--spacing)*56)}.w-\[200px\]{width:200px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-5{min-width:calc(var(--spacing)*5)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.origin-\(--radix-tooltip-content-transform-origin\){transform-origin:var(--radix-tooltip-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-\[calc\(-50\%_-_2px\)\]{--tw-translate-y: calc(-50% - 2px) ;translate:var(--tw-translate-x)var(--tw-translate-y)}.rotate-45{rotate:45deg}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-start{justify-content:flex-start}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-input{border-color:var(--input)}.border-sidebar-border{border-color:var(--sidebar-border)}.bg-accent{background-color:var(--accent)}.bg-background{background-color:var(--background)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-transparent{background-color:#0000}.fill-current{fill:currentColor}.fill-primary{fill:var(--primary)}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-1{padding-inline:calc(var(--spacing)*1)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-6{padding-block:calc(var(--spacing)*6)}.py-10{padding-block:calc(var(--spacing)*10)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-8{padding-right:calc(var(--spacing)*8)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-loose{--tw-leading:var(--leading-loose);line-height:var(--leading-loose)}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.whitespace-nowrap{white-space:nowrap}.text-accent-foreground{color:var(--accent-foreground)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-foreground{color:var(--foreground)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab,red,red)){.text-sidebar-foreground\/70{color:color-mix(in oklab,var(--sidebar-foreground)70%,transparent)}}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing)*8)}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing)*-8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing)*8)!important;height:calc(var(--spacing)*8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing)*4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing)*4)) + 2px)}.group-data-\[collapsible\=icon\]\:overflow-hidden:is(:where(.group)[data-collapsible=icon] *){overflow:hidden}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing)*0)!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing)*2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offcanvas\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offcanvas] *){right:calc(var(--sidebar-width)*-1)}.group-data-\[collapsible\=offcanvas\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offcanvas] *){left:calc(var(--sidebar-width)*-1)}.group-data-\[collapsible\=offcanvas\]\:w-0:is(:where(.group)[data-collapsible=offcanvas] *){width:calc(var(--spacing)*0)}.group-data-\[collapsible\=offcanvas\]\:translate-x-0:is(:where(.group)[data-collapsible=offcanvas] *){--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing)*-4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:calc(var(--spacing)*0)}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[state\=open\]\/collapsible\:rotate-180:is(:where(.group\/collapsible)[data-state=open] *){rotate:180deg}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:var(--radius)}.group-data-\[variant\=floating\]\:border:is(:where(.group)[data-variant=floating] *){border-style:var(--tw-border-style);border-width:1px}.group-data-\[variant\=floating\]\:border-sidebar-border:is(:where(.group)[data-variant=floating] *){border-color:var(--sidebar-border)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-\[active\=true\]\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active=true]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing)*1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing)*2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:calc(var(--spacing)*1)}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing)*-2)}.after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--spacing)*0)}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.group-data-\[collapsible\=offcanvas\]\:after\:left-full:is(:where(.group)[data-collapsible=offcanvas] *):after{content:var(--tw-content);left:100%}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:group-data-\[collapsible\=offcanvas\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offcanvas] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[active\=true\]\:bg-sidebar-accent[data-active=true]{background-color:var(--sidebar-accent)}.data-\[active\=true\]\:font-medium[data-active=true]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-\[active\=true\]\:text-sidebar-accent-foreground[data-active=true]{color:var(--sidebar-accent-foreground)}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[error\=true\]\:text-destructive[data-error=true]{color:var(--destructive)}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:duration-300[data-state=closed]{--tw-duration:.3s;transition-duration:.3s}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=closed\]\:slide-out-to-bottom[data-state=closed]{--tw-exit-translate-y:100%}.data-\[state\=closed\]\:slide-out-to-left[data-state=closed]{--tw-exit-translate-x:-100%}.data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.data-\[state\=closed\]\:slide-out-to-top[data-state=closed]{--tw-exit-translate-y:-100%}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:bg-secondary[data-state=open]{background-color:var(--secondary)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:opacity-100[data-state=open]{opacity:1}.data-\[state\=open\]\:duration-500[data-state=open]{--tw-duration:.5s;transition-duration:.5s}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=open\]\:slide-in-from-bottom[data-state=open]{--tw-enter-translate-y:100%}.data-\[state\=open\]\:slide-in-from-left[data-state=open]{--tw-enter-translate-x:-100%}.data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}.data-\[state\=open\]\:slide-in-from-top[data-state=open]{--tw-enter-translate-y:-100%}@media (hover:hover){.data-\[state\=open\]\:hover\:bg-sidebar-accent[data-state=open]:hover{background-color:var(--sidebar-accent)}.data-\[state\=open\]\:hover\:text-sidebar-accent-foreground[data-state=open]:hover{color:var(--sidebar-accent-foreground)}}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:flex{display:flex}.sm\:max-w-\[480px\]{max-width:480px}.sm\:max-w-\[520px\]{max-width:520px}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:justify-end{justify-content:flex-end}.sm\:text-left{text-align:left}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:h-24{height:calc(var(--spacing)*24)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:gap-2{gap:calc(var(--spacing)*2)}.md\:px-0{padding-inline:calc(var(--spacing)*0)}.md\:py-0{padding-block:calc(var(--spacing)*0)}.md\:text-left{text-align:left}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing)*2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:calc(var(--spacing)*0)}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing)*2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}@media (hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing)*0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>button\]\:hidden>button{display:none}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}[data-side=left][data-collapsible=offcanvas] .\[\[data-side\=left\]\[data-collapsible\=offcanvas\]_\&\]\:-right-2{right:calc(var(--spacing)*-2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offcanvas] .\[\[data-side\=right\]\[data-collapsible\=offcanvas\]_\&\]\:-left-2{left:calc(var(--spacing)*-2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.1% .005 285.823);--card:oklch(100% 0 0);--card-foreground:oklch(14.1% .005 285.823);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.1% .005 285.823);--primary:oklch(21% .006 285.885);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(96.7% .001 286.375);--secondary-foreground:oklch(21% .006 285.885);--muted:oklch(96.7% .001 286.375);--muted-foreground:oklch(55.2% .016 285.938);--accent:oklch(96.7% .001 286.375);--accent-foreground:oklch(21% .006 285.885);--destructive:oklch(57.7% .245 27.325);--border:oklch(92% .004 286.32);--input:oklch(92% .004 286.32);--ring:oklch(70.5% .015 286.067);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.1% .005 285.823);--sidebar-primary:oklch(21% .006 285.885);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(96.7% .001 286.375);--sidebar-accent-foreground:oklch(21% .006 285.885);--sidebar-border:oklch(92% .004 286.32);--sidebar-ring:oklch(70.5% .015 286.067)}.dark{--background:oklch(14.1% .005 285.823);--foreground:oklch(98.5% 0 0);--card:oklch(21% .006 285.885);--card-foreground:oklch(98.5% 0 0);--popover:oklch(21% .006 285.885);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(92% .004 286.32);--primary-foreground:oklch(21% .006 285.885);--secondary:oklch(27.4% .006 286.033);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(27.4% .006 286.033);--muted-foreground:oklch(70.5% .015 286.067);--accent:oklch(27.4% .006 286.033);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.2% .016 285.938);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(21% .006 285.885);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(27.4% .006 286.033);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.2% .016 285.938)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}} diff --git a/internal/ui/dist/assets/index-DrmAfy-p.js b/internal/ui/dist/assets/index-DrmAfy-p.js deleted file mode 100644 index 7fdde25..0000000 --- a/internal/ui/dist/assets/index-DrmAfy-p.js +++ /dev/null @@ -1,157 +0,0 @@ -function U1(t,a){for(var l=0;lo[s]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const c of s)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function l(s){const c={};return s.integrity&&(c.integrity=s.integrity),s.referrerPolicy&&(c.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?c.credentials="include":s.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(s){if(s.ep)return;s.ep=!0;const c=l(s);fetch(s.href,c)}})();function ky(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var Zf={exports:{}},Qo={};/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var ev;function L1(){if(ev)return Qo;ev=1;var t=Symbol.for("react.transitional.element"),a=Symbol.for("react.fragment");function l(o,s,c){var d=null;if(c!==void 0&&(d=""+c),s.key!==void 0&&(d=""+s.key),"key"in s){c={};for(var m in s)m!=="key"&&(c[m]=s[m])}else c=s;return s=c.ref,{$$typeof:t,type:o,key:d,ref:s!==void 0?s:null,props:c}}return Qo.Fragment=a,Qo.jsx=l,Qo.jsxs=l,Qo}var tv;function B1(){return tv||(tv=1,Zf.exports=L1()),Zf.exports}var w=B1(),$f={exports:{}},ze={};/** - * @license React - * react.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var nv;function H1(){if(nv)return ze;nv=1;var t=Symbol.for("react.transitional.element"),a=Symbol.for("react.portal"),l=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),d=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),g=Symbol.iterator;function S(R){return R===null||typeof R!="object"?null:(R=g&&R[g]||R["@@iterator"],typeof R=="function"?R:null)}var E={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},O=Object.assign,A={};function z(R,F,re){this.props=R,this.context=F,this.refs=A,this.updater=re||E}z.prototype.isReactComponent={},z.prototype.setState=function(R,F){if(typeof R!="object"&&typeof R!="function"&&R!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,R,F,"setState")},z.prototype.forceUpdate=function(R){this.updater.enqueueForceUpdate(this,R,"forceUpdate")};function _(){}_.prototype=z.prototype;function C(R,F,re){this.props=R,this.context=F,this.refs=A,this.updater=re||E}var k=C.prototype=new _;k.constructor=C,O(k,z.prototype),k.isPureReactComponent=!0;var Y=Array.isArray,L={H:null,A:null,T:null,S:null,V:null},te=Object.prototype.hasOwnProperty;function ee(R,F,re,P,ne,fe){return re=fe.ref,{$$typeof:t,type:R,key:F,ref:re!==void 0?re:null,props:fe}}function W(R,F){return ee(R.type,F,void 0,void 0,void 0,R.props)}function ue(R){return typeof R=="object"&&R!==null&&R.$$typeof===t}function Se(R){var F={"=":"=0",":":"=2"};return"$"+R.replace(/[=:]/g,function(re){return F[re]})}var Ee=/\/+/g;function ie(R,F){return typeof R=="object"&&R!==null&&R.key!=null?Se(""+R.key):F.toString(36)}function ce(){}function ye(R){switch(R.status){case"fulfilled":return R.value;case"rejected":throw R.reason;default:switch(typeof R.status=="string"?R.then(ce,ce):(R.status="pending",R.then(function(F){R.status==="pending"&&(R.status="fulfilled",R.value=F)},function(F){R.status==="pending"&&(R.status="rejected",R.reason=F)})),R.status){case"fulfilled":return R.value;case"rejected":throw R.reason}}throw R}function be(R,F,re,P,ne){var fe=typeof R;(fe==="undefined"||fe==="boolean")&&(R=null);var me=!1;if(R===null)me=!0;else switch(fe){case"bigint":case"string":case"number":me=!0;break;case"object":switch(R.$$typeof){case t:case a:me=!0;break;case y:return me=R._init,be(me(R._payload),F,re,P,ne)}}if(me)return ne=ne(R),me=P===""?"."+ie(R,0):P,Y(ne)?(re="",me!=null&&(re=me.replace(Ee,"$&/")+"/"),be(ne,F,re,"",function(Ke){return Ke})):ne!=null&&(ue(ne)&&(ne=W(ne,re+(ne.key==null||R&&R.key===ne.key?"":(""+ne.key).replace(Ee,"$&/")+"/")+me)),F.push(ne)),1;me=0;var Te=P===""?".":P+":";if(Y(R))for(var xe=0;xe"u")throw new Error(a)}function An(t,a){if(!t){typeof console<"u"&&console.warn(a);try{throw new Error(a)}catch{}}}function Z1(){return Math.random().toString(36).substring(2,10)}function lv(t,a){return{usr:t.state,key:t.key,idx:a}}function cd(t,a,l=null,o){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof a=="string"?ql(a):a,state:l,key:a&&a.key||o||Z1()}}function ri({pathname:t="/",search:a="",hash:l=""}){return a&&a!=="?"&&(t+=a.charAt(0)==="?"?a:"?"+a),l&&l!=="#"&&(t+=l.charAt(0)==="#"?l:"#"+l),t}function ql(t){let a={};if(t){let l=t.indexOf("#");l>=0&&(a.hash=t.substring(l),t=t.substring(0,l));let o=t.indexOf("?");o>=0&&(a.search=t.substring(o),t=t.substring(0,o)),t&&(a.pathname=t)}return a}function $1(t,a,l,o={}){let{window:s=document.defaultView,v5Compat:c=!1}=o,d=s.history,m="POP",p=null,h=y();h==null&&(h=0,d.replaceState({...d.state,idx:h},""));function y(){return(d.state||{idx:null}).idx}function g(){m="POP";let z=y(),_=z==null?null:z-h;h=z,p&&p({action:m,location:A.location,delta:_})}function S(z,_){m="PUSH";let C=cd(A.location,z,_);h=y()+1;let k=lv(C,h),Y=A.createHref(C);try{d.pushState(k,"",Y)}catch(L){if(L instanceof DOMException&&L.name==="DataCloneError")throw L;s.location.assign(Y)}c&&p&&p({action:m,location:A.location,delta:1})}function E(z,_){m="REPLACE";let C=cd(A.location,z,_);h=y();let k=lv(C,h),Y=A.createHref(C);d.replaceState(k,"",Y),c&&p&&p({action:m,location:A.location,delta:0})}function O(z){return Y1(z)}let A={get action(){return m},get location(){return t(s,d)},listen(z){if(p)throw new Error("A history only accepts one active listener");return s.addEventListener(rv,g),p=z,()=>{s.removeEventListener(rv,g),p=null}},createHref(z){return a(s,z)},createURL:O,encodeLocation(z){let _=O(z);return{pathname:_.pathname,search:_.search,hash:_.hash}},push:S,replace:E,go(z){return d.go(z)}};return A}function Y1(t,a=!1){let l="http://localhost";typeof window<"u"&&(l=window.location.origin!=="null"?window.location.origin:window.location.href),tt(l,"No window.location.(origin|href) available to create URL");let o=typeof t=="string"?t:ri(t);return o=o.replace(/ $/,"%20"),!a&&o.startsWith("//")&&(o=l+o),new URL(o,l)}function Ly(t,a,l="/"){return q1(t,a,l,!1)}function q1(t,a,l,o){let s=typeof a=="string"?ql(a):a,c=za(s.pathname||"/",l);if(c==null)return null;let d=By(t);G1(d);let m=null;for(let p=0;m==null&&p{let y={relativePath:h===void 0?d.path||"":h,caseSensitive:d.caseSensitive===!0,childrenIndex:m,route:d};if(y.relativePath.startsWith("/")){if(!y.relativePath.startsWith(o)&&p)return;tt(y.relativePath.startsWith(o),`Absolute route path "${y.relativePath}" nested under path "${o}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),y.relativePath=y.relativePath.slice(o.length)}let g=_a([o,y.relativePath]),S=l.concat(y);d.children&&d.children.length>0&&(tt(d.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${g}".`),By(d.children,a,S,g,p)),!(d.path==null&&!d.index)&&a.push({path:g,score:W1(g,d.index),routesMeta:S})};return t.forEach((d,m)=>{if(d.path===""||!d.path?.includes("?"))c(d,m);else for(let p of Hy(d.path))c(d,m,!0,p)}),a}function Hy(t){let a=t.split("/");if(a.length===0)return[];let[l,...o]=a,s=l.endsWith("?"),c=l.replace(/\?$/,"");if(o.length===0)return s?[c,""]:[c];let d=Hy(o.join("/")),m=[];return m.push(...d.map(p=>p===""?c:[c,p].join("/"))),s&&m.push(...d),m.map(p=>t.startsWith("/")&&p===""?"/":p)}function G1(t){t.sort((a,l)=>a.score!==l.score?l.score-a.score:I1(a.routesMeta.map(o=>o.childrenIndex),l.routesMeta.map(o=>o.childrenIndex)))}var P1=/^:[\w-]+$/,X1=3,F1=2,Q1=1,K1=10,J1=-2,ov=t=>t==="*";function W1(t,a){let l=t.split("/"),o=l.length;return l.some(ov)&&(o+=J1),a&&(o+=F1),l.filter(s=>!ov(s)).reduce((s,c)=>s+(P1.test(c)?X1:c===""?Q1:K1),o)}function I1(t,a){return t.length===a.length&&t.slice(0,-1).every((o,s)=>o===a[s])?t[t.length-1]-a[a.length-1]:0}function eS(t,a,l=!1){let{routesMeta:o}=t,s={},c="/",d=[];for(let m=0;m{if(y==="*"){let O=m[S]||"";d=c.slice(0,c.length-O.length).replace(/(.)\/+$/,"$1")}const E=m[S];return g&&!E?h[y]=void 0:h[y]=(E||"").replace(/%2F/g,"/"),h},{}),pathname:c,pathnameBase:d,pattern:t}}function tS(t,a=!1,l=!0){An(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let o=[],s="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(d,m,p)=>(o.push({paramName:m,isOptional:p!=null}),p?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(o.push({paramName:"*"}),s+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):l?s+="\\/*$":t!==""&&t!=="/"&&(s+="(?:(?=\\/|$))"),[new RegExp(s,a?void 0:"i"),o]}function nS(t){try{return t.split("/").map(a=>decodeURIComponent(a).replace(/\//g,"%2F")).join("/")}catch(a){return An(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${a}).`),t}}function za(t,a){if(a==="/")return t;if(!t.toLowerCase().startsWith(a.toLowerCase()))return null;let l=a.endsWith("/")?a.length-1:a.length,o=t.charAt(l);return o&&o!=="/"?null:t.slice(l)||"/"}function aS(t,a="/"){let{pathname:l,search:o="",hash:s=""}=typeof t=="string"?ql(t):t;return{pathname:l?l.startsWith("/")?l:rS(l,a):a,search:iS(o),hash:sS(s)}}function rS(t,a){let l=a.replace(/\/+$/,"").split("/");return t.split("/").forEach(s=>{s===".."?l.length>1&&l.pop():s!=="."&&l.push(s)}),l.length>1?l.join("/"):"/"}function Yf(t,a,l,o){return`Cannot include a '${t}' character in a manually specified \`to.${a}\` field [${JSON.stringify(o)}]. Please separate it out to the \`to.${l}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function lS(t){return t.filter((a,l)=>l===0||a.route.path&&a.route.path.length>0)}function Od(t){let a=lS(t);return a.map((l,o)=>o===a.length-1?l.pathname:l.pathnameBase)}function Dd(t,a,l,o=!1){let s;typeof t=="string"?s=ql(t):(s={...t},tt(!s.pathname||!s.pathname.includes("?"),Yf("?","pathname","search",s)),tt(!s.pathname||!s.pathname.includes("#"),Yf("#","pathname","hash",s)),tt(!s.search||!s.search.includes("#"),Yf("#","search","hash",s)));let c=t===""||s.pathname==="",d=c?"/":s.pathname,m;if(d==null)m=l;else{let g=a.length-1;if(!o&&d.startsWith("..")){let S=d.split("/");for(;S[0]==="..";)S.shift(),g-=1;s.pathname=S.join("/")}m=g>=0?a[g]:"/"}let p=aS(s,m),h=d&&d!=="/"&&d.endsWith("/"),y=(c||d===".")&&l.endsWith("/");return!p.pathname.endsWith("/")&&(h||y)&&(p.pathname+="/"),p}var _a=t=>t.join("/").replace(/\/\/+/g,"/"),oS=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),iS=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,sS=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t;function uS(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}var Vy=["POST","PUT","PATCH","DELETE"];new Set(Vy);var cS=["GET",...Vy];new Set(cS);var Gl=b.createContext(null);Gl.displayName="DataRouter";var du=b.createContext(null);du.displayName="DataRouterState";b.createContext(!1);var Zy=b.createContext({isTransitioning:!1});Zy.displayName="ViewTransition";var fS=b.createContext(new Map);fS.displayName="Fetchers";var dS=b.createContext(null);dS.displayName="Await";var kn=b.createContext(null);kn.displayName="Navigation";var ui=b.createContext(null);ui.displayName="Location";var Un=b.createContext({outlet:null,matches:[],isDataRoute:!1});Un.displayName="Route";var zd=b.createContext(null);zd.displayName="RouteError";function mS(t,{relative:a}={}){tt(Pl(),"useHref() may be used only in the context of a component.");let{basename:l,navigator:o}=b.useContext(kn),{hash:s,pathname:c,search:d}=ci(t,{relative:a}),m=c;return l!=="/"&&(m=c==="/"?l:_a([l,c])),o.createHref({pathname:m,search:d,hash:s})}function Pl(){return b.useContext(ui)!=null}function Ln(){return tt(Pl(),"useLocation() may be used only in the context of a component."),b.useContext(ui).location}var $y="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Yy(t){b.useContext(kn).static||b.useLayoutEffect(t)}function pr(){let{isDataRoute:t}=b.useContext(Un);return t?CS():hS()}function hS(){tt(Pl(),"useNavigate() may be used only in the context of a component.");let t=b.useContext(Gl),{basename:a,navigator:l}=b.useContext(kn),{matches:o}=b.useContext(Un),{pathname:s}=Ln(),c=JSON.stringify(Od(o)),d=b.useRef(!1);return Yy(()=>{d.current=!0}),b.useCallback((p,h={})=>{if(An(d.current,$y),!d.current)return;if(typeof p=="number"){l.go(p);return}let y=Dd(p,JSON.parse(c),s,h.relative==="path");t==null&&a!=="/"&&(y.pathname=y.pathname==="/"?a:_a([a,y.pathname])),(h.replace?l.replace:l.push)(y,h.state,h)},[a,l,c,s,t])}var pS=b.createContext(null);function gS(t){let a=b.useContext(Un).outlet;return a&&b.createElement(pS.Provider,{value:t},a)}function ci(t,{relative:a}={}){let{matches:l}=b.useContext(Un),{pathname:o}=Ln(),s=JSON.stringify(Od(l));return b.useMemo(()=>Dd(t,JSON.parse(s),o,a==="path"),[t,s,o,a])}function vS(t,a){return qy(t,a)}function qy(t,a,l,o,s){tt(Pl(),"useRoutes() may be used only in the context of a component.");let{navigator:c}=b.useContext(kn),{matches:d}=b.useContext(Un),m=d[d.length-1],p=m?m.params:{},h=m?m.pathname:"/",y=m?m.pathnameBase:"/",g=m&&m.route;{let C=g&&g.path||"";Gy(h,!g||C.endsWith("*")||C.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${h}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let S=Ln(),E;if(a){let C=typeof a=="string"?ql(a):a;tt(y==="/"||C.pathname?.startsWith(y),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${y}" but pathname "${C.pathname}" was given in the \`location\` prop.`),E=C}else E=S;let O=E.pathname||"/",A=O;if(y!=="/"){let C=y.replace(/^\//,"").split("/");A="/"+O.replace(/^\//,"").split("/").slice(C.length).join("/")}let z=Ly(t,{pathname:A});An(g||z!=null,`No routes matched location "${E.pathname}${E.search}${E.hash}" `),An(z==null||z[z.length-1].route.element!==void 0||z[z.length-1].route.Component!==void 0||z[z.length-1].route.lazy!==void 0,`Matched leaf route at location "${E.pathname}${E.search}${E.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let _=SS(z&&z.map(C=>Object.assign({},C,{params:Object.assign({},p,C.params),pathname:_a([y,c.encodeLocation?c.encodeLocation(C.pathname).pathname:C.pathname]),pathnameBase:C.pathnameBase==="/"?y:_a([y,c.encodeLocation?c.encodeLocation(C.pathnameBase).pathname:C.pathnameBase])})),d,l,o,s);return a&&_?b.createElement(ui.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...E},navigationType:"POP"}},_):_}function yS(){let t=RS(),a=uS(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),l=t instanceof Error?t.stack:null,o="rgba(200,200,200, 0.5)",s={padding:"0.5rem",backgroundColor:o},c={padding:"2px 4px",backgroundColor:o},d=null;return console.error("Error handled by React Router default ErrorBoundary:",t),d=b.createElement(b.Fragment,null,b.createElement("p",null,"💿 Hey developer 👋"),b.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",b.createElement("code",{style:c},"ErrorBoundary")," or"," ",b.createElement("code",{style:c},"errorElement")," prop on your route.")),b.createElement(b.Fragment,null,b.createElement("h2",null,"Unexpected Application Error!"),b.createElement("h3",{style:{fontStyle:"italic"}},a),l?b.createElement("pre",{style:s},l):null,d)}var bS=b.createElement(yS,null),xS=class extends b.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,a){return a.location!==t.location||a.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:a.error,location:a.location,revalidation:t.revalidation||a.revalidation}}componentDidCatch(t,a){this.props.unstable_onError?this.props.unstable_onError(t,a):console.error("React Router caught the following error during render",t)}render(){return this.state.error!==void 0?b.createElement(Un.Provider,{value:this.props.routeContext},b.createElement(zd.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function wS({routeContext:t,match:a,children:l}){let o=b.useContext(Gl);return o&&o.static&&o.staticContext&&(a.route.errorElement||a.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=a.route.id),b.createElement(Un.Provider,{value:t},l)}function SS(t,a=[],l=null,o=null,s=null){if(t==null){if(!l)return null;if(l.errors)t=l.matches;else if(a.length===0&&!l.initialized&&l.matches.length>0)t=l.matches;else return null}let c=t,d=l?.errors;if(d!=null){let h=c.findIndex(y=>y.route.id&&d?.[y.route.id]!==void 0);tt(h>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(d).join(",")}`),c=c.slice(0,Math.min(c.length,h+1))}let m=!1,p=-1;if(l)for(let h=0;h=0?c=c.slice(0,p+1):c=[c[0]];break}}}return c.reduceRight((h,y,g)=>{let S,E=!1,O=null,A=null;l&&(S=d&&y.route.id?d[y.route.id]:void 0,O=y.route.errorElement||bS,m&&(p<0&&g===0?(Gy("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),E=!0,A=null):p===g&&(E=!0,A=y.route.hydrateFallbackElement||null)));let z=a.concat(c.slice(0,g+1)),_=()=>{let C;return S?C=O:E?C=A:y.route.Component?C=b.createElement(y.route.Component,null):y.route.element?C=y.route.element:C=h,b.createElement(wS,{match:y,routeContext:{outlet:h,matches:z,isDataRoute:l!=null},children:C})};return l&&(y.route.ErrorBoundary||y.route.errorElement||g===0)?b.createElement(xS,{location:l.location,revalidation:l.revalidation,component:O,error:S,children:_(),routeContext:{outlet:null,matches:z,isDataRoute:!0},unstable_onError:o}):_()},null)}function Nd(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function ES(t){let a=b.useContext(Gl);return tt(a,Nd(t)),a}function _S(t){let a=b.useContext(du);return tt(a,Nd(t)),a}function AS(t){let a=b.useContext(Un);return tt(a,Nd(t)),a}function Md(t){let a=AS(t),l=a.matches[a.matches.length-1];return tt(l.route.id,`${t} can only be used on routes that contain a unique "id"`),l.route.id}function TS(){return Md("useRouteId")}function RS(){let t=b.useContext(zd),a=_S("useRouteError"),l=Md("useRouteError");return t!==void 0?t:a.errors?.[l]}function CS(){let{router:t}=ES("useNavigate"),a=Md("useNavigate"),l=b.useRef(!1);return Yy(()=>{l.current=!0}),b.useCallback(async(s,c={})=>{An(l.current,$y),l.current&&(typeof s=="number"?t.navigate(s):await t.navigate(s,{fromRouteId:a,...c}))},[t,a])}var iv={};function Gy(t,a,l){!a&&!iv[t]&&(iv[t]=!0,An(!1,l))}b.memo(OS);function OS({routes:t,future:a,state:l,unstable_onError:o}){return qy(t,void 0,l,o,a)}function Py({to:t,replace:a,state:l,relative:o}){tt(Pl()," may be used only in the context of a component.");let{static:s}=b.useContext(kn);An(!s," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:c}=b.useContext(Un),{pathname:d}=Ln(),m=pr(),p=Dd(t,Od(c),d,o==="path"),h=JSON.stringify(p);return b.useEffect(()=>{m(JSON.parse(h),{replace:a,state:l,relative:o})},[m,h,o,a,l]),null}function Xy(t){return gS(t.context)}function _t(t){tt(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function DS({basename:t="/",children:a=null,location:l,navigationType:o="POP",navigator:s,static:c=!1}){tt(!Pl(),"You cannot render a inside another . You should never have more than one in your app.");let d=t.replace(/^\/*/,"/"),m=b.useMemo(()=>({basename:d,navigator:s,static:c,future:{}}),[d,s,c]);typeof l=="string"&&(l=ql(l));let{pathname:p="/",search:h="",hash:y="",state:g=null,key:S="default"}=l,E=b.useMemo(()=>{let O=za(p,d);return O==null?null:{location:{pathname:O,search:h,hash:y,state:g,key:S},navigationType:o}},[d,p,h,y,g,S,o]);return An(E!=null,` is not able to match the URL "${p}${h}${y}" because it does not start with the basename, so the won't render anything.`),E==null?null:b.createElement(kn.Provider,{value:m},b.createElement(ui.Provider,{children:a,value:E}))}function zS({children:t,location:a}){return vS(fd(t),a)}function fd(t,a=[]){let l=[];return b.Children.forEach(t,(o,s)=>{if(!b.isValidElement(o))return;let c=[...a,s];if(o.type===b.Fragment){l.push.apply(l,fd(o.props.children,c));return}tt(o.type===_t,`[${typeof o.type=="string"?o.type:o.type.name}] is not a component. All component children of must be a or `),tt(!o.props.index||!o.props.children,"An index route cannot have child routes.");let d={id:o.props.id||c.join("-"),caseSensitive:o.props.caseSensitive,element:o.props.element,Component:o.props.Component,index:o.props.index,path:o.props.path,loader:o.props.loader,action:o.props.action,hydrateFallbackElement:o.props.hydrateFallbackElement,HydrateFallback:o.props.HydrateFallback,errorElement:o.props.errorElement,ErrorBoundary:o.props.ErrorBoundary,hasErrorBoundary:o.props.hasErrorBoundary===!0||o.props.ErrorBoundary!=null||o.props.errorElement!=null,shouldRevalidate:o.props.shouldRevalidate,handle:o.props.handle,lazy:o.props.lazy};o.props.children&&(d.children=fd(o.props.children,c)),l.push(d)}),l}var Ps="get",Xs="application/x-www-form-urlencoded";function mu(t){return t!=null&&typeof t.tagName=="string"}function NS(t){return mu(t)&&t.tagName.toLowerCase()==="button"}function MS(t){return mu(t)&&t.tagName.toLowerCase()==="form"}function jS(t){return mu(t)&&t.tagName.toLowerCase()==="input"}function kS(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function US(t,a){return t.button===0&&(!a||a==="_self")&&!kS(t)}function dd(t=""){return new URLSearchParams(typeof t=="string"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((a,l)=>{let o=t[l];return a.concat(Array.isArray(o)?o.map(s=>[l,s]):[[l,o]])},[]))}function LS(t,a){let l=dd(t);return a&&a.forEach((o,s)=>{l.has(s)||a.getAll(s).forEach(c=>{l.append(s,c)})}),l}var zs=null;function BS(){if(zs===null)try{new FormData(document.createElement("form"),0),zs=!1}catch{zs=!0}return zs}var HS=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function qf(t){return t!=null&&!HS.has(t)?(An(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Xs}"`),null):t}function VS(t,a){let l,o,s,c,d;if(MS(t)){let m=t.getAttribute("action");o=m?za(m,a):null,l=t.getAttribute("method")||Ps,s=qf(t.getAttribute("enctype"))||Xs,c=new FormData(t)}else if(NS(t)||jS(t)&&(t.type==="submit"||t.type==="image")){let m=t.form;if(m==null)throw new Error('Cannot submit a + + + ) +} diff --git a/ui/src/components/footer.tsx b/ui/src/components/footer.tsx index 1f9fa20..4681ac3 100644 --- a/ui/src/components/footer.tsx +++ b/ui/src/components/footer.tsx @@ -1,38 +1,38 @@ -import { FaGithub } from "react-icons/fa"; +import { FaGithub } from "react-icons/fa" export function Footer() { - return ( - - ); + return ( + + ) } diff --git a/ui/src/components/mode-toggle.tsx b/ui/src/components/mode-toggle.tsx index a1fe626..3edb4a8 100644 --- a/ui/src/components/mode-toggle.tsx +++ b/ui/src/components/mode-toggle.tsx @@ -1,4 +1,4 @@ -import { Laptop, Moon, Sun } from "lucide-react" +import { CheckIcon, Laptop, Moon, Sun } from "lucide-react" import { useTheme } from "next-themes" import { Button } from "@/components/ui/button.tsx" @@ -25,9 +25,15 @@ export function ModeToggle() { - setTheme("light")}>Light - setTheme("dark")}>Dark - setTheme("system")}>System + setTheme("light")}> + {theme === "light" && }Light + + setTheme("dark")}> + {theme === "dark" && }Dark + + setTheme("system")}> + {theme === "system" && }System + ) diff --git a/ui/src/components/org-switcher.tsx b/ui/src/components/org-switcher.tsx new file mode 100644 index 0000000..d07ede6 --- /dev/null +++ b/ui/src/components/org-switcher.tsx @@ -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([]) + const [activeOrgId, setActiveOrgIdState] = useState(null) + + async function fetchOrgs() { + try { + const data = await api.get("/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).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 ( + + + + + + {orgs.length === 0 ? ( + No organizations + ) : ( + orgs.map((org) => ( + switchOrg(org.id)} + className={org.id === activeOrgId ? "font-semibold" : undefined} + > + {org.name} + + )) + )} + + + ) +} diff --git a/ui/src/components/require-admin.tsx b/ui/src/components/require-admin.tsx new file mode 100644 index 0000000..5bf5f59 --- /dev/null +++ b/ui/src/components/require-admin.tsx @@ -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 + + return children ? <>{children} : +} diff --git a/ui/src/components/sidebar/items.ts b/ui/src/components/sidebar/items.ts index 78cf43a..4337889 100644 --- a/ui/src/components/sidebar/items.ts +++ b/ui/src/components/sidebar/items.ts @@ -1,105 +1,128 @@ +import type { ComponentType } from "react" import { - BoxesIcon, - BrainCogIcon, - Building2Icon, - BuildingIcon, - ComponentIcon, - FileKey2Icon, - HomeIcon, - KeyIcon, - ListTodoIcon, - LockKeyholeIcon, - ServerIcon, - SettingsIcon, - SprayCanIcon, - TagsIcon, - UsersIcon, -} from "lucide-react"; -import { AiOutlineCluster } from "react-icons/ai"; + BoxesIcon, + BrainCogIcon, + BuildingIcon, + ComponentIcon, + FileKey2Icon, + HomeIcon, + KeyIcon, + ListTodoIcon, + LockKeyholeIcon, + ServerIcon, + SettingsIcon, + ShieldCheckIcon, + SprayCanIcon, + TagsIcon, + UserIcon, + UsersIcon, +} from "lucide-react" +import { AiOutlineCluster } from "react-icons/ai" + +export type NavItem = { + label: string + icon: ComponentType<{ className?: string }> + to?: string + items?: NavItem[] + requiresAdmin?: boolean + requiresOrgAdmin?: boolean +} export const items = [ - { - label: "Dashboard", - icon: HomeIcon, - to: "/dashboard", - }, - { - label: "Core", - icon: BrainCogIcon, - items: [ - { - label: "Cluster", - to: "/core/cluster", - icon: AiOutlineCluster, - }, - { - label: "Node Pools", - icon: BoxesIcon, - to: "/core/node-pools", - }, - { - label: "Labels", - icon: TagsIcon, - to: "/core/labels", - }, - { - label: "Roles", - icon: ComponentIcon, - to: "/core/roles", - }, - { - label: "Taints", - icon: SprayCanIcon, - to: "/core/taints", - }, - { - label: "Servers", - icon: ServerIcon, - to: "/core/servers", - }, - ], - }, - { - label: "Security", - icon: LockKeyholeIcon, - items: [ - { - label: "Keys & Tokens", - icon: KeyIcon, - to: "/security/keys", - }, - { - label: "SSH Keys", - to: "/security/ssh", - icon: FileKey2Icon, - }, - ], - }, - { - label: "Tasks", - icon: ListTodoIcon, - items: [], - }, - { - label: "Settings", - icon: SettingsIcon, - items: [ - { - label: "Organizations", - icon: Building2Icon, - items: [ - { - label: "Organizations", - to: "/settings/orgs", - icon: BuildingIcon, - }, - { - label: "Members", - to: "/settings/members", - icon: UsersIcon, - }, - ], - }, - ], - }, -]; + { + label: "Dashboard", + icon: HomeIcon, + to: "/dashboard", + }, + { + label: "Core", + icon: BrainCogIcon, + items: [ + { + label: "Cluster", + to: "/core/cluster", + icon: AiOutlineCluster, + }, + { + label: "Node Pools", + icon: BoxesIcon, + to: "/core/node-pools", + }, + { + label: "Annotations", + icon: ComponentIcon, + to: "/core/annotations", + }, + { + label: "Labels", + icon: TagsIcon, + to: "/core/labels", + }, + { + label: "Taints", + icon: SprayCanIcon, + to: "/core/taints", + }, + { + label: "Servers", + icon: ServerIcon, + to: "/core/servers", + }, + ], + }, + { + label: "Security", + icon: LockKeyholeIcon, + items: [ + { + label: "Keys & Tokens", + icon: KeyIcon, + to: "/security/keys", + }, + { + label: "SSH Keys", + to: "/security/ssh", + icon: FileKey2Icon, + }, + ], + }, + { + label: "Tasks", + icon: ListTodoIcon, + items: [], + }, + { + label: "Settings", + icon: SettingsIcon, + items: [ + { + label: "Organizations", + to: "/settings/orgs", + icon: BuildingIcon, + }, + { + label: "Members", + to: "/settings/members", + icon: UsersIcon, + }, + { + label: "Profile", + to: "/settings/me", + icon: UserIcon, + }, + ], + }, + { + label: "Admin", + icon: ShieldCheckIcon, + requiresAdmin: true, + items: [ + { + label: "Users", + to: "/admin/users", + icon: UsersIcon, + requiresAdmin: true, + }, + ], + }, +] diff --git a/ui/src/components/ui/alert-dialog.tsx b/ui/src/components/ui/alert-dialog.tsx index 935eecf..c4b8f93 100644 --- a/ui/src/components/ui/alert-dialog.tsx +++ b/ui/src/components/ui/alert-dialog.tsx @@ -4,26 +4,18 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" -function AlertDialog({ - ...props -}: React.ComponentProps) { +function AlertDialog({ ...props }: React.ComponentProps) { return } function AlertDialogTrigger({ ...props }: React.ComponentProps) { - return ( - - ) + return } -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return } function AlertDialogOverlay({ @@ -61,10 +53,7 @@ function AlertDialogContent({ ) } -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) @@ -120,12 +103,7 @@ function AlertDialogAction({ className, ...props }: React.ComponentProps) { - return ( - - ) + return } function AlertDialogCancel({ diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx new file mode 100644 index 0000000..2be0443 --- /dev/null +++ b/ui/src/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return +} + +export { Badge, badgeVariants } diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index a2df8dc..b826bc0 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -9,16 +9,13 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/ui/src/components/ui/collapsible.tsx b/ui/src/components/ui/collapsible.tsx index 77f86be..fe5cbd1 100644 --- a/ui/src/components/ui/collapsible.tsx +++ b/ui/src/components/ui/collapsible.tsx @@ -1,31 +1,19 @@ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" -function Collapsible({ - ...props -}: React.ComponentProps) { +function Collapsible({ ...props }: React.ComponentProps) { return } function CollapsibleTrigger({ ...props }: React.ComponentProps) { - return ( - - ) + return } function CollapsibleContent({ ...props }: React.ComponentProps) { - return ( - - ) + return } export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index 6cb123b..7df9445 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -4,27 +4,19 @@ import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" -function Dialog({ - ...props -}: React.ComponentProps) { +function Dialog({ ...props }: React.ComponentProps) { return } -function DialogTrigger({ - ...props -}: React.ComponentProps) { +function DialogTrigger({ ...props }: React.ComponentProps) { return } -function DialogPortal({ - ...props -}: React.ComponentProps) { +function DialogPortal({ ...props }: React.ComponentProps) { return } -function DialogClose({ - ...props -}: React.ComponentProps) { +function DialogClose({ ...props }: React.ComponentProps) { return } @@ -92,19 +84,13 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) } -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { +function DialogTitle({ className, ...props }: React.ComponentProps) { return ( ) { + return +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectValue({ ...props }: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx index 84649ad..e2121c0 100644 --- a/ui/src/components/ui/sheet.tsx +++ b/ui/src/components/ui/sheet.tsx @@ -10,21 +10,15 @@ function Sheet({ ...props }: React.ComponentProps) { return } -function SheetTrigger({ - ...props -}: React.ComponentProps) { +function SheetTrigger({ ...props }: React.ComponentProps) { return } -function SheetClose({ - ...props -}: React.ComponentProps) { +function SheetClose({ ...props }: React.ComponentProps) { return } -function SheetPortal({ - ...props -}: React.ComponentProps) { +function SheetPortal({ ...props }: React.ComponentProps) { return } @@ -101,10 +95,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { ) } -function SheetTitle({ - className, - ...props -}: React.ComponentProps) { +function SheetTitle({ className, ...props }: React.ComponentProps) { return ( { const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { event.preventDefault() toggleSidebar() } @@ -253,11 +245,7 @@ function Sidebar({ ) } -function SidebarTrigger({ - className, - onClick, - ...props -}: React.ComponentProps) { +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { toggleSidebar } = useSidebar() return ( @@ -318,10 +306,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { ) } -function SidebarInput({ - className, - ...props -}: React.ComponentProps) { +function SidebarInput({ className, ...props }: React.ComponentProps) { return ( ) { ) } -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps) { +function SidebarSeparator({ className, ...props }: React.ComponentProps) { return ( ) { +function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
- {showIcon && ( - - )} + {showIcon && } ) { ) } -function SidebarMenuSubItem({ - className, - ...props -}: React.ComponentProps<"li">) { +function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { return (
  • ) { + return ( +
    + + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
    [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
    + ) +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index 71ee0fe..f4a8d21 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -16,9 +16,7 @@ function TooltipProvider({ ) } -function Tooltip({ - ...props -}: React.ComponentProps) { +function Tooltip({ ...props }: React.ComponentProps) { return ( @@ -26,9 +24,7 @@ function Tooltip({ ) } -function TooltipTrigger({ - ...props -}: React.ComponentProps) { +function TooltipTrigger({ ...props }: React.ComponentProps) { return } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3bc82d2..1e59ec7 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -37,6 +37,11 @@ function authHeaders(): Record { return headers } +function orgContextHeaders(): Record { + const id = localStorage.getItem("active_org_id") + return id ? { "X-Org-ID": id } : {} +} + async function request( path: string, method: Method, @@ -50,6 +55,7 @@ async function request( const merged: Record = { ...baseHeaders, ...(opts.auth === false ? {} : authHeaders()), + ...orgContextHeaders(), ...normalizeHeaders(opts.headers), } @@ -83,6 +89,8 @@ async function request( 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) } diff --git a/ui/src/lib/auth.ts b/ui/src/lib/auth.ts index b691eda..7c187b2 100644 --- a/ui/src/lib/auth.ts +++ b/ui/src/lib/auth.ts @@ -1,5 +1,33 @@ import { api, API_BASE_URL } from "@/lib/api.ts" +export type MeUser = { + id: string + name?: string + email?: string + email_verified?: boolean + role: "admin" | "user" | string + created_at?: string + updated_at?: string +} + +export type MePayload = { + user?: MeUser // preferred shape + user_id?: MeUser // fallback (older shape) + organization_id?: string | null + org_role?: "admin" | "member" | string | null + claims?: any +} + +function getUser(me: MePayload | null | undefined): MeUser | undefined { + return (me && (me.user || me.user_id)) as MeUser | undefined +} +export function isGlobalAdmin(me: MePayload | null | undefined): boolean { + return getUser(me)?.role === "admin" +} +export function isOrgAdmin(me: MePayload | null | undefined): boolean { + return (me?.org_role ?? "") === "admin" +} + export const authStore = { isAuthenticated(): boolean { return !!localStorage.getItem("access_token") @@ -19,9 +47,7 @@ export const authStore = { }, async me() { - return await api.get<{ user_id: string; organization_id?: string; org_role?: string }>( - "/api/v1/auth/me" - ) + return await api.get("/api/v1/auth/me") }, async logout() { diff --git a/ui/src/lib/orgs-sync.ts b/ui/src/lib/orgs-sync.ts new file mode 100644 index 0000000..60b6542 --- /dev/null +++ b/ui/src/lib/orgs-sync.ts @@ -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(EVT_ACTIVE_ORG_CHANGED, { detail: id })) +} + +export function emitOrgsChanged() { + window.dispatchEvent(new Event(EVT_ORGS_CHANGED)) +} diff --git a/ui/src/pages/admin/users.tsx b/ui/src/pages/admin/users.tsx new file mode 100644 index 0000000..4feaff3 --- /dev/null +++ b/ui/src/pages/admin/users.tsx @@ -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 + +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 + +export function AdminUsersPage() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + const [createOpen, setCreateOpen] = useState(false) + const [editOpen, setEditOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + const createForm = useForm({ + resolver: zodResolver(CreateSchema), + mode: "onChange", + defaultValues: { name: "", email: "", role: "user", password: "" }, + }) + + const editForm = useForm({ + resolver: zodResolver(EditSchema), + mode: "onChange", + defaultValues: { name: "", email: "", role: "user", password: "" }, + }) + + async function fetchUsers() { + setLoading(true) + try { + const res = await api.get("/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("/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 = { + 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(`/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(`/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 ( +
    +
    +

    Users

    + +
    + + + {loading ? ( +
    Loading…
    + ) : users.length === 0 ? ( +
    No users yet.
    + ) : ( +
    + {users.map((u) => ( + + + {u.name || u.email} + + +
    Email: {u.email}
    +
    Role: {u.role}
    +
    Verified: {u.email_verified ? "Yes" : "No"}
    +
    Joined: {new Date(u.created_at).toLocaleString()}
    +
    + + + + + + + + + + Delete user? + + This will permanently delete {u.email}. + + + + Cancel + + + + + + + +
    + ))} +
    + )} + + {/* Create dialog */} + + + + Create user + Add a new user account. + + + + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Role + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + + + + + + + {/* Edit dialog */} + + + + Edit user + + Update user details. Leave password blank to keep it unchanged. + + +
    + + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Role + + + + )} + /> + ( + + New password (optional) + + + + + + )} + /> + + + + + + +
    +
    +
    + ) +} diff --git a/ui/src/pages/auth/login.tsx b/ui/src/pages/auth/login.tsx index c72d871..876678c 100644 --- a/ui/src/pages/auth/login.tsx +++ b/ui/src/pages/auth/login.tsx @@ -39,7 +39,7 @@ export function Login() { try { await authStore.login(values.email, values.password) toast.success("Welcome back!") - const to = location.state?.from?.pathname ?? "/auth/me" + const to = location.state?.from?.pathname ?? "/settings/me" navigate(to, { replace: true }) } catch (e: any) { toast.error(e.message || "Login failed") diff --git a/ui/src/pages/error/forbidden.tsx b/ui/src/pages/error/forbidden.tsx new file mode 100644 index 0000000..9996bf5 --- /dev/null +++ b/ui/src/pages/error/forbidden.tsx @@ -0,0 +1,8 @@ +export function Forbidden() { + return ( +
    +

    403 — Forbidden

    +

    You don’t have access to this area.

    +
    + ) +} diff --git a/ui/src/pages/error/not-found.tsx b/ui/src/pages/error/not-found.tsx index 37bfd38..cf5d6ba 100644 --- a/ui/src/pages/error/not-found.tsx +++ b/ui/src/pages/error/not-found.tsx @@ -1,16 +1,15 @@ -import {useNavigate} from "react-router-dom"; -import {Button} from "@/components/ui/button.tsx"; +import { useNavigate } from "react-router-dom" + +import { Button } from "@/components/ui/button.tsx" export const NotFoundPage = () => { - const navigate = useNavigate(); + const navigate = useNavigate() - return ( -
    -

    404

    -

    Oops! Page not found

    - -
    - ); -}; \ No newline at end of file + return ( +
    +

    404

    +

    Oops! Page not found

    + +
    + ) +} diff --git a/ui/src/pages/security/ssh.tsx b/ui/src/pages/security/ssh.tsx new file mode 100644 index 0000000..85ccac9 --- /dev/null +++ b/ui/src/pages/security/ssh.tsx @@ -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 +type CreateKeyOutput = z.output + +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([]) + const [error, setError] = useState(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("/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(`/api/v1/ssh/${encodeURIComponent(id)}`) + await fetchSshKeys() + } catch (e) { + console.error(e) + alert("Failed to delete key") + } + } + + const form = useForm({ + resolver: zodResolver(CreateKeySchema), + defaultValues: { name: "", comment: "deploy@autoglue", bits: "4096" }, + }) + + async function onSubmit(values: CreateKeyInput) { + try { + await api.post("/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
    Loading SSH Keys…
    + if (error) return
    {error}
    + + return ( + +
    +
    +

    SSH Keys

    + +
    + setFilter(e.target.value)} + placeholder="Search by name, fingerprint or key" + /> +
    + + + + + + + + Create SSH Keypair + + +
    + + ( + + Name + + + + + + )} + /> + + ( + + Comment + + + + + + )} + /> + + ( + + Key size + + + + + + )} + /> + + + + + + + +
    +
    +
    + +
    +
    + + + + Name + Public Key + Fingerprint + Created + Actions + + + + {filtered.map((sshKey) => { + const keyType = getKeyType(sshKey.public_keys) + const truncated = truncateMiddle(sshKey.public_keys, 18) + return ( + + {sshKey.name} + + +
    + + {keyType} + + + + + {truncated} + + + +
    +

    {sshKey.public_keys}

    +
    +
    +
    +
    +
    + + + {sshKey.fingerprint} + + + + {new Date(sshKey.created_at).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + + +
    + + + + + + + + downloadKeyPair(sshKey.id, "both")}> + Public + Private (.zip) + + downloadKeyPair(sshKey.id, "public")} + > + Public only (.pub) + + downloadKeyPair(sshKey.id, "private")} + > + Private only (.pem) + + + + + +
    +
    +
    + ) + })} +
    +
    +
    +
    +
    +
    + ) +} diff --git a/ui/src/pages/settings/members.tsx b/ui/src/pages/settings/members.tsx new file mode 100644 index 0000000..4412e0c --- /dev/null +++ b/ui/src/pages/settings/members.tsx @@ -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 + +export const MemberManagement = () => { + const [loading, setLoading] = useState(true) + const [members, setMembers] = useState([]) + const [me, setMe] = useState(null) + const [inviteOpen, setInviteOpen] = useState(false) + const [inviting, setInviting] = useState(false) + const [deletingId, setDeletingId] = useState(null) + + const activeOrgIdInitial = useMemo(() => getActiveOrgId(), []) + + const form = useForm({ + resolver: zodResolver(InviteSchema), + defaultValues: { email: "", role: "member" }, + mode: "onChange", + }) + + async function fetchMe() { + try { + const data = await api.get("/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("/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(`/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 ( +
    +
    +

    Members

    + +
    + +
    + {[...Array(6)].map((_, i) => ( + + + + + + + + + + + + + ))} +
    +
    + ) + } + + if (!getActiveOrgId()) { + return ( +
    +
    +

    Members

    +
    + +

    + No organization selected. Choose an organization to manage its members. +

    +
    + ) + } + + return ( +
    +
    +

    Members

    + + + + + + + + Invite member + Send an invite to join this organization. + + +
    + + ( + + Email + + + + + + )} + /> + + ( + + Role + + + + )} + /> + + + + + + + +
    +
    +
    + + + + {members.length === 0 ? ( +
    No members yet.
    + ) : ( +
    + {members.map((m) => { + const isSelf = me?.id && m.userId === me.id + return ( + + + {m.name || m.email || m.userId} + + + {m.email &&
    Email: {m.email}
    } +
    Role: {m.role}
    + {m.joinedAt &&
    Joined: {new Date(m.joinedAt).toLocaleString()}
    } +
    + +
    + + + + + + + Remove member? + + This will remove {m.name || m.email || m.userId} from the + organization. + + + + + Cancel + + + + + + + + + + ) + })} +
    + )} +
    + ) +} diff --git a/ui/src/pages/settings/orgs.tsx b/ui/src/pages/settings/orgs.tsx index 055bb5b..4f8aec9 100644 --- a/ui/src/pages/settings/orgs.tsx +++ b/ui/src/pages/settings/orgs.tsx @@ -1,281 +1,357 @@ -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; -import { useEffect, useRef, useState } from "react"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { api, ApiError } from "@/lib/api"; // <-- import ApiError -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { slugify } from "@/lib/utils"; -import { toast } from "sonner"; +import { useEffect, useRef, useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { TrashIcon } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { api, ApiError } from "@/lib/api.ts" import { - Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle -} from "@/components/ui/dialog"; + emitOrgsChanged, + EVT_ACTIVE_ORG_CHANGED, + EVT_ORGS_CHANGED, + getActiveOrgId, + setActiveOrgId as setActiveOrgIdLS, +} from "@/lib/orgs-sync.ts" +import { slugify } from "@/lib/utils.ts" import { - Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog.tsx" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx" import { - AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, - AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger -} from "@/components/ui/alert-dialog"; -import { TrashIcon } from "lucide-react"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx" +import { Input } from "@/components/ui/input.tsx" +import { Separator } from "@/components/ui/separator.tsx" +import { Skeleton } from "@/components/ui/skeleton.tsx" type Organization = { - id: string; // confirm with your API; change to number if needed - name: string; - slug: string; - created_at: string; -}; + id: string // confirm with your API; change to number if needed + name: string + slug: string + created_at: string +} const OrgSchema = z.object({ - name: z.string().min(2).max(100), - slug: z.string() - .min(2).max(50) - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."), -}); -type OrgFormValues = z.infer; + name: z.string().min(2).max(100), + slug: z + .string() + .min(2) + .max(50) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."), +}) + +type OrgFormValues = z.infer export const OrgManagement = () => { - const [organizations, setOrganizations] = useState([]); - const [loading, setLoading] = useState(true); - const [createOpen, setCreateOpen] = useState(false); - const slugEditedRef = useRef(false); - const [activeOrgId, setActiveOrgId] = useState(null); - const [deletingId, setDeletingId] = useState(null); + const [organizations, setOrganizations] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [activeOrgId, setActiveOrgIdState] = useState(null) + const [deletingId, setDeletingId] = useState(null) + const slugEditedRef = useRef(false) - // initialize active org from localStorage once - useEffect(() => { - setActiveOrgId(localStorage.getItem("active_org_id")); - }, []); + const form = useForm({ + resolver: zodResolver(OrgSchema), + mode: "onChange", + defaultValues: { + name: "", + slug: "", + }, + }) - // keep active org in sync across tabs - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === "active_org_id") setActiveOrgId(e.newValue); - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - const form = useForm({ - resolver: zodResolver(OrgSchema), - mode: "onChange", - defaultValues: { name: "", slug: "" }, - }); - - // auto-generate slug from name unless user edited slug manually - const nameValue = form.watch("name"); - useEffect(() => { - if (!slugEditedRef.current) { - form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true }); - } - }, [nameValue, form]); - - // fetch orgs once - const getOrgs = async () => { - setLoading(true); - try { - const data = await api.get("/api/v1/orgs"); - setOrganizations(data); - setCreateOpen(data.length === 0); - } catch (err) { - const msg = err instanceof ApiError ? err.message : "Failed to load organizations"; - toast.error(msg); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void getOrgs(); - }, []); - - async function onSubmit(values: OrgFormValues) { - try { - const newOrg = await api.post("/api/v1/orgs", values); - setOrganizations(prev => [newOrg, ...prev]); - localStorage.setItem("active_org_id", newOrg.id); - setActiveOrgId(newOrg.id); - toast.success(`Created ${newOrg.name}`); - setCreateOpen(false); - form.reset({ name: "", slug: "" }); - slugEditedRef.current = false; - } catch (err) { - const msg = err instanceof ApiError ? err.message : "Failed to create organization"; - toast.error(msg); - } + // auto-generate slug from name unless user manually edited the slug + const nameValue = form.watch("name") + useEffect(() => { + if (!slugEditedRef.current) { + form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true }) } + }, [nameValue, form]) - function handleSelectOrg(org: Organization) { - localStorage.setItem("active_org_id", org.id); - setActiveOrgId(org.id); - toast.success(`Switched to ${org.name}`); + // fetch organizations + const getOrgs = async () => { + setLoading(true) + try { + const data = await api.get("/api/v1/orgs") + setOrganizations(data) + setCreateOpen(data.length === 0) + } catch (err) { + const msg = err instanceof ApiError ? err.message : "Failed to load organizations" + toast.error(msg) + } finally { + setLoading(false) } + } - async function handleDeleteOrg(org: Organization) { - try { - setDeletingId(org.id); - await api.delete(`/api/v1/orgs/${org.id}`); // <-- correct path - setOrganizations(prev => { - const next = prev.filter(o => o.id !== org.id); // <-- fix shadow bug - if (activeOrgId === org.id) { - const nextId = next[0]?.id ?? null; - if (nextId) localStorage.setItem("active_org_id", nextId); - else localStorage.removeItem("active_org_id"); - setActiveOrgId(nextId); - } - return next; - }); - toast.success(`Deleted ${org.name}`); - } catch (err) { - const msg = err instanceof ApiError ? err.message : "Failed to delete organization"; - toast.error(msg); - } finally { - setDeletingId(null); + // initial load + sync listeners + useEffect(() => { + // initialize active org from storage + setActiveOrgIdState(getActiveOrgId()) + void getOrgs() + + // cross-tab sync for active org + const onStorage = (e: StorageEvent) => { + if (e.key === "active_org_id") setActiveOrgIdState(e.newValue) + } + window.addEventListener("storage", onStorage) + + // same-tab sync for active org (custom event) + const onActive = (e: Event) => { + const id = (e as CustomEvent).detail ?? null + setActiveOrgIdState(id) + } + window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener) + + // orgs list changes from elsewhere (custom event) + const onOrgs = () => void getOrgs() + window.addEventListener(EVT_ORGS_CHANGED, onOrgs) + + return () => { + window.removeEventListener("storage", onStorage) + window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener) + window.removeEventListener(EVT_ORGS_CHANGED, onOrgs) + } + }, []) + + async function onSubmit(values: OrgFormValues) { + try { + const newOrg = await api.post("/api/v1/orgs", values) + setOrganizations((prev) => [newOrg, ...prev]) + + // set as current org and broadcast + setActiveOrgIdLS(newOrg.id) + setActiveOrgIdState(newOrg.id) + emitOrgsChanged() + + toast.success(`Created ${newOrg.name}`) + setCreateOpen(false) + form.reset({ name: "", slug: "" }) + slugEditedRef.current = false + } catch (err) { + const msg = err instanceof ApiError ? err.message : "Failed to create organization" + toast.error(msg) + } + } + + function handleSelectOrg(org: Organization) { + setActiveOrgIdLS(org.id) // updates localStorage + emits event + setActiveOrgIdState(org.id) + toast.success(`Switched to ${org.name}`) + } + + async function handleDeleteOrg(org: Organization) { + try { + setDeletingId(org.id) + await api.delete(`/api/v1/orgs/${org.id}`) + + setOrganizations((prev) => { + const next = prev.filter((o) => o.id !== org.id) + + // if we deleted the active org, move to the first remaining org (or clear) + if (activeOrgId === org.id) { + const nextId = next[0]?.id ?? null + setActiveOrgIdLS(nextId) + setActiveOrgIdState(nextId) } - } - if (loading) { - return ( -
    -
    -

    Organizations

    -
    -
    - {[...Array(6)].map((_, i) => ( - - - - - - - - - ))} -
    -
    - ); - } + return next + }) + emitOrgsChanged() + toast.success(`Deleted ${org.name}`) + } catch (err) { + const msg = err instanceof ApiError ? err.message : "Failed to delete organization" + toast.error(msg) + } finally { + setDeletingId(null) + } + } + + if (loading) { return ( -
    -
    -

    Organizations

    - -
    - - - {organizations.length === 0 ? ( -
    No organizations yet.
    - ) : ( -
    - {organizations.map(org => ( - - {org.name} - -
    Slug: {org.slug}
    -
    ID: {org.id}
    -
    Created: {new Date(org.created_at).toUTCString()}
    -
    - - - - - - - - - - Delete organization? - - This will permanently delete {org.name}. This action cannot be undone. - - - - Cancel - - - - - - - -
    - ))} -
    - )} - - - - - Create organization - Set a name and a URL-friendly slug. - - -
    - - ( - - Name - - This is your organization’s display name. - - - )} - /> - - ( - - Slug - - { slugEditedRef.current = true; field.onChange(e); }} - onBlur={(e) => { - const normalized = slugify(e.target.value); - form.setValue("slug", normalized, { shouldValidate: true }); - field.onBlur(); - }} - /> - - Lowercase, numbers and hyphens only. - - - )} - /> - - - - - - - -
    -
    +
    +
    +

    Organizations

    - ); -}; +
    + {[...Array(6)].map((_, i) => ( + + + + + + + + + + + + + ))} +
    +
    + ) + } + + return ( +
    +
    +

    Organizations

    + +
    + + + {organizations.length === 0 ? ( +
    No organizations yet.
    + ) : ( +
    + {organizations.map((org) => ( + + + {org.name} + + +
    Slug: {org.slug}
    +
    ID: {org.id}
    +
    + + + + + + + + + + Delete organization? + + This will permanently delete {org.name}. This action cannot be + undone. + + + + Cancel + + + + + + + +
    + ))} +
    + )} + + + + + Create organization + Set a name and a URL-friendly slug. + + +
    + + ( + + Name + + + + This is your organization’s display name. + + + )} + /> + + ( + + Slug + + { + slugEditedRef.current = true // user manually edited slug + field.onChange(e) + }} + onBlur={(e) => { + // normalize on blur + const normalized = slugify(e.target.value) + form.setValue("slug", normalized, { shouldValidate: true }) + field.onBlur() + }} + /> + + Lowercase, numbers and hyphens only. + + + )} + /> + + + + + + + +
    +
    +
    + ) +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8a9525a..e1c1877 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,11 +1,21 @@ import path from "path" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" +import { visualizer } from "rollup-plugin-visualizer" import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + visualizer({ + filename: "dist/stats.html", + template: "treemap", + gzipSize: true, + brotliSize: true, + }), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"), @@ -20,7 +30,26 @@ export default defineConfig({ }, }, build: { + chunkSizeWarningLimit: 1000, outDir: "../internal/ui/dist", emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes("node_modules")) return + + if (id.includes("react-router")) return "router" + if (id.includes("@radix-ui")) return "radix" + if (id.includes("lucide-react") || id.includes("react-icons")) return "icons" + if (id.includes("recharts") || id.includes("d3")) return "charts" + if (id.includes("date-fns") || id.includes("dayjs")) return "dates" + + return "vendor" + }, + }, + }, + }, + optimizeDeps: { + include: ["react", "react-dom", "react-router-dom"], }, }) diff --git a/ui/yarn.lock b/ui/yarn.lock index e540d19..d4e7cad 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -753,6 +753,11 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + "@radix-ui/primitive@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" @@ -966,6 +971,33 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-controllable-state" "1.2.2" +"@radix-ui/react-select@^2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.2.6.tgz#022cf8dab16bf05d0d1b4df9e53e4bea1b744fd9" + integrity sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.11" + "@radix-ui/react-focus-guards" "1.1.3" + "@radix-ui/react-focus-scope" "1.1.7" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.8" + "@radix-ui/react-portal" "1.1.9" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-previous" "1.1.1" + "@radix-ui/react-visually-hidden" "1.2.3" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + "@radix-ui/react-separator@^1.1.7": version "1.1.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470" @@ -1030,6 +1062,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== +"@radix-ui/react-use-previous@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5" + integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ== + "@radix-ui/react-use-rect@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152" @@ -1832,6 +1869,11 @@ deepmerge@^4.3.1: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + depd@2.0.0, depd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -2528,6 +2570,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -2600,6 +2647,13 @@ is-unicode-supported@^2.0.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -3032,6 +3086,15 @@ onetime@^7.0.0: dependencies: mimic-function "^5.0.0" +open@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -3342,6 +3405,16 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== +rollup-plugin-visualizer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.3.tgz#d05bd17e358a6d04bf593cf73556219c9c6d8dad" + integrity sha512-ZU41GwrkDcCpVoffviuM9Clwjy5fcUxlz0oMoTXTYsK+tcIFzbdacnrr2n8TXcHxbGKKXtOdjxM2HUS4HjkwIw== + dependencies: + open "^8.0.0" + picomatch "^4.0.2" + source-map "^0.7.4" + yargs "^17.5.1" + rollup@^4.43.0: version "4.50.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.0.tgz#6f237f598b7163ede33ce827af8534c929aaa186" @@ -3563,6 +3636,11 @@ source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map@^0.7.4: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -3941,7 +4019,7 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^17.7.2: +yargs@^17.5.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==