Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/app/languine.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions apps/app/src/actions/organization/create-api-key-action.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
}
});
70 changes: 70 additions & 0 deletions apps/app/src/actions/organization/get-api-keys-action.ts
Original file line number Diff line number Diff line change
@@ -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",
},
};
}
};
53 changes: 53 additions & 0 deletions apps/app/src/actions/organization/revoke-api-key-action.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
});
10 changes: 9 additions & 1 deletion apps/app/src/actions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const updaterMenuSchema = z.array(
z.object({
path: z.string(),
name: z.string(),
}),
})
);

export const organizationWebsiteSchema = z.object({
Expand Down Expand Up @@ -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"]),
});
Loading