Add smtp settings ui (#56)
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import { Label } from "@unsend/ui/src/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
const apiKeySchema = z.object({
|
||||
name: z.string({ required_error: "Name is required" }).min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function AddApiKey() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const createApiKeyMutation = api.apiKey.createToken.useMutation();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
|
||||
resolver: zodResolver(apiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave(values: z.infer<typeof apiKeySchema>) {
|
||||
createApiKeyMutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
permission: "FULL",
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
utils.apiKey.invalidate();
|
||||
setApiKey(data);
|
||||
apiKeyForm.reset();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function copyAndClose() {
|
||||
handleCopy();
|
||||
setApiKey("");
|
||||
setOpen(false);
|
||||
setShowApiKey(false);
|
||||
toast.success("API key copied to clipboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add API Key
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{apiKey ? (
|
||||
<DialogContent key={apiKey}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Copy API key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-1 bg-secondary rounded-lg px-4 flex items-center justify-between mt-2">
|
||||
<div>
|
||||
{showApiKey ? (
|
||||
<p className="text-sm">{apiKey}</p>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 40 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-1 h-1 bg-muted-foreground rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={copyAndClose}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new API key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...apiKeyForm}>
|
||||
<form
|
||||
onSubmit={apiKeyForm.handleSubmit(handleSave)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={apiKeyForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API key name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod key" {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
Use a name to easily identify this API key.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
type="submit"
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import DeleteApiKey from "./delete-api-key";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
|
||||
export default function ApiList() {
|
||||
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="border rounded-xl">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Name</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead className="rounded-tr-xl">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeysQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : apiKeysQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<p>No API keys added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
apiKeysQuery.data?.map((apiKey) => (
|
||||
<TableRow key={apiKey.id}>
|
||||
<TableCell>{apiKey.name}</TableCell>
|
||||
<TableCell>{apiKey.partialToken}</TableCell>
|
||||
<TableCell>{apiKey.permission}</TableCell>
|
||||
<TableCell>
|
||||
{apiKey.lastUsed
|
||||
? formatDistanceToNow(apiKey.lastUsed)
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteApiKey apiKey={apiKey} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import { Input } from "@unsend/ui/src/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import React, { useState } from "react";
|
||||
import { ApiKey } from "@prisma/client";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@unsend/ui/src/form";
|
||||
|
||||
const apiKeySchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const DeleteApiKey: React.FC<{
|
||||
apiKey: Partial<ApiKey> & { id: number };
|
||||
}> = ({ apiKey }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
|
||||
resolver: zodResolver(apiKeySchema),
|
||||
});
|
||||
|
||||
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
|
||||
if (values.name !== apiKey.name) {
|
||||
apiKeyForm.setError("name", {
|
||||
message: "Name does not match",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
deleteApiKeyMutation.mutate(
|
||||
{
|
||||
id: apiKey.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.apiKey.invalidate();
|
||||
setOpen(false);
|
||||
toast.success(`API key deleted`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const name = apiKeyForm.watch("name");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-600/80" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-primary">{apiKey.name}</span>?
|
||||
You can't reverse this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Form {...apiKeyForm}>
|
||||
<form
|
||||
onSubmit={apiKeyForm.handleSubmit(onDomainDelete)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={apiKeyForm.control}
|
||||
name="name"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{formState.errors.name ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription className=" text-transparent">
|
||||
.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
deleteApiKeyMutation.isPending || apiKey.name !== name
|
||||
}
|
||||
>
|
||||
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteApiKey;
|
16
apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import AddApiKey from "./add-api-key";
|
||||
import ApiList from "./api-list";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-medium">API Keys</h2>
|
||||
<AddApiKey />
|
||||
</div>
|
||||
<ApiList />
|
||||
</div>
|
||||
);
|
||||
}
|
26
apps/web/src/app/(dashboard)/dev-settings/layout.tsx
Normal file
26
apps/web/src/app/(dashboard)/dev-settings/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@unsend/ui/src/tabs";
|
||||
import { useState } from "react";
|
||||
import { SettingsNavButton } from "./settings-nav-button";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function ApiKeysPage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg">Developer settings</h1>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<SettingsNavButton href="/dev-settings/api-keys">
|
||||
API Keys
|
||||
</SettingsNavButton>
|
||||
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
|
||||
</div>
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
16
apps/web/src/app/(dashboard)/dev-settings/page.tsx
Normal file
16
apps/web/src/app/(dashboard)/dev-settings/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import AddApiKey from "./api-keys/add-api-key";
|
||||
import ApiList from "./api-keys/api-list";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-medium">API Keys</h2>
|
||||
<AddApiKey />
|
||||
</div>
|
||||
<ApiList />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
export const SettingsNavButton: React.FC<{
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
comingSoon?: boolean;
|
||||
}> = ({ href, children, comingSoon }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = pathname?.startsWith(href);
|
||||
|
||||
if (comingSoon) {
|
||||
return (
|
||||
<div className="flex items-center justify-between hover:text-primary cursor-not-allowed mt-1">
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
|
||||
soon
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-primary ${isActive ? " bg-accent" : "text-muted-foreground"}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
70
apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx
Normal file
70
apps/web/src/app/(dashboard)/dev-settings/smtp/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import { Code } from "@unsend/ui/src/code";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@unsend/ui/src/card";
|
||||
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
|
||||
|
||||
export default function ExampleCard() {
|
||||
const smtpDetails = {
|
||||
smtp: "smtp.example.com",
|
||||
port: "587",
|
||||
user: "user@example.com",
|
||||
password: "supersecretpassword",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-9 max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>SMTP</CardTitle>
|
||||
<CardDescription>
|
||||
Send emails using SMTP instead of the REST API. See documentation for
|
||||
more information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<strong>Host:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 text-zinc-500 rounded-lg mt-1 p-2 w-full bg-gray-900"
|
||||
value={"smtp.unsend.dev"}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Port:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 text-zinc-500 rounded-lg mt-1 p-2 w-full bg-gray-900"
|
||||
value={"465"}
|
||||
></TextWithCopyButton>
|
||||
<p className="ml-1 mt-1 text-zinc-500 ">
|
||||
For encrypted/TLS connections use <strong>2465</strong>,{" "}
|
||||
<strong>587</strong> or <strong>2587</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>User:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 text-zinc-500 rounded-lg mt-1 p-2 w-full bg-gray-900"
|
||||
value={"unsend"}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Password:</strong>
|
||||
<TextWithCopyButton
|
||||
className="ml-1 text-zinc-500 rounded-lg mt-1 p-2 w-full bg-gray-900"
|
||||
value={"YOUR_API_KEY"}
|
||||
></TextWithCopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user