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
204 changes: 103 additions & 101 deletions apps/app/src/actions/organization/create-api-key-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,114 +4,116 @@ import { authActionClient } from "@/actions/safe-action";
import { apiKeySchema } from "@/actions/schema";
import { generateApiKey, generateSalt, hashApiKey } from "@/lib/api-key";
import { db } from "@bubba/db";
import { revalidatePath } from "next/cache";

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}`);
.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}`
);
// 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");
}
// 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}`);
// 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);
revalidatePath("/settings/api-keys");

// Provide more specific error messages based on error type
if (error instanceof Error) {
console.error(`Error details: ${error.message}`);
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);

if (error.message.includes("Unique constraint")) {
return {
success: false,
error: {
code: "DUPLICATE_NAME",
message: "An API key with this name already exists",
},
};
}
// Provide more specific error messages based on error type
if (error instanceof Error) {
console.error(`Error details: ${error.message}`);

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",
},
};
}
}
if (error.message.includes("Unique constraint")) {
return {
success: false,
error: {
code: "DUPLICATE_NAME",
message: "An API key with this name already exists",
},
};
}

return {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred while creating the API key",
},
};
}
});
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",
},
};
}
});
82 changes: 42 additions & 40 deletions apps/app/src/actions/organization/revoke-api-key-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,53 @@
import { authActionClient } from "@/actions/safe-action";
import { z } from "zod";
import { db } from "@bubba/db";
import { revalidatePath } from "next/cache";

const revokeApiKeySchema = z.object({
id: z.string().min(1),
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;
.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,
},
});
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",
};
}
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",
};
}
});
revalidatePath("/settings/api-keys");

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",
};
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@bubba/ui/card";
import { Button } from "@bubba/ui/button";
import { getI18n } from "@/locales/server";
import { Plus } from "lucide-react";
import { LogoSpinner } from "@/components/logo-spinner";

export default async function Loading() {
const t = await getI18n();

return (
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>{t("settings.api_keys.list_title")}</CardTitle>
<CardDescription>
{t("settings.api_keys.list_description")}
</CardDescription>
</div>
<Button className="flex items-center gap-1 self-start sm:self-auto" disabled aria-label={t("settings.api_keys.create")}>
<Plus className="h-4 w-4" />
{t("settings.api_keys.create")}
</Button>
</CardHeader>
<CardContent>
<LogoSpinner />
</CardContent>
<CardFooter className="text-sm text-muted-foreground">
{t("settings.api_keys.security_note")}
</CardFooter>
</Card>
</div>
);
}
Loading