standardize header, save all button for targets, fix update site on resource

This commit is contained in:
Milo Schwartz 2024-11-13 20:08:05 -05:00
parent cf3cf4d827
commit 44b932937f
No known key found for this signature in database
33 changed files with 577 additions and 397 deletions

View file

@ -150,6 +150,7 @@ authenticated.get(
authenticated.post(
"/resource/:resourceId",
verifyResourceAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.updateResource
);
@ -370,7 +371,7 @@ authRouter.use(
authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout);
authRouter.post('/newt/get-token', getToken);
authRouter.post("/newt/get-token", getToken);
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post(

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { Resource, resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
@ -12,12 +12,7 @@ const getResourceSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export type GetResourceResponse = {
resourceId: number;
siteId: number;
orgId: string;
name: string;
};
export type GetResourceResponse = Resource;
export async function getResource(
req: Request,
@ -53,12 +48,7 @@ export async function getResource(
}
return response(res, {
data: {
resourceId: resource[0].resourceId,
siteId: resource[0].siteId,
orgId: resource[0].orgId,
name: resource[0].name,
},
data: resource[0],
success: true,
error: false,
message: "Resource retrieved successfully",

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { resources, sites } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
@ -17,6 +17,8 @@ const updateResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
subdomain: z.string().min(1).max(255).optional(),
ssl: z.boolean().optional(),
siteId: z.number(),
})
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update",

View file

@ -29,6 +29,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = {
open: boolean;
@ -74,9 +75,10 @@ export default function CreateRoleForm({
toast({
variant: "destructive",
title: "Failed to create role",
description:
e.response?.data?.message ||
"An error occurred while creating the role.",
description: formatAxiosError(
e,
"An error occurred while creating the role."
),
});
});

View file

@ -36,6 +36,7 @@ import {
SelectValue,
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = {
open: boolean;
@ -71,9 +72,10 @@ export default function DeleteRoleForm({
toast({
variant: "destructive",
title: "Failed to fetch roles",
description:
e.message ||
"An error occurred while fetching the roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
@ -109,9 +111,10 @@ export default function DeleteRoleForm({
toast({
variant: "destructive",
title: "Failed to remove role",
description:
e.response?.data?.message ||
"An error occurred while removing the role.",
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
});
});

View file

@ -28,6 +28,8 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
@ -60,9 +62,10 @@ export default function AccessControlsPage() {
toast({
variant: "destructive",
title: "Failed to fetch roles",
description:
e.message ||
"An error occurred while fetching the roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
@ -87,9 +90,10 @@ export default function AccessControlsPage() {
toast({
variant: "destructive",
title: "Failed to add user to role",
description:
e.response?.data?.message ||
"An error occurred while adding user to the role.",
description: formatAxiosError(
e,
"An error occurred while adding user to the role."
),
});
});
@ -106,14 +110,11 @@ export default function AccessControlsPage() {
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Access Controls
</h2>
<p className="text-muted-foreground">
Manage what this user can access and do in the organization
</p>
</div>
<SettingsSectionTitle
title="Access Controls"
description="Manage what this user can access and do in the organization"
size="1xl"
/>
<Form {...form}>
<form

View file

@ -53,7 +53,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
className="text-muted-foreground hover:underline"
>
<div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Users</span>
<ArrowLeft className="w-4 h-4" />{" "}
<span>All Users</span>
</div>
</Link>
</div>

View file

@ -38,6 +38,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type InviteUserFormProps = {
open: boolean;
@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({
variant: "destructive",
title: "Failed to fetch roles",
description:
e.message ||
"An error occurred while fetching the roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({
variant: "destructive",
title: "Failed to invite user",
description:
e.response?.data?.message ||
"An error occurred while inviting the user.",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
),
});
});

View file

@ -19,6 +19,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
export type UserRow = {
id: string;
@ -162,9 +163,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
toast({
variant: "destructive",
title: "Failed to remove user",
description:
e.message ??
"An error occurred while removing the user.",
description: formatAxiosError(
e,
"An error occurred while removing the user."
),
});
});

View file

@ -21,6 +21,7 @@ import {
SelectValue,
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org";
import Link from "next/link";
import { useRouter } from "next/navigation";
@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
console.error("Error logging out", e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out"),
});
})
.then(() => {

View file

@ -1,5 +1,6 @@
import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { SidebarSettings } from "@app/components/SidebarSettings";
import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider";
@ -67,14 +68,12 @@ export default async function GeneralSettingsPage({
<>
<OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
General
</h2>
<p className="text-muted-foreground">
Configure your organization's general settings
</p>
</div>
<SettingsSectionTitle
title="General"
description="Configure your organization's general settings"
size="1xl"
/>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>

View file

@ -1,9 +1,7 @@
"use client";
import { useEffect, useState, use } from "react";
import { Trash2, Server, Globe, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -14,13 +12,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import api from "@app/api";
import { AxiosResponse } from "axios";
import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { set, z } from "zod";
import {
Form,
FormControl,
@ -50,16 +47,14 @@ import {
} from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Target } from "@server/db/schema";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { Dot } from "lucide-react";
import { formatAxiosError } from "@app/lib/utils";
import { escape } from "querystring";
const addTargetSchema = z.object({
ip: z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
"Invalid IP address format"
),
ip: z.string().ip(),
method: z.string(),
port: z
.string()
@ -72,6 +67,11 @@ const addTargetSchema = z.object({
type AddTargetFormValues = z.infer<typeof addTargetSchema>;
type LocalTarget = ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
};
export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>;
}) {
@ -80,7 +80,9 @@ export default function ReverseProxyTargets(props: {
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]);
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
@ -103,9 +105,10 @@ export default function ReverseProxyTargets(props: {
toast({
variant: "destructive",
title: "Failed to fetch targets",
description:
err.message ||
"An error occurred while fetching targets",
description: formatAxiosError(
err,
"An error occurred while fetching targets"
),
});
});
@ -117,68 +120,145 @@ export default function ReverseProxyTargets(props: {
}, []);
async function addTarget(data: AddTargetFormValues) {
const res = await api
.put<AxiosResponse<CreateTargetResponse>>(
`/resource/${params.resourceId}/target`,
{
...data,
resourceId: undefined,
}
const newTarget: LocalTarget = {
...data,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
};
setTargets([...targets, newTarget]);
addTargetForm.reset();
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId),
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
setTargets(
targets.map((target) =>
target.targetId === targetId
? { ...target, ...data, updated: true }
: target
)
);
}
async function saveAll() {
const res = await api
.post(`/resource/${params.resourceId}`, { ssl: sslEnabled })
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to add target",
description:
err.message || "An error occurred while adding target",
title: "Failed to update resource",
description: formatAxiosError(
err,
"Failed to update resource"
),
});
});
if (res && res.status === 201) {
setTargets([...targets, res.data.data]);
addTargetForm.reset();
}
}
const removeTarget = (targetId: number) => {
api.delete(`/target/${targetId}`)
.catch((err) => {
console.error(err);
})
.then((res) => {
setTargets(
targets.filter((target) => target.targetId !== targetId)
);
.then(() => {
updateResource({ ssl: sslEnabled });
});
};
async function updateTarget(targetId: number, data: Partial<Target>) {
setTargets(
targets.map((target) =>
target.targetId === targetId ? { ...target, ...data } : target
)
);
for (const target of targets) {
const data = {
ip: target.ip,
port: target.port,
method: target.method,
protocol: target.protocol,
enabled: target.enabled,
};
const res = await api.post(`/target/${targetId}`, data).catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update target",
description:
err.message || "An error occurred while updating target",
});
if (target.new) {
await api
.put<AxiosResponse<CreateTargetResponse>>(
`/resource/${params.resourceId}/target`,
data
)
.then((res) => {
setTargets(
targets.map((t) => {
if (
t.new &&
t.targetId === res.data.data.targetId
) {
return {
...t,
new: false,
};
}
return t;
})
);
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to add target",
description: formatAxiosError(
err,
"Failed to add target"
),
});
});
} else if (target.updated) {
const res = await api
.post(`/target/${target.targetId}`, data)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update target",
description: formatAxiosError(
err,
"Failed to update target"
),
});
});
}
}
for (const targetId of targetsToRemove) {
await api
.delete(`/target/${targetId}`)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to remove target",
description: formatAxiosError(
err,
"Failed to remove target"
),
});
})
.then((res) => {
setTargets(
targets.filter((target) => target.targetId !== targetId)
);
});
}
toast({
title: "Resource updated",
description: "Resource and targets updated successfully",
});
if (res && res.status === 200) {
toast({
title: "Target updated",
description: "The target has been updated successfully",
});
}
setTargetsToRemove([]);
}
const columns: ColumnDef<ListTargetsResponse["targets"][0]>[] = [
const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "ip",
header: "IP Address",
@ -259,12 +339,17 @@ export default function ReverseProxyTargets(props: {
{
id: "actions",
cell: ({ row }) => (
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
>
Delete
</Button>
<>
<div className="flex items-center justify-end space-x-2">
{row.original.new && <Dot />}
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
>
Delete
</Button>
</div>
</>
),
},
];
@ -286,10 +371,15 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionTitle
title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates"
size="1xl"
/>
<div className="flex items-center space-x-2">
<Switch id="ssl-toggle" />
<Switch
id="ssl-toggle"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</div>
@ -298,6 +388,7 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionTitle
title="Targets"
description="Setup targets to route traffic to your services"
size="1xl"
/>
<Form {...addTargetForm}>
@ -355,8 +446,8 @@ export default function ReverseProxyTargets(props: {
</Select>
</FormControl>
<FormDescription>
Choose the method for the target
connection
Choose the method for how the
target is accessed
</FormDescription>
<FormMessage />
</FormItem>
@ -422,7 +513,9 @@ export default function ReverseProxyTargets(props: {
)}
/>
</div>
<Button type="submit">Add Target</Button>
<Button type="submit" variant="gray">
Add Target
</Button>
</form>
</Form>
</div>
@ -466,7 +559,7 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length}
className="h-24 text-center"
>
No results.
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
@ -474,6 +567,10 @@ export default function ReverseProxyTargets(props: {
</Table>
</div>
</div>
<div className="mt-8">
<Button onClick={saveAll}>Save Changes</Button>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { cn, formatAxiosError } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
@ -38,6 +38,7 @@ import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
const GeneralFormSchema = z.object({
name: z.string(),
@ -48,64 +49,71 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralForm() {
const params = useParams();
const orgId = params.orgId;
const { resource, updateResource } = useResourceContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const orgId = params.orgId;
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource?.name,
siteId: resource?.siteId,
name: resource.name,
siteId: resource.siteId!,
},
mode: "onChange",
});
useEffect(() => {
if (typeof window !== "undefined") {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}, []);
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
updateResource({ name: data.name, siteId: data.siteId });
await api
.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
siteId: data.siteId,
}
)
api.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
siteId: data.siteId,
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update resource",
description:
e.response?.data?.message ||
"An error occurred while updating the resource",
description: formatAxiosError(
e,
"An error occurred while updating the resource"
),
});
});
})
.then(() => {
toast({
title: "Resource updated",
description: "The resource has been updated successfully",
});
})
.finally(() => setSaveLoading(false));
}
return (
<>
<div className="lg:max-w-2xl">
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
General Settings
</h2>
<p className="text-muted-foreground">
Configure the general settings for this resource
</p>
</div>
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this resource"
size="1xl"
/>
<Form {...form}>
<form
@ -160,10 +168,10 @@ export default function GeneralForm() {
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandInput placeholder="Search sites" />
<CommandList>
<CommandEmpty>
No site found.
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
@ -206,7 +214,13 @@ export default function GeneralForm() {
</FormItem>
)}
/>
<Button type="submit">Update Resource</Button>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
>
Save Changes
</Button>
</form>
</Form>
</div>

View file

@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
interface ResourceLayoutProps {
children: React.ReactNode;
@ -53,19 +54,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
className="text-muted-foreground hover:underline"
>
<div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Resources</span>
<ArrowLeft className="w-4 h-4" />{" "}
<span>All Resources</span>
</div>
</Link>
</div>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
{resource?.name + " Settings"}
</h2>
<p className="text-muted-foreground">
Configure the settings on your resource
</p>
</div>
<SettingsSectionTitle
title={`${resource?.name} Settings`}
description="Configure the settings on your resource"
/>
<ResourceProvider resource={resource}>
<SidebarSettings

View file

@ -29,7 +29,7 @@ import {
} from "@app/components/Credenza";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { cn } from "@app/lib/utils";
import { cn, formatAxiosError } from "@app/lib/utils";
import { CheckIcon } from "lucide-react";
import {
Popover,
@ -123,7 +123,12 @@ export default function CreateResourceForm({
)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating resource",
description: formatAxiosError(
e,
"An error occurred when creating the resource"
),
});
});

View file

@ -17,6 +17,8 @@ import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/utils";
import { useToast } from "@app/hooks/useToast";
export type ResourceRow = {
id: number;
@ -34,6 +36,8 @@ type ResourcesTableProps = {
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
@ -43,6 +47,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error("Error deleting resource", e);
toast({
variant: "destructive",
title: "Error deleting resource",
description: formatAxiosError(e, "Error deleting resource"),
});
})
.then(() => {
router.refresh();

View file

@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@ -33,14 +34,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Manage Resources
</h2>
<p className="text-muted-foreground">
Create secure proxies to your private applications.
</p>
</div>
<SettingsSectionTitle
title="Manage Resources"
description="Create secure proxies to your private applications"
/>
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
</>

View file

@ -18,6 +18,8 @@ import { useForm } from "react-hook-form";
import api from "@app/api";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const GeneralFormSchema = z.object({
name: z.string(),
@ -41,18 +43,19 @@ export default function GeneralPage() {
async function onSubmit(data: GeneralFormValues) {
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
description:
e.message ||
"An error occurred while updating the site.",
.post(`/site/${site?.siteId}`, {
name: data.name,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
description: formatAxiosError(
e,
"An error occurred while updating the site."
),
});
});
});
updateSite({ name: data.name });
@ -61,14 +64,11 @@ export default function GeneralPage() {
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
General Settings
</h2>
<p className="text-muted-foreground">
Configure the general settings for this site
</p>
</div>
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"
size="1xl"
/>
<Form {...form}>
<form

View file

@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
interface SettingsLayoutProps {
children: React.ReactNode;
@ -44,19 +45,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
className="text-muted-foreground hover:underline"
>
<div className="flex flex-row items-center gap-1">
<ArrowLeft /> <span>All Sites</span>
<ArrowLeft className="w-4 h-4" /> <span>All Sites</span>
</div>
</Link>
</div>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
{site?.name + " Settings"}
</h2>
<p className="text-muted-foreground">
Configure the settings on your site
</p>
</div>
<SettingsSectionTitle
title={`${site?.name} Settings`}
description="Configure the settings on your site"
/>
<SiteProvider site={site}>
<SidebarSettings

View file

@ -40,6 +40,7 @@ import {
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { formatAxiosError } from "@app/lib/utils";
const method = [
{ label: "Wireguard", value: "wg" },
@ -108,7 +109,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error picking site defaults",
description: formatAxiosError(e),
});
})
.then((res) => {
@ -130,7 +133,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
})
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
description: formatAxiosError(e),
});
});

View file

@ -17,6 +17,8 @@ import { AxiosResponse } from "axios";
import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
export type SiteRow = {
id: number;
@ -35,6 +37,8 @@ type SitesTableProps = {
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
@ -48,6 +52,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error("Error deleting site", e);
toast({
variant: "destructive",
title: "Error deleting site",
description: formatAxiosError(e, "Error deleting site"),
});
})
.then(() => {
router.refresh();

View file

@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) {
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Manage Sites
</h2>
<p className="text-muted-foreground">
Manage your existing sites here or create a new one.
</p>
</div>
<SettingsSectionTitle
title="Manage Sites"
description="Manage your existing sites here or create a new one."
/>
<SitesTable sites={siteRows} orgId={params.orgId} />
</>

View file

@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
import { api } from "@app/api";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type LoginFormProps = {
redirect?: string;
@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
.catch((e) => {
console.error(e);
setError(
e.response?.data?.message ||
"An error occurred while logging in",
formatAxiosError(e, "An error occurred while logging in")
);
});
@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) {
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" loading={loading}>
<Button
type="submit"
className="w-full"
loading={loading}
>
Login
</Button>
</form>

View file

@ -27,6 +27,7 @@ import { api } from "@app/api";
import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type SignupFormProps = {
redirect?: string;
@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
.catch((e) => {
console.error(e);
setError(
e.response?.data?.message ||
"An error occurred while signing up",
formatAxiosError(e, "An error occurred while signing up")
);
});

View file

@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
@ -76,14 +77,14 @@ export default function VerifyEmailForm({
code: data.pin,
})
.catch((e) => {
setError(e.response?.data?.message || "An error occurred");
setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to verify email:", e);
});
if (res && res.data?.data?.valid) {
setError(null);
setSuccessMessage(
"Email successfully verified! Redirecting you...",
"Email successfully verified! Redirecting you..."
);
setTimeout(() => {
if (redirect && redirect.includes("http")) {
@ -103,7 +104,7 @@ export default function VerifyEmailForm({
setIsResending(true);
const res = await api.post("/auth/verify-email/request").catch((e) => {
setError(e.response?.data?.message || "An error occurred");
setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to resend verification code:", e);
});

View file

@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import InviteStatusCard from "./InviteStatusCard";
import { formatAxiosError } from "@app/lib/utils";
export default async function InvitePage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@ -47,8 +48,7 @@ export default async function InvitePage(props: {
await authCookieHeader()
)
.catch((e) => {
error = e.response?.data?.message;
console.log(error);
console.error(e);
});
if (res && res.status === 200) {

View file

@ -15,6 +15,7 @@ import {
CardTitle,
} from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/utils";
type Step = "org" | "site" | "resources";
@ -43,7 +44,7 @@ export default function StepperForm() {
const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability],
[checkOrgIdAvailability]
);
useEffect(() => {
@ -76,7 +77,9 @@ export default function StepperForm() {
})
.catch((e) => {
toast({
title: "Error creating org...",
variant: "destructive",
title: "Error creating org",
description: formatAxiosError(e),
});
});
@ -106,36 +109,60 @@ export default function StepperForm() {
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "org" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
1
</div>
<span
className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`}
className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
>
Create Org
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "site" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
2
</div>
<span
className={`text-sm font-medium ${currentStep === "site" ? "text-primary" : "text-muted-foreground"}`}
className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
>
Create Site
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "resources" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
3
</div>
<span
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`}
className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
>
Create Resources
</span>
@ -251,7 +278,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;

View file

@ -1,15 +1,23 @@
type SettingsSectionTitleProps = {
title: string | React.ReactNode;
description: string | React.ReactNode;
size?: "2xl" | "1xl";
};
export default function SettingsSectionTitle({
title,
description,
size,
}: SettingsSectionTitleProps) {
return (
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
<h2
className={`text-${
size ? size : "2xl"
} font-bold tracking-tight`}
>
{title}
</h2>
<p className="text-muted-foreground">{description}</p>
</div>
);

View file

@ -17,7 +17,7 @@ export function Toaster() {
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<Toast key={id} {...props} className="mt-2">
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (

View file

@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
import { createContext } from "react";
interface ResourceContextType {
resource: GetResourceResponse | null;
resource: GetResourceResponse;
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
}

View file

@ -1,194 +1,192 @@
"use client"
"use client";
// Inspired by react-hot-toast library
import * as React from "react"
import * as React from "react";
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 5 * 1000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[]
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = []
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] }
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
return {
...state,
toast,
dismiss: (toastId?: string) =>
dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast }
export { useToast, toast };

View file

@ -1,6 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export function formatAxiosError(error: any, defaultMessage?: string): string {
return (
error.response?.data?.message ||
error?.message ||
defaultMessage ||
"An error occurred"
);
}

View file

@ -6,16 +6,15 @@ import { useState } from "react";
interface ResourceProviderProps {
children: React.ReactNode;
resource: GetResourceResponse | null;
resource: GetResourceResponse;
}
export function ResourceProvider({
children,
resource: serverResource,
}: ResourceProviderProps) {
const [resource, setResource] = useState<GetResourceResponse | null>(
serverResource
);
const [resource, setResource] =
useState<GetResourceResponse>(serverResource);
const updateResource = (updatedResource: Partial<GetResourceResponse>) => {
if (!resource) {