diff --git a/thoughts-frontend/app/settings/api-keys/page.tsx b/thoughts-frontend/app/settings/api-keys/page.tsx new file mode 100644 index 0000000..629b071 --- /dev/null +++ b/thoughts-frontend/app/settings/api-keys/page.tsx @@ -0,0 +1,27 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getApiKeys } from "@/lib/api"; +import { ApiKeyList } from "@/components/api-keys-list"; + +export default async function ApiKeysPage() { + const token = (await cookies()).get("auth_token")?.value; + if (!token) { + redirect("/login"); + } + + const initialApiKeys = await getApiKeys(token).catch(() => ({ + apiKeys: [], + })); + + return ( +
+
+

API Keys

+

+ Manage API keys for third-party applications. +

+
+ +
+ ); +} diff --git a/thoughts-frontend/app/settings/layout.tsx b/thoughts-frontend/app/settings/layout.tsx index dd58833..7054ce5 100644 --- a/thoughts-frontend/app/settings/layout.tsx +++ b/thoughts-frontend/app/settings/layout.tsx @@ -7,7 +7,10 @@ const sidebarNavItems = [ title: "Profile", href: "/settings/profile", }, - // You can add more links here later, e.g., "Account", "API Keys" + { + title: "API Keys", + href: "/settings/api-keys", + }, ]; export default function SettingsLayout({ @@ -17,7 +20,7 @@ export default function SettingsLayout({ }) { return (
-
+

Settings

Manage your account settings and profile. diff --git a/thoughts-frontend/app/settings/profile/page.tsx b/thoughts-frontend/app/settings/profile/page.tsx index efb1067..f0d6b12 100644 --- a/thoughts-frontend/app/settings/profile/page.tsx +++ b/thoughts-frontend/app/settings/profile/page.tsx @@ -18,8 +18,8 @@ export default async function EditProfilePage() { } return ( -

-
+
+

Profile

This is how others will see you on the site. diff --git a/thoughts-frontend/components/api-keys-list.tsx b/thoughts-frontend/components/api-keys-list.tsx new file mode 100644 index 0000000..7fd8486 --- /dev/null +++ b/thoughts-frontend/components/api-keys-list.tsx @@ -0,0 +1,214 @@ +// thoughts-frontend/components/api-key-list.tsx +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; +import { + ApiKey, + CreateApiKeySchema, + createApiKey, + deleteApiKey, +} from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Copy, KeyRound, Plus, Trash2 } from "lucide-react"; +import { format } from "date-fns"; + +interface ApiKeyListProps { + initialApiKeys: ApiKey[]; +} + +export function ApiKeyList({ initialApiKeys }: ApiKeyListProps) { + const [keys, setKeys] = useState(initialApiKeys); + const [newKey, setNewKey] = useState(null); + const { token } = useAuth(); + + const form = useForm>({ + resolver: zodResolver(CreateApiKeySchema), + defaultValues: { name: "" }, + }); + + async function onSubmit(values: z.infer) { + if (!token) return; + try { + const newKeyResponse = await createApiKey(values, token); + setKeys((prev) => [...prev, newKeyResponse]); + setNewKey(newKeyResponse.plaintextKey ?? null); + form.reset(); + toast.success("API Key created successfully."); + } catch { + toast.error("Failed to create API key."); + } + } + + const handleDelete = async (keyId: string) => { + if (!token) return; + try { + await deleteApiKey(keyId, token); + setKeys((prev) => prev.filter((key) => key.id !== keyId)); + toast.success("API Key deleted successfully."); + } catch { + toast.error("Failed to delete API key."); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Key copied to clipboard!"); + }; + + return ( +

+ + + Existing Keys + + These are the API keys associated with your account. + + + + {keys.length > 0 ? ( +
    + {keys.map((key) => ( +
  • +
    + +
    +

    {key.name}

    +

    + {`Created on ${format(key.createdAt, "PPP")}`} +

    +

    + {`${key.keyPrefix}...`} +

    +
    +
    + + + + + + + Are you sure? + + This will permanently delete the key "{key.name} + ". This action cannot be undone. + + + + Cancel + handleDelete(key.id)}> + Delete + + + + +
  • + ))} +
+ ) : ( +

+ You have no API keys. +

+ )} +
+
+ + {/* Display New Key */} + {newKey && ( + + + New API Key Generated + + Please copy this key and store it securely. You will not be able + to see it again. + + + + + + + + + + + )} + + + + Create New API Key + + Give your new key a descriptive name. + + +
+ + + ( + + Key Name + + + + + + )} + /> + + + + +
+ +
+
+ ); +} diff --git a/thoughts-frontend/components/popular-tags.tsx b/thoughts-frontend/components/popular-tags.tsx index 83fed69..0b5b3f1 100644 --- a/thoughts-frontend/components/popular-tags.tsx +++ b/thoughts-frontend/components/popular-tags.tsx @@ -25,7 +25,7 @@ export async function PopularTags() { return ( - Popular Tags + Popular Tags {tags.map((tag) => ( diff --git a/thoughts-frontend/components/settings-nav.tsx b/thoughts-frontend/components/settings-nav.tsx index e5f4833..7172282 100644 --- a/thoughts-frontend/components/settings-nav.tsx +++ b/thoughts-frontend/components/settings-nav.tsx @@ -29,10 +29,8 @@ export function SettingsNav({ className, items, ...props }: SettingsNavProps) { href={item.href} className={cn( buttonVariants({ variant: "ghost" }), - pathname === item.href - ? "bg-muted hover:bg-muted" - : "hover:bg-transparent hover:underline", - "justify-start" + pathname === item.href ? "bg-muted" : "hover:underline", + "justify-start glass-effect glossy-effect bottom shadow-fa-md" )} > {item.title} diff --git a/thoughts-frontend/components/user-nav.tsx b/thoughts-frontend/components/user-nav.tsx index e51fd3d..9829b86 100644 --- a/thoughts-frontend/components/user-nav.tsx +++ b/thoughts-frontend/components/user-nav.tsx @@ -61,7 +61,11 @@ export function UserNav() { - +

diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index 67cdcb8..9ab51a2 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -65,11 +65,32 @@ export const SearchResultsSchema = z.object({ thoughts: z.object({ thoughts: z.array(ThoughtSchema) }), }); +export const ApiKeySchema = z.object({ + id: z.uuid(), + name: z.string(), + keyPrefix: z.string(), + createdAt: z.coerce.date(), +}); + +export const ApiKeyResponseSchema = ApiKeySchema.extend({ + plaintextKey: z.string().optional(), +}); + +export const ApiKeyListSchema = z.object({ + apiKeys: z.array(ApiKeySchema), +}); + +export const CreateApiKeySchema = z.object({ + name: z.string().min(1, "Key name cannot be empty."), +}); + export type User = z.infer; export type Me = z.infer; export type Thought = z.infer; export type Register = z.infer; export type Login = z.infer; +export type ApiKey = z.infer; +export type ApiKeyResponse = z.infer; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; @@ -247,4 +268,30 @@ export const search = (query: string, token: string | null) => {}, SearchResultsSchema, token + ); + + +export const getApiKeys = (token: string) => + apiFetch(`/users/me/api-keys`, {}, ApiKeyListSchema, token); + +export const createApiKey = ( + data: z.infer, + token: string +) => + apiFetch( + `/users/me/api-keys`, + { + method: "POST", + body: JSON.stringify(data), + }, + ApiKeyResponseSchema, + token + ); + +export const deleteApiKey = (keyId: string, token: string) => + apiFetch( + `/users/me/api-keys/${keyId}`, + { method: "DELETE" }, + z.null(), + token ); \ No newline at end of file