From 5ea25976d0e766f2936dba783fc98789559c932d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 4 Mar 2025 13:31:08 -0500 Subject: [PATCH 1/3] feat(api-keys): Add API key management infrastructure - Introduce OrganizationApiKey model in Prisma schema - Create API key schema and validation in actions - Update ActionResponse type to support more detailed error handling - Add API keys section to settings layout and translations - Prepare groundwork for API key creation, management, and security features --- .../organization/create-api-key-action.ts | 117 ++++++++ .../organization/get-api-keys-action.ts | 70 +++++ .../organization/revoke-api-key-action.ts | 53 ++++ apps/app/src/actions/schema.ts | 10 +- apps/app/src/actions/types.ts | 2 +- .../(dashboard)/settings/api-keys/page.tsx | 38 +++ .../(app)/(dashboard)/settings/layout.tsx | 43 +-- apps/app/src/app/api/v1/api-keys/route.ts | 174 +++++++++++ .../src/app/api/v1/employees/[id]/route.ts | 273 ++++++++++++++++++ .../app/src/app/api/v1/employees/add/route.ts | 66 +++++ apps/app/src/app/api/v1/employees/route.ts | 141 +++++++++ .../tables/api-keys/create-api-key-dialog.tsx | 231 +++++++++++++++ .../src/components/tables/api-keys/index.tsx | 214 ++++++++++++++ apps/app/src/hooks/use-api-keys.ts | 49 ++++ apps/app/src/lib/api-key.ts | 176 +++++++++++ apps/app/src/locales/en.ts | 54 ++++ .../20250304160745_add_api_keys/migration.sql | 25 ++ .../migration.sql | 2 + packages/db/prisma/schema.prisma | 17 ++ 19 files changed, 1732 insertions(+), 23 deletions(-) create mode 100644 apps/app/src/actions/organization/create-api-key-action.ts create mode 100644 apps/app/src/actions/organization/get-api-keys-action.ts create mode 100644 apps/app/src/actions/organization/revoke-api-key-action.ts create mode 100644 apps/app/src/app/[locale]/(app)/(dashboard)/settings/api-keys/page.tsx create mode 100644 apps/app/src/app/api/v1/api-keys/route.ts create mode 100644 apps/app/src/app/api/v1/employees/[id]/route.ts create mode 100644 apps/app/src/app/api/v1/employees/add/route.ts create mode 100644 apps/app/src/app/api/v1/employees/route.ts create mode 100644 apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx create mode 100644 apps/app/src/components/tables/api-keys/index.tsx create mode 100644 apps/app/src/hooks/use-api-keys.ts create mode 100644 apps/app/src/lib/api-key.ts create mode 100644 packages/db/prisma/migrations/20250304160745_add_api_keys/migration.sql create mode 100644 packages/db/prisma/migrations/20250304165547_add_salt_to_api_keys/migration.sql diff --git a/apps/app/src/actions/organization/create-api-key-action.ts b/apps/app/src/actions/organization/create-api-key-action.ts new file mode 100644 index 0000000000..a2f24d66a7 --- /dev/null +++ b/apps/app/src/actions/organization/create-api-key-action.ts @@ -0,0 +1,117 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { apiKeySchema } from "@/actions/schema"; +import { generateApiKey, generateSalt, hashApiKey } from "@/lib/api-key"; +import { db } from "@bubba/db"; + +export const createApiKeyAction = authActionClient + .schema(apiKeySchema) + .metadata({ + name: "createApiKey", + track: { + event: "createApiKey", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + try { + const { name, expiresAt } = parsedInput; + console.log(`Creating API key "${name}" with expiration: ${expiresAt}`); + + // Generate a new API key and salt + const apiKey = generateApiKey(); + const salt = generateSalt(); + const hashedKey = hashApiKey(apiKey, salt); + console.log( + `Generated new API key for organization: ${ctx.user.organizationId}` + ); + + // Parse the expiration date + let expirationDate: Date | null = null; + if (expiresAt && expiresAt !== "never") { + const now = new Date(); + switch (expiresAt) { + case "30days": + expirationDate = new Date(now.setDate(now.getDate() + 30)); + break; + case "90days": + expirationDate = new Date(now.setDate(now.getDate() + 90)); + break; + case "1year": + expirationDate = new Date(now.setFullYear(now.getFullYear() + 1)); + break; + } + console.log(`Set expiration date to: ${expirationDate?.toISOString()}`); + } else { + console.log("No expiration date set for API key"); + } + + // Create the API key in the database + const apiKeyRecord = await db.organizationApiKey.create({ + data: { + name, + key: hashedKey, + salt, // Store the salt with the hashed key + expiresAt: expirationDate, + organizationId: ctx.user.organizationId!, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + }, + }); + console.log(`Successfully created API key with ID: ${apiKeyRecord.id}`); + + // Return the API key (this is the only time the plain text key will be available) + return { + success: true, + data: { + ...apiKeyRecord, + key: apiKey, + createdAt: apiKeyRecord.createdAt.toISOString(), + expiresAt: apiKeyRecord.expiresAt + ? apiKeyRecord.expiresAt.toISOString() + : null, + }, + }; + } catch (error) { + console.error("Error creating API key:", error); + + // Provide more specific error messages based on error type + if (error instanceof Error) { + console.error(`Error details: ${error.message}`); + + if (error.message.includes("Unique constraint")) { + return { + success: false, + error: { + code: "DUPLICATE_NAME", + message: "An API key with this name already exists", + }, + }; + } + + if (error.message.includes("Foreign key constraint")) { + return { + success: false, + error: { + code: "INVALID_ORGANIZATION", + message: + "The organization does not exist or you don't have access", + }, + }; + } + } + + return { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "An unexpected error occurred while creating the API key", + }, + }; + } + }); diff --git a/apps/app/src/actions/organization/get-api-keys-action.ts b/apps/app/src/actions/organization/get-api-keys-action.ts new file mode 100644 index 0000000000..73bb53998c --- /dev/null +++ b/apps/app/src/actions/organization/get-api-keys-action.ts @@ -0,0 +1,70 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { auth } from "@/auth"; +import { db } from "@bubba/db"; +import type { ActionResponse } from "@/actions/types"; + +export const getApiKeysAction = async (): Promise< + ActionResponse< + { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + isActive: boolean; + }[] + > +> => { + try { + const session = await auth(); + + if (!session?.user.organizationId) { + return { + success: false, + error: { + code: "UNAUTHORIZED", + message: "You must be logged in to perform this action", + }, + }; + } + + const apiKeys = await db.organizationApiKey.findMany({ + where: { + organizationId: session.user.organizationId, + isActive: true, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + lastUsedAt: true, + isActive: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return { + success: true, + data: apiKeys.map((key) => ({ + ...key, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, + lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, + })), + }; + } catch (error) { + console.error("Error fetching API keys:", error); + return { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "An error occurred while fetching API keys", + }, + }; + } +}; diff --git a/apps/app/src/actions/organization/revoke-api-key-action.ts b/apps/app/src/actions/organization/revoke-api-key-action.ts new file mode 100644 index 0000000000..77663176fa --- /dev/null +++ b/apps/app/src/actions/organization/revoke-api-key-action.ts @@ -0,0 +1,53 @@ +"use server"; + +import { authActionClient } from "@/actions/safe-action"; +import { z } from "zod"; +import { db } from "@bubba/db"; + +const revokeApiKeySchema = z.object({ + id: z.string().min(1), +}); + +export const revokeApiKeyAction = authActionClient + .schema(revokeApiKeySchema) + .metadata({ + name: "revokeApiKey", + track: { + event: "revokeApiKey", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + try { + const { id } = parsedInput; + + // Update the API key to set isActive to false + const result = await db.organizationApiKey.updateMany({ + where: { + id, + organizationId: ctx.user.organizationId!, + }, + data: { + isActive: false, + }, + }); + + if (result.count === 0) { + return { + success: false, + error: "API key not found or not authorized to revoke", + }; + } + + return { + success: true, + message: "API key revoked successfully", + }; + } catch (error) { + console.error("Error revoking API key:", error); + return { + success: false, + error: "An error occurred while revoking the API key", + }; + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 2391da858c..e12ed67f11 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -53,7 +53,7 @@ export const updaterMenuSchema = z.array( z.object({ path: z.string(), name: z.string(), - }), + }) ); export const organizationWebsiteSchema = z.object({ @@ -301,3 +301,11 @@ export const updatePolicyFormSchema = z.object({ review_frequency: z.nativeEnum(Frequency), review_date: z.date(), }); + +export const apiKeySchema = z.object({ + name: z + .string() + .min(1, { message: "Name is required" }) + .max(64, { message: "Name must be less than 64 characters" }), + expiresAt: z.enum(["30days", "90days", "1year", "never"]), +}); diff --git a/apps/app/src/actions/types.ts b/apps/app/src/actions/types.ts index 1bdc5801e1..708b40c629 100644 --- a/apps/app/src/actions/types.ts +++ b/apps/app/src/actions/types.ts @@ -1,7 +1,7 @@ export interface ActionResponse { success: boolean; data?: T | null; - error?: string; + error?: string | { code: string; message: string }; } export type ActionData = diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/settings/api-keys/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/api-keys/page.tsx new file mode 100644 index 0000000000..fb13e34e96 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/api-keys/page.tsx @@ -0,0 +1,38 @@ +import { ApiKeysTable } from "@/components/tables/api-keys"; +import { auth } from "@/auth"; +import { getI18n } from "@/locales/server"; +import type { Metadata } from "next"; +import { setStaticParamsLocale } from "next-international/server"; +import { redirect } from "next/navigation"; + +export default async function ApiKeysPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + const session = await auth(); + + if (!session?.user.organizationId) { + return redirect("/"); + } + + return ; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + setStaticParamsLocale(locale); + const t = await getI18n(); + + return { + title: t("settings.api_keys.title"), + }; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx index 2d2292fc25..5f1625c5ac 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/settings/layout.tsx @@ -3,29 +3,30 @@ import { SecondaryMenu } from "@bubba/ui/secondary-menu"; import { Suspense } from "react"; export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const t = await getI18n(); + const t = await getI18n(); - return ( -
- Loading...
}> - - + return ( +
+ Loading...
}> + + -
{children}
- - ); +
{children}
+ + ); } diff --git a/apps/app/src/app/api/v1/api-keys/route.ts b/apps/app/src/app/api/v1/api-keys/route.ts new file mode 100644 index 0000000000..be78fe55e2 --- /dev/null +++ b/apps/app/src/app/api/v1/api-keys/route.ts @@ -0,0 +1,174 @@ +import { db } from "@bubba/db"; +import { auth } from "@/auth"; +import { NextResponse, type NextRequest } from "next/server"; +import { generateApiKey, hashApiKey } from "@/lib/api-key"; + +// GET: List API keys for the authenticated user's organization +export async function GET(request: NextRequest) { + const session = await auth(); + + // Check if the user is authenticated + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if the user has an organization + if (!session.user.organizationId) { + return NextResponse.json( + { error: "User is not associated with an organization" }, + { status: 400 } + ); + } + + try { + const apiKeys = await db.organizationApiKey.findMany({ + where: { + organizationId: session.user.organizationId, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + lastUsedAt: true, + isActive: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return NextResponse.json({ success: true, data: apiKeys }); + } catch (error) { + console.error("Error fetching API keys:", error); + return NextResponse.json( + { error: "Failed to fetch API keys" }, + { status: 500 } + ); + } +} + +// POST: Create a new API key for the authenticated user's organization +export async function POST(request: NextRequest) { + const session = await auth(); + + // Check if the user is authenticated + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if the user has an organization + if (!session.user.organizationId) { + return NextResponse.json( + { error: "User is not associated with an organization" }, + { status: 400 } + ); + } + + try { + const body = await request.json(); + const { name, expiresAt } = body; + + // Validate the name + if (!name || typeof name !== "string" || name.trim() === "") { + return NextResponse.json( + { error: "A valid name for the API key is required" }, + { status: 400 } + ); + } + + // Generate a new API key + const apiKey = generateApiKey(); + const hashedKey = hashApiKey(apiKey); + + // Create the API key in the database + const apiKeyRecord = await db.organizationApiKey.create({ + data: { + name: name.trim(), + key: hashedKey, + expiresAt: expiresAt ? new Date(expiresAt) : null, + organizationId: session.user.organizationId, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + }, + }); + + // Return the API key (this is the only time the plain text key will be available) + return NextResponse.json({ + success: true, + data: { + ...apiKeyRecord, + key: apiKey, + }, + }); + } catch (error) { + console.error("Error creating API key:", error); + return NextResponse.json( + { error: "Failed to create API key" }, + { status: 500 } + ); + } +} + +// DELETE: Revoke an API key +export async function DELETE(request: NextRequest) { + const session = await auth(); + + // Check if the user is authenticated + if (!session || !session.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Check if the user has an organization + if (!session.user.organizationId) { + return NextResponse.json( + { error: "User is not associated with an organization" }, + { status: 400 } + ); + } + + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "API key ID is required" }, + { status: 400 } + ); + } + + // Update the API key to set isActive to false + const apiKey = await db.organizationApiKey.updateMany({ + where: { + id, + organizationId: session.user.organizationId, + }, + data: { + isActive: false, + }, + }); + + if (apiKey.count === 0) { + return NextResponse.json( + { error: "API key not found or not authorized to revoke" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + message: "API key revoked successfully", + }); + } catch (error) { + console.error("Error revoking API key:", error); + return NextResponse.json( + { error: "Failed to revoke API key" }, + { status: 500 } + ); + } +} diff --git a/apps/app/src/app/api/v1/employees/[id]/route.ts b/apps/app/src/app/api/v1/employees/[id]/route.ts new file mode 100644 index 0000000000..a3a2aeb726 --- /dev/null +++ b/apps/app/src/app/api/v1/employees/[id]/route.ts @@ -0,0 +1,273 @@ +import { db, Departments } from "@bubba/db"; +import { NextResponse, type NextRequest } from "next/server"; +import { getOrganizationFromApiKey } from "@/lib/api-key"; +import { z } from "zod"; + +// Define the schema for employee update +const employeeUpdateSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }).optional(), + email: z.string().email({ message: "Valid email is required" }).optional(), + department: z.nativeEnum(Departments).optional(), + isActive: z.boolean().optional(), + externalEmployeeId: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + linkId: z.string().optional().nullable(), +}); + +// Type for the validated update data +type EmployeeUpdateInput = z.infer; + +/** + * GET /api/v1/employees/:id + * + * Get a single employee by ID for the organization associated with the API key + * + * Headers: + * - Authorization: Bearer {api_key} or X-API-Key: {api_key} + * + * Path Parameters: + * - id: string - The ID of the employee to fetch + * + * Returns: + * - 200: { success: true, data: Employee } + * - 401: { error: "Invalid or missing API key" } + * - 404: { error: "Employee not found" } + * - 500: { error: "Failed to fetch employee" } + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const employeeId = params.id; + + // Fetch the employee + const employee = await db.employee.findFirst({ + where: { + id: employeeId, + organizationId: organizationId!, + }, + select: { + id: true, + name: true, + email: true, + department: true, + isActive: true, + externalEmployeeId: true, + createdAt: true, + updatedAt: true, + }, + }); + + // If employee not found, return 404 + if (!employee) { + return NextResponse.json( + { error: "Employee not found" }, + { status: 404 } + ); + } + + // Format dates for JSON response + const formattedEmployee = { + ...employee, + createdAt: employee.createdAt.toISOString(), + updatedAt: employee.updatedAt.toISOString(), + }; + + return NextResponse.json({ success: true, data: formattedEmployee }); + } catch (error) { + console.error("Error fetching employee:", error); + return NextResponse.json( + { error: "Failed to fetch employee" }, + { status: 500 } + ); + } +} + +/** + * PUT /api/v1/employees/:id + * + * Update an employee by ID for the organization associated with the API key + * + * Headers: + * - Authorization: Bearer {api_key} or X-API-Key: {api_key} + * + * Path Parameters: + * - id: string - The ID of the employee to update + * + * Body: + * - name: string - The name of the employee (optional) + * - email: string - The email of the employee (optional) + * - department: Departments - The department of the employee (optional) + * - isActive: boolean - Whether the employee is active (optional) + * - externalEmployeeId: string - External employee ID (optional) + * - userId: string - User ID (optional) + * - linkId: string - Link ID (optional) + * + * Returns: + * - 200: { success: true, data: Employee } + * - 400: { error: "Validation failed", details: {...} } + * - 401: { error: "Invalid or missing API key" } + * - 404: { error: "Employee not found" } + * - 500: { error: "Failed to update employee" } + */ +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const employeeId = params.id; + const body = await request.json(); + + // Validate the request body against the schema + const validationResult = employeeUpdateSchema.safeParse(body); + + if (!validationResult.success) { + // Return validation errors + return NextResponse.json( + { + error: "Validation failed", + details: validationResult.error.format(), + }, + { status: 400 } + ); + } + + // Extract validated data + const validatedData: EmployeeUpdateInput = validationResult.data; + + // Check if the employee exists and belongs to the organization + const existingEmployee = await db.employee.findFirst({ + where: { + id: employeeId, + organizationId: organizationId!, + }, + }); + + if (!existingEmployee) { + return NextResponse.json( + { error: "Employee not found" }, + { status: 404 } + ); + } + + // Update the employee + const updatedEmployee = await db.employee.update({ + where: { + id: employeeId, + }, + data: validatedData, + select: { + id: true, + name: true, + email: true, + department: true, + isActive: true, + externalEmployeeId: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Format dates for JSON response + const formattedEmployee = { + ...updatedEmployee, + createdAt: updatedEmployee.createdAt.toISOString(), + updatedAt: updatedEmployee.updatedAt.toISOString(), + }; + + return NextResponse.json({ success: true, data: formattedEmployee }); + } catch (error) { + console.error("Error updating employee:", error); + return NextResponse.json( + { error: "Failed to update employee" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/v1/employees/:id + * + * Delete an employee by ID for the organization associated with the API key + * + * Headers: + * - Authorization: Bearer {api_key} or X-API-Key: {api_key} + * + * Path Parameters: + * - id: string - The ID of the employee to delete + * + * Returns: + * - 200: { success: true, message: "Employee deleted successfully" } + * - 401: { error: "Invalid or missing API key" } + * - 404: { error: "Employee not found" } + * - 500: { error: "Failed to delete employee" } + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const employeeId = params.id; + + // Check if the employee exists and belongs to the organization + const existingEmployee = await db.employee.findFirst({ + where: { + id: employeeId, + organizationId: organizationId!, + }, + }); + + if (!existingEmployee) { + return NextResponse.json( + { error: "Employee not found" }, + { status: 404 } + ); + } + + // Delete the employee + await db.employee.delete({ + where: { + id: employeeId, + }, + }); + + return NextResponse.json({ + success: true, + message: "Employee deleted successfully", + }); + } catch (error) { + console.error("Error deleting employee:", error); + return NextResponse.json( + { error: "Failed to delete employee" }, + { status: 500 } + ); + } +} diff --git a/apps/app/src/app/api/v1/employees/add/route.ts b/apps/app/src/app/api/v1/employees/add/route.ts new file mode 100644 index 0000000000..a1ecd55227 --- /dev/null +++ b/apps/app/src/app/api/v1/employees/add/route.ts @@ -0,0 +1,66 @@ +import { db, Departments } from "@bubba/db"; +import { NextResponse, type NextRequest } from "next/server"; +import { getOrganizationFromApiKey } from "@/lib/api-key"; +import { z } from "zod"; + +// Define the schema for employee creation +const employeeCreateSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + email: z.string().email({ message: "Valid email is required" }), + department: z.nativeEnum(Departments).optional().default(Departments.none), + isActive: z.boolean().optional().default(true), + externalEmployeeId: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + linkId: z.string().optional().nullable(), +}); + +// Type for the validated data +type EmployeeCreateInput = z.infer; + +export async function POST(request: NextRequest) { + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + const body = await request.json(); + + // Validate the request body against the schema + const validationResult = employeeCreateSchema.safeParse(body); + + if (!validationResult.success) { + // Return validation errors + return NextResponse.json( + { + error: "Validation failed", + details: validationResult.error.format(), + }, + { status: 400 } + ); + } + + // Extract validated data + const validatedData: EmployeeCreateInput = validationResult.data; + + // Create the employee using the organization ID from the API key + const employee = await db.employee.create({ + data: { + ...validatedData, + organizationId: organizationId!, + }, + }); + + return NextResponse.json({ success: true, data: employee }); + } catch (error) { + console.error("Error creating employee:", error); + return NextResponse.json( + { error: "Failed to create employee" }, + { status: 500 } + ); + } +} diff --git a/apps/app/src/app/api/v1/employees/route.ts b/apps/app/src/app/api/v1/employees/route.ts new file mode 100644 index 0000000000..c61ac07239 --- /dev/null +++ b/apps/app/src/app/api/v1/employees/route.ts @@ -0,0 +1,141 @@ +import { db, Departments } from "@bubba/db"; +import { NextResponse, type NextRequest } from "next/server"; +import { getOrganizationFromApiKey } from "@/lib/api-key"; +import { z } from "zod"; + +// Define the schema for query parameters +const queryParamsSchema = z.object({ + active: z + .string() + .optional() + .transform((val) => val === "true"), + department: z.nativeEnum(Departments).optional(), + search: z.string().optional(), +}); + +// Type for the validated query parameters +type QueryParams = z.infer; + +/** + * GET /api/v1/employees + * + * Get all employees for the organization associated with the API key + * + * Headers: + * - Authorization: Bearer {api_key} or X-API-Key: {api_key} + * + * Query Parameters: + * - active: boolean - Filter by active status (optional) + * - department: string - Filter by department (optional) + * - search: string - Search by name or email (optional) + * + * Returns: + * - 200: { success: true, data: Employee[] } + * - 401: { error: "Invalid or missing API key" } + * - 400: { error: "Validation failed", details: {...} } + * - 500: { error: "Failed to fetch employees" } + */ +export async function GET(request: NextRequest) { + // Get the organization ID from the API key + const { organizationId, errorResponse } = + await getOrganizationFromApiKey(request); + + // If there's an error response, return it + if (errorResponse) { + return errorResponse; + } + + try { + // Get query parameters + const searchParams = request.nextUrl.searchParams; + + // Create an object from the search params + const queryParamsObj = { + active: searchParams.get("active") || undefined, + department: searchParams.get("department") || undefined, + search: searchParams.get("search") || undefined, + }; + + // Validate query parameters + const validationResult = queryParamsSchema.safeParse(queryParamsObj); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Validation failed", + details: validationResult.error.format(), + }, + { status: 400 } + ); + } + + // Extract validated query parameters + const { active, department, search } = validationResult.data; + + // Build the where clause + const where: any = { + organizationId: organizationId!, + }; + + // Add active filter if provided + if (active !== undefined) { + where.isActive = active; + } + + // Add department filter if provided + if (department) { + where.department = department; + } + + // Add search filter if provided + if (search) { + where.OR = [ + { + name: { + contains: search, + mode: "insensitive", + }, + }, + { + email: { + contains: search, + mode: "insensitive", + }, + }, + ]; + } + + // Fetch employees + const employees = await db.employee.findMany({ + where, + select: { + id: true, + name: true, + email: true, + department: true, + isActive: true, + externalEmployeeId: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + name: "asc", + }, + }); + + // Format dates for JSON response + const formattedEmployees = employees.map((employee) => ({ + ...employee, + createdAt: employee.createdAt.toISOString(), + updatedAt: employee.updatedAt.toISOString(), + })); + + return NextResponse.json({ success: true, data: formattedEmployees }); + } catch (error) { + console.error("Error fetching employees:", error); + return NextResponse.json( + { error: "Failed to fetch employees" }, + { status: 500 } + ); + } +} diff --git a/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx b/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx new file mode 100644 index 0000000000..0de66e024c --- /dev/null +++ b/apps/app/src/components/tables/api-keys/create-api-key-dialog.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { createApiKeyAction } from "@/actions/organization/create-api-key-action"; +import { apiKeySchema } from "@/actions/schema"; +import { useI18n } from "@/locales/client"; +import { Button } from "@bubba/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bubba/ui/dialog"; +import { Input } from "@bubba/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { Copy, Check, Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { z } from "zod"; + +interface CreateApiKeyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreateApiKeyDialog({ + open, + onOpenChange, + onSuccess, +}: CreateApiKeyDialogProps) { + const t = useI18n(); + const [name, setName] = useState(""); + const [expiration, setExpiration] = useState< + "never" | "30days" | "90days" | "1year" + >("never"); + const [createdApiKey, setCreatedApiKey] = useState(null); + const [copied, setCopied] = useState(false); + + const { execute: createApiKey, status: isCreating } = useAction( + createApiKeyAction, + { + onSuccess: (data) => { + if (data.data?.data?.key) { + setCreatedApiKey(data.data.data.key); + if (onSuccess) onSuccess(); + } + }, + onError: (error) => { + console.log("error", error); + toast.error(t("settings.api_keys.create_error")); + }, + }, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + createApiKey({ + name, + expiresAt: expiration, + }); + }; + + const handleClose = () => { + if (isCreating !== "executing") { + setName(""); + setExpiration("never"); + setCreatedApiKey(null); + setCopied(false); + onOpenChange(false); + } + }; + + const copyToClipboard = async () => { + if (createdApiKey) { + try { + await navigator.clipboard.writeText(createdApiKey); + setCopied(true); + toast.success(t("settings.api_keys.copied")); + + // Reset copied state after 2 seconds + setTimeout(() => { + setCopied(false); + }, 2000); + } catch (err) { + toast.error(t("common.actions.error")); + } + } + }; + + return ( + + + {createdApiKey ? ( + <> + + {t("settings.api_keys.created_title")} + + {t("settings.api_keys.created_description")} + + +
+
+

+ {t("settings.api_keys.api_key")} +

+
+
+
+
+ + {createdApiKey} + +
+
+ +
+
+

+ {t("settings.api_keys.save_warning")} +

+
+
+ + + + + ) : ( + <> + + {t("settings.api_keys.create_title")} + + {t("settings.api_keys.create_description")} + + +
+
+ + setName(e.target.value)} + placeholder={t("settings.api_keys.name_placeholder")} + required + /> +
+
+ + +
+ + + + +
+ + )} +
+
+ ); +} diff --git a/apps/app/src/components/tables/api-keys/index.tsx b/apps/app/src/components/tables/api-keys/index.tsx new file mode 100644 index 0000000000..cd29f78b9e --- /dev/null +++ b/apps/app/src/components/tables/api-keys/index.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { revokeApiKeyAction } from "@/actions/organization/revoke-api-key-action"; +import { useApiKeys } from "@/hooks/use-api-keys"; +import { useI18n } from "@/locales/client"; +import { Button } from "@bubba/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@bubba/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bubba/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@bubba/ui/table"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { toast } from "sonner"; +import { CreateApiKeyDialog } from "./create-api-key-dialog"; + +export function ApiKeysTable() { + const t = useI18n(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [keyToRevoke, setKeyToRevoke] = useState(null); + const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false); + + // Use the custom hook for API keys data fetching + const { apiKeys, isLoading, error, refresh: refreshApiKeys } = useApiKeys(); + + // Use the useAction hook for revoking API keys + const { execute: revokeApiKey, status: isRevoking } = useAction( + revokeApiKeyAction, + { + onSuccess: () => { + toast.success(t("settings.api_keys.revoked_success")); + setIsRevokeDialogOpen(false); + setKeyToRevoke(null); + // Refresh the API keys data after successful revocation + refreshApiKeys(); + }, + onError: () => { + toast.error(t("settings.api_keys.revoked_error")); + setIsRevokeDialogOpen(false); + setKeyToRevoke(null); + }, + }, + ); + + const handleRevokeClick = (id: string) => { + setKeyToRevoke(id); + setIsRevokeDialogOpen(true); + }; + + const handleConfirmRevoke = () => { + if (keyToRevoke) { + revokeApiKey({ id: keyToRevoke }); + } + }; + + const handleCancelRevoke = () => { + setIsRevokeDialogOpen(false); + setKeyToRevoke(null); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + return new Date(dateString).toLocaleDateString(); + }; + + if (error) { + return ( + + + {t("settings.api_keys.list_title")} + + +
{error}
+
+
+ ); + } + + return ( + <> + + +
+ {t("settings.api_keys.list_title")} + + {t("settings.api_keys.list_description")} + +
+ +
+ + {isLoading ? ( +
+ +
+ ) : apiKeys.length === 0 ? ( +
+ {t("settings.api_keys.no_keys")} +
+ ) : ( + + + + {t("settings.api_keys.name")} + {t("settings.api_keys.created")} + {t("settings.api_keys.expires")} + {t("settings.api_keys.last_used")} + + {t("settings.api_keys.actions")} + + + + + {apiKeys.map((apiKey) => ( + + {apiKey.name} + {formatDate(apiKey.createdAt)} + + {apiKey.expiresAt + ? formatDate(apiKey.expiresAt) + : t("settings.api_keys.never")} + + + {apiKey.lastUsedAt + ? formatDate(apiKey.lastUsedAt) + : t("settings.api_keys.never_used")} + + + + + + ))} + +
+ )} +
+ + {t("settings.api_keys.security_note")} + + + +
+ + + + + {t("settings.api_keys.revoke_title")} + + {t("settings.api_keys.revoke_confirm")} + + + + + + + + + + ); +} diff --git a/apps/app/src/hooks/use-api-keys.ts b/apps/app/src/hooks/use-api-keys.ts new file mode 100644 index 0000000000..acb4256977 --- /dev/null +++ b/apps/app/src/hooks/use-api-keys.ts @@ -0,0 +1,49 @@ +"use client"; + +import { getApiKeysAction } from "@/actions/organization/get-api-keys-action"; +import { useI18n } from "@/locales/client"; +import { useCallback } from "react"; +import useSWR from "swr"; + +export interface ApiKey { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + isActive: boolean; +} + +/** + * Custom hook for fetching API keys + */ +export function useApiKeys() { + const t = useI18n(); + + // Fetcher function that calls the server action + const fetcher = useCallback(async () => { + const result = await getApiKeysAction(); + if (result.success && result.data) { + return result.data; + } + throw new Error(t("settings.api_keys.fetch_error")); + }, [t]); + + // Use SWR for data fetching with caching and revalidation + const { + data: apiKeys, + error, + isLoading, + mutate, + } = useSWR("api-keys", fetcher, { + revalidateOnFocus: false, + dedupingInterval: 10000, // 10 seconds + }); + + return { + apiKeys: apiKeys || [], + isLoading, + error: error ? error.message : null, + refresh: mutate, + }; +} diff --git a/apps/app/src/lib/api-key.ts b/apps/app/src/lib/api-key.ts new file mode 100644 index 0000000000..f66f434d84 --- /dev/null +++ b/apps/app/src/lib/api-key.ts @@ -0,0 +1,176 @@ +import { db } from "@bubba/db"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { createHash, randomBytes } from "node:crypto"; + +/** + * Generate a new API key + * @returns A new API key with prefix + */ +export function generateApiKey(): string { + // Generate a random string for the API key + const apiKey = randomBytes(32).toString("hex"); + // Add a prefix to make it easily identifiable + return `bubba_${apiKey}`; +} + +/** + * Generate a random salt for API key hashing + * @returns A random salt string + */ +export function generateSalt(): string { + return randomBytes(16).toString("hex"); +} + +/** + * Hash an API key for storage + * @param apiKey The API key to hash + * @param salt Optional salt to use for hashing. If not provided, the key is hashed without a salt (for backward compatibility). + * @returns The hashed API key + */ +export function hashApiKey(apiKey: string, salt?: string): string { + if (salt) { + // If salt is provided, use it for hashing + return createHash("sha256") + .update(apiKey + salt) + .digest("hex"); + } + // For backward compatibility, hash without salt + return createHash("sha256").update(apiKey).digest("hex"); +} + +/** + * Validate an API key from the request headers + * @param req The Next.js request object + * @returns The organization ID if the API key is valid, null otherwise + */ +export async function validateApiKey(req: NextRequest): Promise { + // Get the API key from the Authorization header + const authHeader = req.headers.get("Authorization"); + + if (!authHeader) { + return null; + } + + // Check if it's a Bearer token + if (authHeader.startsWith("Bearer ")) { + const apiKey = authHeader.substring(7); + return await validateApiKeyValue(apiKey); + } + + // Check if it's an X-API-Key header + const apiKey = req.headers.get("X-API-Key"); + if (apiKey) { + return await validateApiKeyValue(apiKey); + } + + return null; +} + +/** + * Validate an API key value + * @param apiKey The API key to validate + * @returns The organization ID if the API key is valid, null otherwise + */ +async function validateApiKeyValue(apiKey: string): Promise { + if (!apiKey) { + return null; + } + + try { + // Check if the model exists in the Prisma client + if (typeof db.organizationApiKey === "undefined") { + console.error( + "OrganizationApiKey model not found. Make sure to run migrations." + ); + return null; + } + + // Look up the API key in the database + const apiKeyRecords = await db.organizationApiKey.findMany({ + where: { + isActive: true, + }, + select: { + id: true, + key: true, + salt: true, + organizationId: true, + expiresAt: true, + }, + }); + + // Find the matching API key by hashing with each record's salt + const matchingRecord = apiKeyRecords.find((record) => { + // Hash the provided API key with the record's salt + const hashedKey = record.salt + ? hashApiKey(apiKey, record.salt) + : hashApiKey(apiKey); // For backward compatibility + + return hashedKey === record.key; + }); + + // If no matching key or the key is expired, return null + if ( + !matchingRecord || + (matchingRecord.expiresAt && matchingRecord.expiresAt < new Date()) + ) { + return null; + } + + // Update the lastUsedAt timestamp + await db.organizationApiKey.update({ + where: { + id: matchingRecord.id, + }, + data: { + lastUsedAt: new Date(), + }, + }); + + // Return the organization ID + return matchingRecord.organizationId; + } catch (error) { + console.error("Error validating API key:", error); + return null; + } +} + +/** + * Middleware to validate API keys for API routes + * @param req The Next.js request object + * @returns A response if the API key is invalid, or the organization ID if valid + */ +export async function apiKeyMiddleware( + req: NextRequest +): Promise { + const organizationId = await validateApiKey(req); + + if (!organizationId) { + return NextResponse.json( + { error: "Invalid or missing API key" }, + { status: 401 } + ); + } + + return organizationId; +} + +/** + * Get the organization ID from the API key in the request + * This is a helper function that handles the result of apiKeyMiddleware + * @param req The Next.js request object + * @returns An object with the organization ID and/or error response + */ +export async function getOrganizationFromApiKey(req: NextRequest): Promise<{ + organizationId?: string; + errorResponse?: NextResponse; +}> { + const result = await apiKeyMiddleware(req); + + if (result instanceof NextResponse) { + return { errorResponse: result }; + } + + return { organizationId: result }; +} diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts index b511c47dbd..914c4a6e63 100644 --- a/apps/app/src/locales/en.ts +++ b/apps/app/src/locales/en.ts @@ -632,6 +632,60 @@ export default { members: { title: "Members", }, + api_keys: { + title: "API Keys", + description: + "Manage API keys for programmatic access to your organization's data.", + list_title: "API Keys", + list_description: + "API keys allow secure access to your organization's data via our API.", + create: "Create API Key", + create_title: "Create API Key", + create_description: + "Create a new API key for programmatic access to your organization's data.", + created_title: "API Key Created", + created_description: + "Your API key has been created. Make sure to copy it now as you won't be able to see it again.", + name: "Name", + name_label: "Name", + name_placeholder: "Enter a name for this API key", + expiration: "Expiration", + expiration_placeholder: "Select expiration", + expires_label: "Expires", + expires_placeholder: "Select expiration", + expires_30days: "30 days", + expires_90days: "90 days", + expires_1year: "1 year", + expires_never: "Never", + thirty_days: "30 days", + ninety_days: "90 days", + one_year: "1 year", + your_key: "Your API Key", + api_key: "API Key", + save_warning: + "This key will only be shown once. Make sure to copy it now.", + copied: "API key copied to clipboard", + close_confirm: + "Are you sure you want to close? You won't be able to see this API key again.", + revoke_confirm: + "Are you sure you want to revoke this API key? This action cannot be undone.", + revoke_title: "Revoke API Key", + revoke: "Revoke", + created: "Created", + expires: "Expires", + last_used: "Last Used", + actions: "Actions", + never: "Never", + never_used: "Never used", + no_keys: "No API keys found. Create one to get started.", + security_note: + "API keys provide full access to your organization's data. Keep them secure and rotate them regularly.", + fetch_error: "Failed to fetch API keys", + create_error: "Failed to create API key", + revoked_success: "API key revoked successfully", + revoked_error: "Failed to revoke API key", + done: "Done", + }, billing: { title: "Billing", }, diff --git a/packages/db/prisma/migrations/20250304160745_add_api_keys/migration.sql b/packages/db/prisma/migrations/20250304160745_add_api_keys/migration.sql new file mode 100644 index 0000000000..f51fe08653 --- /dev/null +++ b/packages/db/prisma/migrations/20250304160745_add_api_keys/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "OrganizationApiKey" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3), + "lastUsedAt" TIMESTAMP(3), + "organizationId" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "OrganizationApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationApiKey_key_key" ON "OrganizationApiKey"("key"); + +-- CreateIndex +CREATE INDEX "OrganizationApiKey_organizationId_idx" ON "OrganizationApiKey"("organizationId"); + +-- CreateIndex +CREATE INDEX "OrganizationApiKey_key_idx" ON "OrganizationApiKey"("key"); + +-- AddForeignKey +ALTER TABLE "OrganizationApiKey" ADD CONSTRAINT "OrganizationApiKey_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250304165547_add_salt_to_api_keys/migration.sql b/packages/db/prisma/migrations/20250304165547_add_salt_to_api_keys/migration.sql new file mode 100644 index 0000000000..173c35fdb1 --- /dev/null +++ b/packages/db/prisma/migrations/20250304165547_add_salt_to_api_keys/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrganizationApiKey" ADD COLUMN "salt" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9fbe8fd1d6..ca26e26e3e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -111,6 +111,7 @@ model Organization { VendorTaskComments VendorTaskComments[] Vendors Vendors[] PortalUser PortalUser[] + apiKeys OrganizationApiKey[] @@index([stripeCustomerId]) } @@ -865,6 +866,22 @@ model PortalVerification { @@map("portal_verification") } +model OrganizationApiKey { + id String @id @default(cuid()) + name String // A friendly name for the API key + key String @unique // The hashed API key + salt String? // Salt used for hashing the API key (nullable for backward compatibility) + createdAt DateTime @default(now()) + expiresAt DateTime? // Optional expiration date + lastUsedAt DateTime? // Track when the key was last used + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + isActive Boolean @default(true) + + @@index([organizationId]) + @@index([key]) +} + enum Tier { free pro From cb9d2a1f5ebd47684df3a915d00e080b57727427 Mon Sep 17 00:00:00 2001 From: Languine Bot Date: Tue, 4 Mar 2025 18:32:09 +0000 Subject: [PATCH 2/3] chore: (i18n) update translations using Languine.ai --- apps/app/languine.lock | 44 ++++++++++++++++++++++++++++++++++++ apps/app/src/locales/es.ts | 46 ++++++++++++++++++++++++++++++++++++++ apps/app/src/locales/fr.ts | 46 ++++++++++++++++++++++++++++++++++++++ apps/app/src/locales/no.ts | 46 ++++++++++++++++++++++++++++++++++++++ apps/app/src/locales/pt.ts | 46 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+) diff --git a/apps/app/languine.lock b/apps/app/languine.lock index d687ca41bb..c4989e1820 100644 --- a/apps/app/languine.lock +++ b/apps/app/languine.lock @@ -413,6 +413,50 @@ files: settings.general.delete_confirm_tip: 97e957e2354c8329aa437ed16be68eef settings.general.cancel_button: ea4788705e6873b424c65e91c2846b19 settings.members.title: ef53538ae41a651c7f72ab6cb1135d8c + settings.api_keys.title: b4717f6ab3c2357c13c0c2cd32dfc4ca + settings.api_keys.description: a73bc7101920a4a2a821a6640ae95fc5 + settings.api_keys.list_title: b4717f6ab3c2357c13c0c2cd32dfc4ca + settings.api_keys.list_description: 43aeedd6a4d60ad7a7c81cd81386e63e + settings.api_keys.create: 35cd846f199e312c7fc78d231c4152c5 + settings.api_keys.create_title: 35cd846f199e312c7fc78d231c4152c5 + settings.api_keys.create_description: b4e48d0aee029d3a32fb5ca5e2e82a3a + settings.api_keys.created_title: 46906edf876a3f62534fe9dd55b42e80 + settings.api_keys.created_description: 445e205d7cc8d54e322b0b1270060b31 + settings.api_keys.name: 49ee3087348e8d44e1feda1917443987 + settings.api_keys.name_label: 49ee3087348e8d44e1feda1917443987 + settings.api_keys.name_placeholder: 08280d19b14f9f2f93f631f14339faec + settings.api_keys.expiration: d30fd576bd308f6facc9343ebd031b8e + settings.api_keys.expiration_placeholder: a3cd16ee0d64b456fe67ea56201ac0a1 + settings.api_keys.expires_label: cfc36842a01da69c484ffdcc6782f437 + settings.api_keys.expires_placeholder: a3cd16ee0d64b456fe67ea56201ac0a1 + settings.api_keys.expires_30days: 947d8520f04473da621f2718138f3bc6 + settings.api_keys.expires_90days: ed0c9b5fb2f44a77915d0ce45d0c501c + settings.api_keys.expires_1year: ca4c73c1f333c437a47c58afd0623530 + settings.api_keys.expires_never: 6e7b34fa59e1bd229b207892956dc41c + settings.api_keys.thirty_days: 947d8520f04473da621f2718138f3bc6 + settings.api_keys.ninety_days: ed0c9b5fb2f44a77915d0ce45d0c501c + settings.api_keys.one_year: ca4c73c1f333c437a47c58afd0623530 + settings.api_keys.your_key: df450f0d7d14608dfd9c93b45e05a5db + settings.api_keys.api_key: d876ff8da67c3731ae25d8335a4168b4 + settings.api_keys.save_warning: 5e7347709aff7059dcbeaf7cd614d24a + settings.api_keys.copied: 62a2f11192b5dfcff44b7d0b4953dcdf + settings.api_keys.close_confirm: c845770ff7a61dcedeedbd05934319d5 + settings.api_keys.revoke_confirm: c4cc0ad8b9b76fb0e1a81b3c7f9f438e + settings.api_keys.revoke_title: d5f76995f7a0856da6776f4947c44ad8 + settings.api_keys.revoke: 21313f76b111bc0df501bf89fc96ba46 + settings.api_keys.created: 0eceeb45861f9585dd7a97a3e36f85c6 + settings.api_keys.expires: cfc36842a01da69c484ffdcc6782f437 + settings.api_keys.last_used: 3b76a1f6eacb8549628a549d808ef9d9 + settings.api_keys.actions: 06df33001c1d7187fdd81ea1f5b277aa + settings.api_keys.never: 6e7b34fa59e1bd229b207892956dc41c + settings.api_keys.never_used: 59c73af4d807a1dab868f1c65b83db57 + settings.api_keys.no_keys: 939f147b56110a35a041bc72bff5379f + settings.api_keys.security_note: aabfa355e4937f843febe97ca0365b19 + settings.api_keys.fetch_error: 0c51948a1c6264a813f6a45c01d1791d + settings.api_keys.create_error: 694f468276bfc08b290150a6c945f936 + settings.api_keys.revoked_success: 883ce66d572b9f1317370578a0852bc1 + settings.api_keys.revoked_error: cea5338af704d2fb6ec7cf7fbfbd3b1a + settings.api_keys.done: f92965e2c8a7afb3c1b9a5c09a263636 settings.billing.title: 780c462e85ba4399a5d42e88f69a15ca user_menu.theme: d721757161f7f70c5b0949fdb6ec2c30 user_menu.language: 4994a8ffeba4ac3140beb89e8d41f174 diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts index 33ea1d54a1..45c104052d 100644 --- a/apps/app/src/locales/es.ts +++ b/apps/app/src/locales/es.ts @@ -530,6 +530,52 @@ export default { }, billing: { title: "Facturación" + }, + api_keys: { + title: "Claves API", + description: "Administra las claves API para el acceso programático a los datos de tu organización.", + list_title: "Claves API", + list_description: "Las claves API permiten el acceso seguro a los datos de tu organización a través de nuestra API.", + create: "Crear clave API", + create_title: "Crear clave API", + create_description: "Crea una nueva clave API para el acceso programático a los datos de tu organización.", + created_title: "Clave API creada", + created_description: "Tu clave API ha sido creada. Asegúrate de copiarla ahora, ya que no podrás volver a verla.", + name: "Nombre", + name_label: "Nombre", + name_placeholder: "Ingresa un nombre para esta clave API", + expiration: "Expiración", + expiration_placeholder: "Selecciona la expiración", + expires_label: "Expira", + expires_placeholder: "Selecciona la expiración", + expires_30days: "30 días", + expires_90days: "90 días", + expires_1year: "1 año", + expires_never: "Nunca", + thirty_days: "30 días", + ninety_days: "90 días", + one_year: "1 año", + your_key: "Tu clave API", + api_key: "Clave API", + save_warning: "Esta clave solo se mostrará una vez. Asegúrate de copiarla ahora.", + copied: "Clave API copiada al portapapeles", + close_confirm: "¿Estás seguro de que deseas cerrar? No podrás volver a ver esta clave API.", + revoke_confirm: "¿Estás seguro de que deseas revocar esta clave API? Esta acción no se puede deshacer.", + revoke_title: "Revocar clave API", + revoke: "Revocar", + created: "Creada", + expires: "Expira", + last_used: "Último uso", + actions: "Acciones", + never: "Nunca", + never_used: "Nunca utilizada", + no_keys: "No se encontraron claves API. Crea una para comenzar.", + security_note: "Las claves API proporcionan acceso completo a los datos de tu organización. Mantenlas seguras y cámbialas regularmente.", + fetch_error: "Error al obtener las claves API", + create_error: "Error al crear la clave API", + revoked_success: "Clave API revocada con éxito", + revoked_error: "Error al revocar la clave API", + done: "Hecho" } }, user_menu: { diff --git a/apps/app/src/locales/fr.ts b/apps/app/src/locales/fr.ts index 5ca6b636f3..052cf97af4 100644 --- a/apps/app/src/locales/fr.ts +++ b/apps/app/src/locales/fr.ts @@ -530,6 +530,52 @@ export default { }, billing: { title: "Facturation" + }, + api_keys: { + title: "Clés API", + description: "Gérez les clés API pour un accès programmatique aux données de votre organisation.", + list_title: "Clés API", + list_description: "Les clés API permettent un accès sécurisé aux données de votre organisation via notre API.", + create: "Créer une clé API", + create_title: "Créer une clé API", + create_description: "Créez une nouvelle clé API pour un accès programmatique aux données de votre organisation.", + created_title: "Clé API créée", + created_description: "Votre clé API a été créée. Assurez-vous de la copier maintenant car vous ne pourrez plus la voir.", + name: "Nom", + name_label: "Nom", + name_placeholder: "Entrez un nom pour cette clé API", + expiration: "Expiration", + expiration_placeholder: "Sélectionner l'expiration", + expires_label: "Expire", + expires_placeholder: "Sélectionner l'expiration", + expires_30days: "30 jours", + expires_90days: "90 jours", + expires_1year: "1 an", + expires_never: "Jamais", + thirty_days: "30 jours", + ninety_days: "90 jours", + one_year: "1 an", + your_key: "Votre clé API", + api_key: "Clé API", + save_warning: "Cette clé ne sera affichée qu'une seule fois. Assurez-vous de la copier maintenant.", + copied: "Clé API copiée dans le presse-papiers", + close_confirm: "Êtes-vous sûr de vouloir fermer ? Vous ne pourrez plus voir cette clé API.", + revoke_confirm: "Êtes-vous sûr de vouloir révoquer cette clé API ? Cette action ne peut pas être annulée.", + revoke_title: "Révoquer la clé API", + revoke: "Révoquer", + created: "Créée", + expires: "Expire", + last_used: "Dernière utilisation", + actions: "Actions", + never: "Jamais", + never_used: "Jamais utilisée", + no_keys: "Aucune clé API trouvée. Créez-en une pour commencer.", + security_note: "Les clés API fournissent un accès complet aux données de votre organisation. Gardez-les sécurisées et renouvelez-les régulièrement.", + fetch_error: "Échec de la récupération des clés API", + create_error: "Échec de la création de la clé API", + revoked_success: "Clé API révoquée avec succès", + revoked_error: "Échec de la révocation de la clé API", + done: "Terminé" } }, user_menu: { diff --git a/apps/app/src/locales/no.ts b/apps/app/src/locales/no.ts index 6cd8d175b9..746f142981 100644 --- a/apps/app/src/locales/no.ts +++ b/apps/app/src/locales/no.ts @@ -530,6 +530,52 @@ export default { }, billing: { title: "Fakturering" + }, + api_keys: { + title: "API-nøkler", + description: "Administrer API-nøkler for programmatisk tilgang til dataene i organisasjonen din.", + list_title: "API-nøkler", + list_description: "API-nøkler gir sikker tilgang til dataene i organisasjonen din via vårt API.", + create: "Opprett API-nøkkel", + create_title: "Opprett API-nøkkel", + create_description: "Opprett en ny API-nøkkel for programmatisk tilgang til dataene i organisasjonen din.", + created_title: "API-nøkkel opprettet", + created_description: "API-nøkkelen din har blitt opprettet. Sørg for å kopiere den nå, da du ikke vil kunne se den igjen.", + name: "Navn", + name_label: "Navn", + name_placeholder: "Skriv inn et navn for denne API-nøkkelen", + expiration: "Utløp", + expiration_placeholder: "Velg utløp", + expires_label: "Utløper", + expires_placeholder: "Velg utløp", + expires_30days: "30 dager", + expires_90days: "90 dager", + expires_1year: "1 år", + expires_never: "Aldri", + thirty_days: "30 dager", + ninety_days: "90 dager", + one_year: "1 år", + your_key: "Din API-nøkkel", + api_key: "API-nøkkel", + save_warning: "Denne nøkkelen vil kun bli vist én gang. Sørg for å kopiere den nå.", + copied: "API-nøkkel kopiert til utklippstavlen", + close_confirm: "Er du sikker på at du vil lukke? Du vil ikke kunne se denne API-nøkkelen igjen.", + revoke_confirm: "Er du sikker på at du vil tilbakekalle denne API-nøkkelen? Denne handlingen kan ikke angres.", + revoke_title: "Tilbakekall API-nøkkel", + revoke: "Tilbakekall", + created: "Opprettet", + expires: "Utløper", + last_used: "Sist brukt", + actions: "Handlinger", + never: "Aldri", + never_used: "Aldri brukt", + no_keys: "Ingen API-nøkler funnet. Opprett en for å komme i gang.", + security_note: "API-nøkler gir full tilgang til dataene i organisasjonen din. Hold dem sikre og roter dem regelmessig.", + fetch_error: "Feil ved henting av API-nøkler", + create_error: "Feil ved oppretting av API-nøkkel", + revoked_success: "API-nøkkel tilbakekalt med suksess", + revoked_error: "Feil ved tilbakekalling av API-nøkkel", + done: "Ferdig" } }, user_menu: { diff --git a/apps/app/src/locales/pt.ts b/apps/app/src/locales/pt.ts index 3cf1805245..9cc44d3f8f 100644 --- a/apps/app/src/locales/pt.ts +++ b/apps/app/src/locales/pt.ts @@ -530,6 +530,52 @@ export default { }, billing: { title: "Faturamento" + }, + api_keys: { + title: "Chaves da API", + description: "Gerencie chaves da API para acesso programático aos dados da sua organização.", + list_title: "Chaves da API", + list_description: "As chaves da API permitem acesso seguro aos dados da sua organização através da nossa API.", + create: "Criar Chave da API", + create_title: "Criar Chave da API", + create_description: "Crie uma nova chave da API para acesso programático aos dados da sua organização.", + created_title: "Chave da API Criada", + created_description: "Sua chave da API foi criada. Certifique-se de copiá-la agora, pois você não poderá vê-la novamente.", + name: "Nome", + name_label: "Nome", + name_placeholder: "Insira um nome para esta chave da API", + expiration: "Expiração", + expiration_placeholder: "Selecione a expiração", + expires_label: "Expira", + expires_placeholder: "Selecione a expiração", + expires_30days: "30 dias", + expires_90days: "90 dias", + expires_1year: "1 ano", + expires_never: "Nunca", + thirty_days: "30 dias", + ninety_days: "90 dias", + one_year: "1 ano", + your_key: "Sua Chave da API", + api_key: "Chave da API", + save_warning: "Esta chave será exibida apenas uma vez. Certifique-se de copiá-la agora.", + copied: "Chave da API copiada para a área de transferência", + close_confirm: "Você tem certeza de que deseja fechar? Você não poderá ver esta chave da API novamente.", + revoke_confirm: "Você tem certeza de que deseja revogar esta chave da API? Esta ação não pode ser desfeita.", + revoke_title: "Revogar Chave da API", + revoke: "Revogar", + created: "Criada", + expires: "Expira", + last_used: "Último Uso", + actions: "Ações", + never: "Nunca", + never_used: "Nunca usada", + no_keys: "Nenhuma chave da API encontrada. Crie uma para começar.", + security_note: "As chaves da API fornecem acesso total aos dados da sua organização. Mantenha-as seguras e rotacione-as regularmente.", + fetch_error: "Falha ao buscar chaves da API", + create_error: "Falha ao criar chave da API", + revoked_success: "Chave da API revogada com sucesso", + revoked_error: "Falha ao revogar chave da API", + done: "Concluído" } }, user_menu: { From 5a07694b0382bf9782870fed7e2794201fe2636f Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 4 Mar 2025 13:51:13 -0500 Subject: [PATCH 3/3] remove unused api --- .../src/app/api/v1/employees/[id]/route.ts | 273 ------------------ 1 file changed, 273 deletions(-) delete mode 100644 apps/app/src/app/api/v1/employees/[id]/route.ts diff --git a/apps/app/src/app/api/v1/employees/[id]/route.ts b/apps/app/src/app/api/v1/employees/[id]/route.ts deleted file mode 100644 index a3a2aeb726..0000000000 --- a/apps/app/src/app/api/v1/employees/[id]/route.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { db, Departments } from "@bubba/db"; -import { NextResponse, type NextRequest } from "next/server"; -import { getOrganizationFromApiKey } from "@/lib/api-key"; -import { z } from "zod"; - -// Define the schema for employee update -const employeeUpdateSchema = z.object({ - name: z.string().min(1, { message: "Name is required" }).optional(), - email: z.string().email({ message: "Valid email is required" }).optional(), - department: z.nativeEnum(Departments).optional(), - isActive: z.boolean().optional(), - externalEmployeeId: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - linkId: z.string().optional().nullable(), -}); - -// Type for the validated update data -type EmployeeUpdateInput = z.infer; - -/** - * GET /api/v1/employees/:id - * - * Get a single employee by ID for the organization associated with the API key - * - * Headers: - * - Authorization: Bearer {api_key} or X-API-Key: {api_key} - * - * Path Parameters: - * - id: string - The ID of the employee to fetch - * - * Returns: - * - 200: { success: true, data: Employee } - * - 401: { error: "Invalid or missing API key" } - * - 404: { error: "Employee not found" } - * - 500: { error: "Failed to fetch employee" } - */ -export async function GET( - request: NextRequest, - { params }: { params: { id: string } } -) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); - - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } - - try { - const employeeId = params.id; - - // Fetch the employee - const employee = await db.employee.findFirst({ - where: { - id: employeeId, - organizationId: organizationId!, - }, - select: { - id: true, - name: true, - email: true, - department: true, - isActive: true, - externalEmployeeId: true, - createdAt: true, - updatedAt: true, - }, - }); - - // If employee not found, return 404 - if (!employee) { - return NextResponse.json( - { error: "Employee not found" }, - { status: 404 } - ); - } - - // Format dates for JSON response - const formattedEmployee = { - ...employee, - createdAt: employee.createdAt.toISOString(), - updatedAt: employee.updatedAt.toISOString(), - }; - - return NextResponse.json({ success: true, data: formattedEmployee }); - } catch (error) { - console.error("Error fetching employee:", error); - return NextResponse.json( - { error: "Failed to fetch employee" }, - { status: 500 } - ); - } -} - -/** - * PUT /api/v1/employees/:id - * - * Update an employee by ID for the organization associated with the API key - * - * Headers: - * - Authorization: Bearer {api_key} or X-API-Key: {api_key} - * - * Path Parameters: - * - id: string - The ID of the employee to update - * - * Body: - * - name: string - The name of the employee (optional) - * - email: string - The email of the employee (optional) - * - department: Departments - The department of the employee (optional) - * - isActive: boolean - Whether the employee is active (optional) - * - externalEmployeeId: string - External employee ID (optional) - * - userId: string - User ID (optional) - * - linkId: string - Link ID (optional) - * - * Returns: - * - 200: { success: true, data: Employee } - * - 400: { error: "Validation failed", details: {...} } - * - 401: { error: "Invalid or missing API key" } - * - 404: { error: "Employee not found" } - * - 500: { error: "Failed to update employee" } - */ -export async function PUT( - request: NextRequest, - { params }: { params: { id: string } } -) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); - - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } - - try { - const employeeId = params.id; - const body = await request.json(); - - // Validate the request body against the schema - const validationResult = employeeUpdateSchema.safeParse(body); - - if (!validationResult.success) { - // Return validation errors - return NextResponse.json( - { - error: "Validation failed", - details: validationResult.error.format(), - }, - { status: 400 } - ); - } - - // Extract validated data - const validatedData: EmployeeUpdateInput = validationResult.data; - - // Check if the employee exists and belongs to the organization - const existingEmployee = await db.employee.findFirst({ - where: { - id: employeeId, - organizationId: organizationId!, - }, - }); - - if (!existingEmployee) { - return NextResponse.json( - { error: "Employee not found" }, - { status: 404 } - ); - } - - // Update the employee - const updatedEmployee = await db.employee.update({ - where: { - id: employeeId, - }, - data: validatedData, - select: { - id: true, - name: true, - email: true, - department: true, - isActive: true, - externalEmployeeId: true, - createdAt: true, - updatedAt: true, - }, - }); - - // Format dates for JSON response - const formattedEmployee = { - ...updatedEmployee, - createdAt: updatedEmployee.createdAt.toISOString(), - updatedAt: updatedEmployee.updatedAt.toISOString(), - }; - - return NextResponse.json({ success: true, data: formattedEmployee }); - } catch (error) { - console.error("Error updating employee:", error); - return NextResponse.json( - { error: "Failed to update employee" }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/v1/employees/:id - * - * Delete an employee by ID for the organization associated with the API key - * - * Headers: - * - Authorization: Bearer {api_key} or X-API-Key: {api_key} - * - * Path Parameters: - * - id: string - The ID of the employee to delete - * - * Returns: - * - 200: { success: true, message: "Employee deleted successfully" } - * - 401: { error: "Invalid or missing API key" } - * - 404: { error: "Employee not found" } - * - 500: { error: "Failed to delete employee" } - */ -export async function DELETE( - request: NextRequest, - { params }: { params: { id: string } } -) { - // Get the organization ID from the API key - const { organizationId, errorResponse } = - await getOrganizationFromApiKey(request); - - // If there's an error response, return it - if (errorResponse) { - return errorResponse; - } - - try { - const employeeId = params.id; - - // Check if the employee exists and belongs to the organization - const existingEmployee = await db.employee.findFirst({ - where: { - id: employeeId, - organizationId: organizationId!, - }, - }); - - if (!existingEmployee) { - return NextResponse.json( - { error: "Employee not found" }, - { status: 404 } - ); - } - - // Delete the employee - await db.employee.delete({ - where: { - id: employeeId, - }, - }); - - return NextResponse.json({ - success: true, - message: "Employee deleted successfully", - }); - } catch (error) { - console.error("Error deleting employee:", error); - return NextResponse.json( - { error: "Failed to delete employee" }, - { status: 500 } - ); - } -}