mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix: refactoring the api keys section and add documentation link. (#304)
This commit is contained in:
@@ -2,16 +2,8 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
AlertCircle,
|
import { useMutation } from "@tanstack/react-query";
|
||||||
Check,
|
|
||||||
Copy,
|
|
||||||
KeyRound,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { authClient } from "@/lib/auth/auth-client";
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
@@ -27,24 +19,7 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { EmailSchema, EmailSchemaType } from "./account.schema";
|
import { EmailSchema, EmailSchemaType } from "./account.schema";
|
||||||
import { BetterAuthError } from "@/types/auth";
|
import { BetterAuthError } from "@/types/auth";
|
||||||
import {
|
import { ProfileApiKeys } from "./profile-api-keys";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { timeAgo } from "@/utils/date-formatting";
|
|
||||||
import {
|
|
||||||
createApiKeysAction,
|
|
||||||
deleteApiKeyAction,
|
|
||||||
getApiKeysAction,
|
|
||||||
} from "@/features/profile/profile.action";
|
|
||||||
import { copyToClipboardWithMeta } from "@/components/common/copy-button";
|
|
||||||
|
|
||||||
interface ProfileAccountProps {
|
interface ProfileAccountProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -52,15 +27,8 @@ interface ProfileAccountProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
|
|
||||||
const [apiKeyName, setApiKeyName] = useState("");
|
|
||||||
|
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
|
|
||||||
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
|
||||||
|
|
||||||
const emailForm = useZodForm({
|
const emailForm = useZodForm({
|
||||||
schema: EmailSchema,
|
schema: EmailSchema,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -74,31 +42,21 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
|||||||
newEmail: values.email,
|
newEmail: values.email,
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return values.email;
|
return values.email;
|
||||||
},
|
},
|
||||||
onSuccess: (newEmail) => {
|
onSuccess: (newEmail) => {
|
||||||
toast.success("Email updated successfully.");
|
toast.success("Email updated successfully.");
|
||||||
|
|
||||||
emailForm.reset({ email: newEmail });
|
emailForm.reset({ email: newEmail });
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
onError: (error: BetterAuthError) => {
|
onError: (error: BetterAuthError) => {
|
||||||
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||||
toast.error(
|
toast.error("User already exists, use another email address!");
|
||||||
"User already exists, use another email address!",
|
|
||||||
);
|
|
||||||
|
|
||||||
emailForm.reset({ email: user.email });
|
emailForm.reset({ email: user.email });
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error("An error occurred while trying to update your password!");
|
||||||
"An error occurred while trying to update your password!",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -109,23 +67,19 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
|||||||
} = useMutation({
|
} = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const currentEmailInput = emailForm.getValues("email");
|
const currentEmailInput = emailForm.getValues("email");
|
||||||
|
|
||||||
let error: BetterAuthError | null = null;
|
let error: BetterAuthError | null = null;
|
||||||
|
|
||||||
if (currentEmailInput === user.email) {
|
if (currentEmailInput === user.email) {
|
||||||
const sendVerification =
|
const sendVerification = await authClient.sendVerificationEmail({
|
||||||
await authClient.sendVerificationEmail({
|
email: currentEmailInput,
|
||||||
email: currentEmailInput,
|
callbackURL: window.location.href,
|
||||||
callbackURL: window.location.href,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
error = sendVerification.error;
|
error = sendVerification.error;
|
||||||
} else {
|
} else {
|
||||||
const result = await authClient.changeEmail({
|
const result = await authClient.changeEmail({
|
||||||
callbackURL: window.location.href,
|
callbackURL: window.location.href,
|
||||||
newEmail: currentEmailInput,
|
newEmail: currentEmailInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
error = result.error;
|
error = result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,405 +93,90 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
data: apikeys,
|
|
||||||
isLoading: isLoadingApiKeys,
|
|
||||||
refetch: refetchApiKeys,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["apikeys"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await getApiKeysAction();
|
|
||||||
|
|
||||||
if (result?.data?.success) {
|
|
||||||
return result.data.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
throw new Error("Failed to fetch API Keys");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: addApiKey, isPending: isAddingApikey } = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
|
|
||||||
const result = await createApiKeysAction({
|
|
||||||
name: apiKeyName || "My API Key"
|
|
||||||
});
|
|
||||||
console.log(result);
|
|
||||||
if (!result?.data?.success) {
|
|
||||||
throw new Error("Failed to create API Key");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: (result: any) => {
|
|
||||||
setCreatedApiKey(result.data.value.key);
|
|
||||||
|
|
||||||
toast.success("API Key created successfully");
|
|
||||||
|
|
||||||
setIsAddApiKeyOpen(false);
|
|
||||||
setApiKeyName("");
|
|
||||||
|
|
||||||
refetchApiKeys();
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: (error: any) => {
|
|
||||||
toast.error(error.message || "Failed to create API Key");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: revokeApiKey, isPending: isRevokingApiKey } =
|
|
||||||
useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
const result = await deleteApiKeyAction({ id });
|
|
||||||
|
|
||||||
if (!result?.data?.success) {
|
|
||||||
throw new Error("Failed to revoke API Key");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("API Key revoked successfully");
|
|
||||||
|
|
||||||
refetchApiKeys();
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to revoke API Key");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCopyApiKey = async () => {
|
|
||||||
if (!createdApiKey) return;
|
|
||||||
|
|
||||||
await copyToClipboardWithMeta(createdApiKey);
|
|
||||||
|
|
||||||
setCopiedApiKey(true);
|
|
||||||
|
|
||||||
toast.success("API Key copied");
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopiedApiKey(false);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
<div className="mb-6 space-y-1">
|
||||||
<div className="mb-6 space-y-1">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
Account Settings
|
||||||
Account Settings
|
</h2>
|
||||||
</h2>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Update your email and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="space-y-4">
|
||||||
Update your email and preferences.
|
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
|
||||||
</p>
|
<div className="grid gap-3">
|
||||||
</div>
|
<FormField
|
||||||
|
control={emailForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email Address</FormLabel>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col gap-3">
|
||||||
<Form
|
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
||||||
form={emailForm}
|
<FormControl>
|
||||||
onSubmit={(values) => updateEmail(values)}
|
<Input
|
||||||
>
|
{...field}
|
||||||
<div className="grid gap-3">
|
placeholder="Your email address"
|
||||||
<FormField
|
/>
|
||||||
control={emailForm.control}
|
</FormControl>
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Email Address
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
|
<Button
|
||||||
<FormControl>
|
type="submit"
|
||||||
<Input
|
variant="secondary"
|
||||||
{...field}
|
disabled={
|
||||||
placeholder="Your email address"
|
isUpdatingEmail ||
|
||||||
/>
|
!emailForm.formState.isDirty
|
||||||
</FormControl>
|
}
|
||||||
|
>
|
||||||
|
{isUpdatingEmail && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-3">
|
{!user.emailVerified && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
onClick={() => resendVerificationEmail()}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdatingEmail ||
|
isResendingVerification ||
|
||||||
!emailForm.formState
|
emailForm.formState.errors.email !==
|
||||||
.isDirty
|
undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isUpdatingEmail && (
|
{isResendingVerification && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
|
Resend Verification
|
||||||
Update
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{!user.emailVerified && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
resendVerificationEmail()
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
isResendingVerification ||
|
|
||||||
emailForm
|
|
||||||
.formState
|
|
||||||
.errors
|
|
||||||
.email !==
|
|
||||||
undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isResendingVerification && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
Resend Verification
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!user.emailVerified && (
|
<FormMessage />
|
||||||
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
|
|
||||||
<span>
|
|
||||||
Your email is not verified. Please check
|
|
||||||
your inbox.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiEnabled === true && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-medium">
|
|
||||||
API Keys
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Manage your personal access tokens for API
|
|
||||||
authentication
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isAddApiKeyOpen}
|
|
||||||
onOpenChange={setIsAddApiKeyOpen}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Add API Key
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
Add New API Key
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
Give a name to your API Key to identify
|
|
||||||
it later.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
Key Name
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder="e.g. flow-1, LLM, Backend"
|
|
||||||
value={apiKeyName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setApiKeyName(e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
{!user.emailVerified && (
|
||||||
<Button
|
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
|
||||||
variant="outline"
|
<AlertCircle className="w-4 h-4" />
|
||||||
onClick={() =>
|
<span>
|
||||||
setIsAddApiKeyOpen(false)
|
Your email is not verified. Please check your inbox.
|
||||||
}
|
</span>
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => addApiKey()}
|
|
||||||
disabled={isAddingApikey}
|
|
||||||
>
|
|
||||||
{isAddingApikey && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
Create API Key
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border rounded-lg divide-y">
|
|
||||||
{isLoadingApiKeys ? (
|
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : apikeys && apikeys.length > 0 ? (
|
|
||||||
apikeys.map((ak: any) => (
|
|
||||||
<ApiKeyRow
|
|
||||||
key={ak.id}
|
|
||||||
apikey={ak}
|
|
||||||
onRevoke={(id) => revokeApiKey(id)}
|
|
||||||
isRevoking={isRevokingApiKey}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
|
||||||
No API Key found.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Form>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
{apiEnabled && <ProfileApiKeys />}
|
||||||
open={!!createdApiKey}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setCreatedApiKey(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Your API Key</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This API Key will only be displayed once.
|
|
||||||
<br />
|
|
||||||
Copy it now before closing this dialog.
|
|
||||||
<br />
|
|
||||||
For security reasons, it cannot be viewed again.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
readOnly
|
|
||||||
value={createdApiKey || ""}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopyApiKey}
|
|
||||||
>
|
|
||||||
{copiedApiKey ? (
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
|
|
||||||
Store this API Key securely. You will not be able
|
|
||||||
to see it again after closing this dialog.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setCreatedApiKey(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
I copied my API Key
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiKeyRow({
|
|
||||||
apikey,
|
|
||||||
onRevoke,
|
|
||||||
isRevoking,
|
|
||||||
}: {
|
|
||||||
apikey: any;
|
|
||||||
onRevoke: (id: string) => void;
|
|
||||||
isRevoking: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
|
||||||
<KeyRound className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-medium text-sm">
|
|
||||||
{apikey.name || "Unnamed API Key"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apikey?.start && apikey?.prefix ? (
|
|
||||||
<div className="text-xs font-mono text-muted-foreground">
|
|
||||||
{apikey.start}••••••••
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Created {timeAgo(new Date(apikey.createdAt))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => onRevoke(apikey.id)}
|
|
||||||
disabled={isRevoking}
|
|
||||||
>
|
|
||||||
{isRevoking ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="sr-only">Revoke</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Check, Copy, KeyRound, Loader2, Plus, Trash2} from "lucide-react";
|
||||||
|
import {useMutation, useQuery} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {Label} from "@/components/ui/label";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {timeAgo} from "@/utils/date-formatting";
|
||||||
|
import {
|
||||||
|
createApiKeysAction,
|
||||||
|
deleteApiKeyAction,
|
||||||
|
getApiKeysAction,
|
||||||
|
} from "@/features/profile/profile.action";
|
||||||
|
import {copyToClipboardWithMeta} from "@/components/common/copy-button";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function ProfileApiKeys() {
|
||||||
|
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false);
|
||||||
|
const [apiKeyName, setApiKeyName] = useState("");
|
||||||
|
const [createdApiKey, setCreatedApiKey] = useState<string | null>(null);
|
||||||
|
const [copiedApiKey, setCopiedApiKey] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: apikeys,
|
||||||
|
isLoading: isLoadingApiKeys,
|
||||||
|
refetch: refetchApiKeys,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["apikeys"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await getApiKeysAction();
|
||||||
|
if (result?.data?.success) return result.data.value;
|
||||||
|
throw new Error("Failed to fetch API Keys");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {mutate: addApiKey, isPending: isAddingApikey} = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const result = await createApiKeysAction({
|
||||||
|
name: apiKeyName || "My API Key",
|
||||||
|
});
|
||||||
|
if (!result?.data?.success) throw new Error("Failed to create API Key");
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
setCreatedApiKey(result.data.value.key);
|
||||||
|
toast.success("API Key created successfully");
|
||||||
|
setIsAddApiKeyOpen(false);
|
||||||
|
setApiKeyName("");
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message || "Failed to create API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {mutate: revokeApiKey, isPending: isRevokingApiKey} = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const result = await deleteApiKeyAction({id});
|
||||||
|
if (!result?.data?.success) throw new Error("Failed to revoke API Key");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("API Key revoked successfully");
|
||||||
|
refetchApiKeys();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to revoke API Key");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopyApiKey = async () => {
|
||||||
|
if (!createdApiKey) return;
|
||||||
|
await copyToClipboardWithMeta(createdApiKey);
|
||||||
|
setCopiedApiKey(true);
|
||||||
|
toast.success("API Key copied");
|
||||||
|
setTimeout(() => setCopiedApiKey(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-medium">API Keys</h3>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Manage your personal access tokens for API authentication.{" "}
|
||||||
|
<Link
|
||||||
|
href="https://portabase.io/docs/dashboard/api/introduction"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline underline-offset-4 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
View docs
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isAddApiKeyOpen} onOpenChange={setIsAddApiKeyOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4"/>
|
||||||
|
Add API Key
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Give a name to your API Key to identify it later.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Key Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. flow-1, LLM, Backend"
|
||||||
|
value={apiKeyName}
|
||||||
|
onChange={(e) => setApiKeyName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAddApiKeyOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => addApiKey()} disabled={isAddingApikey}>
|
||||||
|
{isAddingApikey && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
|
||||||
|
)}
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{isLoadingApiKeys ? (
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground"/>
|
||||||
|
</div>
|
||||||
|
) : apikeys && apikeys.length > 0 ? (
|
||||||
|
apikeys.map((ak: any) => (
|
||||||
|
<ApiKeyRow
|
||||||
|
key={ak.id}
|
||||||
|
apikey={ak}
|
||||||
|
onRevoke={(id) => revokeApiKey(id)}
|
||||||
|
isRevoking={isRevokingApiKey}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No API Key found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!createdApiKey}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCreatedApiKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Your API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This API Key will only be displayed once.
|
||||||
|
<br/>
|
||||||
|
Copy it now before closing this dialog.
|
||||||
|
<br/>
|
||||||
|
For security reasons, it cannot be viewed again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={createdApiKey || ""}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
>
|
||||||
|
{copiedApiKey ? (
|
||||||
|
<Check className="w-4 h-4"/>
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4"/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
Store this API Key securely. You will not be able to see it
|
||||||
|
again after closing this dialog.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setCreatedApiKey(null)}>
|
||||||
|
I copied my API Key
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeyRow({
|
||||||
|
apikey,
|
||||||
|
onRevoke,
|
||||||
|
isRevoking,
|
||||||
|
}: {
|
||||||
|
apikey: any;
|
||||||
|
onRevoke: (id: string) => void;
|
||||||
|
isRevoking: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
<KeyRound className="w-5 h-5"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{apikey.name || "Unnamed API Key"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apikey?.start && apikey?.prefix ? (
|
||||||
|
<div className="text-xs font-mono text-muted-foreground">
|
||||||
|
{apikey.start}••••••••
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Created {timeAgo(new Date(apikey.createdAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onRevoke(apikey.id)}
|
||||||
|
disabled={isRevoking}
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin"/>
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4"/>
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user