Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: API key #188

Merged
merged 10 commits into from
Jan 19, 2025
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
3 changes: 2 additions & 1 deletion apps/api/app/v1/[[...route]]/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { checkLogin } from "@/app/actions/checkLogin";
import { checkAdmin } from "@/app/actions/checkAdmin";

const app = new Hono()
.use(checkLogin)
.post("/", zValidator("json", feedbackSchema), async (c) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
Expand Down Expand Up @@ -40,7 +41,7 @@ const app = new Hono()
);
}
})
.use(checkLogin, checkAdmin)
.use(checkAdmin)
.get("/all", async (c) => {
try {
const feedbacks = await prisma.feedback.findMany();
Expand Down
50 changes: 48 additions & 2 deletions apps/api/app/v1/[[...route]]/project.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { zValidator } from "@hono/zod-validator";
import { prisma } from "@plura/db";
import { Hono } from "hono";
import { projectSchema } from "@repo/types";
import { projectApiSchema, projectSchema } from "@repo/types";
import { auth } from "@plura/auth";
import { cache } from "@plura/cache";
import { nanoid } from "nanoid";
import { checkLogin } from "@/app/actions/checkLogin";

const CACHE_EXPIRY = 300;
const app = new Hono()
.use(checkLogin)
.get("/workspace/:workspaceId", async (c) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
Expand All @@ -28,11 +30,55 @@ const app = new Hono()
createdAt: "asc",
},
});
return c.json(projects[0], 200);
return c.json(projects, 200);
} catch (error) {
return c.json({ message: "Error fetching projects", status: 400 }, 400);
}
})
.get("/:projectid", async(c)=>{
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ message: "Unauthorized", status: 401 }, 401);
}
const projectId = c.req.param("projectid");
if(!projectId){
return c.json({ message: "Missing project id", status: 400 }, 400);
}
try{
const project = await prisma.project.findUnique({
where:{
id: projectId
}
})
return c.json(project, 200);
} catch(error){
return c.json({ message: "Error fetching project", status: 400 }, 400);
}
})
.patch("/:projectid", zValidator("json", projectApiSchema), async (c) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ message: "Unauthorized", status: 401 }, 401);
}
const projectId = c.req.param("projectid");
const body = c.req.valid("json");
if(!projectId){
return c.json({ message: "Missing project id", status: 400 }, 400);
}
try{
const project = await prisma.project.update({
where: { id: projectId },
data: { apiKey: body.apiKey },
})
return c.json(project, 200);
} catch(error){
return c.json({ message: "Error fetching project", status: 400 }, 400);
}
})
.post("/", zValidator("json", projectSchema), async (c) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
Expand Down
3 changes: 2 additions & 1 deletion apps/api/app/v1/[[...route]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,6 @@ const POST = handle(app);
const PATCH = handle(app);
const DELETE = handle(app);
const OPTIONS = handle(app);
const PUT = handle(app);

export { GET, PATCH, POST, DELETE, OPTIONS };
export { GET, PUT, PATCH, POST, DELETE, OPTIONS };
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@plura/mor": "workspace:*",
"@prisma/client": "^5.22.0",
"@repo/types": "workspace:*",
"@unkey/api": "^0.31.0",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.34.3",
"hono": "^4.6.16",
Expand Down
82 changes: 82 additions & 0 deletions apps/app/actions/project.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use server";

import { betterFetch } from "@better-fetch/fetch";
import { getSession } from "./session";
import { headers } from "next/headers";
import { Unkey } from "@unkey/api";
import { revalidatePath } from "next/cache";

const API_ENDPOINT =
process.env.NODE_ENV === "production"
? "https://api.plura.pro/"
: "http://localhost:3001";

interface Project {
id: string;
name: string;
slug: string;
createdAt: string; // Use `string` for dates if they come as ISO strings from the backend
updatedAt: string;
workspaceId: string; // Define a Workspace interface separately
userId: string;
apiKey: string;
}

export const createProject = async ({
workspaceId,
name,
Expand Down Expand Up @@ -57,3 +71,71 @@ export const getProjectOfUser = async (workspaceId: string) => {
return project;
} catch (error) {}
};

export const curnProjectData = async({
projectId
}:{
projectId:string
}) => {
const user = await getSession();
if(!user){
return;
}
const curnProject = await betterFetch<Project>(`${API_ENDPOINT}/v1/project/${projectId}`,{
method: "GET",
headers:{
cookie: (await headers()).get("cookie") || "",
}
})
return curnProject;
}

export const createProjectKey = async ({
projectId,
expire,
}:{
projectId:string
expire:number
}) =>{
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY!});
const user = await getSession();
if(!user){
return;
}

const apiKey = await unkey.keys.create({
apiId:process.env.UNKEY_API_KEY!,
prefix:"plura",
byteLength:64,
ownerId:"chronark",
meta:{
hello: "world"
},
expires: 86400000 * expire,
ratelimit: {
type: "fast",
duration: 1000,
limit: 10,
},
remaining: 1000,
refill: {
interval: "monthly",
amount: 100,
refillDay: 15,
},
enabled: true
})

const projectKey = await betterFetch(`${API_ENDPOINT}/v1/project/${projectId}`,{
method: "PATCH",
body:{
projectId:projectId,
apiKey:apiKey.result?.keyId
},
headers:{
cookie: (await headers()).get("cookie") || "",
}
})
revalidatePath("/settings");
return projectKey;
}
10 changes: 8 additions & 2 deletions apps/app/app/(routes)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
"use client";
import InfoBreadCrumb from "@/components/custom/infobar/bread-crumb";
import ApiSettings from "@/components/custom/settings/api.settings";
import ApiSkeleton from "@/components/custom/settings/api.skeleton";
import BillingSettings from "@/components/custom/settings/billing.settings";
import ThemeSettings from "@/components/custom/settings/theme.settings";
import React from "react";
import {Suspense} from "react";

export default function SettingsPage() {

return (
<div className="flex flex-col h-full w-full items-start overflow-hidden px-5 md:px-2">
<InfoBreadCrumb />
<div className="flex flex-col gap-10">
<BillingSettings />
<ThemeSettings />
<Suspense fallback={<ApiSkeleton />}>
{/* @ts-expect-error Async Server Component */}
<ApiSettings />
</Suspense>
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions apps/app/components/custom/infobar/bread-crumb.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"use client";
import { usePathname } from "next/navigation";
import React from "react";

Expand Down
91 changes: 91 additions & 0 deletions apps/app/components/custom/settings/api.button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { createProjectKey } from "@/actions/project";

const apiSchema = z.object({
expire: z.number().min(1, "Minimum 1 day"),
});
type ApiSchema = z.infer<typeof apiSchema>;

export function ApiButton() {
const [isFirstDialogOpen, setIsFirstDialogOpen] = useState(false); // First dialog state

const form = useForm<ApiSchema>({
resolver: zodResolver(apiSchema),
});

const onSubmit: SubmitHandler<ApiSchema> = async (data) => {
try {
const validatedData = await apiSchema.parseAsync(data);
await createProjectKey({
projectId: "27f0281c-716f-4f46-b1e8-c8661b5fc34b",
expire: validatedData.expire
})
toast.success(`API Created Succesfully!`);
} catch (error) {
console.error(error);
toast.error(`Error in Creating api key! Please try again.`);
}finally{
setIsFirstDialogOpen(false);
}
};
return (
<div>
<Dialog open={isFirstDialogOpen} onOpenChange={setIsFirstDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="bg-primary hover:bg-primary text-white dark:text-black mt-5" onClick={() => setIsFirstDialogOpen(true)}>
Create API Key
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create New Secret Key</DialogTitle>
<DialogDescription>
Make your api key here. Click to create .
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3 max-w-md mx-auto border p-6 rounded-lg shadow-md" >
<FormField control={form.control} name="expire" render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center space-x-4">
<Label htmlFor="firstName" className="w-1/3">
Exipres in day
</Label>
<Input id="expire" type="number" placeholder="eg: 69" className="flex-1" required {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))}/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{form.formState.isSubmitting ? "Creating..." : "Create API Key"}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
);
}

export default ApiButton;
43 changes: 43 additions & 0 deletions apps/app/components/custom/settings/api.key.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { Button } from '@/components/ui/button'
import { Clipboard, ClipboardCheck, Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner';

export default function ApiKey({ apiKey }: { apiKey: string }) {
const [visible, setVisible] = useState<boolean>(false);
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
toast.success("APIKey Copied!!");
setTimeout(() => setCopied(false), 1000);
} catch (error) {
toast.error("Failed to copy APIKey.");
}
};

return (
<div className="mt-4 flex items-center gap-1">
<div className={"rounded-md bg-white dark:text-black px-4 py-2 text-sm font-medium border"}>
<h2 className={`${!visible && "blur-[3px]"}`}>{apiKey}</h2>
</div>
<Button
variant="default" // Replace with your preferred ShadCN button variant
onClick={handleCopy}
disabled={copied} // Disable the button temporarily after copy
className="border"
>
{copied ? <ClipboardCheck /> : <Clipboard />}
</Button>
<Button onClick={() => (setVisible(!visible))} className="border" variant="default">
{visible ? <Eye /> : (
<EyeOff />
)}
</Button>
</div>
)
}
Loading
Loading