From bef036bd464a977ab04681f35d2c71166d96f900 Mon Sep 17 00:00:00 2001 From: Lasim Date: Sat, 5 Jul 2025 16:51:56 +0200 Subject: [PATCH 01/18] feat: Implement cloud credentials management UI and service integration - Added AddCredentialDialog component for creating new cloud credentials. - Created columns definition for displaying cloud credentials in a table. - Added English localization for credentials management. - Developed CredentialsService for API interactions related to cloud credentials. - Defined TypeScript types for cloud providers and credentials. - Implemented form validation and error handling for credential creation. - Integrated loading states and error messages in the UI. - Added functionality for searching and managing cloud credentials. --- services/backend/api-spec.json | 250 +++++++++- services/backend/api-spec.yaml | 171 ++++++- .../src/routes/cloud-credentials/index.ts | 71 ++- .../src/routes/cloud-credentials/schemas.ts | 61 +++ services/backend/src/routes/users/index.ts | 18 +- .../src/services/cloudCredentialsService.ts | 26 +- .../frontend/public/images/provider/aws.svg | 38 ++ .../frontend/public/images/provider/flyio.svg | 1 + .../frontend/public/images/provider/k8s.svg | 84 ++++ .../public/images/provider/railway.svg | 1 + .../public/images/provider/render.svg | 60 +++ .../credentials/AddCredentialDialog.vue | 404 ++++++++++++++++ .../src/components/credentials/columns.ts | 131 +++++ .../frontend/src/composables/useEventBus.ts | 3 + .../src/i18n/locales/en/credentials.ts | 106 ++++ .../frontend/src/i18n/locales/en/index.ts | 2 + .../src/services/credentialsService.ts | 423 ++++++++++++++++ services/frontend/src/services/teamService.ts | 4 +- services/frontend/src/types/credentials.ts | 125 +++++ services/frontend/src/views/Credentials.vue | 453 +++++++++++++++++- 20 files changed, 2408 insertions(+), 24 deletions(-) create mode 100644 services/frontend/public/images/provider/aws.svg create mode 100644 services/frontend/public/images/provider/flyio.svg create mode 100644 services/frontend/public/images/provider/k8s.svg create mode 100644 services/frontend/public/images/provider/railway.svg create mode 100644 services/frontend/public/images/provider/render.svg create mode 100644 services/frontend/src/components/credentials/AddCredentialDialog.vue create mode 100644 services/frontend/src/components/credentials/columns.ts create mode 100644 services/frontend/src/i18n/locales/en/credentials.ts create mode 100644 services/frontend/src/services/credentialsService.ts create mode 100644 services/frontend/src/types/credentials.ts diff --git a/services/backend/api-spec.json b/services/backend/api-spec.json index a7f7dea04..c6c0fe967 100644 --- a/services/backend/api-spec.json +++ b/services/backend/api-spec.json @@ -16,6 +16,34 @@ "schemas": {} }, "paths": { + "/api/plugin/example-plugin/examples": { + "get": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/api/plugin/example-plugin/examples/{id}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/": { "get": { "summary": "API health check", @@ -3315,9 +3343,9 @@ ], "description": "User role in the team" }, - "is_owner": { + "is_admin": { "type": "boolean", - "description": "Whether the user is the owner of this team" + "description": "Whether the user is an admin of this team" } }, "required": [ @@ -3327,7 +3355,9 @@ "description", "owner_id", "created_at", - "updated_at" + "updated_at", + "role", + "is_admin" ], "additionalProperties": false }, @@ -3486,9 +3516,9 @@ ], "description": "User role in the team" }, - "is_owner": { + "is_admin": { "type": "boolean", - "description": "Whether the user is the owner of this team" + "description": "Whether the user is an admin of this team" } }, "required": [ @@ -3498,7 +3528,9 @@ "description", "owner_id", "created_at", - "updated_at" + "updated_at", + "role", + "is_admin" ], "additionalProperties": false }, @@ -8846,6 +8878,212 @@ } } }, + "/teams/{teamId}/cloud-credentials/search": { + "get": { + "summary": "Search team cloud credentials", + "tags": [ + "Cloud Credentials" + ], + "description": "Search for cloud credentials within a team by name or comment. Returns only metadata, no secret values. Team membership is required.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "in": "query", + "name": "q", + "required": true, + "description": "Search query for credential name or comment" + }, + { + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "in": "query", + "name": "limit", + "required": false, + "description": "Maximum number of results to return" + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "teamId", + "required": true + } + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Search completed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "comment": { + "type": "string", + "nullable": true + }, + "providerId": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "comment", + "providerId", + "provider", + "createdAt", + "updatedAt" + ], + "additionalProperties": false + }, + "description": "Array of matching credentials (metadata only, no secret values)" + } + }, + "required": [ + "success", + "data" + ], + "additionalProperties": false, + "description": "Search completed successfully" + } + } + } + }, + "401": { + "description": "Unauthorized - Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": false, + "description": "Indicates if the operation was successful (false for errors)" + }, + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "description": "Unauthorized - Authentication required" + } + } + } + }, + "403": { + "description": "Forbidden - Insufficient permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": false, + "description": "Indicates if the operation was successful (false for errors)" + }, + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "description": "Forbidden - Insufficient permissions" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": false, + "description": "Indicates if the operation was successful (false for errors)" + }, + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "description": "Internal Server Error" + } + } + } + } + } + } + }, "/api/auth/email/register": { "post": { "summary": "User registration via email", diff --git a/services/backend/api-spec.yaml b/services/backend/api-spec.yaml index 2a8d20fd2..10c05f68f 100644 --- a/services/backend/api-spec.yaml +++ b/services/backend/api-spec.yaml @@ -11,6 +11,22 @@ components: name: auth_session schemas: {} paths: + /api/plugin/example-plugin/examples: + get: + responses: + "200": + description: Default Response + /api/plugin/example-plugin/examples/{id}: + get: + parameters: + - schema: + type: string + in: path + name: id + required: true + responses: + "200": + description: Default Response /: get: summary: API health check @@ -2312,9 +2328,9 @@ paths: - team_admin - team_user description: User role in the team - is_owner: + is_admin: type: boolean - description: Whether the user is the owner of this team + description: Whether the user is an admin of this team required: - id - name @@ -2323,6 +2339,8 @@ paths: - owner_id - created_at - updated_at + - role + - is_admin additionalProperties: false description: Array of user teams required: @@ -2434,9 +2452,9 @@ paths: - team_admin - team_user description: User role in the team - is_owner: + is_admin: type: boolean - description: Whether the user is the owner of this team + description: Whether the user is an admin of this team required: - id - name @@ -2445,6 +2463,8 @@ paths: - owner_id - created_at - updated_at + - role + - is_admin additionalProperties: false description: Array of user teams required: @@ -6171,6 +6191,149 @@ paths: - error additionalProperties: false description: Internal Server Error + /teams/{teamId}/cloud-credentials/search: + get: + summary: Search team cloud credentials + tags: + - Cloud Credentials + description: Search for cloud credentials within a team by name or comment. + Returns only metadata, no secret values. Team membership is required. + parameters: + - schema: + type: string + minLength: 1 + in: query + name: q + required: true + description: Search query for credential name or comment + - schema: + type: number + minimum: 1 + maximum: 100 + default: 50 + in: query + name: limit + required: false + description: Maximum number of results to return + - schema: + type: string + in: path + name: teamId + required: true + security: + - cookieAuth: [] + responses: + "200": + description: Search completed successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: Indicates if the operation was successful + data: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + comment: + type: string + nullable: true + providerId: + type: string + provider: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + required: + - id + - name + - description + additionalProperties: false + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - comment + - providerId + - provider + - createdAt + - updatedAt + additionalProperties: false + description: Array of matching credentials (metadata only, no secret values) + required: + - success + - data + additionalProperties: false + description: Search completed successfully + "401": + description: Unauthorized - Authentication required + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: false + description: Indicates if the operation was successful (false for errors) + error: + type: string + description: Error message + required: + - error + additionalProperties: false + description: Unauthorized - Authentication required + "403": + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: false + description: Indicates if the operation was successful (false for errors) + error: + type: string + description: Error message + required: + - error + additionalProperties: false + description: Forbidden - Insufficient permissions + "500": + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + default: false + description: Indicates if the operation was successful (false for errors) + error: + type: string + description: Error message + required: + - error + additionalProperties: false + description: Internal Server Error /api/auth/email/register: post: summary: User registration via email diff --git a/services/backend/src/routes/cloud-credentials/index.ts b/services/backend/src/routes/cloud-credentials/index.ts index 56625aa25..fdeffcdf0 100644 --- a/services/backend/src/routes/cloud-credentials/index.ts +++ b/services/backend/src/routes/cloud-credentials/index.ts @@ -11,10 +11,13 @@ import { getCredentialSchema, updateCredentialSchema, deleteCredentialSchema, + searchCredentialsSchema, CreateCloudCredentialSchema, UpdateCloudCredentialSchema, + SearchCredentialsQuerySchema, type CreateCloudCredentialInput, - type UpdateCloudCredentialInput + type UpdateCloudCredentialInput, + type SearchCredentialsQuery } from './schemas'; /** @@ -446,4 +449,70 @@ export default async function cloudCredentialsRoutes(fastify: FastifyInstance) { }); } }); + + // Search team's cloud credentials + fastify.get('/teams/:teamId/cloud-credentials/search', { + schema: searchCredentialsSchema + }, async (request, reply) => { + try { + const { teamId } = request.params as { teamId: string }; + const userId = request.user?.id; + + if (!userId) { + return reply.status(401).send({ + success: false, + error: 'User not authenticated' + }); + } + + // Check permissions + const { allowed } = await checkCloudCredentialsPermission(teamId, userId, 'view'); + if (!allowed) { + return reply.status(403).send({ + success: false, + error: 'Insufficient permissions' + }); + } + + // Validate query parameters + const validationResult = SearchCredentialsQuerySchema.safeParse(request.query); + if (!validationResult.success) { + return reply.status(400).send({ + success: false, + error: 'Validation failed', + details: validationResult.error.errors.map(err => err.message) + }); + } + + const { q, limit = 50 }: SearchCredentialsQuery = validationResult.data; + + // Search credentials within the team + const results = await cloudCredentialsService.searchTeamCredentials(teamId, q, limit); + + request.log.info({ + operation: 'search_team_credentials', + teamId, + query: q, + resultsCount: results.length, + userId + }, 'Cloud credentials search completed'); + + return reply.status(200).send({ + success: true, + data: results + }); + } catch (error) { + request.log.error({ + error, + operation: 'search_team_credentials', + teamId: (request.params as any).teamId, + query: (request.query as any)?.q + }, 'Failed to search team credentials'); + + return reply.status(500).send({ + success: false, + error: 'Failed to search team credentials' + }); + } + }); } diff --git a/services/backend/src/routes/cloud-credentials/schemas.ts b/services/backend/src/routes/cloud-credentials/schemas.ts index 9381a913e..8e1ad487b 100644 --- a/services/backend/src/routes/cloud-credentials/schemas.ts +++ b/services/backend/src/routes/cloud-credentials/schemas.ts @@ -78,9 +78,29 @@ export const UpdateCloudCredentialSchema = z.object({ credentials: z.record(z.string()).optional(), }); +export const SearchCredentialsQuerySchema = z.object({ + q: z.string().min(1, 'Search query is required').describe('Search query for credential name or comment'), + limit: z.number().min(1).max(100).default(50).optional().describe('Maximum number of results to return'), +}); + +export const SearchCredentialsResponseSchema = z.object({ + id: z.string(), + name: z.string(), + comment: z.string().nullable(), + providerId: z.string(), + provider: z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + }), + createdAt: z.string(), + updatedAt: z.string(), +}); + // Request/Response types export type CreateCloudCredentialInput = z.infer; export type UpdateCloudCredentialInput = z.infer; +export type SearchCredentialsQuery = z.infer; // Route schemas for OpenAPI documentation export const listProvidersSchema = { @@ -365,3 +385,44 @@ export const deleteCredentialSchema = { }), } }; + +export const searchCredentialsSchema = { + tags: ['Cloud Credentials'], + summary: 'Search team cloud credentials', + description: 'Search for cloud credentials within a team by name or comment. Returns only metadata, no secret values. Team membership is required.', + security: [{ cookieAuth: [] }], + querystring: zodToJsonSchema(SearchCredentialsQuerySchema, { + $refStrategy: 'none', + target: 'openApi3' + }), + response: { + 200: zodToJsonSchema(z.object({ + success: z.boolean().describe('Indicates if the operation was successful'), + data: z.array(SearchCredentialsResponseSchema).describe('Array of matching credentials (metadata only, no secret values)'), + }).describe('Search completed successfully'), { + $refStrategy: 'none', + target: 'openApi3' + }), + 401: zodToJsonSchema(z.object({ + success: z.boolean().default(false).describe('Indicates if the operation was successful (false for errors)'), + error: z.string().describe('Error message'), + }).describe('Unauthorized - Authentication required'), { + $refStrategy: 'none', + target: 'openApi3' + }), + 403: zodToJsonSchema(z.object({ + success: z.boolean().default(false).describe('Indicates if the operation was successful (false for errors)'), + error: z.string().describe('Error message'), + }).describe('Forbidden - Insufficient permissions'), { + $refStrategy: 'none', + target: 'openApi3' + }), + 500: zodToJsonSchema(z.object({ + success: z.boolean().default(false).describe('Indicates if the operation was successful (false for errors)'), + error: z.string().describe('Error message'), + }).describe('Internal Server Error'), { + $refStrategy: 'none', + target: 'openApi3' + }), + } +}; diff --git a/services/backend/src/routes/users/index.ts b/services/backend/src/routes/users/index.ts index c97a2c78f..33fbba9f1 100644 --- a/services/backend/src/routes/users/index.ts +++ b/services/backend/src/routes/users/index.ts @@ -41,8 +41,8 @@ const userTeamsResponseSchema = z.object({ owner_id: z.string().describe('Team owner ID'), created_at: z.date().describe('Team creation date'), updated_at: z.date().describe('Team last update date'), - role: z.enum(['team_admin', 'team_user']).optional().describe('User role in the team'), - is_owner: z.boolean().optional().describe('Whether the user is the owner of this team') + role: z.enum(['team_admin', 'team_user']).describe('User role in the team'), + is_admin: z.boolean().describe('Whether the user is an admin of this team') })).describe('Array of user teams') }); @@ -626,9 +626,21 @@ export default async function usersRoute(fastify: FastifyInstance) { const teams = await TeamService.getUserTeams(request.user.id); + // Add role information to each team + const teamsWithRoles = await Promise.all( + teams.map(async (team) => { + const membership = await TeamService.getTeamMembership(team.id, request.user!.id); + return { + ...team, + role: membership?.role || 'team_user', + is_admin: membership?.role === 'team_admin' + }; + }) + ); + return reply.status(200).send({ success: true, - teams: teams, + teams: teamsWithRoles, }); } catch (error) { fastify.log.error(error, 'Error fetching user teams'); diff --git a/services/backend/src/services/cloudCredentialsService.ts b/services/backend/src/services/cloudCredentialsService.ts index fa86094fa..468719fdc 100644 --- a/services/backend/src/services/cloudCredentialsService.ts +++ b/services/backend/src/services/cloudCredentialsService.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getDb, getSchema } from '../db'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, or, like } from 'drizzle-orm'; import { generateId } from 'lucia'; import { encrypt, decrypt } from '../utils/encryption'; import { getCloudProvider, validateCredentialData, validateCredentialDataForUpdate } from '../config/cloud-providers'; @@ -282,6 +282,30 @@ export class CloudCredentialsService { return result.changes > 0; } + /** + * Search team credentials by name or comment + */ + async searchTeamCredentials(teamId: string, query: string, limit: number = 50): Promise { + const { db, schema } = this.getDbAndSchema(); + const credentialsTable = schema.teamCloudCredentials; + + const searchPattern = `%${query}%`; + + const credentials = await (db as any) + .select() + .from(credentialsTable) + .where(and( + eq(credentialsTable.team_id, teamId), + or( + like(credentialsTable.name, searchPattern), + like(credentialsTable.comment, searchPattern) + ) + )) + .limit(limit); + + return credentials.map((cred: any) => this.formatCredentialBasicResponse(cred)); + } + /** * Get decrypted credentials for deployment use (internal only) */ diff --git a/services/frontend/public/images/provider/aws.svg b/services/frontend/public/images/provider/aws.svg new file mode 100644 index 000000000..80192a12d --- /dev/null +++ b/services/frontend/public/images/provider/aws.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/services/frontend/public/images/provider/flyio.svg b/services/frontend/public/images/provider/flyio.svg new file mode 100644 index 000000000..0d0086b7e --- /dev/null +++ b/services/frontend/public/images/provider/flyio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/public/images/provider/k8s.svg b/services/frontend/public/images/provider/k8s.svg new file mode 100644 index 000000000..bedd3b88e --- /dev/null +++ b/services/frontend/public/images/provider/k8s.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/services/frontend/public/images/provider/railway.svg b/services/frontend/public/images/provider/railway.svg new file mode 100644 index 000000000..619ce5071 --- /dev/null +++ b/services/frontend/public/images/provider/railway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/frontend/public/images/provider/render.svg b/services/frontend/public/images/provider/render.svg new file mode 100644 index 000000000..853c6f1e0 --- /dev/null +++ b/services/frontend/public/images/provider/render.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/frontend/src/components/credentials/AddCredentialDialog.vue b/services/frontend/src/components/credentials/AddCredentialDialog.vue new file mode 100644 index 000000000..80316de17 --- /dev/null +++ b/services/frontend/src/components/credentials/AddCredentialDialog.vue @@ -0,0 +1,404 @@ + + + diff --git a/services/frontend/src/components/credentials/columns.ts b/services/frontend/src/components/credentials/columns.ts new file mode 100644 index 000000000..025a0e7fc --- /dev/null +++ b/services/frontend/src/components/credentials/columns.ts @@ -0,0 +1,131 @@ +import { h } from 'vue' +import type { ColumnDef, Row } from '@tanstack/vue-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { MoreHorizontal, Edit, Trash2 } from 'lucide-vue-next' +import type { CloudCredential, CloudCredentialBasic } from '@/types/credentials' + +export function createColumns( + handleEdit: (credentialId: string) => void, + handleDelete: (credentialId: string) => void, + userPermissions: string[] +): ColumnDef[] { + const canEdit = userPermissions.includes('cloud_credentials.edit') + const canDelete = userPermissions.includes('cloud_credentials.delete') + const showActions = canEdit || canDelete + + return [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => { + const name = row.getValue('name') as string + return h('div', { class: 'font-medium' }, name) + }, + }, + { + accessorKey: 'provider', + header: 'Provider', + cell: ({ row }) => { + const provider = row.getValue('provider') as { id: string; name: string } + + return h('div', { class: 'flex items-center gap-2' }, [ + // Provider SVG icon + h('img', { + src: `/images/provider/${provider.id}.svg`, + alt: provider.name, + class: 'w-5 h-5', + onError: (event: Event) => { + // Hide broken image on error + const img = event.target as HTMLImageElement + img.style.display = 'none' + } + }), + // Provider badge + h(Badge, { variant: 'secondary' }, () => provider.name) + ]) + }, + }, + { + accessorKey: 'comment', + header: 'Comment', + cell: ({ row }) => { + const comment = row.getValue('comment') as string | null + return h('div', { + class: comment ? 'text-sm' : 'text-sm text-muted-foreground italic' + }, comment || 'No comment') + }, + }, + { + accessorKey: 'createdAt', + header: 'Created', + cell: ({ row }) => { + const createdAt = row.getValue('createdAt') as string + const date = new Date(createdAt) + return h('div', { class: 'text-sm text-muted-foreground' }, + date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + ) + }, + }, + ...(showActions ? [{ + id: 'actions', + header: 'Actions', + cell: ({ row }: { row: Row }) => { + const credential = row.original + + return h('div', { class: 'flex justify-end' }, [ + h(DropdownMenu, {}, { + default: () => [ + h(DropdownMenuTrigger, { asChild: true }, { + default: () => h(Button, { + variant: 'ghost', + class: 'h-8 w-8 p-0' + }, { + default: () => [ + h('span', { class: 'sr-only' }, 'Open menu'), + h(MoreHorizontal, { class: 'h-4 w-4' }) + ] + }) + }), + h(DropdownMenuContent, { align: 'end' }, { + default: () => [ + ...(canEdit ? [ + h(DropdownMenuItem, { + onClick: () => handleEdit(credential.id) + }, { + default: () => [ + h(Edit, { class: 'mr-2 h-4 w-4' }), + 'Edit' + ] + }) + ] : []), + ...(canDelete ? [ + h(DropdownMenuItem, { + onClick: () => handleDelete(credential.id), + class: 'text-destructive focus:text-destructive' + }, { + default: () => [ + h(Trash2, { class: 'mr-2 h-4 w-4' }), + 'Delete' + ] + }) + ] : []) + ] + }) + ] + }) + ]) + }, + }] : []) + ] +} diff --git a/services/frontend/src/composables/useEventBus.ts b/services/frontend/src/composables/useEventBus.ts index 80eb35a79..04960b162 100644 --- a/services/frontend/src/composables/useEventBus.ts +++ b/services/frontend/src/composables/useEventBus.ts @@ -6,6 +6,9 @@ export type EventBusEvents = { 'team-created': void 'team-deleted': void 'team-selected': { teamId: string; teamName: string } + 'credentials-updated': void + 'credential-created': { credentialId: string; credentialName: string } + 'credential-deleted': { credentialId: string; credentialName: string } } export function useEventBus() { diff --git a/services/frontend/src/i18n/locales/en/credentials.ts b/services/frontend/src/i18n/locales/en/credentials.ts new file mode 100644 index 000000000..934914bac --- /dev/null +++ b/services/frontend/src/i18n/locales/en/credentials.ts @@ -0,0 +1,106 @@ +export default { + credentials: { + title: 'Cloud Credentials', + description: 'Manage your team\'s cloud provider credentials securely', + addButton: 'Add Credential', + search: { + placeholder: 'Search credentials by name or comment...', + noResults: 'No credentials found', + results: 'Found {count} credential{count, plural, one {} other {s}} for "{query}"' + }, + table: { + columns: { + name: 'Name', + provider: 'Provider', + comment: 'Comment', + createdAt: 'Created', + actions: 'Actions' + }, + loading: 'Loading credentials...', + error: 'Failed to load credentials: {error}', + noResults: 'No credentials found for this team' + }, + form: { + title: { + add: 'Add Cloud Credential', + edit: 'Edit Cloud Credential' + }, + fields: { + provider: { + label: 'Cloud Provider', + placeholder: 'Select a cloud provider', + required: 'Please select a cloud provider' + }, + name: { + label: 'Credential Name', + placeholder: 'Enter a name for this credential set', + required: 'Credential name is required', + maxLength: 'Name must be 100 characters or less' + }, + comment: { + label: 'Comment (Optional)', + placeholder: 'Add a description or note about this credential', + maxLength: 'Comment must be 500 characters or less' + } + }, + validation: { + required: 'This field is required', + minLength: '{field} must be at least {min} characters', + maxLength: '{field} must be {max} characters or less', + pattern: '{field} format is invalid' + }, + buttons: { + cancel: 'Cancel', + save: 'Save Credential', + saving: 'Saving...' + }, + messages: { + success: { + create: 'Credential "{name}" created successfully', + update: 'Credential "{name}" updated successfully' + }, + error: { + create: 'Failed to create credential', + update: 'Failed to update credential', + validation: 'Please fix the validation errors', + duplicate: 'A credential with this name already exists for this provider' + } + } + }, + actions: { + edit: 'Edit', + delete: 'Delete', + view: 'View Details' + }, + delete: { + title: 'Delete Credential', + message: 'Are you sure you want to delete the credential "{name}"? This action cannot be undone.', + confirm: 'Delete', + cancel: 'Cancel', + success: 'Credential "{name}" deleted successfully', + error: 'Failed to delete credential' + }, + providers: { + aws: 'Amazon Web Services', + render: 'Render.com', + loading: 'Loading providers...', + error: 'Failed to load cloud providers' + }, + fields: { + secret: 'Secret field (value hidden)', + hasValue: 'Configured', + noValue: 'Not configured', + placeholder: '••••••••' + }, + permissions: { + noAccess: 'You don\'t have permission to view credentials for this team', + readOnly: 'You have read-only access to credentials', + adminRequired: 'Team admin permissions required to manage credentials' + }, + empty: { + title: 'No credentials yet', + description: 'Get started by adding your first cloud provider credential', + action: 'Add First Credential' + } + } +} diff --git a/services/frontend/src/i18n/locales/en/index.ts b/services/frontend/src/i18n/locales/en/index.ts index bfe8a9c17..209ea798c 100644 --- a/services/frontend/src/i18n/locales/en/index.ts +++ b/services/frontend/src/i18n/locales/en/index.ts @@ -11,6 +11,7 @@ import verifyEmailMessages from './verifyEmail' import forgotPasswordMessages from './forgotPassword' import resetPasswordMessages from './resetPassword' import teamsMessages from './teams' +import credentialsMessages from './credentials' export default { ...commonMessages, @@ -25,6 +26,7 @@ export default { ...forgotPasswordMessages, ...resetPasswordMessages, ...teamsMessages, + ...credentialsMessages, // If there are any top-level keys directly under 'en', they can be added here. // For example, if you had a global 'appName': 'My Application' // appName: 'DeployStack Application', diff --git a/services/frontend/src/services/credentialsService.ts b/services/frontend/src/services/credentialsService.ts new file mode 100644 index 000000000..dcd4ec2cb --- /dev/null +++ b/services/frontend/src/services/credentialsService.ts @@ -0,0 +1,423 @@ +import { getEnv } from '@/utils/env' +import type { + CloudProvider, + CloudCredential, + CloudCredentialBasic, + CreateCredentialInput, + UpdateCredentialInput, + CloudProvidersResponse, + CloudCredentialsResponse, + CloudCredentialResponse, + SearchCredentialsResponse, + ApiError +} from '@/types/credentials' + +export class CredentialsService { + private static getApiUrl(): string { + const apiUrl = getEnv('VITE_DEPLOYSTACK_BACKEND_URL') + if (!apiUrl) { + throw new Error('API URL not configured. Make sure VITE_DEPLOYSTACK_BACKEND_URL is set.') + } + return apiUrl + } + + /** + * Get all cloud providers available for a team + */ + static async getCloudProviders(teamId: string): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-providers`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + throw new Error(`Failed to fetch cloud providers: ${response.status}`) + } + + const data: CloudProvidersResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error fetching cloud providers:', error) + throw error + } + } + + /** + * Get all credentials for a team + */ + static async getTeamCredentials(teamId: string): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + throw new Error(`Failed to fetch team credentials: ${response.status}`) + } + + const data: CloudCredentialsResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error fetching team credentials:', error) + throw error + } + } + + /** + * Search credentials within a team by name or comment + */ + static async searchCredentials( + teamId: string, + query: string, + limit: number = 50 + ): Promise { + try { + const apiUrl = this.getApiUrl() + const searchParams = new URLSearchParams({ q: query, limit: limit.toString() }) + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/search?${searchParams}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + throw new Error(`Failed to search credentials: ${response.status}`) + } + + const data: SearchCredentialsResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error searching credentials:', error) + throw error + } + } + + /** + * Get a specific credential by ID + */ + static async getCredential(teamId: string, credentialId: string): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + if (response.status === 404) { + throw new Error('Credential not found') + } + throw new Error(`Failed to fetch credential: ${response.status}`) + } + + const data: CloudCredentialResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error fetching credential:', error) + throw error + } + } + + /** + * Create a new credential + */ + static async createCredential( + teamId: string, + input: CreateCredentialInput + ): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(input), + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + if (response.status === 409) { + throw new Error('A credential with this name already exists for this provider') + } + + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `Failed to create credential: ${response.status}`) + } + + const data: CloudCredentialResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error creating credential:', error) + throw error + } + } + + /** + * Update an existing credential + */ + static async updateCredential( + teamId: string, + credentialId: string, + input: UpdateCredentialInput + ): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(input), + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + if (response.status === 404) { + throw new Error('Credential not found') + } + if (response.status === 409) { + throw new Error('A credential with this name already exists for this provider') + } + + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `Failed to update credential: ${response.status}`) + } + + const data: CloudCredentialResponse = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + + return data.data + } catch (error) { + console.error('Error updating credential:', error) + throw error + } + } + + /** + * Delete a credential + */ + static async deleteCredential(teamId: string, credentialId: string): Promise { + try { + const apiUrl = this.getApiUrl() + + const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in') + } + if (response.status === 403) { + throw new Error('Insufficient permissions') + } + if (response.status === 404) { + throw new Error('Credential not found') + } + + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `Failed to delete credential: ${response.status}`) + } + + const data = await response.json() + + if (!data.success) { + throw new Error((data as unknown as ApiError).error) + } + } catch (error) { + console.error('Error deleting credential:', error) + throw error + } + } + + /** + * Validate credential data against provider schema + */ + static validateCredentialData( + provider: CloudProvider, + credentials: Record + ): { valid: boolean; errors: string[] } { + const errors: string[] = [] + + for (const field of provider.fields) { + const value = credentials[field.key] + + // Check required fields + if (field.required && (!value || value.trim() === '')) { + errors.push(`${field.label} is required`) + continue + } + + // Skip validation for empty optional fields + if (!value || value.trim() === '') { + continue + } + + // Validate field constraints + if (field.validation) { + const { minLength, maxLength, pattern } = field.validation + + if (minLength && value.length < minLength) { + errors.push(`${field.label} must be at least ${minLength} characters`) + } + + if (maxLength && value.length > maxLength) { + errors.push(`${field.label} must be ${maxLength} characters or less`) + } + + if (pattern) { + const regex = new RegExp(pattern) + if (!regex.test(value)) { + errors.push(`${field.label} format is invalid`) + } + } + } + } + + return { + valid: errors.length === 0, + errors + } + } + + /** + * Get provider by ID from a list of providers + */ + static getProviderById(providers: CloudProvider[], providerId: string): CloudProvider | null { + return providers.find(p => p.id === providerId) || null + } + + /** + * Format credential for display (hide secret values) + */ + static formatCredentialForDisplay(credential: CloudCredential): CloudCredential { + if (!credential.fields) return credential + + const formattedFields = { ...credential.fields } + + Object.keys(formattedFields).forEach(key => { + const field = formattedFields[key] + if (field.secret && field.value) { + // Replace secret values with placeholder + formattedFields[key] = { + ...field, + value: '••••••••' + } + } + }) + + return { + ...credential, + fields: formattedFields + } + } + + /** + * Check if user can manage credentials (create, edit, delete) + */ + static canManageCredentials(userPermissions: string[]): boolean { + return userPermissions.includes('cloud_credentials.create') || + userPermissions.includes('cloud_credentials.edit') || + userPermissions.includes('cloud_credentials.delete') + } + + /** + * Check if user can view credentials + */ + static canViewCredentials(userPermissions: string[]): boolean { + return userPermissions.includes('cloud_credentials.view') || + this.canManageCredentials(userPermissions) + } +} + +export default CredentialsService diff --git a/services/frontend/src/services/teamService.ts b/services/frontend/src/services/teamService.ts index 765827801..59d626fe4 100644 --- a/services/frontend/src/services/teamService.ts +++ b/services/frontend/src/services/teamService.ts @@ -10,7 +10,9 @@ export const TeamSchema = z.object({ owner_id: z.string(), is_default: z.boolean(), created_at: z.date(), - updated_at: z.date() + updated_at: z.date(), + role: z.enum(['team_admin', 'team_user']).optional(), + is_admin: z.boolean().optional() }) export const TeamWithRoleSchema = TeamSchema.extend({ diff --git a/services/frontend/src/types/credentials.ts b/services/frontend/src/types/credentials.ts new file mode 100644 index 000000000..4b22d350d --- /dev/null +++ b/services/frontend/src/types/credentials.ts @@ -0,0 +1,125 @@ +export interface CloudProvider { + id: string + name: string + description: string + fields: CredentialField[] + enabled: boolean +} + +export interface CredentialField { + key: string + label: string + type: 'text' | 'password' | 'textarea' + required: boolean + secret: boolean + placeholder?: string + description?: string + validation?: { + pattern?: string + minLength?: number + maxLength?: number + } +} + +export interface CloudCredential { + id: string + teamId: string + providerId: string + name: string + comment?: string | null + provider: { + id: string + name: string + description: string + } + fields?: Record + createdBy: string + createdAt: string + updatedAt: string +} + +export interface CloudCredentialBasic { + id: string + teamId: string + providerId: string + name: string + comment?: string | null + provider: { + id: string + name: string + description: string + } + createdBy: string + createdAt: string + updatedAt: string +} + +export interface CreateCredentialInput { + providerId: string + name: string + comment?: string + credentials: Record +} + +export interface UpdateCredentialInput { + name?: string + comment?: string + credentials?: Record +} + +// API Response types +export interface CloudProvidersResponse { + success: boolean + data: CloudProvider[] +} + +export interface CloudCredentialsResponse { + success: boolean + data: CloudCredential[] +} + +export interface CloudCredentialResponse { + success: boolean + data: CloudCredential + message?: string +} + +export interface SearchCredentialsResponse { + success: boolean + data: CloudCredentialBasic[] +} + +export interface ApiError { + success: false + error: string + details?: string[] +} + +// Form validation types +export interface CredentialFormData { + providerId: string + name: string + comment: string + credentials: Record +} + +export interface FieldError { + field: string + message: string +} + +export interface ValidationResult { + valid: boolean + errors: FieldError[] +} + +// Event bus types for credentials +export interface CredentialEvents { + 'credentials-updated': void + 'credential-created': { credentialId: string; credentialName: string } + 'credential-deleted': { credentialId: string; credentialName: string } +} diff --git a/services/frontend/src/views/Credentials.vue b/services/frontend/src/views/Credentials.vue index 9bfb5df5c..609a69dee 100644 --- a/services/frontend/src/views/Credentials.vue +++ b/services/frontend/src/views/Credentials.vue @@ -1,20 +1,457 @@ From 1d58ee4d227acdfbbfcf5c1cc9ba7174ad605f23 Mon Sep 17 00:00:00 2001 From: Lasim Date: Sat, 5 Jul 2025 16:54:12 +0200 Subject: [PATCH 02/18] feat: Enhance credentials search functionality with manual search button --- .../src/i18n/locales/en/credentials.ts | 3 +- services/frontend/src/views/Credentials.vue | 44 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/services/frontend/src/i18n/locales/en/credentials.ts b/services/frontend/src/i18n/locales/en/credentials.ts index 934914bac..df618e022 100644 --- a/services/frontend/src/i18n/locales/en/credentials.ts +++ b/services/frontend/src/i18n/locales/en/credentials.ts @@ -4,7 +4,8 @@ export default { description: 'Manage your team\'s cloud provider credentials securely', addButton: 'Add Credential', search: { - placeholder: 'Search credentials by name or comment...', + placeholder: 'Search credentials', + button: 'Search', noResults: 'No credentials found', results: 'Found {count} credential{count, plural, one {} other {s}} for "{query}"' }, diff --git a/services/frontend/src/views/Credentials.vue b/services/frontend/src/views/Credentials.vue index 609a69dee..f937018a2 100644 --- a/services/frontend/src/views/Credentials.vue +++ b/services/frontend/src/views/Credentials.vue @@ -23,7 +23,7 @@ import { } from '@/components/ui/table' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Plus, Loader2 } from 'lucide-vue-next' +import { Plus, Loader2, Search } from 'lucide-vue-next' import DashboardLayout from '@/components/DashboardLayout.vue' import { CredentialsService } from '@/services/credentialsService' import { UserService } from '@/services/userService' @@ -162,6 +162,27 @@ const debouncedSearch = debounce(async (query: string) => { } }, 300) +// Manual search function for button click +const handleManualSearch = async () => { + if (!selectedTeam.value || !searchQuery.value.trim()) { + searchResults.value = [] + return + } + + try { + isSearching.value = true + searchResults.value = await CredentialsService.searchCredentials( + selectedTeam.value.id, + searchQuery.value.trim() + ) + } catch (error) { + console.error('Search error:', error) + searchResults.value = [] + } finally { + isSearching.value = false + } +} + // Watch search query watch(searchQuery, (newQuery) => { if (newQuery.trim()) { @@ -325,18 +346,25 @@ const table = useVueTable({
- +
-
+
- +
From e0459d69c59543ee5584fe93fc45c99787b9c145 Mon Sep 17 00:00:00 2001 From: Lasim Date: Sat, 5 Jul 2025 20:45:54 +0200 Subject: [PATCH 03/18] Refactor API routes to use centralized /api prefix and update route paths for roles, teams, and users - Centralized API routes under /api prefix in index.ts - Updated route paths in roles, teams, and users to remove /api prefix - Enhanced CloudCredentialsService to include user information in credential responses - Updated frontend components to handle new credential detail view and manage actions - Added new CredentialDetail.vue view for displaying credential details - Implemented user permissions for managing cloud credentials - Updated translations for credential detail view --- services/backend/api-spec.json | 116 ++++- services/backend/api-spec.yaml | 76 +++- .../src/routes/cloud-credentials/index.ts | 99 ++++- .../src/routes/cloud-credentials/schemas.ts | 11 +- services/backend/src/routes/db/setup.ts | 2 +- services/backend/src/routes/db/status.ts | 4 +- .../src/routes/globalSettings/index.ts | 44 +- services/backend/src/routes/index.ts | 33 +- services/backend/src/routes/roles/index.ts | 24 +- services/backend/src/routes/teams/index.ts | 24 +- services/backend/src/routes/users/index.ts | 40 +- services/backend/src/server.ts | 2 +- .../src/services/cloudCredentialsService.ts | 92 +++- services/backend/src/types/cloud-providers.ts | 10 +- .../src/components/credentials/columns.ts | 49 ++- .../src/i18n/locales/en/credentials.ts | 40 ++ services/frontend/src/router/index.ts | 6 + .../src/services/credentialsService.ts | 14 +- services/frontend/src/types/credentials.ts | 10 +- .../frontend/src/views/CredentialDetail.vue | 402 ++++++++++++++++++ services/frontend/src/views/Credentials.vue | 22 +- 21 files changed, 984 insertions(+), 136 deletions(-) create mode 100644 services/frontend/src/views/CredentialDetail.vue diff --git a/services/backend/api-spec.json b/services/backend/api-spec.json index c6c0fe967..abe302f37 100644 --- a/services/backend/api-spec.json +++ b/services/backend/api-spec.json @@ -7397,7 +7397,7 @@ } } }, - "/teams/{teamId}/cloud-providers": { + "/api/teams/{teamId}/cloud-providers": { "get": { "summary": "List available cloud providers", "tags": [ @@ -7609,7 +7609,7 @@ } } }, - "/teams/{teamId}/cloud-credentials": { + "/api/teams/{teamId}/cloud-credentials": { "get": { "summary": "List team cloud credentials", "tags": [ @@ -7707,7 +7707,32 @@ } }, "createdBy": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "email" + ], + "additionalProperties": false + }, + { + "type": "string" + } + ], + "description": "User object when available, fallback to user ID" }, "createdAt": { "type": "string" @@ -7955,7 +7980,32 @@ } }, "createdBy": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "email" + ], + "additionalProperties": false + }, + { + "type": "string" + } + ], + "description": "User object when available, fallback to user ID" }, "createdAt": { "type": "string" @@ -8135,7 +8185,7 @@ } } }, - "/teams/{teamId}/cloud-credentials/{credentialId}": { + "/api/teams/{teamId}/cloud-credentials/{credentialId}": { "get": { "summary": "Get cloud credential by ID", "tags": [ @@ -8239,7 +8289,32 @@ } }, "createdBy": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "email" + ], + "additionalProperties": false + }, + { + "type": "string" + } + ], + "description": "User object when available, fallback to user ID" }, "createdAt": { "type": "string" @@ -8510,7 +8585,32 @@ } }, "createdBy": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "email" + ], + "additionalProperties": false + }, + { + "type": "string" + } + ], + "description": "User object when available, fallback to user ID" }, "createdAt": { "type": "string" @@ -8878,7 +8978,7 @@ } } }, - "/teams/{teamId}/cloud-credentials/search": { + "/api/teams/{teamId}/cloud-credentials/search": { "get": { "summary": "Search team cloud credentials", "tags": [ diff --git a/services/backend/api-spec.yaml b/services/backend/api-spec.yaml index 10c05f68f..e87d2fd4b 100644 --- a/services/backend/api-spec.yaml +++ b/services/backend/api-spec.yaml @@ -5166,7 +5166,7 @@ paths: - error additionalProperties: false description: Internal Server Error - /teams/{teamId}/cloud-providers: + /api/teams/{teamId}/cloud-providers: get: summary: List available cloud providers tags: @@ -5311,7 +5311,7 @@ paths: - error additionalProperties: false description: Internal Server Error - /teams/{teamId}/cloud-credentials: + /api/teams/{teamId}/cloud-credentials: get: summary: List team cloud credentials tags: @@ -5384,7 +5384,22 @@ paths: - secret additionalProperties: false createdBy: - type: string + anyOf: + - type: object + properties: + id: + type: string + username: + type: string + email: + type: string + required: + - id + - username + - email + additionalProperties: false + - type: string + description: User object when available, fallback to user ID createdAt: type: string updatedAt: @@ -5556,7 +5571,22 @@ paths: - secret additionalProperties: false createdBy: - type: string + anyOf: + - type: object + properties: + id: + type: string + username: + type: string + email: + type: string + required: + - id + - username + - email + additionalProperties: false + - type: string + description: User object when available, fallback to user ID createdAt: type: string updatedAt: @@ -5678,7 +5708,7 @@ paths: - error additionalProperties: false description: Internal Server Error - /teams/{teamId}/cloud-credentials/{credentialId}: + /api/teams/{teamId}/cloud-credentials/{credentialId}: get: summary: Get cloud credential by ID tags: @@ -5755,7 +5785,22 @@ paths: - secret additionalProperties: false createdBy: - type: string + anyOf: + - type: object + properties: + id: + type: string + username: + type: string + email: + type: string + required: + - id + - username + - email + additionalProperties: false + - type: string + description: User object when available, fallback to user ID createdAt: type: string updatedAt: @@ -5942,7 +5987,22 @@ paths: - secret additionalProperties: false createdBy: - type: string + anyOf: + - type: object + properties: + id: + type: string + username: + type: string + email: + type: string + required: + - id + - username + - email + additionalProperties: false + - type: string + description: User object when available, fallback to user ID createdAt: type: string updatedAt: @@ -6191,7 +6251,7 @@ paths: - error additionalProperties: false description: Internal Server Error - /teams/{teamId}/cloud-credentials/search: + /api/teams/{teamId}/cloud-credentials/search: get: summary: Search team cloud credentials tags: diff --git a/services/backend/src/routes/cloud-credentials/index.ts b/services/backend/src/routes/cloud-credentials/index.ts index fdeffcdf0..856a26ff4 100644 --- a/services/backend/src/routes/cloud-credentials/index.ts +++ b/services/backend/src/routes/cloud-credentials/index.ts @@ -398,7 +398,22 @@ export default async function cloudCredentialsRoutes(fastify: FastifyInstance) { const { teamId, credentialId } = request.params as { teamId: string; credentialId: string }; const userId = request.user?.id; + request.log.debug({ + operation: 'delete_cloud_credential_start', + teamId, + credentialId, + userId, + headers: request.headers, + method: request.method, + url: request.url + }, 'Starting cloud credential deletion'); + if (!userId) { + request.log.debug({ + operation: 'delete_cloud_credential_auth_fail', + teamId, + credentialId + }, 'User not authenticated'); return reply.status(401).send({ success: false, error: 'User not authenticated' @@ -406,17 +421,84 @@ export default async function cloudCredentialsRoutes(fastify: FastifyInstance) { } // Check permissions - const { allowed } = await checkCloudCredentialsPermission(teamId, userId, 'delete'); + request.log.debug({ + operation: 'delete_cloud_credential_permission_check', + teamId, + credentialId, + userId + }, 'Checking delete permissions'); + + const { allowed, userType } = await checkCloudCredentialsPermission(teamId, userId, 'delete'); + + request.log.debug({ + operation: 'delete_cloud_credential_permission_result', + teamId, + credentialId, + userId, + allowed, + userType + }, 'Permission check result'); + if (!allowed) { + request.log.debug({ + operation: 'delete_cloud_credential_permission_denied', + teamId, + credentialId, + userId, + userType + }, 'Insufficient permissions for deletion'); return reply.status(403).send({ success: false, error: 'Insufficient permissions' }); } + // Get credential info before deletion for logging + let credentialInfo = null; + try { + credentialInfo = await cloudCredentialsService.getCredentialById(credentialId, teamId); + request.log.debug({ + operation: 'delete_cloud_credential_info', + teamId, + credentialId, + userId, + credentialName: credentialInfo?.name, + credentialProvider: credentialInfo?.providerId + }, 'Retrieved credential info before deletion'); + } catch (infoError) { + request.log.debug({ + operation: 'delete_cloud_credential_info_error', + teamId, + credentialId, + userId, + error: infoError + }, 'Could not retrieve credential info before deletion'); + } + + request.log.debug({ + operation: 'delete_cloud_credential_execute', + teamId, + credentialId, + userId + }, 'Executing credential deletion'); + const deleted = await cloudCredentialsService.deleteCredentials(credentialId, teamId); + request.log.debug({ + operation: 'delete_cloud_credential_result', + teamId, + credentialId, + userId, + deleted + }, 'Credential deletion result'); + if (!deleted) { + request.log.debug({ + operation: 'delete_cloud_credential_not_found', + teamId, + credentialId, + userId + }, 'Cloud credential not found for deletion'); return reply.status(404).send({ success: false, error: 'Cloud credential not found' @@ -424,11 +506,14 @@ export default async function cloudCredentialsRoutes(fastify: FastifyInstance) { } request.log.info({ - operation: 'delete_cloud_credential', + operation: 'delete_cloud_credential_success', teamId, credentialId, - userId: request.user?.id - }, 'Cloud credential deleted successfully'); + userId, + credentialName: credentialInfo?.name, + credentialProvider: credentialInfo?.providerId, + deletedBy: userId + }, `Cloud credential '${credentialInfo?.name || credentialId}' (${credentialInfo?.providerId || 'unknown provider'}) deleted successfully by user ${userId} from team ${teamId}`); return reply.status(200).send({ success: true, @@ -437,10 +522,12 @@ export default async function cloudCredentialsRoutes(fastify: FastifyInstance) { } catch (error) { request.log.error({ error, - operation: 'delete_cloud_credential', + operation: 'delete_cloud_credential_error', teamId: (request.params as any).teamId, credentialId: (request.params as any).credentialId, - userId: request.user?.id + userId: request.user?.id, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined }, 'Failed to delete cloud credential'); return reply.status(500).send({ diff --git a/services/backend/src/routes/cloud-credentials/schemas.ts b/services/backend/src/routes/cloud-credentials/schemas.ts index 8e1ad487b..3e84d1f96 100644 --- a/services/backend/src/routes/cloud-credentials/schemas.ts +++ b/services/backend/src/routes/cloud-credentials/schemas.ts @@ -29,6 +29,13 @@ export const CredentialFieldResponseSchema = z.object({ secret: z.boolean(), }); +// User info schema for createdBy field +export const UserInfoSchema = z.object({ + id: z.string(), + username: z.string(), + email: z.string(), +}); + export const CloudCredentialResponseSchema = z.object({ id: z.string(), teamId: z.string(), @@ -41,7 +48,7 @@ export const CloudCredentialResponseSchema = z.object({ description: z.string(), }), fields: z.record(CredentialFieldResponseSchema), - createdBy: z.string(), + createdBy: z.union([UserInfoSchema, z.string()]).describe('User object when available, fallback to user ID'), createdAt: z.string(), updatedAt: z.string(), }); @@ -57,7 +64,7 @@ export const CloudCredentialBasicResponseSchema = z.object({ name: z.string(), description: z.string(), }), - createdBy: z.string(), + createdBy: z.union([UserInfoSchema, z.string()]).describe('User object when available, fallback to user ID'), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/services/backend/src/routes/db/setup.ts b/services/backend/src/routes/db/setup.ts index 96fa07291..9511597b0 100644 --- a/services/backend/src/routes/db/setup.ts +++ b/services/backend/src/routes/db/setup.ts @@ -233,7 +233,7 @@ async function setupDbHandler( // Fastify plugin to register the database setup route export default async function dbSetupRoute(server: FastifyInstance) { server.post( - '/api/db/setup', + '/db/setup', { schema: dbSetupRouteSchema }, async (request, reply) => setupDbHandler(request, reply, server) ); diff --git a/services/backend/src/routes/db/status.ts b/services/backend/src/routes/db/status.ts index 07d099782..3d9cb9891 100644 --- a/services/backend/src/routes/db/status.ts +++ b/services/backend/src/routes/db/status.ts @@ -50,10 +50,10 @@ async function getDbStatusHandler( } } -// Fastify plugin to register the /api/db/status route +// Fastify plugin to register the /db/status route export default async function dbStatusRoute(server: FastifyInstance) { server.get( - '/api/db/status', + '/db/status', { schema: dbStatusRouteSchema }, async (request, reply) => getDbStatusHandler(request, reply, server) ); diff --git a/services/backend/src/routes/globalSettings/index.ts b/services/backend/src/routes/globalSettings/index.ts index 7ef06d550..2a16260eb 100644 --- a/services/backend/src/routes/globalSettings/index.ts +++ b/services/backend/src/routes/globalSettings/index.ts @@ -88,8 +88,8 @@ const paramsWithGroupIdSchema = z.object({ }); export default async function globalSettingsRoute(fastify: FastifyInstance) { - // GET /api/settings/groups - List all groups with their settings (admin only) - fastify.get('/api/settings/groups', { + // GET /settings/groups - List all groups with their settings (admin only) + fastify.get('/settings/groups', { schema: { tags: ['Global Settings'], summary: 'List all setting groups', @@ -131,8 +131,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // GET /api/settings - List all global settings (admin only) - fastify.get('/api/settings', { + // GET /settings - List all global settings (admin only) + fastify.get('/settings', { schema: { tags: ['Global Settings'], summary: 'List all global settings', @@ -174,8 +174,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // GET /api/settings/:key - Get specific global setting (admin only) - fastify.get<{ Params: { key: string } }>('/api/settings/:key', { + // GET /settings/:key - Get specific global setting (admin only) + fastify.get<{ Params: { key: string } }>('/settings/:key', { schema: { tags: ['Global Settings'], summary: 'Get global setting by key', @@ -234,8 +234,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // POST /api/settings - Create new global setting (admin only) - fastify.post<{ Body: CreateGlobalSettingInput }>('/api/settings', { + // POST /settings - Create new global setting (admin only) + fastify.post<{ Body: CreateGlobalSettingInput }>('/settings', { schema: { tags: ['Global Settings'], summary: 'Create new global setting', @@ -320,8 +320,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // PUT /api/settings/:key - Update existing global setting (admin only) - fastify.put<{ Params: { key: string }; Body: UpdateGlobalSettingInput }>('/api/settings/:key', { + // PUT /settings/:key - Update existing global setting (admin only) + fastify.put<{ Params: { key: string }; Body: UpdateGlobalSettingInput }>('/settings/:key', { schema: { tags: ['Global Settings'], summary: 'Update global setting', @@ -400,8 +400,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // DELETE /api/settings/:key - Delete global setting (admin only) - fastify.delete<{ Params: { key: string } }>('/api/settings/:key', { + // DELETE /settings/:key - Delete global setting (admin only) + fastify.delete<{ Params: { key: string } }>('/settings/:key', { schema: { tags: ['Global Settings'], summary: 'Delete global setting', @@ -461,8 +461,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // GET /api/settings/group/:groupId - Get settings by group (admin only) - fastify.get<{ Params: { groupId: string } }>('/api/settings/group/:groupId', { + // GET /settings/group/:groupId - Get settings by group (admin only) + fastify.get<{ Params: { groupId: string } }>('/settings/group/:groupId', { schema: { tags: ['Global Settings'], summary: 'Get settings by group', @@ -510,8 +510,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // GET /api/settings/categories - Get all categories (admin only) - fastify.get('/api/settings/categories', { + // GET /settings/categories - Get all categories (admin only) + fastify.get('/settings/categories', { schema: { tags: ['Global Settings'], summary: 'Get all categories', @@ -554,8 +554,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // POST /api/settings/search - Search settings by key pattern (admin only) - fastify.post<{ Body: SearchGlobalSettingsInput }>('/api/settings/search', { + // POST /settings/search - Search settings by key pattern (admin only) + fastify.post<{ Body: SearchGlobalSettingsInput }>('/settings/search', { schema: { tags: ['Global Settings'], summary: 'Search settings', @@ -616,8 +616,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // POST /api/settings/bulk - Bulk create/update settings (admin only) - fastify.post<{ Body: BulkGlobalSettingsInput }>('/api/settings/bulk', { + // POST /settings/bulk - Bulk create/update settings (admin only) + fastify.post<{ Body: BulkGlobalSettingsInput }>('/settings/bulk', { schema: { tags: ['Global Settings'], summary: 'Bulk create/update settings', @@ -721,8 +721,8 @@ export default async function globalSettingsRoute(fastify: FastifyInstance) { } }); - // GET /api/settings/health - Health check for encryption system (admin only) - fastify.get('/api/settings/health', { + // GET /settings/health - Health check for encryption system (admin only) + fastify.get('/settings/health', { schema: { tags: ['Global Settings'], summary: 'Health check', diff --git a/services/backend/src/routes/index.ts b/services/backend/src/routes/index.ts index 79698180d..6a2d22fa6 100644 --- a/services/backend/src/routes/index.ts +++ b/services/backend/src/routes/index.ts @@ -23,22 +23,25 @@ const healthCheckResponseSchema = z.object({ }); export const registerRoutes = (server: FastifyInstance): void => { - // Register the individual database setup routes - server.register(dbStatusRoute); - server.register(dbSetupRoute); + // Register all API routes with centralized /api prefix + server.register(async (apiInstance) => { + // Register the individual database setup routes + await apiInstance.register(dbStatusRoute); + await apiInstance.register(dbSetupRoute); + + // Register role and user management routes + await apiInstance.register(rolesRoute); + await apiInstance.register(usersRoute); - // Register role and user management routes - server.register(rolesRoute); - server.register(usersRoute); - - // Register global settings routes - server.register(globalSettingsRoute); - - // Register teams routes - server.register(teamsRoute); - - // Register cloud credentials routes - server.register(cloudCredentialsRoute); + // Register global settings routes + await apiInstance.register(globalSettingsRoute); + + // Register teams routes + await apiInstance.register(teamsRoute); + + // Register cloud credentials routes + await apiInstance.register(cloudCredentialsRoute); + }, { prefix: '/api' }); // Define a default route with comprehensive OpenAPI documentation server.get('/', { diff --git a/services/backend/src/routes/roles/index.ts b/services/backend/src/routes/roles/index.ts index cb1aacc91..9a58777c4 100644 --- a/services/backend/src/routes/roles/index.ts +++ b/services/backend/src/routes/roles/index.ts @@ -50,8 +50,8 @@ const paramsWithIdSchema = z.object({ export default async function rolesRoute(fastify: FastifyInstance) { const roleService = new RoleService(); - // GET /api/roles - List all roles - fastify.get('/api/roles', { + // GET /roles - List all roles + fastify.get('/roles', { schema: { tags: ['Roles'], summary: 'List all roles', @@ -93,8 +93,8 @@ export default async function rolesRoute(fastify: FastifyInstance) { } }); - // GET /api/roles/:id - Get role by ID - fastify.get<{ Params: { id: string } }>('/api/roles/:id', { + // GET /roles/:id - Get role by ID + fastify.get<{ Params: { id: string } }>('/roles/:id', { schema: { tags: ['Roles'], summary: 'Get role by ID', @@ -153,8 +153,8 @@ export default async function rolesRoute(fastify: FastifyInstance) { } }); - // POST /api/roles - Create new role - fastify.post<{ Body: CreateRoleInput }>('/api/roles', { + // POST /roles - Create new role + fastify.post<{ Body: CreateRoleInput }>('/roles', { schema: { tags: ['Roles'], summary: 'Create new role', @@ -243,8 +243,8 @@ export default async function rolesRoute(fastify: FastifyInstance) { } }); - // PUT /api/roles/:id - Update role - fastify.put<{ Params: { id: string }; Body: UpdateRoleInput }>('/api/roles/:id', { + // PUT /roles/:id - Update role + fastify.put<{ Params: { id: string }; Body: UpdateRoleInput }>('/roles/:id', { schema: { tags: ['Roles'], summary: 'Update role', @@ -346,8 +346,8 @@ export default async function rolesRoute(fastify: FastifyInstance) { } }); - // DELETE /api/roles/:id - Delete role - fastify.delete<{ Params: { id: string } }>('/api/roles/:id', { + // DELETE /roles/:id - Delete role + fastify.delete<{ Params: { id: string } }>('/roles/:id', { schema: { tags: ['Roles'], summary: 'Delete role', @@ -426,8 +426,8 @@ export default async function rolesRoute(fastify: FastifyInstance) { } }); - // GET /api/roles/permissions - Get available permissions - fastify.get('/api/roles/permissions', { + // GET /roles/permissions - Get available permissions + fastify.get('/roles/permissions', { schema: { tags: ['Roles'], summary: 'Get available permissions', diff --git a/services/backend/src/routes/teams/index.ts b/services/backend/src/routes/teams/index.ts index 1d1a8e20c..30b72503b 100644 --- a/services/backend/src/routes/teams/index.ts +++ b/services/backend/src/routes/teams/index.ts @@ -14,8 +14,8 @@ import { } from './schemas'; export default async function teamsRoute(fastify: FastifyInstance) { - // GET /api/teams/me/default - Get current user's default team (must come before /me route) - fastify.get('/api/teams/me/default', { + // GET /teams/me/default - Get current user's default team (must come before /me route) + fastify.get('/teams/me/default', { schema: { tags: ['Teams'], summary: 'Get current user default team', @@ -72,8 +72,8 @@ export default async function teamsRoute(fastify: FastifyInstance) { } }); - // GET /api/teams/me - Get current user's teams (must come before /:id route) - fastify.get('/api/teams/me', { + // GET /teams/me - Get current user's teams (must come before /:id route) + fastify.get('/teams/me', { schema: { tags: ['Teams'], summary: 'Get current user teams', @@ -129,8 +129,8 @@ export default async function teamsRoute(fastify: FastifyInstance) { } }); - // GET /api/teams/:id - Get team by ID - fastify.get<{ Params: { id: string } }>('/api/teams/:id', { + // GET /teams/:id - Get team by ID + fastify.get<{ Params: { id: string } }>('/teams/:id', { schema: { tags: ['Teams'], summary: 'Get team by ID', @@ -208,8 +208,8 @@ export default async function teamsRoute(fastify: FastifyInstance) { } }); - // POST /api/teams - Create a new team - fastify.post<{ Body: CreateTeamInput }>('/api/teams', { + // POST /teams - Create a new team + fastify.post<{ Body: CreateTeamInput }>('/teams', { schema: { tags: ['Teams'], summary: 'Create new team', @@ -303,8 +303,8 @@ export default async function teamsRoute(fastify: FastifyInstance) { } }); - // PUT /api/teams/:id - Update team - fastify.put<{ Params: { id: string }; Body: UpdateTeamInput }>('/api/teams/:id', { + // PUT /teams/:id - Update team + fastify.put<{ Params: { id: string }; Body: UpdateTeamInput }>('/teams/:id', { schema: { tags: ['Teams'], summary: 'Update team', @@ -412,8 +412,8 @@ export default async function teamsRoute(fastify: FastifyInstance) { } }); - // DELETE /api/teams/:id - Delete team - fastify.delete<{ Params: { id: string } }>('/api/teams/:id', { + // DELETE /teams/:id - Delete team + fastify.delete<{ Params: { id: string } }>('/teams/:id', { schema: { tags: ['Teams'], summary: 'Delete team', diff --git a/services/backend/src/routes/users/index.ts b/services/backend/src/routes/users/index.ts index 33fbba9f1..d5246d263 100644 --- a/services/backend/src/routes/users/index.ts +++ b/services/backend/src/routes/users/index.ts @@ -68,8 +68,8 @@ const roleParamsSchema = z.object({ export default async function usersRoute(fastify: FastifyInstance) { const userService = new UserService(); - // GET /api/users - List all users (admin only) - fastify.get('/api/users', { + // GET /users - List all users (admin only) + fastify.get('/users', { schema: { tags: ['Users'], summary: 'List all users', @@ -111,8 +111,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/:id - Get user by ID (own profile or admin) - fastify.get<{ Params: { id: string } }>('/api/users/:id', { + // GET /users/:id - Get user by ID (own profile or admin) + fastify.get<{ Params: { id: string } }>('/users/:id', { schema: { tags: ['Users'], summary: 'Get user by ID', @@ -168,8 +168,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // PUT /api/users/:id - Update user (own profile or admin) - fastify.put<{ Params: { id: string }; Body: UpdateUserInput }>('/api/users/:id', { + // PUT /users/:id - Update user (own profile or admin) + fastify.put<{ Params: { id: string }; Body: UpdateUserInput }>('/users/:id', { schema: { tags: ['Users'], summary: 'Update user', @@ -278,8 +278,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // DELETE /api/users/:id - Delete user (admin only) - fastify.delete<{ Params: { id: string } }>('/api/users/:id', { + // DELETE /users/:id - Delete user (admin only) + fastify.delete<{ Params: { id: string } }>('/users/:id', { schema: { tags: ['Users'], summary: 'Delete user', @@ -354,8 +354,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // PUT /api/users/:id/role - Assign role to user (admin only) - fastify.put<{ Params: { id: string }; Body: AssignRoleInput }>('/api/users/:id/role', { + // PUT /users/:id/role - Assign role to user (admin only) + fastify.put<{ Params: { id: string }; Body: AssignRoleInput }>('/users/:id/role', { schema: { tags: ['Users'], summary: 'Assign role to user', @@ -444,8 +444,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/stats - Get user statistics (admin only) - fastify.get('/api/users/stats', { + // GET /users/stats - Get user statistics (admin only) + fastify.get('/users/stats', { schema: { tags: ['Users'], summary: 'Get user statistics', @@ -490,8 +490,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/role/:roleId - Get users by role (admin only) - fastify.get<{ Params: { roleId: string } }>('/api/users/role/:roleId', { + // GET /users/role/:roleId - Get users by role (admin only) + fastify.get<{ Params: { roleId: string } }>('/users/role/:roleId', { schema: { tags: ['Users'], summary: 'Get users by role', @@ -539,8 +539,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/me - Get current user profile - fastify.get('/api/users/me', { + // GET /users/me - Get current user profile + fastify.get('/users/me', { schema: { tags: ['Users'], summary: 'Get current user profile', @@ -593,8 +593,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/me/teams - Get current user's teams - fastify.get('/api/users/me/teams', { + // GET /users/me/teams - Get current user's teams + fastify.get('/users/me/teams', { schema: { tags: ['Users'], summary: 'Get current user teams', @@ -651,8 +651,8 @@ export default async function usersRoute(fastify: FastifyInstance) { } }); - // GET /api/users/:id/teams - Get teams for specific user (admin only) - fastify.get<{ Params: { id: string } }>('/api/users/:id/teams', { + // GET /users/:id/teams - Get teams for specific user (admin only) + fastify.get<{ Params: { id: string } }>('/users/:id/teams', { schema: { tags: ['Users'], summary: 'Get user teams by ID', diff --git a/services/backend/src/server.ts b/services/backend/src/server.ts index 18c14ceab..97e059dd8 100644 --- a/services/backend/src/server.ts +++ b/services/backend/src/server.ts @@ -424,7 +424,7 @@ export const createServer = async () => { }); // Register core routes and API for DB setup - registerRoutes(server); + registerRoutes(server); // Register Authentication Routes server.register(async (authInstance) => { diff --git a/services/backend/src/services/cloudCredentialsService.ts b/services/backend/src/services/cloudCredentialsService.ts index 468719fdc..abce5ed1b 100644 --- a/services/backend/src/services/cloudCredentialsService.ts +++ b/services/backend/src/services/cloudCredentialsService.ts @@ -26,10 +26,25 @@ export class CloudCredentialsService { async getTeamCredentials(teamId: string): Promise { const { db, schema } = this.getDbAndSchema(); const credentialsTable = schema.teamCloudCredentials; + const authUserTable = schema.authUser; const credentials = await (db as any) - .select() + .select({ + id: credentialsTable.id, + team_id: credentialsTable.team_id, + provider_id: credentialsTable.provider_id, + name: credentialsTable.name, + comment: credentialsTable.comment, + credentials: credentialsTable.credentials, + created_by: credentialsTable.created_by, + created_at: credentialsTable.created_at, + updated_at: credentialsTable.updated_at, + // User information + user_username: authUserTable.username, + user_email: authUserTable.email, + }) .from(credentialsTable) + .leftJoin(authUserTable, eq(credentialsTable.created_by, authUserTable.id)) .where(eq(credentialsTable.team_id, teamId)); return credentials.map((cred: any) => this.formatCredentialResponse(cred)); @@ -41,10 +56,25 @@ export class CloudCredentialsService { async getTeamCredentialsBasic(teamId: string): Promise { const { db, schema } = this.getDbAndSchema(); const credentialsTable = schema.teamCloudCredentials; + const authUserTable = schema.authUser; const credentials = await (db as any) - .select() + .select({ + id: credentialsTable.id, + team_id: credentialsTable.team_id, + provider_id: credentialsTable.provider_id, + name: credentialsTable.name, + comment: credentialsTable.comment, + credentials: credentialsTable.credentials, + created_by: credentialsTable.created_by, + created_at: credentialsTable.created_at, + updated_at: credentialsTable.updated_at, + // User information + user_username: authUserTable.username, + user_email: authUserTable.email, + }) .from(credentialsTable) + .leftJoin(authUserTable, eq(credentialsTable.created_by, authUserTable.id)) .where(eq(credentialsTable.team_id, teamId)); return credentials.map((cred: any) => this.formatCredentialBasicResponse(cred)); @@ -71,10 +101,25 @@ export class CloudCredentialsService { async getCredentialById(credentialId: string, teamId: string): Promise { const { db, schema } = this.getDbAndSchema(); const credentialsTable = schema.teamCloudCredentials; + const authUserTable = schema.authUser; const credentials = await (db as any) - .select() + .select({ + id: credentialsTable.id, + team_id: credentialsTable.team_id, + provider_id: credentialsTable.provider_id, + name: credentialsTable.name, + comment: credentialsTable.comment, + credentials: credentialsTable.credentials, + created_by: credentialsTable.created_by, + created_at: credentialsTable.created_at, + updated_at: credentialsTable.updated_at, + // User information + user_username: authUserTable.username, + user_email: authUserTable.email, + }) .from(credentialsTable) + .leftJoin(authUserTable, eq(credentialsTable.created_by, authUserTable.id)) .where(and( eq(credentialsTable.id, credentialId), eq(credentialsTable.team_id, teamId) @@ -272,14 +317,29 @@ export class CloudCredentialsService { const { db, schema } = this.getDbAndSchema(); const credentialsTable = schema.teamCloudCredentials; - const result = await (db as any) + // First check if the credential exists + const existing = await (db as any) + .select({ id: credentialsTable.id }) + .from(credentialsTable) + .where(and( + eq(credentialsTable.id, credentialId), + eq(credentialsTable.team_id, teamId) + )) + .limit(1); + + if (existing.length === 0) { + return false; + } + + // Delete the credential + await (db as any) .delete(credentialsTable) .where(and( eq(credentialsTable.id, credentialId), eq(credentialsTable.team_id, teamId) )); - return result.changes > 0; + return true; } /** @@ -390,6 +450,15 @@ export class CloudCredentialsService { } } + // Format createdBy as user object if user info is available + const createdBy = credentialData.user_username && credentialData.user_email + ? { + id: credentialData.created_by, + username: credentialData.user_username, + email: credentialData.user_email, + } + : credentialData.created_by; // Fallback to ID if user info not available + return { id: credentialData.id, teamId: credentialData.team_id, @@ -402,7 +471,7 @@ export class CloudCredentialsService { description: provider.description, }, fields, - createdBy: credentialData.created_by, + createdBy, createdAt: credentialData.created_at.toISOString(), updatedAt: credentialData.updated_at.toISOString(), }; @@ -417,6 +486,15 @@ export class CloudCredentialsService { throw new Error('Invalid provider ID in stored credential'); } + // Format createdBy as user object if user info is available + const createdBy = credentialData.user_username && credentialData.user_email + ? { + id: credentialData.created_by, + username: credentialData.user_username, + email: credentialData.user_email, + } + : credentialData.created_by; // Fallback to ID if user info not available + return { id: credentialData.id, teamId: credentialData.team_id, @@ -428,7 +506,7 @@ export class CloudCredentialsService { name: provider.name, description: provider.description, }, - createdBy: credentialData.created_by, + createdBy, createdAt: credentialData.created_at.toISOString(), updatedAt: credentialData.updated_at.toISOString(), }; diff --git a/services/backend/src/types/cloud-providers.ts b/services/backend/src/types/cloud-providers.ts index fe6805c4a..aedee57ed 100644 --- a/services/backend/src/types/cloud-providers.ts +++ b/services/backend/src/types/cloud-providers.ts @@ -12,6 +12,12 @@ export interface CredentialFieldResponse { secret: boolean; // From provider config } +export interface UserInfo { + id: string; + username: string; + email: string; +} + export interface CloudCredentialResponse { id: string; teamId: string; @@ -24,7 +30,7 @@ export interface CloudCredentialResponse { description: string; }; fields: Record; - createdBy: string; + createdBy: UserInfo | string; // User object when available, fallback to ID createdAt: string; updatedAt: string; } @@ -40,7 +46,7 @@ export interface CloudCredentialBasicResponse { name: string; description: string; }; - createdBy: string; + createdBy: UserInfo | string; // User object when available, fallback to ID createdAt: string; updatedAt: string; } diff --git a/services/frontend/src/components/credentials/columns.ts b/services/frontend/src/components/credentials/columns.ts index 025a0e7fc..145dc757c 100644 --- a/services/frontend/src/components/credentials/columns.ts +++ b/services/frontend/src/components/credentials/columns.ts @@ -8,16 +8,21 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { MoreHorizontal, Edit, Trash2 } from 'lucide-vue-next' +import { MoreHorizontal, Edit, Trash2, Settings } from 'lucide-vue-next' import type { CloudCredential, CloudCredentialBasic } from '@/types/credentials' export function createColumns( handleEdit: (credentialId: string) => void, handleDelete: (credentialId: string) => void, - userPermissions: string[] + handleManage: (credentialId: string) => void, + userPermissions: string[], + isTeamAdmin: boolean = false ): ColumnDef[] { - const canEdit = userPermissions.includes('cloud_credentials.edit') - const canDelete = userPermissions.includes('cloud_credentials.delete') + // Cloud credentials permissions are team-contextual + // Only team_admin can edit/delete, team_user has read-only access + const isGlobalAdmin = userPermissions.includes('system.admin') + const canEdit = isTeamAdmin || isGlobalAdmin + const canDelete = isTeamAdmin // Only team_admin can delete, never team_user const showActions = canEdit || canDelete return [ @@ -62,6 +67,22 @@ export function createColumns( }, comment || 'No comment') }, }, + { + accessorKey: 'createdBy', + header: 'Created By', + cell: ({ row }) => { + const createdBy = row.getValue('createdBy') as any + if (typeof createdBy === 'object' && createdBy.username) { + return h('div', { class: 'text-sm' }, [ + h('div', { class: 'font-medium' }, createdBy.username), + h('div', { class: 'text-xs text-muted-foreground' }, createdBy.email) + ]) + } + return h('div', { class: 'text-sm text-muted-foreground' }, + typeof createdBy === 'string' ? createdBy : 'Unknown' + ) + }, + }, { accessorKey: 'createdAt', header: 'Created', @@ -77,6 +98,26 @@ export function createColumns( ) }, }, + { + id: 'manage', + header: 'Manage', + enableHiding: false, + cell: ({ row }: { row: Row }) => { + const credential = row.original + + return h('div', { class: 'flex justify-end' }, [ + h(Button, { + variant: 'outline', + size: 'sm', + class: 'h-8 px-3', + onClick: () => handleManage(credential.id) + }, () => [ + h(Settings, { class: 'h-4 w-4 mr-1' }), + 'Manage' + ]) + ]) + }, + }, ...(showActions ? [{ id: 'actions', header: 'Actions', diff --git a/services/frontend/src/i18n/locales/en/credentials.ts b/services/frontend/src/i18n/locales/en/credentials.ts index df618e022..593426ead 100644 --- a/services/frontend/src/i18n/locales/en/credentials.ts +++ b/services/frontend/src/i18n/locales/en/credentials.ts @@ -102,6 +102,46 @@ export default { title: 'No credentials yet', description: 'Get started by adding your first cloud provider credential', action: 'Add First Credential' + }, + detail: { + title: 'Credential Details', + backToCredentials: 'Back to Credentials', + loading: 'Loading credential...', + error: 'Failed to load credential: {error}', + notFound: 'Credential not found', + credentialInformation: 'Credential Information', + providerInformation: 'Provider Information', + fieldInformation: 'Field Information', + auditInformation: 'Audit Information', + fields: { + name: 'Name', + provider: 'Provider', + comment: 'Comment', + createdAt: 'Created', + updatedAt: 'Last Updated', + createdBy: 'Created By', + credentialId: 'Credential ID', + providerDescription: 'Provider Description', + fieldType: 'Field Type', + fieldRequired: 'Required', + fieldSecret: 'Secret', + hasValue: 'Has Value' + }, + values: { + noComment: 'No comment provided', + notProvided: 'Not provided', + yes: 'Yes', + no: 'No', + text: 'Text', + password: 'Password', + textarea: 'Text Area', + configured: 'Configured', + notConfigured: 'Not configured' + }, + permissions: { + teamUserNote: 'As a team user, you can view basic credential information but cannot see field details.', + teamAdminNote: 'As a team admin, you can view all credential details including field information.' + } } } } diff --git a/services/frontend/src/router/index.ts b/services/frontend/src/router/index.ts index 96e5a0b16..f52b78027 100644 --- a/services/frontend/src/router/index.ts +++ b/services/frontend/src/router/index.ts @@ -89,6 +89,12 @@ const routes = [ component: () => import('../views/Credentials.vue'), meta: { requiresSetup: true }, }, + { + path: '/credentials/:id', + name: 'CredentialDetail', + component: () => import('../views/CredentialDetail.vue'), + meta: { requiresSetup: true }, + }, { path: '/teams', name: 'Teams', diff --git a/services/frontend/src/services/credentialsService.ts b/services/frontend/src/services/credentialsService.ts index dcd4ec2cb..5e9218f8c 100644 --- a/services/frontend/src/services/credentialsService.ts +++ b/services/frontend/src/services/credentialsService.ts @@ -28,7 +28,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-providers`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-providers`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -66,7 +66,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -109,7 +109,7 @@ export class CredentialsService { const apiUrl = this.getApiUrl() const searchParams = new URLSearchParams({ q: query, limit: limit.toString() }) - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/search?${searchParams}`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials/search?${searchParams}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -147,7 +147,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials/${credentialId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -191,7 +191,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -239,7 +239,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials/${credentialId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -286,7 +286,7 @@ export class CredentialsService { try { const apiUrl = this.getApiUrl() - const response = await fetch(`${apiUrl}/teams/${teamId}/cloud-credentials/${credentialId}`, { + const response = await fetch(`${apiUrl}/api/teams/${teamId}/cloud-credentials/${credentialId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', diff --git a/services/frontend/src/types/credentials.ts b/services/frontend/src/types/credentials.ts index 4b22d350d..68f80e086 100644 --- a/services/frontend/src/types/credentials.ts +++ b/services/frontend/src/types/credentials.ts @@ -21,6 +21,12 @@ export interface CredentialField { } } +export interface UserInfo { + id: string + username: string + email: string +} + export interface CloudCredential { id: string teamId: string @@ -37,7 +43,7 @@ export interface CloudCredential { secret: boolean value?: string // Only for non-secret fields for team_admin }> - createdBy: string + createdBy: UserInfo | string // User object when available, fallback to user ID createdAt: string updatedAt: string } @@ -53,7 +59,7 @@ export interface CloudCredentialBasic { name: string description: string } - createdBy: string + createdBy: UserInfo | string // User object when available, fallback to user ID createdAt: string updatedAt: string } diff --git a/services/frontend/src/views/CredentialDetail.vue b/services/frontend/src/views/CredentialDetail.vue new file mode 100644 index 000000000..1871f07aa --- /dev/null +++ b/services/frontend/src/views/CredentialDetail.vue @@ -0,0 +1,402 @@ + + + diff --git a/services/frontend/src/views/Credentials.vue b/services/frontend/src/views/Credentials.vue index f937018a2..a20d08107 100644 --- a/services/frontend/src/views/Credentials.vue +++ b/services/frontend/src/views/Credentials.vue @@ -1,6 +1,7 @@ - - diff --git a/services/frontend/src/components/WelcomeItem.vue b/services/frontend/src/components/WelcomeItem.vue deleted file mode 100644 index 6d7086aea..000000000 --- a/services/frontend/src/components/WelcomeItem.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/services/frontend/src/components/credentials/AddCredentialDialog.vue b/services/frontend/src/components/credentials/AddCredentialDialog.vue index 80316de17..b727ed6b5 100644 --- a/services/frontend/src/components/credentials/AddCredentialDialog.vue +++ b/services/frontend/src/components/credentials/AddCredentialDialog.vue @@ -1,7 +1,6 @@ + + diff --git a/services/frontend/src/components/credentials/columns.ts b/services/frontend/src/components/credentials/columns.ts deleted file mode 100644 index 145dc757c..000000000 --- a/services/frontend/src/components/credentials/columns.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { h } from 'vue' -import type { ColumnDef, Row } from '@tanstack/vue-table' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { MoreHorizontal, Edit, Trash2, Settings } from 'lucide-vue-next' -import type { CloudCredential, CloudCredentialBasic } from '@/types/credentials' - -export function createColumns( - handleEdit: (credentialId: string) => void, - handleDelete: (credentialId: string) => void, - handleManage: (credentialId: string) => void, - userPermissions: string[], - isTeamAdmin: boolean = false -): ColumnDef[] { - // Cloud credentials permissions are team-contextual - // Only team_admin can edit/delete, team_user has read-only access - const isGlobalAdmin = userPermissions.includes('system.admin') - const canEdit = isTeamAdmin || isGlobalAdmin - const canDelete = isTeamAdmin // Only team_admin can delete, never team_user - const showActions = canEdit || canDelete - - return [ - { - accessorKey: 'name', - header: 'Name', - cell: ({ row }) => { - const name = row.getValue('name') as string - return h('div', { class: 'font-medium' }, name) - }, - }, - { - accessorKey: 'provider', - header: 'Provider', - cell: ({ row }) => { - const provider = row.getValue('provider') as { id: string; name: string } - - return h('div', { class: 'flex items-center gap-2' }, [ - // Provider SVG icon - h('img', { - src: `/images/provider/${provider.id}.svg`, - alt: provider.name, - class: 'w-5 h-5', - onError: (event: Event) => { - // Hide broken image on error - const img = event.target as HTMLImageElement - img.style.display = 'none' - } - }), - // Provider badge - h(Badge, { variant: 'secondary' }, () => provider.name) - ]) - }, - }, - { - accessorKey: 'comment', - header: 'Comment', - cell: ({ row }) => { - const comment = row.getValue('comment') as string | null - return h('div', { - class: comment ? 'text-sm' : 'text-sm text-muted-foreground italic' - }, comment || 'No comment') - }, - }, - { - accessorKey: 'createdBy', - header: 'Created By', - cell: ({ row }) => { - const createdBy = row.getValue('createdBy') as any - if (typeof createdBy === 'object' && createdBy.username) { - return h('div', { class: 'text-sm' }, [ - h('div', { class: 'font-medium' }, createdBy.username), - h('div', { class: 'text-xs text-muted-foreground' }, createdBy.email) - ]) - } - return h('div', { class: 'text-sm text-muted-foreground' }, - typeof createdBy === 'string' ? createdBy : 'Unknown' - ) - }, - }, - { - accessorKey: 'createdAt', - header: 'Created', - cell: ({ row }) => { - const createdAt = row.getValue('createdAt') as string - const date = new Date(createdAt) - return h('div', { class: 'text-sm text-muted-foreground' }, - date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - }) - ) - }, - }, - { - id: 'manage', - header: 'Manage', - enableHiding: false, - cell: ({ row }: { row: Row }) => { - const credential = row.original - - return h('div', { class: 'flex justify-end' }, [ - h(Button, { - variant: 'outline', - size: 'sm', - class: 'h-8 px-3', - onClick: () => handleManage(credential.id) - }, () => [ - h(Settings, { class: 'h-4 w-4 mr-1' }), - 'Manage' - ]) - ]) - }, - }, - ...(showActions ? [{ - id: 'actions', - header: 'Actions', - cell: ({ row }: { row: Row }) => { - const credential = row.original - - return h('div', { class: 'flex justify-end' }, [ - h(DropdownMenu, {}, { - default: () => [ - h(DropdownMenuTrigger, { asChild: true }, { - default: () => h(Button, { - variant: 'ghost', - class: 'h-8 w-8 p-0' - }, { - default: () => [ - h('span', { class: 'sr-only' }, 'Open menu'), - h(MoreHorizontal, { class: 'h-4 w-4' }) - ] - }) - }), - h(DropdownMenuContent, { align: 'end' }, { - default: () => [ - ...(canEdit ? [ - h(DropdownMenuItem, { - onClick: () => handleEdit(credential.id) - }, { - default: () => [ - h(Edit, { class: 'mr-2 h-4 w-4' }), - 'Edit' - ] - }) - ] : []), - ...(canDelete ? [ - h(DropdownMenuItem, { - onClick: () => handleDelete(credential.id), - class: 'text-destructive focus:text-destructive' - }, { - default: () => [ - h(Trash2, { class: 'mr-2 h-4 w-4' }), - 'Delete' - ] - }) - ] : []) - ] - }) - ] - }) - ]) - }, - }] : []) - ] -} diff --git a/services/frontend/src/components/icons/GitHubIcon.vue b/services/frontend/src/components/icons/GitHubIcon.vue deleted file mode 100644 index 49ebdacff..000000000 --- a/services/frontend/src/components/icons/GitHubIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/services/frontend/src/components/icons/IconCommunity.vue b/services/frontend/src/components/icons/IconCommunity.vue deleted file mode 100644 index 2dc8b0552..000000000 --- a/services/frontend/src/components/icons/IconCommunity.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/services/frontend/src/components/icons/IconDocumentation.vue b/services/frontend/src/components/icons/IconDocumentation.vue deleted file mode 100644 index 6d4791cfb..000000000 --- a/services/frontend/src/components/icons/IconDocumentation.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/services/frontend/src/components/icons/IconEcosystem.vue b/services/frontend/src/components/icons/IconEcosystem.vue deleted file mode 100644 index c3a4f078c..000000000 --- a/services/frontend/src/components/icons/IconEcosystem.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/services/frontend/src/components/icons/IconSupport.vue b/services/frontend/src/components/icons/IconSupport.vue deleted file mode 100644 index 7452834d3..000000000 --- a/services/frontend/src/components/icons/IconSupport.vue +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/services/frontend/src/components/icons/IconTooling.vue b/services/frontend/src/components/icons/IconTooling.vue deleted file mode 100644 index 660598d7c..000000000 --- a/services/frontend/src/components/icons/IconTooling.vue +++ /dev/null @@ -1,19 +0,0 @@ - - diff --git a/services/frontend/src/i18n/locales/en/credentials.ts b/services/frontend/src/i18n/locales/en/credentials.ts index 593426ead..d67157870 100644 --- a/services/frontend/src/i18n/locales/en/credentials.ts +++ b/services/frontend/src/i18n/locales/en/credentials.ts @@ -71,7 +71,10 @@ export default { actions: { edit: 'Edit', delete: 'Delete', - view: 'View Details' + view: 'View Details', + editCredential: 'Edit Credential', + editName: 'Edit Name', + updateSecrets: 'Update Secrets' }, delete: { title: 'Delete Credential', diff --git a/services/frontend/src/views/CredentialDetail.vue b/services/frontend/src/views/CredentialDetail.vue index 1871f07aa..3369a9d44 100644 --- a/services/frontend/src/views/CredentialDetail.vue +++ b/services/frontend/src/views/CredentialDetail.vue @@ -15,11 +15,17 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { ArrowLeft, Key, Shield, Calendar, User, AlertTriangle, Trash2, CheckCircle } from 'lucide-vue-next' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { ArrowLeft, Key, Shield, Calendar, User, AlertTriangle, Trash2, CheckCircle, Settings, Edit } from 'lucide-vue-next' import DashboardLayout from '@/components/DashboardLayout.vue' import { CredentialsService } from '@/services/credentialsService' import { TeamService, type Team } from '@/services/teamService' -import { UserService } from '@/services/userService' import { useEventBus } from '@/composables/useEventBus' import type { CloudCredential } from '@/types/credentials' @@ -165,12 +171,23 @@ const cancelDelete = () => { deleteError.value = null showDeleteModal.value = false } + +// Placeholder functions for new dropdown actions +const handleEditName = () => { + console.log('Edit Name clicked - functionality to be implemented') + // TODO: Implement edit name functionality +} + +const handleUpdateSecrets = () => { + console.log('Update Secrets clicked - functionality to be implemented') + // TODO: Implement update secrets functionality +}