Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

# Project Description

This is the dashboard code for easily interacting with [Juno](https://github.com/GTBitsOfGood/juno), [Bits of Good](https://bitsofgood.org/)'s central microservice architecture. See the main repository for more details.
This is the dashboard code for easily interacting with [Juno](https://github.com/GTBitsOfGood/juno), [Bits of Good](https://bitsofgood.org/)'s central microservice architecture. See the main repository for more details.

## Prequisites

Expand All @@ -32,7 +32,7 @@ Installing all needed packages:
bun install
```

## Development
## Development

### Running locally

Expand Down Expand Up @@ -66,6 +66,6 @@ Now, you should be able to enter API request fields. The username and password s

or if you are using a different project, then replace with that project name.

### Components
### Components

This repository uses [shadcn/ui](https://ui.shadcn.com/) for streamlining component development.
47 changes: 45 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion components.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}
3 changes: 1 addition & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
};
const nextConfig: NextConfig = {};

export default nextConfig;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client";

import { EmailDomainTable } from "@/components/emailDomainTable/emailDomainTable";
import { EmailSenderTable } from "@/components/emailSenderTable/emailSenderTable";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { getProjectById } from "@/lib/project";
import { getEmailConfig } from "@/lib/settings";
import { useQuery } from "@tanstack/react-query";
import { ProjectResponse } from "juno-sdk/build/main/internal/index";
import { Mail, Settings } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";

const EmailConfigurationsPage = () => {
const { projectId } = useParams<{ projectId: string }>();

const [hasEmailConfig, setHasEmailConfig] = useState(null);
const [emailConfigLoading, setEmailConfigLoading] = useState(true);

const {
isLoading,
isError,
data: project,
error,
} = useQuery<ProjectResponse>({
queryKey: ["project", projectId],
queryFn: async () => {
const result = await getProjectById(Number(projectId));
if (!result.success) {
throw new Error(result.error);
}
return result.project;
},
});

useEffect(() => {
if (isError) {
toast.error("Error", {
description: `Failed to fetch project: ${JSON.stringify(error)}`,
});
}
}, [isError, error]);

useEffect(() => {
const loadEmailConfig = async () => {
try {
const configRes = await getEmailConfig(String(projectId));
if (configRes) {
setHasEmailConfig(configRes);
}
} catch (e) {
console.error("Error loading email config:", e);
toast.error("Error loading email config", {
description: "Please try again later",
});
} finally {
setEmailConfigLoading(false);
}
};

loadEmailConfig();
}, [projectId]);

const breadcrumb = (
<Breadcrumb className="mb-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/projects">Projects</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={`/projects/${projectId}`}>
{isLoading ? "****" : (project?.name ?? "Unknown")}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={`/projects/${projectId}/services/email`}>
Email
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Configurations</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);

if (emailConfigLoading) {
return (
<div className="flex flex-col">
{breadcrumb}
<Separator className="mb-8" />
<h1 className="mb-4 text-lg font-bold">Email Configurations</h1>
<div className="space-y-4">
<div className="h-64 animate-pulse rounded-md bg-muted" />
<div className="h-64 animate-pulse rounded-md bg-muted" />
</div>
</div>
);
}

if (!hasEmailConfig) {
return (
<div className="flex flex-col">
{breadcrumb}
<Separator className="mb-8" />
<h1 className="mb-4 text-lg font-bold">Email Configurations</h1>
<Card className="max-w-[35%]">
<CardHeader>
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-muted-foreground" />
<CardTitle>No Email Configuration</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Configure your email service first to manage senders and domains.
</p>
<Button asChild>
<Link href={`/projects/${projectId}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>
</Button>
</CardContent>
</Card>
</div>
);
}

return (
<div className="flex flex-col">
{breadcrumb}
<Separator className="mb-8" />
<div className="flex flex-col gap-8">
<EmailSenderTable projectId={projectId} />
<EmailDomainTable projectId={projectId} />
</div>
</div>
);
};

export default EmailConfigurationsPage;
62 changes: 62 additions & 0 deletions src/components/emailDomainTable/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { ColumnDef } from "@tanstack/react-table";

export type EmailDomainColumn = {
id: number;
domain: string;
subdomain?: string;
valid: boolean;
};

export const emailDomainColumns: ColumnDef<EmailDomainColumn>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2 align-middle mr-5"
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
className="ms-2 align-middle"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
size: 50,
},
{
accessorKey: "domain",
header: "Domain",
},
{
accessorKey: "subdomain",
header: "Subdomain",
cell: ({ row }) => {
return row.original.subdomain || "-";
},
},
{
accessorKey: "valid",
header: "Status",
cell: ({ row }) => {
const valid = row.original.valid;
return (
<Badge variant={valid ? "default" : "secondary"}>
{valid ? "Verified" : "Pending"}
</Badge>
);
},
size: 100,
},
{
accessorKey: "id",
header: "SendGrid ID",
size: 100,
},
];
124 changes: 124 additions & 0 deletions src/components/emailDomainTable/emailDomainTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import {
EmailDomainColumn,
emailDomainColumns,
} from "@/components/emailDomainTable/columns";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { getEmailDomains } from "@/lib/settings";
import { registerJunoDomain } from "@/lib/sdkUtils";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { BaseTable } from "../baseTable";
import AddEmailDomainForm from "../forms/AddEmailDomainForm";
import { DialogHeader } from "../ui/dialog";

interface EmailDomainTableProps {
projectId: string;
}

export function EmailDomainTable({ projectId }: EmailDomainTableProps) {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);

const queryClient = useQueryClient();
const {
isLoading,
isError,
data: domainsResult,
error,
} = useQuery({
queryKey: ["emailDomains", projectId],
queryFn: async () => {
return await getEmailDomains(projectId);
},
});

useEffect(() => {
if (isError) {
toast.error("Error", {
description: `Failed to fetch email domains: ${JSON.stringify(error)}`,
});
}
if (domainsResult && !domainsResult.success && domainsResult.error) {
toast.error("Error", {
description: domainsResult.error,
});
}
}, [isError, error, domainsResult]);

const domainRowData: EmailDomainColumn[] = (domainsResult?.domains ?? []).map(
(domain: EmailDomainColumn) => ({
id: domain.id,
domain: domain.domain ?? "",
subdomain: domain.subdomain,
valid: domain.valid ?? false,
}),
);

const addDomainHandler = useMutation({
mutationFn: async (options: { domain: string; subdomain?: string }) => {
const result = await registerJunoDomain(
options.domain,
options.subdomain,
);
if (!result.success) {
throw new Error(result.message);
}
return result;
},
onSuccess: () => {
toast.success("Success", {
description: "Successfully registered domain.",
});
queryClient.invalidateQueries({
queryKey: ["emailDomains", projectId],
});
},
onSettled: () => {
setIsAddDialogOpen(false);
},
onError: (error) =>
toast.error(
`An error occurred while registering domain: ${error.message}`,
),
});

return (
<div className="flex flex-col gap-4">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Register Email Domain</DialogTitle>
<DialogDescription>
Register a new domain with SendGrid for email authentication
</DialogDescription>
</DialogHeader>
<AddEmailDomainForm
isPending={addDomainHandler.isPending}
onAddDomain={(options) => addDomainHandler.mutate(options)}
/>
</DialogContent>
</Dialog>

<h1 className="text-lg font-bold">Email Domains</h1>
<BaseTable
data={domainRowData}
columns={emailDomainColumns}
isLoading={isLoading}
filterParams={{
placeholder: "Filter by domain...",
filterColumn: "domain",
}}
onAddNewRow={() => {
setIsAddDialogOpen(true);
}}
/>
</div>
);
}
Loading
Loading