fix: refactoring the api keys section and add documentation link. (#304)

This commit is contained in:
Charles GTE
2026-06-02 09:17:21 +02:00
committed by GitHub
parent 44f0112620
commit 56a9922057
2 changed files with 353 additions and 432 deletions
+71 -432
View File
@@ -2,16 +2,8 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertCircle,
Check,
Copy,
KeyRound,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth/auth-client";
@@ -27,24 +19,7 @@ import {
} from "@/components/ui/form";
import { EmailSchema, EmailSchemaType } from "./account.schema";
import { BetterAuthError } from "@/types/auth";
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 { ProfileApiKeys } from "./profile-api-keys";
interface ProfileAccountProps {
user: User;
@@ -52,15 +27,8 @@ interface ProfileAccountProps {
}
export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
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({
schema: EmailSchema,
defaultValues: {
@@ -74,31 +42,21 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
newEmail: values.email,
callbackURL: window.location.href,
});
if (error) throw error;
return values.email;
},
onSuccess: (newEmail) => {
toast.success("Email updated successfully.");
emailForm.reset({ email: newEmail });
router.refresh();
},
onError: (error: BetterAuthError) => {
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
toast.error(
"User already exists, use another email address!",
);
toast.error("User already exists, use another email address!");
emailForm.reset({ email: user.email });
router.refresh();
} else {
toast.error(
"An error occurred while trying to update your password!",
);
toast.error("An error occurred while trying to update your password!");
}
},
});
@@ -109,23 +67,19 @@ export function ProfileAccount({ user, apiEnabled }: ProfileAccountProps) {
} = useMutation({
mutationFn: async () => {
const currentEmailInput = emailForm.getValues("email");
let error: BetterAuthError | null = null;
if (currentEmailInput === user.email) {
const sendVerification =
await authClient.sendVerificationEmail({
email: currentEmailInput,
callbackURL: window.location.href,
});
const sendVerification = await authClient.sendVerificationEmail({
email: currentEmailInput,
callbackURL: window.location.href,
});
error = sendVerification.error;
} else {
const result = await authClient.changeEmail({
callbackURL: window.location.href,
newEmail: currentEmailInput,
});
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 (
<>
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Account Settings
</h2>
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Account Settings
</h2>
<p className="text-sm text-muted-foreground">
Update your email and preferences.
</p>
</div>
<p className="text-sm text-muted-foreground">
Update your email and preferences.
</p>
</div>
<div className="space-y-4">
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
<div className="grid gap-3">
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<div className="space-y-4">
<Form
form={emailForm}
onSubmit={(values) => updateEmail(values)}
>
<div className="grid gap-3">
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Address
</FormLabel>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
<FormControl>
<Input
{...field}
placeholder="Your email address"
/>
</FormControl>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
<FormControl>
<Input
{...field}
placeholder="Your email address"
/>
</FormControl>
<div className="flex flex-col md:flex-row gap-3">
<Button
type="submit"
variant="secondary"
disabled={
isUpdatingEmail ||
!emailForm.formState.isDirty
}
>
{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
type="submit"
type="button"
variant="secondary"
onClick={() => resendVerificationEmail()}
disabled={
isUpdatingEmail ||
!emailForm.formState
.isDirty
isResendingVerification ||
emailForm.formState.errors.email !==
undefined
}
>
{isUpdatingEmail && (
{isResendingVerification && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Update
Resend Verification
</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>
<FormMessage />
</div>
</FormItem>
)}
/>
{!user.emailVerified && (
<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)
}
/>
<FormMessage />
</div>
</div>
</FormItem>
)}
/>
<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.
{!user.emailVerified && (
<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>
</div>
)}
</Form>
</div>
<Dialog
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>
{apiEnabled && <ProfileApiKeys />}
</div>
);
}
}
+282
View File
@@ -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>
);
}