Skip to content
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
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ ENCRYPT_SALT=
# Set this to true if you haven't set `TINYBIRD_TOKEN`.
# Some of the app's featues will be disabled when this is set.
# NEXT_PUBLIC_DISABLE_TINYBIRD=true
# Generate a random secret here: https://generate-secret.vercel.app/32
API_KEY_SALT=

LOOPS_API_SECRET=

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(app)/automation/group/Groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function Groups() {
<div className="flex justify-between">
<div className="space-y-1.5">
<CardTitle>Groups</CardTitle>
<CardDescription>
<CardDescription className="max-w-prose">
Groups are used to group together emails that are related to each
other. They can be created manually, or preset group can be
generated for you automatically with AI.
Expand Down
10 changes: 4 additions & 6 deletions apps/web/app/(app)/automation/group/[groupId]/examples/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
"use client";

import Link from "next/link";
import useSWR from "swr";
import groupBy from "lodash/groupBy";
import { TopSection } from "@/components/TopSection";
import { Button } from "@/components/ui/button";
import { ExampleList } from "@/app/(app)/automation/rule/[ruleId]/examples/example-list";
import type { ExamplesResponse } from "@/app/api/user/rules/[id]/example/route";
import type { GroupEmailsResponse } from "@/app/api/user/group/[groupId]/messages/controller";
import { LoadingContent } from "@/components/LoadingContent";

export const dynamic = "force-dynamic";
Expand All @@ -16,11 +14,11 @@ export default function RuleExamplesPage({
}: {
params: { groupId: string };
}) {
const { data, isLoading, error } = useSWR<ExamplesResponse>(
`/api/user/group/${params.groupId}/examples`,
const { data, isLoading, error } = useSWR<GroupEmailsResponse>(
`/api/user/group/${params.groupId}/messages`,
);

const threads = groupBy(data, (m) => m.threadId);
const threads = groupBy(data?.messages, (m) => m.threadId);
const groupedBySenders = groupBy(threads, (t) => t[0]?.headers.from);

const hasExamples = Object.keys(groupedBySenders).length > 0;
Expand Down
113 changes: 113 additions & 0 deletions apps/web/app/(app)/settings/ApiKeysCreateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"use client";

import { useCallback, useState } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { Button } from "@/components/Button";
import { Button as UiButton } from "@/components/ui/button";
import { Input } from "@/components/Input";
import { isActionError } from "@/utils/error";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { createApiKeyBody, CreateApiKeyBody } from "@/utils/actions/validation";
import {
createApiKeyAction,
deactivateApiKeyAction,
} from "@/utils/actions/api-key";
import { handleActionResult } from "@/utils/server-action";
import { toastError } from "@/components/Toast";
import { CopyInput } from "@/components/CopyInput";
import { SectionDescription } from "@/components/Typography";

export function ApiKeysCreateButtonModal() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Create new secret key</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create new secret key</DialogTitle>
<DialogDescription>
This will create a new secret key for your account. You will need to
use this secret key to authenticate your requests to the API.
</DialogDescription>
</DialogHeader>

<ApiKeysForm />
</DialogContent>
</Dialog>
);
}

function ApiKeysForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateApiKeyBody>({
resolver: zodResolver(createApiKeyBody),
defaultValues: {},
});

const [secretKey, setSecretKey] = useState("");

const onSubmit: SubmitHandler<CreateApiKeyBody> = useCallback(
async (data) => {
const result = await createApiKeyAction(data);
handleActionResult(result, "API key created!");

if (!isActionError(result) && result?.secretKey) {
setSecretKey(result.secretKey);
} else {
toastError({ description: "Failed to create API key" });
}
},
[],
);

return !secretKey ? (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
type="text"
name="name"
label="Name (optional)"
placeholder="My secret key"
registerProps={register("name")}
error={errors.name}
/>

<Button type="submit" loading={isSubmitting}>
Create
</Button>
</form>
) : (
<div className="space-y-2">
<SectionDescription>
This will only be shown once. Please copy it. Your secret key is:
</SectionDescription>
<CopyInput value={secretKey} />
</div>
);
}
Comment on lines +49 to +98
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add missing dependency in useCallback hook.

The useCallback hook is missing the handleActionResult and toastError dependencies.

-   [],
+   [handleActionResult, toastError],
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function ApiKeysForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<CreateApiKeyBody>({
resolver: zodResolver(createApiKeyBody),
defaultValues: {},
});
const [secretKey, setSecretKey] = useState("");
const onSubmit: SubmitHandler<CreateApiKeyBody> = useCallback(
async (data) => {
const result = await createApiKeyAction(data);
handleActionResult(result, "API key created!");
if (!isActionError(result) && result?.secretKey) {
setSecretKey(result.secretKey);
} else {
toastError({ description: "Failed to create API key" });
}
},
[],
);
return !secretKey ? (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
type="text"
name="name"
label="Name (optional)"
placeholder="My secret key"
registerProps={register("name")}
error={errors.name}
/>
<Button type="submit" loading={isSubmitting}>
Create
</Button>
</form>
) : (
<div className="space-y-2">
<SectionDescription>
This will only be shown once. Please copy it. Your secret key is:
</SectionDescription>
<CopyInput value={secretKey} />
</div>
);
}
const onSubmit: SubmitHandler<CreateApiKeyBody> = useCallback(
async (data) => {
const result = await createApiKeyAction(data);
handleActionResult(result, "API key created!");
if (!isActionError(result) && result?.secretKey) {
setSecretKey(result.secretKey);
} else {
toastError({ description: "Failed to create API key" });
}
},
[handleActionResult, toastError],
);


export function ApiKeysDeactivateButton({ id }: { id: string }) {
return (
<UiButton
variant="outline"
size="sm"
onClick={async () => {
const result = await deactivateApiKeyAction({ id });
handleActionResult(result, "API key deactivated!");
}}
>
Revoke
</UiButton>
);
}
69 changes: 69 additions & 0 deletions apps/web/app/(app)/settings/ApiKeysSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { auth } from "@/app/api/auth/[...nextauth]/auth";
import { FormSection, FormSectionLeft } from "@/components/Form";
import prisma from "@/utils/prisma";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ApiKeysCreateButtonModal,
ApiKeysDeactivateButton,
} from "@/app/(app)/settings/ApiKeysCreateForm";
import { Card } from "@/components/ui/card";

export async function ApiKeysSection() {
const session = await auth();
const userId = session?.user.id;
if (!userId) throw new Error("Not authenticated");

const apiKeys = await prisma.apiKey.findMany({
where: { userId, isActive: true },
select: {
id: true,
name: true,
createdAt: true,
},
});

return (
<FormSection>
<FormSectionLeft
title="API keys"
description="Create an API key to access the Inbox Zero API. Do not share your API key with others, or expose it in the browser or other client-side code."
/>

<div className="col-span-2 space-y-4">
{apiKeys.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.createdAt.toLocaleString()}</TableCell>
<TableCell>
<ApiKeysDeactivateButton id={apiKey.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : null}

<ApiKeysCreateButtonModal />
</div>
</FormSection>
);
}
Comment on lines +18 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle the null session case and use self-closing elements.

The function should handle the case where session is null, and the JSX element without children should be self-closing.

- export async function ApiKeysSection() {
+ export async function ApiKeysSection(): Promise<JSX.Element> {
    const session = await auth();
-   const userId = session?.user.id;
+   if (!session) throw new Error("Not authenticated");
+   const userId = session.user.id;
    if (!userId) throw new Error("Not authenticated");

    const apiKeys = await prisma.apiKey.findMany({
        where: { userId, isActive: true },
        select: {
            id: true,
            name: true,
            createdAt: true,
        },
    });

    return (
        <FormSection>
            <FormSectionLeft
                title="API keys"
                description="Create an API key to access the Inbox Zero API. Do not share your API key with others, or expose it in the browser or other client-side code."
            />
            <div className="col-span-2 space-y-4">
                {apiKeys.length > 0 ? (
                    <Card>
                        <Table>
                            <TableHeader>
                                <TableRow>
                                    <TableHead>Name</TableHead>
                                    <TableHead>Created</TableHead>
-                                   <TableHead></TableHead>
+                                   <TableHead />
                                </TableRow>
                            </TableHeader>
                            <TableBody>
                                {apiKeys.map((apiKey) => (
                                    <TableRow key={apiKey.id}>
                                        <TableCell>{apiKey.name}</TableCell>
                                        <TableCell>{apiKey.createdAt.toLocaleString()}</TableCell>
                                        <TableCell>
                                            <ApiKeysDeactivateButton id={apiKey.id} />
                                        </TableCell>
                                    </TableRow>
                                ))}
                            </TableBody>
                        </Table>
                    </Card>
                ) : null}
                <ApiKeysCreateButtonModal />
            </div>
        </FormSection>
    );
}
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function ApiKeysSection() {
const session = await auth();
const userId = session?.user.id;
if (!userId) throw new Error("Not authenticated");
const apiKeys = await prisma.apiKey.findMany({
where: { userId, isActive: true },
select: {
id: true,
name: true,
createdAt: true,
},
});
return (
<FormSection>
<FormSectionLeft
title="API keys"
description="Create an API key to access the Inbox Zero API. Do not share your API key with others, or expose it in the browser or other client-side code."
/>
<div className="col-span-2 space-y-4">
{apiKeys.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.createdAt.toLocaleString()}</TableCell>
<TableCell>
<ApiKeysDeactivateButton id={apiKey.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : null}
<ApiKeysCreateButtonModal />
</div>
</FormSection>
);
}
export async function ApiKeysSection(): Promise<JSX.Element> {
const session = await auth();
if (!session) throw new Error("Not authenticated");
const userId = session.user.id;
if (!userId) throw new Error("Not authenticated");
const apiKeys = await prisma.apiKey.findMany({
where: { userId, isActive: true },
select: {
id: true,
name: true,
createdAt: true,
},
});
return (
<FormSection>
<FormSectionLeft
title="API keys"
description="Create an API key to access the Inbox Zero API. Do not share your API key with others, or expose it in the browser or other client-side code."
/>
<div className="col-span-2 space-y-4">
{apiKeys.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.createdAt.toLocaleString()}</TableCell>
<TableCell>
<ApiKeysDeactivateButton id={apiKey.id} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : null}
<ApiKeysCreateButtonModal />
</div>
</FormSection>
);
}
Tools
Biome

[error] 47-47: JSX elements without children should be marked as self-closing. In JSX, it is valid for any element to be self-closing.

Unsafe fix: Use a SelfClosingElement instead

(lint/style/useSelfClosingElements)

1 change: 0 additions & 1 deletion apps/web/app/(app)/settings/DeleteSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { Button } from "@/components/Button";
import { FormSection, FormSectionLeft } from "@/components/Form";
import { toastError, toastSuccess } from "@/components/Toast";
import { deleteAccountAction } from "@/utils/actions/user";
import { handleActionResult } from "@/utils/server-action";
import { logOut } from "@/utils/user";
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DeleteSection } from "@/app/(app)/settings/DeleteSection";
import { ModelSection } from "@/app/(app)/settings/ModelSection";
import { EmailUpdatesSection } from "@/app/(app)/settings/EmailUpdatesSection";
import { MultiAccountSection } from "@/app/(app)/settings/MultiAccountSection";
import { ApiKeysSection } from "@/app/(app)/settings/ApiKeysSection";

export default function Settings() {
return (
Expand All @@ -14,6 +15,7 @@ export default function Settings() {
<ModelSection />
<EmailUpdatesSection />
<MultiAccountSection />
<ApiKeysSection />
<DeleteSection />
</FormWrapper>
);
Expand Down
39 changes: 0 additions & 39 deletions apps/web/app/api/user/group/[groupId]/examples/route.ts

This file was deleted.

Loading