diff --git a/apps/app/languine.lock b/apps/app/languine.lock index 3201ac70bb..13b2a542a5 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/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 91cee13a00..43c1238b18 100644 --- a/apps/app/src/actions/types.ts +++ b/apps/app/src/actions/types.ts @@ -1,79 +1,79 @@ export interface ActionResponse { - success: boolean; - data?: T | null; - error?: string; + success: boolean; + data?: T | null; + error?: string | { code: string; message: string }; } export type ActionData = - | { - data: T; - error?: never; - } - | { - error: string; - data?: never; - }; + | { + data: T; + error?: never; + } + | { + error: string; + data?: never; + }; export type DomainVerificationStatusProps = - | "Valid Configuration" - | "Invalid Configuration" - | "Pending Verification" - | "Domain Not Found" - | "Unknown Error"; + | "Valid Configuration" + | "Invalid Configuration" + | "Pending Verification" + | "Domain Not Found" + | "Unknown Error"; // From https://vercel.com/docs/rest-api/endpoints#get-a-project-domain export interface DomainResponse { - name: string; - apexName: string; - projectId: string; - redirect?: string | null; - redirectStatusCode?: (307 | 301 | 302 | 308) | null; - gitBranch?: string | null; - updatedAt?: number; - createdAt?: number; - /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ - verified: boolean; - /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ - verification: { - type: string; - domain: string; - value: string; - reason: string; - }[]; + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; } // From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration export interface DomainConfigResponse { - /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */ - configuredBy?: ("CNAME" | "A" | "http") | null; - /** Which challenge types the domain can use for issuing certs. */ - acceptedChallenges?: ("dns-01" | "http-01")[]; - /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */ - misconfigured: boolean; + /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */ + configuredBy?: ("CNAME" | "A" | "http") | null; + /** Which challenge types the domain can use for issuing certs. */ + acceptedChallenges?: ("dns-01" | "http-01")[]; + /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */ + misconfigured: boolean; } // From https://vercel.com/docs/rest-api/endpoints#verify-project-domain export interface DomainVerificationResponse { - name: string; - apexName: string; - projectId: string; - redirect?: string | null; - redirectStatusCode?: (307 | 301 | 302 | 308) | null; - gitBranch?: string | null; - updatedAt?: number; - createdAt?: number; - /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ - verified: boolean; - /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ - verification?: { - type: string; - domain: string; - value: string; - reason: string; - }[]; + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification?: { + type: string; + domain: string; + value: string; + reason: string; + }[]; } export const UPLOAD_TYPE = { - evidence: "evidence", - riskTask: "risk-task", + evidence: "evidence", + riskTask: "risk-task", } as const; 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/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 a9fa136326..914c4a6e63 100644 --- a/apps/app/src/locales/en.ts +++ b/apps/app/src/locales/en.ts @@ -1,865 +1,878 @@ export default { - language: { - title: "Languages", - description: "Change the language used in the user interface.", - placeholder: "Select language", - }, - languages: { - en: "English", - es: "Spanish", - fr: "French", - no: "Norwegian", - pt: "Portuguese", - }, - common: { - frequency: { - daily: "Daily", - weekly: "Weekly", - monthly: "Monthly", - quarterly: "Quarterly", - yearly: "Yearly", - }, - notifications: { - inbox: "Inbox", - archive: "Archive", - archive_all: "Archive all", - no_notifications: "No new notifications", - }, - actions: { - save: "Save", - edit: "Edit", - delete: "Delete", - cancel: "Cancel", - clear: "Clear", - create: "Create", - addNew: "Add New", - send: "Send", - return: "Return", - success: "Success", - error: "Error", - next: "Next", - complete: "Complete", - }, - assignee: { - label: "Assignee", - placeholder: "Select assignee", - }, - date: { - pick: "Pick a date", - due_date: "Due Date", - }, - status: { - open: "Open", - pending: "Pending", - closed: "Closed", - archived: "Archived", - compliant: "Compliant", - non_compliant: "Non Compliant", - not_started: "Not Started", - in_progress: "In Progress", - published: "Published", - needs_review: "Needs Review", - draft: "Draft", - not_assessed: "Not Assessed", - assessed: "Assessed", - active: "Active", - inactive: "Inactive", - title: "Status", - }, - filters: { - clear: "Clear filters", - search: "Search...", - status: "Status", - department: "Department", - owner: { - label: "Assignee", - placeholder: "Filter by assignee", - }, - }, - table: { - title: "Title", - status: "Status", - assigned_to: "Assigned To", - due_date: "Due Date", - last_updated: "Last Updated", - no_results: "No results found", - }, - empty_states: { - no_results: { - title: "No results found", - title_tasks: "No tasks found", - title_risks: "No risks found", - description: "Try another search, or adjusting the filters", - description_filters: "Try another search, or adjusting the filters", - description_no_tasks: "Create a task to get started", - description_no_risks: "Create a risk to get started", - }, - no_items: { - title: "No items found", - description: "Try adjusting your search or filters", - }, - }, - pagination: { - of: "of", - items_per_page: "Items per page", - rows_per_page: "Rows per page", - page_x_of_y: "Page {{current}} of {{total}}", - go_to_first_page: "Go to first page", - go_to_previous_page: "Go to previous page", - go_to_next_page: "Go to next page", - go_to_last_page: "Go to last page", - }, - comments: { - title: "Comments", - description: "Add a comment using the form below.", - add: "New Comment", - new: "New Comment", - save: "Save Comment", - success: "Comment added successfully", - error: "Failed to add comment", - placeholder: "Write your comment here...", - empty: { - title: "No comments yet", - description: "Be the first to add a comment", - }, - }, - attachments: { - title: "Attachments", - description: "Add a file by clicking 'Add Attachment'.", - upload: "Upload attachment", - upload_description: - "Upload an attachment or add a link to an external resource.", - drop: "Drop the files here", - drop_description: - "Drop files here or click to choose files from your device.", - drop_files_description: "Files can be up to ", - empty: { - title: "No attachments", - description: "Add a file by clicking 'Add Attachment'.", - }, - toasts: { - error: "Something went wrong, please try again.", - error_uploading_files: "Cannot upload more than 1 file at a time", - error_uploading_files_multiple: "Cannot upload more than 10 files", - error_no_files_selected: "No files selected", - error_file_rejected: "File {file} was rejected", - error_failed_to_upload_files: "Failed to upload files", - error_failed_to_upload_files_multiple: "Failed to upload files", - error_failed_to_upload_files_single: "Failed to upload file", - success_uploading_files: "Files uploaded successfully", - success_uploading_files_multiple: "Files uploaded successfully", - success_uploading_files_single: "File uploaded successfully", - success_uploading_files_target: "Files uploaded", - uploading_files: "Uploading {target}...", - remove_file: "Remove file", - }, - }, - edit: "Edit", - errors: { - unexpected_error: "An unexpected error occurred", - }, - description: "Description", - last_updated: "Last Updated", - }, - header: { - discord: { - button: "Join us on Discord", - }, - feedback: { - button: "Feedback", - title: "Thank you for your feedback!", - description: "We will be back with you as soon as possible", - placeholder: "Ideas to improve this page or issues you are experiencing.", - success: "Thank you for your feedback!", - error: "Error sending feedback - try again?", - send: "Send Feedback", - }, - }, - not_found: { - title: "404 - Page not found", - description: "The page you are looking for does not exist.", - return: "Return to dashboard", - }, - theme: { - options: { - light: "Light", - dark: "Dark", - system: "System", - }, - }, - sidebar: { - overview: "Overview", - policies: "Policies", - risk: "Risk Management", - vendors: "Vendors", - integrations: "Integrations", - settings: "Settings", - evidence: "Evidence Tasks", - people: "People", - }, - sub_pages: { - evidence: { - title: "Evidence", - list: "Evidence List", - overview: "Evidence Overview", - }, - risk: { - overview: "Risk Management", - register: "Risk Register", - risk_overview: "Risk Overview", - risk_comments: "Risk Comments", - tasks: { - task_overview: "Task Overview", - }, - }, - policies: { - all: "All Policies", - editor: "Policy Editor", - policy_details: "Policy Details", - }, - people: { - all: "People", - employee_details: "Employee Details", - }, - settings: { - members: "Team Members", - }, - frameworks: { - overview: "Frameworks", - }, - }, - auth: { - title: "Automate SOC 2, ISO 27001 and GDPR compliance with AI.", - description: - "Create a free account or log in with an existing account to continue.", - options: "More options", - google: "Continue with Google", - email: { - description: "Enter your email address to continue.", - placeholder: "Enter email address", - button: "Continue with email", - magic_link_sent: "Magic link sent", - magic_link_description: "Check your inbox for a magic link.", - magic_link_try_again: "Try again.", - success: "Email sent - check your inbox!", - error: "Error sending email - try again?", - }, - terms: - "By clicking continue, you acknowledge that you have read and agree to the Terms of Service and Privacy Policy.", - }, - onboarding: { - title: "Create an organization", - setup: "Setup", - description: "Tell us a bit about your organization.", - fields: { - fullName: { - label: "Your Name", - placeholder: "Your full name", - }, - name: { - label: "Organization Name", - placeholder: "Your organization name", - }, - subdomain: { - label: "Subdomain", - placeholder: "example", - }, - website: { - label: "Website", - placeholder: "Your organization website", - }, - }, - success: "Thanks, you're all set!", - error: "Something went wrong, please try again.", - check_availability: "Checking availability", - available: "Available", - unavailable: "Unavailable", - }, - overview: { - title: "Overview", - framework_chart: { - title: "Framework Progress", - }, - requirement_chart: { - title: "Compliance Status", - }, - }, - policies: { - dashboard: { - title: "Dashboard", - all: "All Policies", - policy_status: "Policy by Status", - policies_by_assignee: "Policies by Assignee", - policies_by_framework: "Policies by Framework", - sub_pages: { - overview: "Overview", - edit_policy: "Edit Policy", - }, - }, - overview: { - title: "Policy Overview", - form: { - update_policy: "Update Policy", - update_policy_description: "Update the policy title or description.", - update_policy_success: "Policy updated successfully", - update_policy_error: "Failed to update policy", - update_policy_title: "Policy Name", - review_frequency: "Review Frequency", - review_frequency_placeholder: "Select a review frequency", - review_date: "Review Date", - review_date_placeholder: "Select a review date", - }, - }, - table: { - name: "Policy Name", - statuses: { - draft: "Draft", - published: "Published", - archived: "Archived", - }, - filters: { - owner: { - label: "Assignee", - placeholder: "Filter by assignee", - }, - }, - }, - filters: { - search: "Search policies...", - all: "All Policies", - }, - status: { - draft: "Draft", - published: "Published", - needs_review: "Needs Review", - archived: "Archived", - }, - policies: "policies", - title: "Policies", - create_new: "Create New Policy", - search_placeholder: "Search policies...", - status_filter: "Filter by status", - all_statuses: "All statuses", - no_policies_title: "No policies yet", - no_policies_description: "Get started by creating your first policy", - create_first: "Create first policy", - no_description: "No description provided", - last_updated: "Last updated: {{date}}", - save: "Save", - saving: "Saving...", - saved_success: "Policy saved successfully", - saved_error: "Failed to save policy", - }, - evidence_tasks: { - evidence_tasks: "Evidence Tasks", - overview: "Overview", - }, - risk: { - risks: "risks", - overview: "Overview", - create: "Create New Risk", - vendor: { - title: "Vendor Management", - dashboard: { - title: "Vendor Dashboard", - overview: "Vendor Overview", - vendor_status: "Vendor Status", - vendor_category: "Vendor Categories", - vendors_by_assignee: "Vendors by Assignee", - inherent_risk_description: - "Initial risk level before any controls are applied", - residual_risk_description: - "Remaining risk level after controls are applied", - }, - register: { - title: "Vendor Register", - table: { - name: "Name", - category: "Category", - status: "Status", - owner: "Owner", - }, - }, - assessment: { - title: "Vendor Assessment", - update_success: "Vendor risk assessment updated successfully", - update_error: "Failed to update vendor risk assessment", - inherent_risk: "Inherent Risk", - residual_risk: "Residual Risk", - }, - form: { - vendor_details: "Vendor Details", - vendor_name: "Name", - vendor_name_placeholder: "Enter vendor name", - vendor_website: "Website", - vendor_website_placeholder: "Enter vendor website", - vendor_description: "Description", - vendor_description_placeholder: "Enter vendor description", - vendor_category: "Category", - vendor_category_placeholder: "Select category", - vendor_status: "Status", - vendor_status_placeholder: "Select status", - create_vendor_success: "Vendor created successfully", - create_vendor_error: "Failed to create vendor", - update_vendor: "Update Vendor", - update_vendor_success: "Vendor updated successfully", - update_vendor_error: "Failed to update vendor", - add_comment: "Add Comment", - }, - table: { - name: "Name", - category: "Category", - status: "Status", - owner: "Owner", - }, - filters: { - search_placeholder: "Search vendors...", - status_placeholder: "Filter by status", - category_placeholder: "Filter by category", - owner_placeholder: "Filter by owner", - }, - empty_states: { - no_vendors: { - title: "No vendors yet", - description: "Get started by creating your first vendor", - }, - no_results: { - title: "No results found", - description: "No vendors match your search", - description_with_filters: "Try adjusting your filters", - }, - }, - actions: { - create: "Create Vendor", - }, - status: { - not_assessed: "Not Assessed", - in_progress: "In Progress", - assessed: "Assessed", - }, - category: { - cloud: "Cloud", - infrastructure: "Infrastructure", - software_as_a_service: "Software as a Service", - finance: "Finance", - marketing: "Marketing", - sales: "Sales", - hr: "HR", - other: "Other", - }, - risk_level: { - low: "Low Risk", - medium: "Medium Risk", - high: "High Risk", - unknown: "Unknown Risk", - }, - }, - dashboard: { - title: "Dashboard", - overview: "Risk Overview", - risk_status: "Risk Status", - risks_by_department: "Risks by Department", - risks_by_assignee: "Risks by Assignee", - inherent_risk_description: - "Inherent risk is calculated as likelihood * impact. Calculated before any controls are applied.", - residual_risk_description: - "Residual risk is calculated as likelihood * impact. This is the risk level after controls are applied.", - risk_assessment_description: "Compare inherent and residual risk levels", - }, - register: { - title: "Risk Register", - table: { - risk: "Risk", - }, - empty: { - no_risks: { - title: "Create a risk to get started", - description: - "Track and score risks, create and assign mitigation tasks for your team, and manage your risk register all in one simple interface.", - }, - create_risk: "Create a risk", - }, - }, - metrics: { - probability: "Probability", - impact: "Impact", - inherentRisk: "Inherent Risk", - residualRisk: "Residual Risk", - }, - form: { - update_inherent_risk: "Save Inherent Risk", - update_inherent_risk_description: - "Update the inherent risk of the risk. This is the risk level before any controls are applied.", - update_inherent_risk_success: "Inherent risk updated successfully", - update_inherent_risk_error: "Failed to update inherent risk", - update_residual_risk: "Save Residual Risk", - update_residual_risk_description: - "Update the residual risk of the risk. This is the risk level after controls are applied.", - update_residual_risk_success: "Residual risk updated successfully", - update_residual_risk_error: "Failed to update residual risk", - update_risk: "Update Risk", - update_risk_description: "Update the risk title or description.", - update_risk_success: "Risk updated successfully", - update_risk_error: "Failed to update risk", - create_risk_success: "Risk created successfully", - create_risk_error: "Failed to create risk", - risk_details: "Risk Details", - risk_title: "Risk Title", - risk_title_description: "Enter a name for the risk", - risk_description: "Description", - risk_description_description: "Enter a description for the risk", - risk_category: "Category", - risk_category_placeholder: "Select a category", - risk_department: "Department", - risk_department_placeholder: "Select a department", - risk_status: "Risk Status", - risk_status_placeholder: "Select a risk status", - }, - tasks: { - title: "Tasks", - attachments: "Attachments", - overview: "Task Overview", - form: { - title: "Task Details", - task_title: "Task Title", - status: "Task Status", - status_placeholder: "Select a task status", - task_title_description: "Enter a name for the task", - description: "Description", - description_description: "Enter a description for the task", - due_date: "Due Date", - due_date_description: "Select the due date for the task", - success: "Task created successfully", - error: "Failed to create task", - }, - sheet: { - title: "Create Task", - update: "Update Task", - update_description: "Update the task title or description.", - }, - empty: { - description_create: - "Create a mitigation task for this risk, add a treatment plan, and assign it to a team member.", - }, - }, - }, - people: { - title: "People", - details: { - taskProgress: "Task Progress", - tasks: "Tasks", - noTasks: "No tasks assigned yet", - }, - description: "Manage your team members and their roles.", - filters: { - search: "Search people...", - role: "Filter by role", - }, - actions: { - invite: "Add Employee", - clear: "Clear filters", - }, - table: { - name: "Name", - email: "Email", - department: "Department", - externalId: "External ID", - }, - empty: { - no_employees: { - title: "No employees yet", - description: "Get started by inviting your first team member.", - }, - no_results: { - title: "No results found", - description: "No employees match your search", - description_with_filters: "Try adjusting your filters", - }, - }, - invite: { - title: "Add Employee", - description: "Add an employee to your organization.", - email: { - label: "Email address", - placeholder: "Enter email address", - }, - role: { - label: "Role", - placeholder: "Select a role", - }, - name: { - label: "Name", - placeholder: "Enter name", - }, - department: { - label: "Department", - placeholder: "Select a department", - }, - submit: "Add Employee", - success: "Employee added successfully", - error: "Failed to add employee", - }, - }, - settings: { - general: { - title: "General", - org_name: "Organization name", - org_name_description: - "This is your organizations visible name. You should use the legal name of your organization.", - org_name_tip: "Please use 32 characters at maximum.", - org_website: "Organization Website", - org_website_description: - "This is your organization's official website URL. Make sure to include the full URL with https://.", - org_website_tip: "Please enter a valid URL including https://", - org_website_error: "Error updating organization website", - org_website_updated: "Organization website updated", - org_delete: "Delete organization", - org_delete_description: - "Permanently remove your organization and all of its contents from the Comp AI platform. This action is not reversible - please continue with caution.", - org_delete_alert_title: "Are you absolutely sure?", - org_delete_alert_description: - "This action cannot be undone. This will permanently delete your organization and remove your data from our servers.", - org_delete_error: "Error deleting organization", - org_delete_success: "Organization deleted", - org_name_updated: "Organization name updated", - org_name_error: "Error updating organization name", - save_button: "Save", - delete_button: "Delete", - delete_confirm: "DELETE", - delete_confirm_tip: "Type DELETE to confirm.", - cancel_button: "Cancel", - }, - members: { - title: "Members", - }, - billing: { - title: "Billing", - }, - }, - user_menu: { - theme: "Theme", - language: "Language", - sign_out: "Sign out", - account: "Account", - support: "Support", - settings: "Settings", - teams: "Teams", - }, - frameworks: { - title: "Frameworks", - overview: { - error: "Failed to load frameworks", - loading: "Loading frameworks...", - empty: { - title: "No frameworks selected", - description: - "Select frameworks to get started with your compliance journey", - }, - progress: { - title: "Framework Progress", - empty: { - title: "No frameworks yet", - description: - "Get started by adding a compliance framework to track your progress", - action: "Add Framework", - }, - }, - grid: { - welcome: { - title: "Welcome to Comp AI", - description: - "Get started by selecting the compliance frameworks you would like to implement. We'll help you manage and track your compliance journey across multiple standards.", - action: "Get Started", - }, - title: "Select Frameworks", - version: "Version", - actions: { - clear: "Clear", - confirm: "Confirm Selection", - }, - }, - }, - controls: { - title: "Controls", - description: "Review and manage compliance controls", - table: { - status: "Status", - control: "Control", - artifacts: "Artifacts", - actions: "Actions", - }, - statuses: { - completed: "Completed", - in_progress: "In Progress", - not_started: "Not Started", - }, - }, - }, - vendor: { - title: "Dashboard", - register_title: "Vendor Management", - dashboard: { - title: "Dashboard", - overview: "Vendor Overview", - vendor_status: "Vendor Status", - vendor_category: "Vendor Categories", - vendors_by_assignee: "Vendors by Assignee", - inherent_risk_description: - "Initial risk level before any controls are applied", - residual_risk_description: - "Remaining risk level after controls are applied", - }, - register: { - title: "Vendor Register", - table: { - name: "Name", - category: "Category", - status: "Status", - owner: "Owner", - }, - }, - category: { - cloud: "Cloud", - infrastructure: "Infrastructure", - software_as_a_service: "SaaS", - finance: "Finance", - marketing: "Marketing", - sales: "Sales", - hr: "HR", - other: "Other", - }, - vendors: "vendors", - form: { - vendor_details: "Vendor Details", - vendor_name: "Name", - vendor_name_placeholder: "Enter vendor name", - vendor_website: "Website", - vendor_website_placeholder: "Enter vendor website", - vendor_description: "Description", - vendor_description_placeholder: "Enter vendor description", - vendor_category: "Category", - vendor_category_placeholder: "Select category", - vendor_status: "Status", - vendor_status_placeholder: "Select status", - create_vendor_success: "Vendor created successfully", - create_vendor_error: "Failed to create vendor", - update_vendor_success: "Vendor updated successfully", - update_vendor_error: "Failed to update vendor", - contacts: "Vendor Contacts", - contact_name: "Contact Name", - contact_email: "Contact Email", - contact_role: "Contact Role", - add_contact: "Add Contact", - new_contact: "New Contact", - min_one_contact_required: "A vendor must have at least one contact", - }, - empty_states: { - no_vendors: { - title: "No vendors yet", - description: "Get started by creating your first vendor", - }, - no_results: { - title: "No results found", - description: "No vendors match your search", - description_with_filters: "Try adjusting your filters", - }, - }, - }, - errors: { - unexpected: "Something went wrong, please try again", - }, - editor: { - ai: { - thinking: "AI is thinking", - thinking_spinner: "AI is thinking", - edit_or_generate: "Edit or generate...", - tell_ai_what_to_do_next: "Tell AI what to do next", - request_limit_reached: "You have reached your request limit for the day.", - }, - ai_selector: { - improve: "Improve writing", - fix: "Fix grammar", - shorter: "Make shorter", - longer: "Make longer", - continue: "Continue writing", - replace: "Replace selection", - insert: "Insert below", - discard: "Discard", - }, - }, - evidence: { - title: "Evidence", - list: "All Evidence", - overview: "Evidence Overview", - edit: "Edit Evidence", - dashboard: { - layout: "Dashboard", - layout_back_button: "Back", - title: "Evidence Dashboard", - by_department: "By Department", - by_assignee: "By Assignee", - by_framework: "By Framework", - }, - items: "items", - status: { - up_to_date: "Up to Date", - needs_review: "Needs Review", - draft: "Draft", - empty: "Empty", - }, - departments: { - none: "Uncategorized", - admin: "Administration", - gov: "Governance", - hr: "Human Resources", - it: "Information Technology", - itsm: "IT Service Management", - qms: "Quality Management", - }, - details: { - review_section: "Review Information", - content: "Evidence Content", - }, - }, - upload: { - fileSection: { - filesUploaded: "{count} file(s) uploaded", - upload: "{count} file(s) uploaded", - }, - fileUpload: { - uploadingText: "Uploading...", - dropFileHere: "Drop file here", - releaseToUpload: "Release to upload", - addFiles: "Add Files", - uploadAdditionalEvidence: "Upload a file", - dragDropOrClick: "Drag & drop or click to upload", - dropFileHereAlt: "Drop the file here", - dragDropOrClickToSelect: "Drag & drop a file here, or click to select", - maxFileSize: "Max file size: {size}MB", - uploadingFile: "Uploading file...", - }, - fileCard: { - preview: "Preview", - previewNotAvailable: - "Preview not available. Click the download button to view the file.", - filePreview: "File Preview: {fileName}", - openFile: "Open file", - deleteFile: "Delete file", - deleteFileConfirmTitle: "Delete File", - deleteFileConfirmDescription: - "Are you sure you want to delete this file? This action cannot be undone.", - }, - fileUrl: { - additionalLinks: "Additional Links", - add: "Add", - linksAdded: "{count} link{s} added", - enterUrl: "Enter URL", - addAnotherLink: "Add Another Link", - saveLinks: "Save Links", - urlBadge: "URL", - copyLink: "Copy link", - openLink: "Open link", - deleteLink: "Delete link", - }, - }, + language: { + title: "Languages", + description: "Change the language used in the user interface.", + placeholder: "Select language", + }, + languages: { + en: "English", + es: "Spanish", + fr: "French", + no: "Norwegian", + pt: "Portuguese", + }, + common: { + frequency: { + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + quarterly: "Quarterly", + yearly: "Yearly", + }, + notifications: { + inbox: "Inbox", + archive: "Archive", + archive_all: "Archive all", + no_notifications: "No new notifications", + }, + actions: { + save: "Save", + edit: "Edit", + delete: "Delete", + cancel: "Cancel", + clear: "Clear", + create: "Create", + addNew: "Add New", + send: "Send", + return: "Return", + success: "Success", + error: "Error", + next: "Next", + complete: "Complete", + }, + assignee: { + label: "Assignee", + placeholder: "Select assignee", + }, + date: { + pick: "Pick a date", + due_date: "Due Date", + }, + status: { + open: "Open", + pending: "Pending", + closed: "Closed", + archived: "Archived", + compliant: "Compliant", + non_compliant: "Non Compliant", + not_started: "Not Started", + in_progress: "In Progress", + published: "Published", + needs_review: "Needs Review", + draft: "Draft", + not_assessed: "Not Assessed", + assessed: "Assessed", + active: "Active", + inactive: "Inactive", + title: "Status", + }, + filters: { + clear: "Clear filters", + search: "Search...", + status: "Status", + department: "Department", + owner: { + label: "Assignee", + placeholder: "Filter by assignee", + }, + }, + table: { + title: "Title", + status: "Status", + assigned_to: "Assigned To", + due_date: "Due Date", + last_updated: "Last Updated", + no_results: "No results found", + }, + empty_states: { + no_results: { + title: "No results found", + title_tasks: "No tasks found", + title_risks: "No risks found", + description: "Try another search, or adjusting the filters", + description_filters: "Try another search, or adjusting the filters", + description_no_tasks: "Create a task to get started", + description_no_risks: "Create a risk to get started", + }, + no_items: { + title: "No items found", + description: "Try adjusting your search or filters", + }, + }, + pagination: { + of: "of", + items_per_page: "Items per page", + rows_per_page: "Rows per page", + page_x_of_y: "Page {{current}} of {{total}}", + go_to_first_page: "Go to first page", + go_to_previous_page: "Go to previous page", + go_to_next_page: "Go to next page", + go_to_last_page: "Go to last page", + }, + comments: { + title: "Comments", + description: "Add a comment using the form below.", + add: "New Comment", + new: "New Comment", + save: "Save Comment", + success: "Comment added successfully", + error: "Failed to add comment", + placeholder: "Write your comment here...", + empty: { + title: "No comments yet", + description: "Be the first to add a comment", + }, + }, + attachments: { + title: "Attachments", + description: "Add a file by clicking 'Add Attachment'.", + upload: "Upload attachment", + upload_description: + "Upload an attachment or add a link to an external resource.", + drop: "Drop the files here", + drop_description: + "Drop files here or click to choose files from your device.", + drop_files_description: "Files can be up to ", + empty: { + title: "No attachments", + description: "Add a file by clicking 'Add Attachment'.", + }, + toasts: { + error: "Something went wrong, please try again.", + error_uploading_files: "Cannot upload more than 1 file at a time", + error_uploading_files_multiple: "Cannot upload more than 10 files", + error_no_files_selected: "No files selected", + error_file_rejected: "File {file} was rejected", + error_failed_to_upload_files: "Failed to upload files", + error_failed_to_upload_files_multiple: "Failed to upload files", + error_failed_to_upload_files_single: "Failed to upload file", + success_uploading_files: "Files uploaded successfully", + success_uploading_files_multiple: "Files uploaded successfully", + success_uploading_files_single: "File uploaded successfully", + success_uploading_files_target: "Files uploaded", + uploading_files: "Uploading {target}...", + remove_file: "Remove file", + }, + }, + edit: "Edit", + errors: { + unexpected_error: "An unexpected error occurred", + }, + description: "Description", + last_updated: "Last Updated", + }, + header: { + discord: { + button: "Join us on Discord", + }, + feedback: { + button: "Feedback", + title: "Thank you for your feedback!", + description: "We will be back with you as soon as possible", + placeholder: "Ideas to improve this page or issues you are experiencing.", + success: "Thank you for your feedback!", + error: "Error sending feedback - try again?", + send: "Send Feedback", + }, + }, + not_found: { + title: "404 - Page not found", + description: "The page you are looking for does not exist.", + return: "Return to dashboard", + }, + theme: { + options: { + light: "Light", + dark: "Dark", + system: "System", + }, + }, + sidebar: { + overview: "Overview", + policies: "Policies", + risk: "Risk Management", + vendors: "Vendors", + integrations: "Integrations", + settings: "Settings", + evidence: "Evidence Tasks", + people: "People", + }, + sub_pages: { + evidence: { + title: "Evidence", + list: "Evidence List", + overview: "Evidence Overview", + }, + risk: { + overview: "Risk Management", + register: "Risk Register", + risk_overview: "Risk Overview", + risk_comments: "Risk Comments", + tasks: { + task_overview: "Task Overview", + }, + }, + policies: { + all: "All Policies", + editor: "Policy Editor", + policy_details: "Policy Details", + }, + people: { + all: "People", + employee_details: "Employee Details", + }, + settings: { + members: "Team Members", + }, + frameworks: { + overview: "Frameworks", + }, + }, + auth: { + title: "Automate SOC 2, ISO 27001 and GDPR compliance with AI.", + description: + "Create a free account or log in with an existing account to continue.", + options: "More options", + google: "Continue with Google", + email: { + description: "Enter your email address to continue.", + placeholder: "Enter email address", + button: "Continue with email", + magic_link_sent: "Magic link sent", + magic_link_description: "Check your inbox for a magic link.", + magic_link_try_again: "Try again.", + success: "Email sent - check your inbox!", + error: "Error sending email - try again?", + }, + terms: + "By clicking continue, you acknowledge that you have read and agree to the Terms of Service and Privacy Policy.", + }, + onboarding: { + title: "Create an organization", + setup: "Setup", + description: "Tell us a bit about your organization.", + fields: { + fullName: { + label: "Your Name", + placeholder: "Your full name", + }, + name: { + label: "Organization Name", + placeholder: "Your organization name", + }, + subdomain: { + label: "Subdomain", + placeholder: "example", + }, + website: { + label: "Website", + placeholder: "Your organization website", + }, + }, + success: "Thanks, you're all set!", + error: "Something went wrong, please try again.", + check_availability: "Checking availability", + available: "Available", + unavailable: "Unavailable", + }, + overview: { + title: "Overview", + framework_chart: { + title: "Framework Progress", + }, + requirement_chart: { + title: "Compliance Status", + }, + }, + policies: { + dashboard: { + title: "Dashboard", + all: "All Policies", + policy_status: "Policy by Status", + policies_by_assignee: "Policies by Assignee", + policies_by_framework: "Policies by Framework", + sub_pages: { + overview: "Overview", + edit_policy: "Edit Policy", + }, + }, + overview: { + title: "Policy Overview", + form: { + update_policy: "Update Policy", + update_policy_description: "Update the policy title or description.", + update_policy_success: "Policy updated successfully", + update_policy_error: "Failed to update policy", + update_policy_title: "Policy Name", + review_frequency: "Review Frequency", + review_frequency_placeholder: "Select a review frequency", + review_date: "Review Date", + review_date_placeholder: "Select a review date", + }, + }, + table: { + name: "Policy Name", + statuses: { + draft: "Draft", + published: "Published", + archived: "Archived", + }, + filters: { + owner: { + label: "Assignee", + placeholder: "Filter by assignee", + }, + }, + }, + filters: { + search: "Search policies...", + all: "All Policies", + }, + status: { + draft: "Draft", + published: "Published", + needs_review: "Needs Review", + archived: "Archived", + }, + policies: "policies", + title: "Policies", + create_new: "Create New Policy", + search_placeholder: "Search policies...", + status_filter: "Filter by status", + all_statuses: "All statuses", + no_policies_title: "No policies yet", + no_policies_description: "Get started by creating your first policy", + create_first: "Create first policy", + no_description: "No description provided", + last_updated: "Last updated: {{date}}", + save: "Save", + saving: "Saving...", + saved_success: "Policy saved successfully", + saved_error: "Failed to save policy", + }, + evidence_tasks: { + evidence_tasks: "Evidence Tasks", + overview: "Overview", + }, + risk: { + risks: "risks", + overview: "Overview", + create: "Create New Risk", + vendor: { + title: "Vendor Management", + dashboard: { + title: "Vendor Dashboard", + overview: "Vendor Overview", + vendor_status: "Vendor Status", + vendor_category: "Vendor Categories", + vendors_by_assignee: "Vendors by Assignee", + inherent_risk_description: + "Initial risk level before any controls are applied", + residual_risk_description: + "Remaining risk level after controls are applied", + }, + register: { + title: "Vendor Register", + table: { + name: "Name", + category: "Category", + status: "Status", + owner: "Owner", + }, + }, + assessment: { + title: "Vendor Assessment", + update_success: "Vendor risk assessment updated successfully", + update_error: "Failed to update vendor risk assessment", + inherent_risk: "Inherent Risk", + residual_risk: "Residual Risk", + }, + form: { + vendor_details: "Vendor Details", + vendor_name: "Name", + vendor_name_placeholder: "Enter vendor name", + vendor_website: "Website", + vendor_website_placeholder: "Enter vendor website", + vendor_description: "Description", + vendor_description_placeholder: "Enter vendor description", + vendor_category: "Category", + vendor_category_placeholder: "Select category", + vendor_status: "Status", + vendor_status_placeholder: "Select status", + create_vendor_success: "Vendor created successfully", + create_vendor_error: "Failed to create vendor", + update_vendor: "Update Vendor", + update_vendor_success: "Vendor updated successfully", + update_vendor_error: "Failed to update vendor", + add_comment: "Add Comment", + }, + table: { + name: "Name", + category: "Category", + status: "Status", + owner: "Owner", + }, + filters: { + search_placeholder: "Search vendors...", + status_placeholder: "Filter by status", + category_placeholder: "Filter by category", + owner_placeholder: "Filter by owner", + }, + empty_states: { + no_vendors: { + title: "No vendors yet", + description: "Get started by creating your first vendor", + }, + no_results: { + title: "No results found", + description: "No vendors match your search", + description_with_filters: "Try adjusting your filters", + }, + }, + actions: { + create: "Create Vendor", + }, + status: { + not_assessed: "Not Assessed", + in_progress: "In Progress", + assessed: "Assessed", + }, + category: { + cloud: "Cloud", + infrastructure: "Infrastructure", + software_as_a_service: "Software as a Service", + finance: "Finance", + marketing: "Marketing", + sales: "Sales", + hr: "HR", + other: "Other", + }, + risk_level: { + low: "Low Risk", + medium: "Medium Risk", + high: "High Risk", + unknown: "Unknown Risk", + }, + }, + dashboard: { + title: "Dashboard", + overview: "Risk Overview", + risk_status: "Risk Status", + risks_by_department: "Risks by Department", + risks_by_assignee: "Risks by Assignee", + inherent_risk_description: + "Inherent risk is calculated as likelihood * impact. Calculated before any controls are applied.", + residual_risk_description: + "Residual risk is calculated as likelihood * impact. This is the risk level after controls are applied.", + risk_assessment_description: "Compare inherent and residual risk levels", + }, + register: { + title: "Risk Register", + table: { + risk: "Risk", + }, + empty: { + no_risks: { + title: "Create a risk to get started", + description: + "Track and score risks, create and assign mitigation tasks for your team, and manage your risk register all in one simple interface.", + }, + create_risk: "Create a risk", + }, + }, + metrics: { + probability: "Probability", + impact: "Impact", + inherentRisk: "Inherent Risk", + residualRisk: "Residual Risk", + }, + form: { + update_inherent_risk: "Save Inherent Risk", + update_inherent_risk_description: + "Update the inherent risk of the risk. This is the risk level before any controls are applied.", + update_inherent_risk_success: "Inherent risk updated successfully", + update_inherent_risk_error: "Failed to update inherent risk", + update_residual_risk: "Save Residual Risk", + update_residual_risk_description: + "Update the residual risk of the risk. This is the risk level after controls are applied.", + update_residual_risk_success: "Residual risk updated successfully", + update_residual_risk_error: "Failed to update residual risk", + update_risk: "Update Risk", + update_risk_description: "Update the risk title or description.", + update_risk_success: "Risk updated successfully", + update_risk_error: "Failed to update risk", + create_risk_success: "Risk created successfully", + create_risk_error: "Failed to create risk", + risk_details: "Risk Details", + risk_title: "Risk Title", + risk_title_description: "Enter a name for the risk", + risk_description: "Description", + risk_description_description: "Enter a description for the risk", + risk_category: "Category", + risk_category_placeholder: "Select a category", + risk_department: "Department", + risk_department_placeholder: "Select a department", + risk_status: "Risk Status", + risk_status_placeholder: "Select a risk status", + }, + tasks: { + title: "Tasks", + attachments: "Attachments", + overview: "Task Overview", + form: { + title: "Task Details", + task_title: "Task Title", + status: "Task Status", + status_placeholder: "Select a task status", + task_title_description: "Enter a name for the task", + description: "Description", + description_description: "Enter a description for the task", + due_date: "Due Date", + due_date_description: "Select the due date for the task", + success: "Task created successfully", + error: "Failed to create task", + }, + sheet: { + title: "Create Task", + update: "Update Task", + update_description: "Update the task title or description.", + }, + empty: { + description_create: + "Create a mitigation task for this risk, add a treatment plan, and assign it to a team member.", + }, + }, + }, + people: { + title: "People", + details: { + taskProgress: "Task Progress", + tasks: "Tasks", + noTasks: "No tasks assigned yet", + }, + description: "Manage your team members and their roles.", + filters: { + search: "Search people...", + role: "Filter by role", + }, + actions: { + invite: "Add Employee", + clear: "Clear filters", + }, + table: { + name: "Name", + email: "Email", + department: "Department", + externalId: "External ID", + }, + empty: { + no_employees: { + title: "No employees yet", + description: "Get started by inviting your first team member.", + }, + no_results: { + title: "No results found", + description: "No employees match your search", + description_with_filters: "Try adjusting your filters", + }, + }, + invite: { + title: "Add Employee", + description: "Add an employee to your organization.", + email: { + label: "Email address", + placeholder: "Enter email address", + }, + role: { + label: "Role", + placeholder: "Select a role", + }, + name: { + label: "Name", + placeholder: "Enter name", + }, + department: { + label: "Department", + placeholder: "Select a department", + }, + submit: "Add Employee", + success: "Employee added successfully", + error: "Failed to add employee", + }, + }, + settings: { + general: { + title: "General", + org_name: "Organization name", + org_name_description: + "This is your organizations visible name. You should use the legal name of your organization.", + org_name_tip: "Please use 32 characters at maximum.", + org_website: "Organization Website", + org_website_description: + "This is your organization's official website URL. Make sure to include the full URL with https://.", + org_website_tip: "Please enter a valid URL including https://", + org_website_error: "Error updating organization website", + org_website_updated: "Organization website updated", + org_delete: "Delete organization", + org_delete_description: + "Permanently remove your organization and all of its contents from the Comp AI platform. This action is not reversible - please continue with caution.", + org_delete_alert_title: "Are you absolutely sure?", + org_delete_alert_description: + "This action cannot be undone. This will permanently delete your organization and remove your data from our servers.", + org_delete_error: "Error deleting organization", + org_delete_success: "Organization deleted", + org_name_updated: "Organization name updated", + org_name_error: "Error updating organization name", + save_button: "Save", + delete_button: "Delete", + delete_confirm: "DELETE", + delete_confirm_tip: "Type DELETE to confirm.", + cancel_button: "Cancel", + }, + 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", + }, + }, + user_menu: { + theme: "Theme", + language: "Language", + sign_out: "Sign out", + account: "Account", + support: "Support", + settings: "Settings", + teams: "Teams", + }, + frameworks: { + title: "Frameworks", + overview: { + error: "Failed to load frameworks", + loading: "Loading frameworks...", + empty: { + title: "No frameworks selected", + description: + "Select frameworks to get started with your compliance journey", + }, + progress: { + title: "Framework Progress", + empty: { + title: "No frameworks yet", + description: + "Get started by adding a compliance framework to track your progress", + action: "Add Framework", + }, + }, + grid: { + welcome: { + title: "Welcome to Comp AI", + description: + "Get started by selecting the compliance frameworks you would like to implement. We'll help you manage and track your compliance journey across multiple standards.", + action: "Get Started", + }, + title: "Select Frameworks", + version: "Version", + actions: { + clear: "Clear", + confirm: "Confirm Selection", + }, + }, + }, + controls: { + title: "Controls", + description: "Review and manage compliance controls", + table: { + status: "Status", + control: "Control", + artifacts: "Artifacts", + actions: "Actions", + }, + statuses: { + completed: "Completed", + in_progress: "In Progress", + not_started: "Not Started", + }, + }, + }, + vendor: { + title: "Dashboard", + register_title: "Vendor Management", + dashboard: { + title: "Dashboard", + overview: "Vendor Overview", + vendor_status: "Vendor Status", + vendor_category: "Vendor Categories", + vendors_by_assignee: "Vendors by Assignee", + inherent_risk_description: + "Initial risk level before any controls are applied", + residual_risk_description: + "Remaining risk level after controls are applied", + }, + register: { + title: "Vendor Register", + table: { + name: "Name", + category: "Category", + status: "Status", + owner: "Owner", + }, + }, + category: { + cloud: "Cloud", + infrastructure: "Infrastructure", + software_as_a_service: "SaaS", + finance: "Finance", + marketing: "Marketing", + sales: "Sales", + hr: "HR", + other: "Other", + }, + vendors: "vendors", + form: { + vendor_details: "Vendor Details", + vendor_name: "Name", + vendor_name_placeholder: "Enter vendor name", + vendor_website: "Website", + vendor_website_placeholder: "Enter vendor website", + vendor_description: "Description", + vendor_description_placeholder: "Enter vendor description", + vendor_category: "Category", + vendor_category_placeholder: "Select category", + vendor_status: "Status", + vendor_status_placeholder: "Select status", + create_vendor_success: "Vendor created successfully", + create_vendor_error: "Failed to create vendor", + update_vendor_success: "Vendor updated successfully", + update_vendor_error: "Failed to update vendor", + contacts: "Vendor Contacts", + contact_name: "Contact Name", + contact_email: "Contact Email", + contact_role: "Contact Role", + add_contact: "Add Contact", + new_contact: "New Contact", + min_one_contact_required: "A vendor must have at least one contact", + }, + empty_states: { + no_vendors: { + title: "No vendors yet", + description: "Get started by creating your first vendor", + }, + no_results: { + title: "No results found", + description: "No vendors match your search", + description_with_filters: "Try adjusting your filters", + }, + }, + }, + errors: { + unexpected: "Something went wrong, please try again", + }, + editor: { + ai: { + thinking: "AI is thinking", + thinking_spinner: "AI is thinking", + edit_or_generate: "Edit or generate...", + tell_ai_what_to_do_next: "Tell AI what to do next", + request_limit_reached: "You have reached your request limit for the day.", + }, + ai_selector: { + improve: "Improve writing", + fix: "Fix grammar", + shorter: "Make shorter", + longer: "Make longer", + continue: "Continue writing", + replace: "Replace selection", + insert: "Insert below", + discard: "Discard", + }, + }, + evidence: { + title: "Evidence", + list: "All Evidence", + overview: "Evidence Overview", + edit: "Edit Evidence", + dashboard: { + layout: "Dashboard", + layout_back_button: "Back", + title: "Evidence Dashboard", + by_department: "By Department", + by_assignee: "By Assignee", + by_framework: "By Framework", + }, + items: "items", + status: { + up_to_date: "Up to Date", + needs_review: "Needs Review", + draft: "Draft", + empty: "Empty", + }, + departments: { + none: "Uncategorized", + admin: "Administration", + gov: "Governance", + hr: "Human Resources", + it: "Information Technology", + itsm: "IT Service Management", + qms: "Quality Management", + }, + details: { + review_section: "Review Information", + content: "Evidence Content", + }, + }, } as const; diff --git a/apps/app/src/locales/es.ts b/apps/app/src/locales/es.ts index 587555035c..cc00e10d4b 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 e36390c376..b175bf4562 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 ab39faf6d6..fc2399b37a 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 85a657490a..a858afbc3b 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: { 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