improve site and resource info cards and other small visual tweaks

This commit is contained in:
Milo Schwartz 2024-12-30 23:41:06 -05:00
parent e6263567a9
commit 172e0f07d5
No known key found for this signature in database
31 changed files with 469 additions and 332 deletions

View file

@ -33,10 +33,20 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
} }
}} }}
> >
<Body className="font-sans"> <Body className="font-sans relative">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Your password has been successfully reset Password Reset Confirmation
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},
@ -46,12 +56,10 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
reset. If you made this change, no further action is reset. If you made this change, no further action is
required. required.
</Text> </Text>
<Section className="text-center my-6"> <Text className="text-base text-gray-700">
<Text className="text-base text-gray-700"> If you did not request this change, please contact
If you did not request this change, please our support team immediately.
contact our support team immediately. </Text>
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Thank you for keeping your account secure. Thank you for keeping your account secure.
</Text> </Text>

View file

@ -37,8 +37,18 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
You've requested to reset your password Password Reset Request
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},
@ -51,7 +61,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
and follow the instructions to reset your password, and follow the instructions to reset your password,
or manually enter the following code: or manually enter the following code:
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center">
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl"> <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
{code} {code}
</Text> </Text>

View file

@ -43,6 +43,16 @@ export const ResourceOTPCode = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Your One-Time Password Your One-Time Password
</Heading> </Heading>
@ -56,12 +66,11 @@ export const ResourceOTPCode = ({
<strong>{organizationName}</strong>. Use the OTP <strong>{organizationName}</strong>. Use the OTP
below to complete your authentication: below to complete your authentication:
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center">
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl"> <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
{otp} {otp}
</Text> </Text>
</Section> </Section>
<Text className="text-sm text-gray-500 mt-6"> <Text className="text-sm text-gray-500 mt-6">
Best regards, Best regards,
<br /> <br />

View file

@ -46,8 +46,18 @@ export const SendInviteLink = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
You're invited to join a Fossorial organization You're Invite to Join {orgName}
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},
@ -65,12 +75,12 @@ export const SendInviteLink = ({
{expiresInDays === "1" ? "day" : "days"}. {expiresInDays === "1" ? "day" : "days"}.
</b> </b>
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center">
<Button <Button
href={inviteLink} href={inviteLink}
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl" className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl"
> >
Accept invitation to {orgName} Accept Invite to {orgName}
</Button> </Button>
</Section> </Section>

View file

@ -36,6 +36,16 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Two-Factor Authentication{" "} Two-Factor Authentication{" "}
{enabled ? "Enabled" : "Disabled"} {enabled ? "Enabled" : "Disabled"}
@ -48,22 +58,19 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
has been successfully{" "} has been successfully{" "}
{enabled ? "enabled" : "disabled"} on your account. {enabled ? "enabled" : "disabled"} on your account.
</Text> </Text>
<Section className="text-center my-6"> {enabled ? (
{enabled ? ( <Text className="text-base text-gray-700">
<Text className="text-base text-gray-700"> With Two-Factor Authentication enabled, your
With Two-Factor Authentication enabled, your account is now more secure. Please ensure you
account is now more secure. Please ensure keep your authentication method safe.
you keep your authentication method safe. </Text>
</Text> ) : (
) : ( <Text className="text-base text-gray-700">
<Text className="text-base text-gray-700"> With Two-Factor Authentication disabled, your
With Two-Factor Authentication disabled, account may be less secure. We recommend
your account may be less secure. We enabling it to protect your account.
recommend enabling it to protect your </Text>
account. )}
</Text>
)}
</Section>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
If you did not make this change, please contact our If you did not make this change, please contact our
support team immediately. support team immediately.

View file

@ -41,17 +41,28 @@ export const VerifyEmail = ({
> >
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-orange-500">
Pangolin
</div>
<div className="text-sm text-gray-500">
{new Date().toLocaleDateString()}
</div>
</div>
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Please verify your email Please Verify Your Email
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {username || "there"}, Hi {username || "there"},
</Text> </Text>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Youve requested to verify your email. Please use Youve 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.
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center">
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl"> <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
{verificationCode} {verificationCode}
</Text> </Text>

View file

@ -25,7 +25,12 @@ const createSiteSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(), 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(), pubKey: z.string().optional(),
subnet: z.string(), subnet: z.string(),
newtId: z.string().optional(), newtId: z.string().optional(),

View file

@ -23,13 +23,25 @@ const getSiteSchema = z
}) })
.strict(); .strict();
export type GetSiteResponse = { async function query(siteId?: number, niceId?: string, orgId?: string) {
siteId: number; if (siteId) {
name: string; const [res] = await db
subdomain: string; .select()
subnet: string; .from(sites)
type: string; .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<Awaited<ReturnType<typeof query>>>;
export async function getSite( export async function getSite(
req: Request, req: Request,
@ -49,42 +61,14 @@ export async function getSite(
const { siteId, niceId, orgId } = parsedParams.data; const { siteId, niceId, orgId } = parsedParams.data;
let site; const site = await query(siteId, niceId, orgId);
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);
}
if (!site) { if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
} }
if (site.length === 0) { return response<GetSiteResponse>(res, {
return next( data: site,
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
},
success: true, success: true,
error: false, error: false,
message: "Site retrieved successfully", message: "Site retrieved successfully",

View file

@ -18,7 +18,12 @@ const updateSiteParamsSchema = z
const updateSiteBodySchema = z const updateSiteBodySchema = 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)
// .transform((val) => val.toLowerCase())
// .optional()
// pubKey: z.string().optional(), // pubKey: z.string().optional(),
// subnet: z.string().optional(), // subnet: z.string().optional(),
// exitNode: z.number().int().positive().optional(), // exitNode: z.number().int().positive().optional(),

View file

@ -6,4 +6,5 @@ export const subdomainSchema = z
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/, /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
"Invalid subdomain format" "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());

View file

@ -13,6 +13,8 @@ type RolesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function RolesPage(props: RolesPageProps) { export default async function RolesPage(props: RolesPageProps) {
const params = await props.params; const params = await props.params;

View file

@ -11,9 +11,10 @@ import {
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import Link from "next/link"; import Link from "next/link";
import { cache } from "react";
interface UserLayoutProps { interface UserLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -27,10 +28,13 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
let user = null; let user = null;
try { try {
const res = await internal.get<AxiosResponse<GetOrgUserResponse>>( const getOrgUser = cache(async () =>
`/org/${params.orgId}/user/${params.userId}`, internal.get<AxiosResponse<GetOrgUserResponse>>(
await authCookieHeader(), `/org/${params.orgId}/user/${params.userId}`,
await authCookieHeader()
)
); );
const res = await getOrgUser();
user = res.data.data; user = res.data.data;
} catch { } catch {
redirect(`/${params.orgId}/settings/sites`); redirect(`/${params.orgId}/settings/sites`);
@ -39,8 +43,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "Access Controls", title: "Access Controls",
href: "/{orgId}/settings/access/users/{userId}/access-controls", href: "/{orgId}/settings/access/users/{userId}/access-controls"
}, }
]; ];
return ( return (

View file

@ -15,6 +15,8 @@ type UsersPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function UsersPage(props: UsersPageProps) { export default async function UsersPage(props: UsersPageProps) {
const params = await props.params; const params = await props.params;

View file

@ -382,8 +382,8 @@ export default function ResourceAuthenticationPage() {
/> />
)} )}
<div className="space-y-12 lg:max-w-2xl"> <div className="space-y-12">
<section className="space-y-8"> <section className="space-y-8 lg:max-w-2xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Users & Roles" title="Users & Roles"
description="Configure which users and roles can visit this resource" description="Configure which users and roles can visit this resource"
@ -541,7 +541,7 @@ export default function ResourceAuthenticationPage() {
<Separator /> <Separator />
<section className="space-y-8"> <section className="space-y-8 lg:max-w-2xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Authentication Methods" title="Authentication Methods"
description="Allow access to the resource via additional auth methods" description="Allow access to the resource via additional auth methods"
@ -613,109 +613,105 @@ export default function ResourceAuthenticationPage() {
)} )}
</div> </div>
</div> </div>
</section>
{env.EMAIL_ENABLED === "true" && ( <Separator />
<>
<Separator /> <section className="space-y-8 lg:max-w-2xl">
<div> {env.EMAIL_ENABLED === "true" && (
<div className="flex items-center space-x-2 mb-2"> <>
<Switch <div>
id="whitelist-toggle" <div className="flex items-center space-x-2 mb-2">
defaultChecked={ <Switch
resource.emailWhitelistEnabled id="whitelist-toggle"
} defaultChecked={
onCheckedChange={(val) => resource.emailWhitelistEnabled
setWhitelistEnabled(val) }
} onCheckedChange={(val) =>
/> setWhitelistEnabled(val)
<Label htmlFor="whitelist-toggle"> }
Email Whitelist />
</Label> <Label htmlFor="whitelist-toggle">
</div> Email Whitelist
<span className="text-muted-foreground text-sm"> </Label>
Enable resource whitelist to require
email-based authentication (one-time
passwords) for resource access.
</span>
</div> </div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require email-based
authentication (one-time passwords) for resource
access.
</span>
</div>
{whitelistEnabled && ( {whitelistEnabled && (
<Form {...whitelistForm}> <Form {...whitelistForm}>
<form className="space-y-4"> <form className="space-y-4">
<FormField <FormField
control={whitelistForm.control} control={whitelistForm.control}
name="emails" name="emails"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel> <FormLabel>
Whitelisted Emails Whitelisted Emails
</FormLabel> </FormLabel>
<FormControl> <FormControl>
{/* @ts-ignore */} {/* @ts-ignore */}
<TagInput <TagInput
{...field} {...field}
activeTagIndex={ activeTagIndex={
activeEmailTagIndex activeEmailTagIndex
} }
validateTag={( validateTag={(tag) => {
tag return z
) => { .string()
return z .email()
.string() .safeParse(tag)
.email() .success;
.safeParse( }}
tag setActiveTagIndex={
).success; setActiveEmailTagIndex
}} }
setActiveTagIndex={ placeholder="Enter an email"
setActiveEmailTagIndex tags={
} whitelistForm.getValues()
placeholder="Enter an email" .emails
tags={ }
whitelistForm.getValues() setTags={(newRoles) => {
.emails whitelistForm.setValue(
} "emails",
setTags={( newRoles as [
newRoles Tag,
) => { ...Tag[]
whitelistForm.setValue( ]
"emails", );
newRoles as [ }}
Tag, allowDuplicates={false}
...Tag[] sortTags={true}
] styleClasses={{
); tag: {
}} body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
allowDuplicates={ },
false input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
} inlineTagsContainer:
sortTags={true} "bg-transparent p-2"
styleClasses={{ }}
tag: { />
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" </FormControl>
}, </FormItem>
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", )}
inlineTagsContainer: />
"bg-transparent p-2" </form>
}} </Form>
/> )}
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)}
<Button <Button
loading={loadingSaveWhitelist} loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist} disabled={loadingSaveWhitelist}
onClick={saveWhitelist} onClick={saveWhitelist}
> >
Save Whitelist Save Whitelist
</Button> </Button>
</> </>
)} )}
</section> </section>
</div> </div>
</> </>

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -14,7 +13,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext"; 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 = {}; type ResourceInfoBoxType = {};
@ -28,86 +34,48 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
resource.subdomain resource.subdomain
}.${org.org.domain}`; }.${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 ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
Resource Information Resource Information
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-3"> <AlertDescription className="mt-4">
<div className="space-y-3"> <InfoSections>
<div> <InfoSection>
{authInfo.password || <InfoSectionTitle>Authentication</InfoSectionTitle>
authInfo.pincode || <InfoSectionContent>
authInfo.sso || {authInfo.password ||
authInfo.whitelist ? ( authInfo.pincode ||
<div className="flex items-center space-x-2 text-green-500"> authInfo.sso ||
<ShieldCheck /> authInfo.whitelist ? (
<span> <div className="flex items-start space-x-2 text-green-500">
This resource is protected with at least one <ShieldCheck className="w-4 h-4 mt-0.5" />
auth method. <span>
</span> This resource is protected with at least
</div> one auth method.
) : ( </span>
<div className="flex items-center space-x-2 text-yellow-500"> </div>
<ShieldOff />
<span>
This resource is not protected with any auth
method. Anyone can access this resource.
</span>
</div>
)}
</div>
<div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md lg:max-w-xl">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline truncate"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : ( ) : (
<CopyIcon className="h-4 w-4" /> <div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>
This resource is not protected with any
auth method. Anyone can access this
resource.
</span>
</div>
)} )}
<span className="ml-2"> </InfoSectionContent>
{copied ? "Copied!" : "Copy"} </InfoSection>
</span> <Separator orientation="vertical" />
</Button> <InfoSection>
</div> <InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
{/* <p className="mt-3"> <CopyToClipboard text={fullUrl} isLink={true} />
To create a proxy to your private services,{" "} </InfoSectionContent>
<Link </InfoSection>
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`} </InfoSections>
className="text-primary hover:underline"
>
add targets
</Link>{" "}
to this resource
</p> */}
</div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );

View file

@ -429,7 +429,7 @@ export default function ReverseProxyTargets(props: {
</div> </div>
</section> </section>
<hr className="lg:max-w-2xl" /> <hr />
<section className="space-y-8"> <section className="space-y-8">
<SettingsSectionTitle <SettingsSectionTitle

View file

@ -29,6 +29,7 @@ import { formatAxiosError } from "@app/lib/utils";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { createApiClient } from "@app/api"; import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
export type ResourceRow = { export type ResourceRow = {
id: number; id: number;
@ -162,55 +163,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<div className="flex items-center"> <CopyToClipboard text={resourceRow.domain} isLink={true} />
<Link
href={resourceRow.domain}
target="_blank"
rel="noopener noreferrer"
className="hover:underline mr-2"
>
{resourceRow.domain}
</Link>
<Button
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
navigator.clipboard.writeText(
resourceRow.domain
);
const originalIcon = document.querySelector(
`#icon-${resourceRow.id}`
);
if (originalIcon) {
originalIcon.classList.add("hidden");
}
const checkIcon = document.querySelector(
`#check-icon-${resourceRow.id}`
);
if (checkIcon) {
checkIcon.classList.remove("hidden");
setTimeout(() => {
checkIcon.classList.add("hidden");
if (originalIcon) {
originalIcon.classList.remove(
"hidden"
);
}
}, 2000);
}
}}
>
<Copy
id={`icon-${resourceRow.id}`}
className="h-4 w-4"
/>
<Check
id={`check-icon-${resourceRow.id}`}
className="hidden text-green-500 h-4 w-4"
/>
<span className="sr-only">Copy domain</span>
</Button>
</div>
); );
} }
}, },

View file

@ -13,6 +13,8 @@ type ResourcesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function ResourcesPage(props: ResourcesPageProps) { export default async function ResourcesPage(props: ResourcesPageProps) {
const params = await props.params; const params = await props.params;
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];

View file

@ -13,6 +13,8 @@ type ShareLinksPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function ShareLinksPage(props: ShareLinksPageProps) { export default async function ShareLinksPage(props: ShareLinksPageProps) {
const params = await props.params; const params = await props.params;

View file

@ -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 (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionContent>
{site.type === "newt"
? "Newt"
: site.type === "wireguard"
? "WireGuard"
: "Unknown"}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View file

@ -67,7 +67,7 @@ export default function GeneralPage() {
return ( return (
<> <>
<div className="space-y-8"> <div className="space-y-8 max-w-xl">
<SettingsSectionTitle <SettingsSectionTitle
title="General Settings" title="General Settings"
description="Configure the general settings for this site" description="Configure the general settings for this site"

View file

@ -13,8 +13,9 @@ import {
BreadcrumbItem, BreadcrumbItem,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator
} from "@app/components/ui/breadcrumb"; } from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./components/SiteInfoCard";
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -30,7 +31,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
try { try {
const res = await internal.get<AxiosResponse<GetSiteResponse>>( const res = await internal.get<AxiosResponse<GetSiteResponse>>(
`/org/${params.orgId}/site/${params.niceId}`, `/org/${params.orgId}/site/${params.niceId}`,
await authCookieHeader(), await authCookieHeader()
); );
site = res.data.data; site = res.data.data;
} catch { } catch {
@ -40,8 +41,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "General", title: "General",
href: "/{orgId}/settings/sites/{niceId}/general", href: "/{orgId}/settings/sites/{niceId}/general"
}, }
]; ];
return ( return (
@ -66,10 +67,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/> />
<SiteProvider site={site}> <SiteProvider site={site}>
<SidebarSettings <SidebarSettings sidebarNavItems={sidebarNavItems}>
sidebarNavItems={sidebarNavItems} <div className="mb-8">
limitWidth={true} <SiteInfoCard />
> </div>
{children} {children}
</SidebarSettings> </SidebarSettings>
</SiteProvider> </SiteProvider>

View file

@ -9,6 +9,8 @@ type SitesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}; };
export const dynamic = "force-dynamic";
export default async function SitesPage(props: SitesPageProps) { export default async function SitesPage(props: SitesPageProps) {
const params = await props.params; const params = await props.params;
let sites: ListSitesResponse["sites"] = []; let sites: ListSitesResponse["sites"] = [];

View file

@ -51,23 +51,29 @@ export default async function RootLayout({
> >
{children} {children}
<footer className="w-full mt-6 py-3"> <footer className="w-full mt-12 py-3 mb-4">
<div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none"> <div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div>Built by Fossorial</div> <div className="whitespace-nowrap">
Pangolin
</div>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
Built by Fossorial
</div>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<a <a
href="https://github.com/fosrl/pangolin" href="https://github.com/fosrl/pangolin"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub" aria-label="GitHub"
className="flex items-center space-x-3 underline" className="flex items-center space-x-3 whitespace-nowrap"
> >
<span>Open Source</span> <span>Open Source</span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="w-4 h-4" className="w-3 h-3"
> >
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" /> <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg> </svg>
@ -75,7 +81,9 @@ export default async function RootLayout({
{version && ( {version && (
<> <>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div>v{version}</div> <div className="whitespace-nowrap">
v{version}
</div>
</> </>
)} )}
</div> </div>

View file

@ -0,0 +1,52 @@
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
type CopyToClipboardProps = {
text: string;
isLink?: boolean;
};
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<div className="flex items-center">
{isLink ? (
<Link
href={text}
target="_blank"
rel="noopener noreferrer"
className="hover:underline mr-2"
>
{text}
</Link>
) : (
<span className="mr-2">{text}</span>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
onClick={handleCopy}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">Copy text</span>
</button>
</div>
);
};
export default CopyToClipboard;

View file

@ -90,11 +90,19 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaContent = isDesktop ? DialogContent : SheetContent; const CredenzaContent = isDesktop ? DialogContent : SheetContent;
return ( return isDesktop ? (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
{...props}
>
{children}
</CredenzaContent>
) : (
<CredenzaContent <CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)} className={cn("overflow-y-auto max-h-screen", className)}
{...props} {...props}
side={"bottom"} side={"bottom"}
onOpenAutoFocus={(e) => e.preventDefault()}
> >
{children} {children}
</CredenzaContent> </CredenzaContent>

View file

@ -0,0 +1,31 @@
"use client";
export function InfoSections({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:gap-4 gap-2 md:grid-cols-[1fr_auto_1fr] md:items-start">
{children}
</div>
);
}
export function InfoSection({ children }: { children: React.ReactNode }) {
return <div className="space-y-1">{children}</div>;
}
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>;
}
export function InfoSectionContent({
children
}: {
children: React.ReactNode;
}) {
return <div className="break-words">{children}</div>;
}
export function Divider() {
return (
<div className="hidden md:block border-l border-gray-300 h-auto mx-4"></div>
);
}

View file

@ -65,7 +65,7 @@ export default function ProfileIcon() {
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} /> <Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} /> <Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<div className="flex items-center gap-4 flex-grow min-w-0"> <div className="flex items-center md:gap-4 gap-2 flex-grow min-w-0">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View file

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
{ {
variants: { variants: {
side: { side: {
@ -80,7 +80,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", "flex flex-col text-center sm:text-left mb-4",
className className
)} )}
{...props} {...props}

View file

@ -2,7 +2,7 @@ import { GetSiteResponse } from "@server/routers/site/getSite";
import { createContext } from "react"; import { createContext } from "react";
interface SiteContextType { interface SiteContextType {
site: GetSiteResponse | null; site: GetSiteResponse;
updateSite: (updatedSite: Partial<GetSiteResponse>) => void; updateSite: (updatedSite: Partial<GetSiteResponse>) => void;
} }

View file

@ -6,14 +6,14 @@ import { useState } from "react";
interface SiteProviderProps { interface SiteProviderProps {
children: React.ReactNode; children: React.ReactNode;
site: GetSiteResponse | null; site: GetSiteResponse;
} }
export function SiteProvider({ export function SiteProvider({
children, children,
site: serverSite, site: serverSite
}: SiteProviderProps) { }: SiteProviderProps) {
const [site, setSite] = useState<GetSiteResponse | null>(serverSite); const [site, setSite] = useState<GetSiteResponse>(serverSite);
const updateSite = (updatedSite: Partial<GetSiteResponse>) => { const updateSite = (updatedSite: Partial<GetSiteResponse>) => {
if (!site) { if (!site) {
@ -25,7 +25,7 @@ export function SiteProvider({
} }
return { return {
...prev, ...prev,
...updatedSite, ...updatedSite
}; };
}); });
}; };