From fd4ab3b7a041dfa2ad4f9c4cd4076f52ae57409b Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 30 Dec 2024 23:41:06 -0500 Subject: [PATCH] improve site and resource info cards and other small visual tweaks --- .../emails/templates/NotifyResetPassword.tsx | 24 ++- server/emails/templates/ResetPasswordCode.tsx | 14 +- server/emails/templates/ResourceOTPCode.tsx | 13 +- server/emails/templates/SendInviteLink.tsx | 16 +- .../templates/TwoFactorAuthNotification.tsx | 39 ++-- server/emails/templates/VerifyEmailCode.tsx | 17 +- server/routers/site/createSite.ts | 7 +- server/routers/site/getSite.ts | 60 ++---- server/routers/site/updateSite.ts | 7 +- server/schemas/subdomainSchema.ts | 3 +- .../[orgId]/settings/access/roles/page.tsx | 2 + .../settings/access/users/[userId]/layout.tsx | 16 +- .../[orgId]/settings/access/users/page.tsx | 2 + .../[resourceId]/authentication/page.tsx | 200 +++++++++--------- .../components/ResourceInfoBox.tsx | 118 ++++------- .../[resourceId]/connectivity/page.tsx | 2 +- .../resources/components/ResourcesTable.tsx | 51 +---- src/app/[orgId]/settings/resources/page.tsx | 2 + src/app/[orgId]/settings/share-links/page.tsx | 2 + .../[niceId]/components/SiteInfoCard.tsx | 56 +++++ .../settings/sites/[niceId]/general/page.tsx | 2 +- .../settings/sites/[niceId]/layout.tsx | 17 +- src/app/[orgId]/settings/sites/page.tsx | 2 + src/app/layout.tsx | 20 +- src/components/CopyToClipboard.tsx | 52 +++++ src/components/Credenza.tsx | 10 +- src/components/InfoSection.tsx | 31 +++ src/components/ProfileIcon.tsx | 2 +- src/components/ui/sheet.tsx | 4 +- src/contexts/siteContext.ts | 2 +- src/providers/SiteProvider.tsx | 8 +- 31 files changed, 469 insertions(+), 332 deletions(-) create mode 100644 src/app/[orgId]/settings/sites/[niceId]/components/SiteInfoCard.tsx create mode 100644 src/components/CopyToClipboard.tsx create mode 100644 src/components/InfoSection.tsx diff --git a/server/emails/templates/NotifyResetPassword.tsx b/server/emails/templates/NotifyResetPassword.tsx index 05ff1f50..e789e4bd 100644 --- a/server/emails/templates/NotifyResetPassword.tsx +++ b/server/emails/templates/NotifyResetPassword.tsx @@ -33,10 +33,20 @@ export const ConfirmPasswordReset = ({ email }: Props) => { } }} > - + +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ - Your password has been successfully reset + Password Reset Confirmation Hi {email || "there"}, @@ -46,12 +56,10 @@ export const ConfirmPasswordReset = ({ email }: Props) => { reset. If you made this change, no further action is required. -
- - If you did not request this change, please - contact our support team immediately. - -
+ + If you did not request this change, please contact + our support team immediately. + Thank you for keeping your account secure. diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx index eb2ec7fb..e0171189 100644 --- a/server/emails/templates/ResetPasswordCode.tsx +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -37,8 +37,18 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => { > +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ - You've requested to reset your password + Password Reset Request Hi {email || "there"}, @@ -51,7 +61,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => { and follow the instructions to reset your password, or manually enter the following code: -
+
{code} diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index 7744400c..32a1fd5d 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -43,6 +43,16 @@ export const ResourceOTPCode = ({ > +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ Your One-Time Password @@ -56,12 +66,11 @@ export const ResourceOTPCode = ({ {organizationName}. Use the OTP below to complete your authentication: -
+
{otp}
- Best regards,
diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index 85ec5ec0..ba454386 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -46,8 +46,18 @@ export const SendInviteLink = ({ > +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ - You're invited to join a Fossorial organization + You're Invite to Join {orgName} Hi {email || "there"}, @@ -65,12 +75,12 @@ export const SendInviteLink = ({ {expiresInDays === "1" ? "day" : "days"}. -
+
diff --git a/server/emails/templates/TwoFactorAuthNotification.tsx b/server/emails/templates/TwoFactorAuthNotification.tsx index adb2e26f..3d1abfa9 100644 --- a/server/emails/templates/TwoFactorAuthNotification.tsx +++ b/server/emails/templates/TwoFactorAuthNotification.tsx @@ -36,6 +36,16 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { > +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ Two-Factor Authentication{" "} {enabled ? "Enabled" : "Disabled"} @@ -48,22 +58,19 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { has been successfully{" "} {enabled ? "enabled" : "disabled"} on your account. -
- {enabled ? ( - - With Two-Factor Authentication enabled, your - account is now more secure. Please ensure - you keep your authentication method safe. - - ) : ( - - With Two-Factor Authentication disabled, - your account may be less secure. We - recommend enabling it to protect your - account. - - )} -
+ {enabled ? ( + + With Two-Factor Authentication enabled, your + account is now more secure. Please ensure you + keep your authentication method safe. + + ) : ( + + With Two-Factor Authentication disabled, your + account may be less secure. We recommend + enabling it to protect your account. + + )} If you did not make this change, please contact our support team immediately. diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index 9adab19b..efd5acfb 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -41,17 +41,28 @@ export const VerifyEmail = ({ > +
+
+ Pangolin +
+ +
+ {new Date().toLocaleDateString()} +
+
+ - Please verify your email + Please Verify Your Email Hi {username || "there"}, You’ve requested to verify your email. Please use - the code below to complete the verification process upon logging in. + the code below to complete the verification process + upon logging in. -
+
{verificationCode} diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 73ecb490..417dcf26 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -25,7 +25,12 @@ const createSiteSchema = z .object({ name: z.string().min(1).max(255), exitNodeId: z.number().int().positive(), - subdomain: z.string().min(1).max(255).optional(), + // subdomain: z + // .string() + // .min(1) + // .max(255) + // .transform((val) => val.toLowerCase()) + // .optional(), pubKey: z.string().optional(), subnet: z.string(), newtId: z.string().optional(), diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 5bc25e09..fd19b30b 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -23,13 +23,25 @@ const getSiteSchema = z }) .strict(); -export type GetSiteResponse = { - siteId: number; - name: string; - subdomain: string; - subnet: string; - type: string; -}; +async function query(siteId?: number, niceId?: string, orgId?: string) { + if (siteId) { + const [res] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + return res; + } else if (niceId && orgId) { + const [res] = await db + .select() + .from(sites) + .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) + .limit(1); + return res; + } +} + +export type GetSiteResponse = NonNullable>>; export async function getSite( req: Request, @@ -49,42 +61,14 @@ export async function getSite( const { siteId, niceId, orgId } = parsedParams.data; - let site; - if (siteId) { - site = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - } else if (niceId && orgId) { - site = await db - .select() - .from(sites) - .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) - .limit(1); - } + const site = await query(siteId, niceId, orgId); if (!site) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } - if (site.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${siteId} not found` - ) - ); - } - - return response(res, { - data: { - siteId: site[0].siteId, - niceId: site[0].niceId, - name: site[0].name, - subnet: site[0].subnet, - type: site[0].type - }, + return response(res, { + data: site, success: true, error: false, message: "Site retrieved successfully", diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 41e764a5..5fc39e69 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -18,7 +18,12 @@ const updateSiteParamsSchema = z const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - subdomain: z.string().min(1).max(255).optional() + // subdomain: z + // .string() + // .min(1) + // .max(255) + // .transform((val) => val.toLowerCase()) + // .optional() // pubKey: z.string().optional(), // subnet: z.string().optional(), // exitNode: z.number().int().positive().optional(), diff --git a/server/schemas/subdomainSchema.ts b/server/schemas/subdomainSchema.ts index 4f761f4a..30ba2ddd 100644 --- a/server/schemas/subdomainSchema.ts +++ b/server/schemas/subdomainSchema.ts @@ -6,4 +6,5 @@ export const subdomainSchema = z /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, "Invalid subdomain format" ) - .min(1, "Subdomain must be at least 1 character long"); + .min(1, "Subdomain must be at least 1 character long") + .transform((val) => val.toLowerCase()); diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 6e0b6783..1555a5d0 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -13,6 +13,8 @@ type RolesPageProps = { params: Promise<{ orgId: string }>; }; +export const dynamic = "force-dynamic"; + export default async function RolesPage(props: RolesPageProps) { const params = await props.params; diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 5568abd3..d7f792f4 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -11,9 +11,10 @@ import { BreadcrumbLink, BreadcrumbList, BreadcrumbPage, - BreadcrumbSeparator, + BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import Link from "next/link"; +import { cache } from "react"; interface UserLayoutProps { children: React.ReactNode; @@ -27,10 +28,13 @@ export default async function UserLayoutProps(props: UserLayoutProps) { let user = null; try { - const res = await internal.get>( - `/org/${params.orgId}/user/${params.userId}`, - await authCookieHeader(), + const getOrgUser = cache(async () => + internal.get>( + `/org/${params.orgId}/user/${params.userId}`, + await authCookieHeader() + ) ); + const res = await getOrgUser(); user = res.data.data; } catch { redirect(`/${params.orgId}/settings/sites`); @@ -39,8 +43,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const sidebarNavItems = [ { title: "Access Controls", - href: "/{orgId}/settings/access/users/{userId}/access-controls", - }, + href: "/{orgId}/settings/access/users/{userId}/access-controls" + } ]; return ( diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 37354c91..d6f3d993 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -15,6 +15,8 @@ type UsersPageProps = { params: Promise<{ orgId: string }>; }; +export const dynamic = "force-dynamic"; + export default async function UsersPage(props: UsersPageProps) { const params = await props.params; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index a3310a46..a1d2c5bc 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -382,8 +382,8 @@ export default function ResourceAuthenticationPage() { /> )} -
-
+
+
-
+
+
- {env.EMAIL_ENABLED === "true" && ( - <> - -
-
- - setWhitelistEnabled(val) - } - /> - -
- - Enable resource whitelist to require - email-based authentication (one-time - passwords) for resource access. - + + +
+ {env.EMAIL_ENABLED === "true" && ( + <> +
+
+ + setWhitelistEnabled(val) + } + /> +
+ + Enable resource whitelist to require email-based + authentication (one-time passwords) for resource + access. + +
- {whitelistEnabled && ( -
- - ( - - - Whitelisted Emails - - - {/* @ts-ignore */} - { - return z - .string() - .email() - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder="Enter an email" - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - styleClasses={{ - tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" - }, - input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", - inlineTagsContainer: - "bg-transparent p-2" - }} - /> - - - )} - /> - - - )} + {whitelistEnabled && ( +
+ + ( + + + Whitelisted Emails + + + {/* @ts-ignore */} + { + return z + .string() + .email() + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder="Enter an email" + tags={ + whitelistForm.getValues() + .emails + } + setTags={(newRoles) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + styleClasses={{ + tag: { + body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" + }, + input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", + inlineTagsContainer: + "bg-transparent p-2" + }} + /> + + + )} + /> + + + )} - - - )} + + + )}
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx index 6eacfc22..73060f2a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/components/ResourceInfoBox.tsx @@ -1,7 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { Card } from "@/components/ui/card"; +import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -14,7 +13,14 @@ import { } from "lucide-react"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; -import Link from "next/link"; +import { Separator } from "@app/components/ui/separator"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; type ResourceInfoBoxType = {}; @@ -28,86 +34,48 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { resource.subdomain }.${org.org.domain}`; - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(fullUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy text: ", err); - } - }; - return ( Resource Information - -
-
- {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist ? ( -
- - - This resource is protected with at least one - auth method. - -
- ) : ( -
- - - This resource is not protected with any auth - method. Anyone can access this resource. - -
- )} -
- -
- - - {fullUrl} - - -
- - {/*

- To create a proxy to your private services,{" "} - - add targets - {" "} - to this resource -

*/} -
+ + + + + URL + + + + +
); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index b5e5027f..c50bbe00 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -429,7 +429,7 @@ export default function ReverseProxyTargets(props: {
-
+
{ const resourceRow = row.original; return ( -
- - {resourceRow.domain} - - -
+ ); } }, diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 3101508a..0cb3da03 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -13,6 +13,8 @@ type ResourcesPageProps = { params: Promise<{ orgId: string }>; }; +export const dynamic = "force-dynamic"; + export default async function ResourcesPage(props: ResourcesPageProps) { const params = await props.params; let resources: ListResourcesResponse["resources"] = []; diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index 73e6c7d2..4ffe834a 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -13,6 +13,8 @@ type ShareLinksPageProps = { params: Promise<{ orgId: string }>; }; +export const dynamic = "force-dynamic"; + export default async function ShareLinksPage(props: ShareLinksPageProps) { const params = await props.params; diff --git a/src/app/[orgId]/settings/sites/[niceId]/components/SiteInfoCard.tsx b/src/app/[orgId]/settings/sites/[niceId]/components/SiteInfoCard.tsx new file mode 100644 index 00000000..d93b815b --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/components/SiteInfoCard.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { useSiteContext } from "@app/hooks/useSiteContext"; +import { Separator } from "@app/components/ui/separator"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type SiteInfoCardProps = {}; + +export default function SiteInfoCard({}: SiteInfoCardProps) { + const { site, updateSite } = useSiteContext(); + + return ( + + + Site Information + + + + Status + + {site.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + Connection Type + + {site.type === "newt" + ? "Newt" + : site.type === "wireguard" + ? "WireGuard" + : "Unknown"} + + +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index d284982d..69c9d58f 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -67,7 +67,7 @@ export default function GeneralPage() { return ( <> -
+
>( `/org/${params.orgId}/site/${params.niceId}`, - await authCookieHeader(), + await authCookieHeader() ); site = res.data.data; } catch { @@ -40,8 +41,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const sidebarNavItems = [ { title: "General", - href: "/{orgId}/settings/sites/{niceId}/general", - }, + href: "/{orgId}/settings/sites/{niceId}/general" + } ]; return ( @@ -66,10 +67,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { /> - + +
+ +
{children}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 0fa6ff50..1957fa7d 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -9,6 +9,8 @@ type SitesPageProps = { params: Promise<{ orgId: string }>; }; +export const dynamic = "force-dynamic"; + export default async function SitesPage(props: SitesPageProps) { const params = await props.params; let sites: ListSitesResponse["sites"] = []; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 959a5785..6b997204 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,23 +51,29 @@ export default async function RootLayout({ > {children} -