mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-13 07:25:05 +02:00
standardize header, save all button for targets, fix update site on resource
This commit is contained in:
parent
cf3cf4d827
commit
44b932937f
33 changed files with 577 additions and 397 deletions
|
@ -150,6 +150,7 @@ authenticated.get(
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifySiteAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource),
|
verifyUserHasAction(ActionsEnum.updateResource),
|
||||||
resource.updateResource
|
resource.updateResource
|
||||||
);
|
);
|
||||||
|
@ -370,7 +371,7 @@ authRouter.use(
|
||||||
authRouter.put("/signup", auth.signup);
|
authRouter.put("/signup", auth.signup);
|
||||||
authRouter.post("/login", auth.login);
|
authRouter.post("/login", auth.login);
|
||||||
authRouter.post("/logout", auth.logout);
|
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("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources } from "@server/db/schema";
|
import { Resource, resources } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -12,12 +12,7 @@ const getResourceSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetResourceResponse = {
|
export type GetResourceResponse = Resource;
|
||||||
resourceId: number;
|
|
||||||
siteId: number;
|
|
||||||
orgId: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getResource(
|
export async function getResource(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
@ -53,12 +48,7 @@ export async function getResource(
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {
|
data: resource[0],
|
||||||
resourceId: resource[0].resourceId,
|
|
||||||
siteId: resource[0].siteId,
|
|
||||||
orgId: resource[0].orgId,
|
|
||||||
name: resource[0].name,
|
|
||||||
},
|
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Resource retrieved successfully",
|
message: "Resource retrieved successfully",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources } from "@server/db/schema";
|
import { resources, sites } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -17,6 +17,8 @@ const updateResourceBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
subdomain: 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, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
message: "At least one field must be provided for update",
|
message: "At least one field must be provided for update",
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type CreateRoleFormProps = {
|
type CreateRoleFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -74,9 +75,10 @@ export default function CreateRoleForm({
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to create role",
|
title: "Failed to create role",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.response?.data?.message ||
|
e,
|
||||||
"An error occurred while creating the role.",
|
"An error occurred while creating the role."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { RoleRow } from "./RolesTable";
|
import { RoleRow } from "./RolesTable";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type CreateRoleFormProps = {
|
type CreateRoleFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -71,9 +72,10 @@ export default function DeleteRoleForm({
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to fetch roles",
|
title: "Failed to fetch roles",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.message ||
|
e,
|
||||||
"An error occurred while fetching the roles",
|
"An error occurred while fetching the roles"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,9 +111,10 @@ export default function DeleteRoleForm({
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to remove role",
|
title: "Failed to remove role",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.response?.data?.message ||
|
e,
|
||||||
"An error occurred while removing the role.",
|
"An error occurred while removing the role."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,8 @@ import { ListRolesResponse } from "@server/routers/role";
|
||||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: "Please enter a valid email" }),
|
email: z.string().email({ message: "Please enter a valid email" }),
|
||||||
|
@ -60,9 +62,10 @@ export default function AccessControlsPage() {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to fetch roles",
|
title: "Failed to fetch roles",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.message ||
|
e,
|
||||||
"An error occurred while fetching the roles",
|
"An error occurred while fetching the roles"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -87,9 +90,10 @@ export default function AccessControlsPage() {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to add user to role",
|
title: "Failed to add user to role",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.response?.data?.message ||
|
e,
|
||||||
"An error occurred while adding user to the role.",
|
"An error occurred while adding user to the role."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -106,14 +110,11 @@ export default function AccessControlsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="Access Controls"
|
||||||
Access Controls
|
description="Manage what this user can access and do in the organization"
|
||||||
</h2>
|
size="1xl"
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
Manage what this user can access and do in the organization
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|
|
@ -53,7 +53,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
className="text-muted-foreground hover:underline"
|
className="text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center gap-1">
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -38,6 +38,7 @@ import {
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type InviteUserFormProps = {
|
type InviteUserFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to fetch roles",
|
title: "Failed to fetch roles",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.message ||
|
e,
|
||||||
"An error occurred while fetching the roles",
|
"An error occurred while fetching the roles"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to invite user",
|
title: "Failed to invite user",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.response?.data?.message ||
|
e,
|
||||||
"An error occurred while inviting the user.",
|
"An error occurred while inviting the user"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -162,9 +163,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to remove user",
|
title: "Failed to remove user",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.message ??
|
e,
|
||||||
"An error occurred while removing the user.",
|
"An error occurred while removing the user."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
|
||||||
console.error("Error logging out", e);
|
console.error("Error logging out", e);
|
||||||
toast({
|
toast({
|
||||||
title: "Error logging out",
|
title: "Error logging out",
|
||||||
|
description: formatAxiosError(e, "Error logging out"),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { internal } from "@app/api";
|
import { internal } from "@app/api";
|
||||||
import { authCookieHeader } from "@app/api/cookies";
|
import { authCookieHeader } from "@app/api/cookies";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
@ -67,14 +68,12 @@ export default async function GeneralSettingsPage({
|
||||||
<>
|
<>
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<OrgUserProvider orgUser={orgUser}>
|
<OrgUserProvider orgUser={orgUser}>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="General"
|
||||||
General
|
description="Configure your organization's general settings"
|
||||||
</h2>
|
size="1xl"
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
Configure your organization's general settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, use } from "react";
|
import { useEffect, useState, use } from "react";
|
||||||
import { Trash2, Server, Globe, Cpu } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
@ -14,13 +12,12 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import api from "@app/api";
|
import api from "@app/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { set, z } from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -50,16 +47,14 @@ import {
|
||||||
} from "@app/components/ui/table";
|
} from "@app/components/ui/table";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Target } from "@server/db/schema";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
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({
|
const addTargetSchema = z.object({
|
||||||
ip: z
|
ip: z.string().ip(),
|
||||||
.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"
|
|
||||||
),
|
|
||||||
method: z.string(),
|
method: z.string(),
|
||||||
port: z
|
port: z
|
||||||
.string()
|
.string()
|
||||||
|
@ -72,6 +67,11 @@ const addTargetSchema = z.object({
|
||||||
|
|
||||||
type AddTargetFormValues = z.infer<typeof addTargetSchema>;
|
type AddTargetFormValues = z.infer<typeof addTargetSchema>;
|
||||||
|
|
||||||
|
type LocalTarget = ArrayElement<ListTargetsResponse["targets"]> & {
|
||||||
|
new?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReverseProxyTargets(props: {
|
export default function ReverseProxyTargets(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
}) {
|
}) {
|
||||||
|
@ -80,7 +80,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { resource, updateResource } = useResourceContext();
|
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({
|
const addTargetForm = useForm({
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
|
@ -103,9 +105,10 @@ export default function ReverseProxyTargets(props: {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to fetch targets",
|
title: "Failed to fetch targets",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
err.message ||
|
err,
|
||||||
"An error occurred while fetching targets",
|
"An error occurred while fetching targets"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -117,68 +120,145 @@ export default function ReverseProxyTargets(props: {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function addTarget(data: AddTargetFormValues) {
|
async function addTarget(data: AddTargetFormValues) {
|
||||||
|
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
|
const res = await api
|
||||||
|
.post(`/resource/${params.resourceId}`, { ssl: sslEnabled })
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update resource",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"Failed to update resource"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
updateResource({ ssl: sslEnabled });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const data = {
|
||||||
|
ip: target.ip,
|
||||||
|
port: target.port,
|
||||||
|
method: target.method,
|
||||||
|
protocol: target.protocol,
|
||||||
|
enabled: target.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target.new) {
|
||||||
|
await api
|
||||||
.put<AxiosResponse<CreateTargetResponse>>(
|
.put<AxiosResponse<CreateTargetResponse>>(
|
||||||
`/resource/${params.resourceId}/target`,
|
`/resource/${params.resourceId}/target`,
|
||||||
{
|
data
|
||||||
...data,
|
|
||||||
resourceId: undefined,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
.then((res) => {
|
||||||
|
setTargets(
|
||||||
|
targets.map((t) => {
|
||||||
|
if (
|
||||||
|
t.new &&
|
||||||
|
t.targetId === res.data.data.targetId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
new: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to add target",
|
title: "Failed to add target",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
err.message || "An error occurred while adding target",
|
err,
|
||||||
|
"Failed to add target"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else if (target.updated) {
|
||||||
if (res && res.status === 201) {
|
const res = await api
|
||||||
setTargets([...targets, res.data.data]);
|
.post(`/target/${target.targetId}`, data)
|
||||||
addTargetForm.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeTarget = (targetId: number) => {
|
|
||||||
api.delete(`/target/${targetId}`)
|
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(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) => {
|
.then((res) => {
|
||||||
setTargets(
|
setTargets(
|
||||||
targets.filter((target) => target.targetId !== targetId)
|
targets.filter((target) => target.targetId !== targetId)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
async function updateTarget(targetId: number, data: Partial<Target>) {
|
|
||||||
setTargets(
|
|
||||||
targets.map((target) =>
|
|
||||||
target.targetId === targetId ? { ...target, ...data } : target
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (res && res.status === 200) {
|
|
||||||
toast({
|
|
||||||
title: "Target updated",
|
|
||||||
description: "The target has been updated successfully",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<ListTargetsResponse["targets"][0]>[] = [
|
toast({
|
||||||
|
title: "Resource updated",
|
||||||
|
description: "Resource and targets updated successfully",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTargetsToRemove([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: "IP Address",
|
header: "IP Address",
|
||||||
|
@ -259,12 +339,17 @@ export default function ReverseProxyTargets(props: {
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
{row.original.new && <Dot />}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => removeTarget(row.original.targetId)}
|
onClick={() => removeTarget(row.original.targetId)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -286,10 +371,15 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="SSL"
|
title="SSL"
|
||||||
description="Setup SSL to secure your connections with LetsEncrypt certificates"
|
description="Setup SSL to secure your connections with LetsEncrypt certificates"
|
||||||
|
size="1xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<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>
|
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -298,6 +388,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Targets"
|
title="Targets"
|
||||||
description="Setup targets to route traffic to your services"
|
description="Setup targets to route traffic to your services"
|
||||||
|
size="1xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form {...addTargetForm}>
|
<Form {...addTargetForm}>
|
||||||
|
@ -355,8 +446,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose the method for the target
|
Choose the method for how the
|
||||||
connection
|
target is accessed
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -422,7 +513,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">Add Target</Button>
|
<Button type="submit" variant="gray">
|
||||||
|
Add Target
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -466,7 +559,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
No results.
|
No targets. Add a target using the form.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
@ -474,6 +567,10 @@ export default function ReverseProxyTargets(props: {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button onClick={saveAll}>Save Changes</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatAxiosError } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
@ -38,6 +38,7 @@ import { useParams } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { GetResourceResponse } from "@server/routers/resource";
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
@ -48,22 +49,24 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const orgId = params.orgId;
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
|
||||||
const { toast } = useToast();
|
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>({
|
const form = useForm<GeneralFormValues>({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: resource?.name,
|
name: resource.name,
|
||||||
siteId: resource?.siteId,
|
siteId: resource.siteId!,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const fetchSites = async () => {
|
const fetchSites = async () => {
|
||||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||||
`/org/${orgId}/sites/`
|
`/org/${orgId}/sites/`
|
||||||
|
@ -71,13 +74,13 @@ export default function GeneralForm() {
|
||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
};
|
};
|
||||||
fetchSites();
|
fetchSites();
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(data: GeneralFormValues) {
|
async function onSubmit(data: GeneralFormValues) {
|
||||||
|
setSaveLoading(true);
|
||||||
updateResource({ name: data.name, siteId: data.siteId });
|
updateResource({ name: data.name, siteId: data.siteId });
|
||||||
await api
|
|
||||||
.post<AxiosResponse<GetResourceResponse>>(
|
api.post<AxiosResponse<GetResourceResponse>>(
|
||||||
`resource/${resource?.resourceId}`,
|
`resource/${resource?.resourceId}`,
|
||||||
{
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
@ -88,24 +91,29 @@ export default function GeneralForm() {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to update resource",
|
title: "Failed to update resource",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.response?.data?.message ||
|
e,
|
||||||
"An error occurred while updating the resource",
|
"An error occurred while updating the resource"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast({
|
||||||
|
title: "Resource updated",
|
||||||
|
description: "The resource has been updated successfully",
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.finally(() => setSaveLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="lg:max-w-2xl">
|
<div className="lg:max-w-2xl">
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="General Settings"
|
||||||
General Settings
|
description="Configure the general settings for this resource"
|
||||||
</h2>
|
size="1xl"
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
Configure the general settings for this resource
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
@ -160,10 +168,10 @@ export default function GeneralForm() {
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[350px] p-0">
|
<PopoverContent className="w-[350px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search site..." />
|
<CommandInput placeholder="Search sites" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
No site found.
|
No sites found.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{sites.map((site) => (
|
{sites.map((site) => (
|
||||||
|
@ -206,7 +214,13 @@ export default function GeneralForm() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Update Resource</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={saveLoading}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
|
||||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
interface ResourceLayoutProps {
|
interface ResourceLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -53,19 +54,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
className="text-muted-foreground hover:underline"
|
className="text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center gap-1">
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title={`${resource?.name} Settings`}
|
||||||
{resource?.name + " Settings"}
|
description="Configure the settings on your resource"
|
||||||
</h2>
|
/>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Configure the settings on your resource
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ResourceProvider resource={resource}>
|
<ResourceProvider resource={resource}>
|
||||||
<SidebarSettings
|
<SidebarSettings
|
||||||
|
|
|
@ -29,7 +29,7 @@ import {
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
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 { CheckIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
|
@ -123,7 +123,12 @@ export default function CreateResourceForm({
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
title: "Error creating resource",
|
title: "Error creating resource",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when creating the resource"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ import CreateResourceForm from "./CreateResourceForm";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { set } from "zod";
|
import { set } from "zod";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
|
||||||
export type ResourceRow = {
|
export type ResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -34,6 +36,8 @@ type ResourcesTableProps = {
|
||||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedResource, setSelectedResource] =
|
const [selectedResource, setSelectedResource] =
|
||||||
|
@ -43,6 +47,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
api.delete(`/resource/${resourceId}`)
|
api.delete(`/resource/${resourceId}`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error("Error deleting resource", e);
|
console.error("Error deleting resource", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error deleting resource",
|
||||||
|
description: formatAxiosError(e, "Error deleting resource"),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
|
||||||
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
|
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ListResourcesResponse } from "@server/routers/resource";
|
import { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
type ResourcesPageProps = {
|
type ResourcesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -33,14 +34,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="Manage Resources"
|
||||||
Manage Resources
|
description="Create secure proxies to your private applications"
|
||||||
</h2>
|
/>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create secure proxies to your private applications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
|
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -18,6 +18,8 @@ import { useForm } from "react-hook-form";
|
||||||
import api from "@app/api";
|
import api from "@app/api";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
@ -48,9 +50,10 @@ export default function GeneralPage() {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to update site",
|
title: "Failed to update site",
|
||||||
description:
|
description: formatAxiosError(
|
||||||
e.message ||
|
e,
|
||||||
"An error occurred while updating the site.",
|
"An error occurred while updating the site."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -61,14 +64,11 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="General Settings"
|
||||||
General Settings
|
description="Configure the general settings for this site"
|
||||||
</h2>
|
size="1xl"
|
||||||
<p className="text-muted-foreground">
|
/>
|
||||||
Configure the general settings for this site
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { authCookieHeader } from "@app/api/cookies";
|
||||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -44,19 +45,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
className="text-muted-foreground hover:underline"
|
className="text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center gap-1">
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title={`${site?.name} Settings`}
|
||||||
{site?.name + " Settings"}
|
description="Configure the settings on your site"
|
||||||
</h2>
|
/>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Configure the settings on your site
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SiteProvider site={site}>
|
<SiteProvider site={site}>
|
||||||
<SidebarSettings
|
<SidebarSettings
|
||||||
|
|
|
@ -40,6 +40,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
const method = [
|
const method = [
|
||||||
{ label: "Wireguard", value: "wg" },
|
{ label: "Wireguard", value: "wg" },
|
||||||
|
@ -108,7 +109,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
|
||||||
api.get(`/org/${orgId}/pick-site-defaults`)
|
api.get(`/org/${orgId}/pick-site-defaults`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
title: "Error picking site defaults",
|
title: "Error picking site defaults",
|
||||||
|
description: formatAxiosError(e),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
@ -130,7 +133,9 @@ export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
title: "Error creating site",
|
title: "Error creating site",
|
||||||
|
description: formatAxiosError(e),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ import { AxiosResponse } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import CreateSiteForm from "./CreateSiteForm";
|
import CreateSiteForm from "./CreateSiteForm";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
export type SiteRow = {
|
export type SiteRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -35,6 +37,8 @@ type SitesTableProps = {
|
||||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
|
@ -48,6 +52,11 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
api.delete(`/site/${siteId}`)
|
api.delete(`/site/${siteId}`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error("Error deleting site", e);
|
console.error("Error deleting site", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error deleting site",
|
||||||
|
description: formatAxiosError(e, "Error deleting site"),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SitesTable, { SiteRow } from "./components/SitesTable";
|
import SitesTable, { SiteRow } from "./components/SitesTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
|
||||||
type SitesPageProps = {
|
type SitesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<SettingsSectionTitle
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
title="Manage Sites"
|
||||||
Manage Sites
|
description="Manage your existing sites here or create a new one."
|
||||||
</h2>
|
/>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your existing sites here or create a new one.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
<SitesTable sites={siteRows} orgId={params.orgId} />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
|
||||||
import { api } from "@app/api";
|
import { api } from "@app/api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(
|
setError(
|
||||||
e.response?.data?.message ||
|
formatAxiosError(e, "An error occurred while logging in")
|
||||||
"An error occurred while logging in",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) {
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { api } from "@app/api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type SignupFormProps = {
|
type SignupFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(
|
setError(
|
||||||
e.response?.data?.message ||
|
formatAxiosError(e, "An error occurred while signing up")
|
||||||
"An error occurred while signing up",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
const FormSchema = z.object({
|
const FormSchema = z.object({
|
||||||
email: z.string().email({ message: "Invalid email address" }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
|
@ -76,14 +77,14 @@ export default function VerifyEmailForm({
|
||||||
code: data.pin,
|
code: data.pin,
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setError(e.response?.data?.message || "An error occurred");
|
setError(formatAxiosError(e, "An error occurred"));
|
||||||
console.error("Failed to verify email:", e);
|
console.error("Failed to verify email:", e);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.data?.data?.valid) {
|
if (res && res.data?.data?.valid) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMessage(
|
setSuccessMessage(
|
||||||
"Email successfully verified! Redirecting you...",
|
"Email successfully verified! Redirecting you..."
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (redirect && redirect.includes("http")) {
|
if (redirect && redirect.includes("http")) {
|
||||||
|
@ -103,7 +104,7 @@ export default function VerifyEmailForm({
|
||||||
setIsResending(true);
|
setIsResending(true);
|
||||||
|
|
||||||
const res = await api.post("/auth/verify-email/request").catch((e) => {
|
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);
|
console.error("Failed to resend verification code:", e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import InviteStatusCard from "./InviteStatusCard";
|
import InviteStatusCard from "./InviteStatusCard";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
export default async function InvitePage(props: {
|
export default async function InvitePage(props: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
@ -47,8 +48,7 @@ export default async function InvitePage(props: {
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
error = e.response?.data?.message;
|
console.error(e);
|
||||||
console.log(error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
|
||||||
type Step = "org" | "site" | "resources";
|
type Step = "org" | "site" | "resources";
|
||||||
|
|
||||||
|
@ -43,7 +44,7 @@ export default function StepperForm() {
|
||||||
|
|
||||||
const debouncedCheckOrgIdAvailability = useCallback(
|
const debouncedCheckOrgIdAvailability = useCallback(
|
||||||
debounce(checkOrgIdAvailability, 300),
|
debounce(checkOrgIdAvailability, 300),
|
||||||
[checkOrgIdAvailability],
|
[checkOrgIdAvailability]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -76,7 +77,9 @@ export default function StepperForm() {
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
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 justify-between mb-2">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<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
|
1
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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
|
Create Org
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<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
|
2
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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
|
Create Site
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<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
|
3
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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
|
Create Resources
|
||||||
</span>
|
</span>
|
||||||
|
@ -251,7 +278,7 @@ export default function StepperForm() {
|
||||||
|
|
||||||
function debounce<T extends (...args: any[]) => any>(
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
func: T,
|
func: T,
|
||||||
wait: number,
|
wait: number
|
||||||
): (...args: Parameters<T>) => void {
|
): (...args: Parameters<T>) => void {
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,23 @@
|
||||||
type SettingsSectionTitleProps = {
|
type SettingsSectionTitleProps = {
|
||||||
title: string | React.ReactNode;
|
title: string | React.ReactNode;
|
||||||
description: string | React.ReactNode;
|
description: string | React.ReactNode;
|
||||||
|
size?: "2xl" | "1xl";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsSectionTitle({
|
export default function SettingsSectionTitle({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
size,
|
||||||
}: SettingsSectionTitleProps) {
|
}: SettingsSectionTitleProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 select-none mb-6">
|
<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>
|
<p className="text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,7 +17,7 @@ export function Toaster() {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
return (
|
return (
|
||||||
<Toast key={id} {...props}>
|
<Toast key={id} {...props} className="mt-2">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
{description && (
|
{description && (
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
interface ResourceContextType {
|
interface ResourceContextType {
|
||||||
resource: GetResourceResponse | null;
|
resource: GetResourceResponse;
|
||||||
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
|
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,78 +1,75 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import type {
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
const TOAST_LIMIT = 3;
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
const TOAST_REMOVE_DELAY = 5 * 1000;
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
type ToasterToast = ToastProps & {
|
||||||
id: string
|
id: string;
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode;
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode;
|
||||||
action?: ToastActionElement
|
action?: ToastActionElement;
|
||||||
}
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
ADD_TOAST: "ADD_TOAST",
|
ADD_TOAST: "ADD_TOAST",
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
let count = 0
|
let count = 0;
|
||||||
|
|
||||||
function genId() {
|
function genId() {
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
return count.toString()
|
return count.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"]
|
type: ActionType["ADD_TOAST"];
|
||||||
toast: ToasterToast
|
toast: ToasterToast;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"]
|
type: ActionType["UPDATE_TOAST"];
|
||||||
toast: Partial<ToasterToast>
|
toast: Partial<ToasterToast>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"]
|
type: ActionType["DISMISS_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"]
|
type: ActionType["REMOVE_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
};
|
||||||
|
|
||||||
interface State {
|
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) => {
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
if (toastTimeouts.has(toastId)) {
|
if (toastTimeouts.has(toastId)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId)
|
toastTimeouts.delete(toastId);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: "REMOVE_TOAST",
|
||||||
toastId: toastId,
|
toastId: toastId,
|
||||||
})
|
});
|
||||||
}, TOAST_REMOVE_DELAY)
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
toastTimeouts.set(toastId, timeout);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -80,7 +77,7 @@ export const reducer = (state: State, action: Action): State => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
}
|
};
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case "UPDATE_TOAST":
|
||||||
return {
|
return {
|
||||||
|
@ -88,19 +85,19 @@ export const reducer = (state: State, action: Action): State => {
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case "DISMISS_TOAST": {
|
||||||
const { toastId } = action
|
const { toastId } = action;
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
// but I'll keep it here for simplicity
|
// but I'll keep it here for simplicity
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
addToRemoveQueue(toastId)
|
addToRemoveQueue(toastId);
|
||||||
} else {
|
} else {
|
||||||
state.toasts.forEach((toast) => {
|
state.toasts.forEach((toast) => {
|
||||||
addToRemoveQueue(toast.id)
|
addToRemoveQueue(toast.id);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -113,44 +110,44 @@ export const reducer = (state: State, action: Action): State => {
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case "REMOVE_TOAST":
|
case "REMOVE_TOAST":
|
||||||
if (action.toastId === undefined) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
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) {
|
function dispatch(action: Action) {
|
||||||
memoryState = reducer(memoryState, action)
|
memoryState = reducer(memoryState, action);
|
||||||
listeners.forEach((listener) => {
|
listeners.forEach((listener) => {
|
||||||
listener(memoryState)
|
listener(memoryState);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ ...props }: Toast) {
|
||||||
const id = genId()
|
const id = genId();
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: "UPDATE_TOAST",
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
})
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: "ADD_TOAST",
|
||||||
|
@ -159,36 +156,37 @@ function toast({ ...props }: Toast) {
|
||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open) => {
|
||||||
if (!open) dismiss()
|
if (!open) dismiss();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
dismiss,
|
dismiss,
|
||||||
update,
|
update,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useToast() {
|
function useToast() {
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listeners.push(setState)
|
listeners.push(setState);
|
||||||
return () => {
|
return () => {
|
||||||
const index = listeners.indexOf(setState)
|
const index = listeners.indexOf(setState);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listeners.splice(index, 1)
|
listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [state])
|
}, [state]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
toast,
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
dismiss: (toastId?: string) =>
|
||||||
}
|
dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useToast, toast }
|
export { useToast, toast };
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,16 +6,15 @@ import { useState } from "react";
|
||||||
|
|
||||||
interface ResourceProviderProps {
|
interface ResourceProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
resource: GetResourceResponse | null;
|
resource: GetResourceResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceProvider({
|
export function ResourceProvider({
|
||||||
children,
|
children,
|
||||||
resource: serverResource,
|
resource: serverResource,
|
||||||
}: ResourceProviderProps) {
|
}: ResourceProviderProps) {
|
||||||
const [resource, setResource] = useState<GetResourceResponse | null>(
|
const [resource, setResource] =
|
||||||
serverResource
|
useState<GetResourceResponse>(serverResource);
|
||||||
);
|
|
||||||
|
|
||||||
const updateResource = (updatedResource: Partial<GetResourceResponse>) => {
|
const updateResource = (updatedResource: Partial<GetResourceResponse>) => {
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue