feat(api): implement advanced API key management with granular controls

This commit is contained in:
Mauricio Siu
2025-03-01 19:58:15 -06:00
parent 5568629839
commit 5dc5292928
17 changed files with 926 additions and 112 deletions

View File

@@ -0,0 +1,468 @@
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
prefix: z.string().optional(),
expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"),
// Rate limiting fields
rateLimitEnabled: z.boolean().optional(),
rateLimitTimeWindow: z.number().nullable(),
rateLimitMax: z.number().nullable(),
// Request limiting fields
remaining: z.number().nullable().optional(),
refillAmount: z.number().nullable().optional(),
refillInterval: z.number().nullable().optional(),
});
type FormValues = z.infer<typeof formSchema>;
const EXPIRATION_OPTIONS = [
{ label: "Never", value: "0" },
{ label: "1 day", value: String(60 * 60 * 24) },
{ label: "7 days", value: String(60 * 60 * 24 * 7) },
{ label: "30 days", value: String(60 * 60 * 24 * 30) },
{ label: "90 days", value: String(60 * 60 * 24 * 90) },
{ label: "1 year", value: String(60 * 60 * 24 * 365) },
];
const TIME_WINDOW_OPTIONS = [
{ label: "1 minute", value: String(60 * 1000) },
{ label: "5 minutes", value: String(5 * 60 * 1000) },
{ label: "15 minutes", value: String(15 * 60 * 1000) },
{ label: "30 minutes", value: String(30 * 60 * 1000) },
{ label: "1 hour", value: String(60 * 60 * 1000) },
{ label: "1 day", value: String(24 * 60 * 60 * 1000) },
];
const REFILL_INTERVAL_OPTIONS = [
{ label: "1 hour", value: String(60 * 60 * 1000) },
{ label: "6 hours", value: String(6 * 60 * 60 * 1000) },
{ label: "12 hours", value: String(12 * 60 * 60 * 1000) },
{ label: "1 day", value: String(24 * 60 * 60 * 1000) },
{ label: "7 days", value: String(7 * 24 * 60 * 60 * 1000) },
{ label: "30 days", value: String(30 * 24 * 60 * 60 * 1000) },
];
export const AddApiKey = () => {
const [open, setOpen] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [newApiKey, setNewApiKey] = useState("");
const { refetch } = api.user.get.useQuery();
const { data: organizations } = api.organization.all.useQuery();
const createApiKey = api.user.createApiKey.useMutation({
onSuccess: (data) => {
if (!data) return;
setNewApiKey(data.key);
setOpen(false);
setShowSuccessModal(true);
form.reset();
void refetch();
},
onError: () => {
toast.error("Failed to generate API key");
},
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
prefix: "",
expiresIn: null,
organizationId: "",
rateLimitEnabled: false,
rateLimitTimeWindow: null,
rateLimitMax: null,
remaining: null,
refillAmount: null,
refillInterval: null,
},
});
const rateLimitEnabled = form.watch("rateLimitEnabled");
const onSubmit = async (values: FormValues) => {
createApiKey.mutate({
name: values.name,
expiresIn: values.expiresIn || undefined,
prefix: values.prefix || undefined,
metadata: {
organizationId: values.organizationId,
},
// Rate limiting
rateLimitEnabled: values.rateLimitEnabled,
rateLimitTimeWindow: values.rateLimitTimeWindow || undefined,
rateLimitMax: values.rateLimitMax || undefined,
// Request limiting
remaining: values.remaining || undefined,
refillAmount: values.refillAmount || undefined,
refillInterval: values.refillInterval || undefined,
});
};
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Generate New Key</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Generate API Key</DialogTitle>
<DialogDescription>
Create a new API key for accessing the API. You can set an
expiration date and a custom prefix for better organization.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My API Key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => (
<FormItem>
<FormLabel>Prefix</FormLabel>
<FormControl>
<Input placeholder="my_app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresIn"
render={({ field }) => (
<FormItem>
<FormLabel>Expiration</FormLabel>
<Select
value={field.value?.toString() || "0"}
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select expiration time" />
</SelectTrigger>
</FormControl>
<SelectContent>
{EXPIRATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="organizationId"
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select organization" />
</SelectTrigger>
</FormControl>
<SelectContent>
{organizations?.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Rate Limiting Section */}
<div className="space-y-4 rounded-lg border p-4">
<h3 className="text-lg font-medium">Rate Limiting</h3>
<FormField
control={form.control}
name="rateLimitEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Enable Rate Limiting</FormLabel>
<FormDescription>
Limit the number of requests within a time window
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{rateLimitEnabled && (
<>
<FormField
control={form.control}
name="rateLimitTimeWindow"
render={({ field }) => (
<FormItem>
<FormLabel>Time Window</FormLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select time window" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TIME_WINDOW_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
The duration in which requests are counted
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="rateLimitMax"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Requests</FormLabel>
<FormControl>
<Input
type="number"
placeholder="100"
value={field.value?.toString() ?? ""}
onChange={(e) =>
field.onChange(
e.target.value
? Number.parseInt(e.target.value, 10)
: null,
)
}
/>
</FormControl>
<FormDescription>
Maximum number of requests allowed within the time
window
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
{/* Request Limiting Section */}
<div className="space-y-4 rounded-lg border p-4">
<h3 className="text-lg font-medium">Request Limiting</h3>
<FormField
control={form.control}
name="remaining"
render={({ field }) => (
<FormItem>
<FormLabel>Total Request Limit</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Leave empty for unlimited"
value={field.value?.toString() ?? ""}
onChange={(e) =>
field.onChange(
e.target.value
? Number.parseInt(e.target.value, 10)
: null,
)
}
/>
</FormControl>
<FormDescription>
Total number of requests allowed (leave empty for
unlimited)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="refillAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Refill Amount</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Amount to refill"
value={field.value?.toString() ?? ""}
onChange={(e) =>
field.onChange(
e.target.value
? Number.parseInt(e.target.value, 10)
: null,
)
}
/>
</FormControl>
<FormDescription>
Number of requests to add on each refill
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="refillInterval"
render={({ field }) => (
<FormItem>
<FormLabel>Refill Interval</FormLabel>
<Select
value={field.value?.toString()}
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select refill interval" />
</SelectTrigger>
</FormControl>
<SelectContent>
{REFILL_INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
How often to refill the request limit
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit">Generate</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>API Key Generated Successfully</DialogTitle>
<DialogDescription>
Please copy your API key now. You won't be able to see it again!
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">
{newApiKey}
</div>
<div className="flex justify-end gap-3">
<Button
onClick={() => {
navigator.clipboard.writeText(newApiKey);
toast.success("API key copied to clipboard");
}}
>
Copy to Clipboard
</Button>
<Button
variant="outline"
onClick={() => setShowSuccessModal(false)}
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,142 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddApiKey } from "./add-api-key";
import { Badge } from "@/components/ui/badge";
export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync: deleteApiKey, isLoading: isLoadingDelete } =
api.user.deleteApiKey.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl flex items-center gap-2">
<KeyIcon className="size-5" />
API/CLI Keys
</CardTitle>
<CardDescription>
Generate and manage API keys to access the API/CLI
</CardDescription>
</div>
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
<span className="text-sm font-medium text-muted-foreground">
Swagger API:
</span>
<Link
href="/swagger"
target="_blank"
className="flex flex-row gap-2 items-center"
>
<span className="text-sm font-medium">View</span>
<ExternalLinkIcon className="size-4" />
</Link>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4">
{data?.user.apiKeys && data.user.apiKeys.length > 0 ? (
data.user.apiKeys.map((apiKey) => (
<div
key={apiKey.id}
className="flex flex-col gap-2 p-4 border rounded-lg"
>
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<span className="font-medium">{apiKey.name}</span>
<div className="flex flex-wrap gap-2 items-center text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3.5" />
Created{" "}
{formatDistanceToNow(new Date(apiKey.createdAt))}{" "}
ago
</span>
{apiKey.prefix && (
<Badge
variant="secondary"
className="flex items-center gap-1"
>
<Tag className="size-3.5" />
{apiKey.prefix}
</Badge>
)}
{apiKey.expiresAt && (
<Badge
variant="outline"
className="flex items-center gap-1"
>
<Clock className="size-3.5" />
Expires in{" "}
{formatDistanceToNow(
new Date(apiKey.expiresAt),
)}{" "}
</Badge>
)}
</div>
</div>
<DialogAction
title="Delete API Key"
description="Are you sure you want to delete this API key? This action cannot be undone."
type="destructive"
onClick={async () => {
try {
await deleteApiKey({
apiKeyId: apiKey.id,
});
await refetch();
toast.success("API key deleted successfully");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error deleting API key",
);
}
}}
>
<Button
variant="ghost"
size="icon"
isLoading={isLoadingDelete}
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
))
) : (
<div className="flex flex-col items-center gap-3 py-6">
<KeyIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No API keys found
</span>
</div>
)}
</div>
{/* Generate new API key */}
<div className="flex justify-end pt-4 border-t">
<AddApiKey />
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,77 +0,0 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
export const GenerateToken = () => {
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync: generateToken, isLoading: isLoadingToken } =
api.user.generateToken.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">API/CLI</CardTitle>
<CardDescription>
Generate a token to access the API/CLI
</CardDescription>
</div>
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
<span className="text-sm font-medium text-muted-foreground">
Swagger API:
</span>
<Link
href="/swagger"
target="_blank"
className="flex flex-row gap-2 items-center"
>
<span className="text-sm font-medium">View</span>
<ExternalLinkIcon className="size-4" />
</Link>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-row gap-2 max-sm:flex-wrap justify-end items-end">
<div className="grid w-full gap-8">
<div className="flex flex-col gap-2">
<Label>Token</Label>
<ToggleVisibilityInput
placeholder="Token"
value={data?.id || ""}
disabled
/>
</div>
</div>
<Button
type="button"
isLoading={isLoadingToken}
onClick={async () => {
await generateToken().then(() => {
refetch();
toast.success("Token generated");
});
}}
>
Generate
</Button>
</div>
</CardContent>
</div>
</Card>
</div>
);
};