major ui tweaks and refactoring

This commit is contained in:
Milo Schwartz 2025-01-04 20:22:01 -05:00
parent 51bf5c1408
commit 64158a823b
No known key found for this signature in database
91 changed files with 1791 additions and 1246 deletions

View file

@ -29,4 +29,6 @@ COPY --from=builder /app/init ./dist/init
COPY config.example.yml ./dist/config.example.yml
COPY server/db/names.json ./dist/names.json
COPY public ./public
CMD ["npm", "start"]

View file

@ -9,7 +9,7 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
## Preview
<img src="public/screenshots/preview.png" alt="Preview"/>
<img src="public/screenshots/sites.png" alt="Preview"/>
_Sites page of Pangolin showing multiple site-to-site tunnels connected to the central server._

View file

@ -9,7 +9,7 @@
"db:push": "npx tsx server/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
"start": "NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
"start": "NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
"email": "email dev --dir server/emails/templates --port 3005"
},
"dependencies": {

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 399.99999 400.00002"
enable-background="new 0 0 419.528 419.528"
xml:space="preserve"
id="svg52"
sodipodi:docname="noun-pangolin-1798092.svg"
width="400"
height="400"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs56" /><sodipodi:namedview
id="namedview54"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.9583914"
inkscape:cx="209.86611"
inkscape:cy="262.20499"
inkscape:window-width="3840"
inkscape:window-height="2136"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg52" /><path
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
id="path46" /></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 399.99999 400.00002"
enable-background="new 0 0 419.528 419.528"
xml:space="preserve"
id="svg52"
sodipodi:docname="pangolin_orange.svg"
width="400"
height="400"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs56" /><sodipodi:namedview
id="namedview54"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.9583914"
inkscape:cx="127.40048"
inkscape:cy="262.71561"
inkscape:window-width="1436"
inkscape:window-height="1236"
inkscape:window-x="2208"
inkscape:window-y="511"
inkscape:window-maximized="0"
inkscape:current-layer="svg52" /><path
d="m 62.232921,184.91974 c 0,2.431 -1.97,4.402 -4.399,4.402 -2.429,0 -4.399,-1.972 -4.399,-4.402 0,-2.429 1.97,-4.399 4.399,-4.399 2.429,-10e-4 4.399,1.97 4.399,4.399 z m 58.993999,-4.821 c -25.943999,-2.826 -38.978999,7.453 -71.181999,31.357 -27.572,20.467 -32.767,4.381 -31.748,-2.614 1.499,-10.282 25.222,-58.573 48.079,-88.461 28.273,7.34 49.869999,30.727 54.850999,59.718 z m -55.915999,4.821 c 0,-4.131 -3.349,-7.478 -7.478,-7.478 -4.129,0 -7.478,3.347 -7.478,7.478 0,4.131 3.349,7.481 7.478,7.481 4.13,0 7.478,-3.35 7.478,-7.481 z m -15.032,48.424 -0.234,14.041 20.413,22.687 -9.818,7.353 33.306,27.492 -11.759,8.124 42.631999,19.939 -10.825,9.747 48.291,8.078 -7.526,10.307 48.758,-4.531 -3.997,11.725 53.916,-18.153 -2.76,13.357 48.077,-34.345 1.479,13.562 34.087,-48.576 7.478,14.206 15.187,-58.89 10.391,8.533 -2.14,-57.884 13.814,5.13 -21.082,-51.204 13.404,0.048 -33.696,-42.131 15.312,-1.366 -47.026,-32.831002 14.255,-8.399 -54.817,-14.682 9.257,-11.695 -49.625,0.352 0.6,-13.337 -38.537,14.084 -1.597,-12.689 -29.984,21.429 -6.446,-10.852 -22.59,26.504 -7.021,-9.572 -18.923,30.294 -9.595999,-8.744 -16.754,30.138002 c 31.509999,10.197 54.979999,37.951 59.126999,71.547 0.404,0.087 -22.37,31.257 10.955,57.85 -0.576,-2.985 -6.113,-53.902 47.496,-57.61 26.668,-1.844 48.4,21.666 48.4,48.399 0,8.184 -2.05,15.883 -5.636,22.64 -15.927,29.611 -64.858,30.755 -80.429,30.596 -45.154,-0.459 -104.051999,-51.521 -104.051999,-51.521 z"
id="path46"
style="fill:#f97315;fill-opacity:1" /></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 484 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 437 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

View file

@ -9,7 +9,7 @@ import { __DIRNAME } from "@server/lib/consts";
const dev = process.env.ENVIRONMENT !== "prod";
let file;
if (!dev) {
file = join("names.json");
file = join(__DIRNAME, "names.json");
} else {
file = join("server/db/names.json");
}

View file

@ -26,7 +26,7 @@ export async function sendEmail(
await emailClient.sendMail({
from: {
name: opts.name || "Pangolin Proxy",
name: opts.name || "Pangolin",
address: opts.from,
},
to: opts.to,

View file

@ -1,16 +1,20 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailText
} from "./components/Email";
interface Props {
email: string;
@ -23,41 +27,31 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#16A34A"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Confirmation
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
<EmailHeading>Password Reset Confirmation</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailText>
This email confirms that your password has just been
reset. If you made this change, no further action is
required.
</Text>
<Text className="text-base text-gray-700 mt-2">
</EmailText>
<EmailText>
Thank you for keeping your account secure.
</Text>
<Text className="text-sm text-gray-500 mt-6">
</EmailText>
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -1,16 +1,22 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSection,
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
interface Props {
email: string;
@ -25,50 +31,39 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Password Reset Request
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
<EmailHeading>Password Reset Request</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailText>
Youve requested to reset your password. Please{" "}
<a href={link} className="text-primary">
click here
</a>{" "}
and follow the instructions to reset your password,
or manually enter the following code:
</Text>
<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">
{code}
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2">
</EmailText>
<EmailSection>
<CopyCodeBox text={code} />
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
this email.
</Text>
<Text className="text-sm text-gray-500 mt-6">
</EmailText>
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -1,16 +1,22 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import {
EmailContainer,
EmailLetterHead,
EmailHeading,
EmailText,
EmailFooter,
EmailSection,
EmailGreeting
} from "./components/Email";
import { themeColors } from "./lib/theme";
import CopyCodeBox from "./components/CopyCodeBox";
interface ResourceOTPCodeProps {
email?: string;
@ -31,44 +37,34 @@ export const ResourceOTPCode = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
<EmailHeading>
Your One-Time Password for {resourceName}
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailText>
Youve requested a one-time password to access{" "}
<strong>{resourceName}</strong> in{" "}
<strong>{organizationName}</strong>. Use the code
below to complete your authentication:
</Text>
<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">
{otp}
</Text>
</Section>
<Text className="text-sm text-gray-500 mt-6">
</EmailText>
<EmailSection>
<CopyCodeBox text={otp} />
</EmailSection>
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -1,17 +1,22 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind,
Button
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSection,
EmailText
} from "./components/Email";
import ButtonLink from "./components/ButtonLink";
interface SendInviteLinkProps {
email: string;
@ -34,55 +39,42 @@ export const SendInviteLink = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Invited to Join {orgName}
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
<EmailHeading>Invited to Join {orgName}</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailText>
Youve been invited to join the organization{" "}
{orgName}
<strong>{orgName}</strong>
{inviterName ? ` by ${inviterName}.` : "."} Please
access the link below to accept the invite.
</Text>
<Text className="text-base text-gray-700 mt-2">
</EmailText>
<EmailText>
This invite will expire in{" "}
<b>
<strong>
{expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}.
</b>
</Text>
<Section className="text-center">
<Button
href={inviteLink}
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl"
>
Accept Invite to {orgName}
</Button>
</Section>
</strong>
</EmailText>
<Text className="text-sm text-gray-500 mt-6">
<EmailSection>
<ButtonLink href={inviteLink}>
Accept Invite to {orgName}
</ButtonLink>
</EmailSection>
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -1,16 +1,20 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailText
} from "./components/Email";
interface Props {
email: string;
@ -24,52 +28,44 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#16A34A"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
<EmailHeading>
Two-Factor Authentication{" "}
{enabled ? "Enabled" : "Disabled"}
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailText>
This email confirms that Two-Factor Authentication
has been successfully{" "}
{enabled ? "enabled" : "disabled"} on your account.
</Text>
</EmailText>
{enabled ? (
<Text className="text-base text-gray-700">
<EmailText>
With Two-Factor Authentication enabled, your
account is now more secure. Please ensure you
keep your authentication method safe.
</Text>
</EmailText>
) : (
<Text className="text-base text-gray-700">
<EmailText>
With Two-Factor Authentication disabled, your
account may be less secure. We recommend
enabling it to protect your account.
</Text>
</EmailText>
)}
<Text className="text-sm text-gray-500 mt-6">
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -1,16 +1,22 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
import LetterHead from "./components/LetterHead";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSection,
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
interface VerifyEmailProps {
username?: string;
@ -29,47 +35,36 @@ export const VerifyEmail = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}
>
<Tailwind config={themeColors}>
<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">
<LetterHead />
<EmailContainer>
<EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Please Verify Your Email
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {username || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
<EmailHeading>Please Verify Your Email</EmailHeading>
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
<EmailText>
Youve requested to verify your email. Please use
the code below to complete the verification process
upon logging in.
</Text>
<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">
{verificationCode}
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2">
</EmailText>
<EmailSection>
<CopyCodeBox text={verificationCode} />
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
this email.
</Text>
<Text className="text-sm text-gray-500 mt-6">
</EmailText>
<EmailFooter>
Best regards,
<br />
Fossorial
</Text>
</Container>
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>

View file

@ -0,0 +1,18 @@
export default function ButtonLink({
href,
children,
className = ""
}: {
href: string;
children: React.ReactNode;
className?: string;
}) {
return (
<a
href={href}
className={`rounded-full bg-primary px-4 py-2 text-center font-semibold text-white text-xl no-underline inline-block ${className}`}
>
{children}
</a>
);
}

View file

@ -0,0 +1,11 @@
import React from "react";
export default function CopyCodeBox({ text }: { text: string }) {
return (
<div className="flex items-center justify-center rounded-lg bg-neutral-100 p-2">
<span className="text-2xl font-mono text-neutral-600 tracking-wide">
{text}
</span>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { Container } from "@react-email/components";
import React from "react";
// EmailContainer: Wraps the entire email layout
export function EmailContainer({ children }: { children: React.ReactNode }) {
return (
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
{children}
</Container>
);
}
// EmailLetterHead: For branding or logo at the top
export function EmailLetterHead() {
return (
<div className="mb-4">
<table
role="presentation"
width="100%"
style={{
marginBottom: "24px"
}}
>
<tr>
<td
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#F97317"
}}
>
Pangolin
</td>
<td
style={{
fontSize: "14px",
textAlign: "right",
color: "#6B7280"
}}
>
{new Date().getFullYear()}
</td>
</tr>
</table>
</div>
);
}
// EmailHeading: For the primary message or headline
export function EmailHeading({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-2xl font-semibold text-gray-800 text-center">
{children}
</h1>
);
}
export function EmailGreeting({ children }: { children: React.ReactNode }) {
return <p className="text-lg text-gray-700 my-4">{children}</p>;
}
// EmailText: For general text content
export function EmailText({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<p className={`my-2 text-base text-gray-700 ${className}`}>
{children}
</p>
);
}
// EmailSection: For visually distinct sections (like OTP)
export function EmailSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={`text-center my-4 ${className}`}>{children}</div>;
}
// EmailFooter: For closing or signature
export function EmailFooter({ children }: { children: React.ReactNode }) {
return <div className="text-sm text-gray-500 mt-6">{children}</div>;
}

View file

@ -1,36 +0,0 @@
import React from "react";
export function LetterHead() {
return (
<table
role="presentation"
width="100%"
style={{
marginBottom: "24px"
}}
>
<tr>
<td
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#F97317"
}}
>
Pangolin
</td>
<td
style={{
fontSize: "14px",
textAlign: "right",
color: "#6B7280"
}}
>
{new Date().getFullYear()}
</td>
</tr>
</table>
);
}
export default LetterHead;

View file

@ -0,0 +1,9 @@
export const themeColors = {
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
};

View file

@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@ -88,7 +89,7 @@ export function RolesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Role
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function RolesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View file

@ -67,7 +67,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [sendEmail, setSendEmail] = useState(env.EMAIL_ENABLED === "true");
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const validFor = [
{ hours: 24, name: "1 day" },
@ -205,7 +205,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
)}
/>
{env.EMAIL_ENABLED === "true" && (
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"

View file

@ -15,6 +15,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@ -88,7 +89,7 @@ export function UsersDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Invite User
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function UsersDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View file

@ -159,7 +159,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-1">
<div className="flex flex-row items-center gap-2">
{userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
@ -186,7 +186,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -6,7 +6,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
@ -14,7 +14,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@ -27,14 +27,23 @@ import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/api";;
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
roleId: z.string().min(1, { message: "Please select a role" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
export default function AccessControlsPage() {
@ -52,8 +61,8 @@ export default function AccessControlsPage() {
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email!,
roleId: user.roleId?.toString(),
},
roleId: user.roleId?.toString()
}
});
useEffect(() => {
@ -68,7 +77,7 @@ export default function AccessControlsPage() {
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@ -86,9 +95,9 @@ export default function AccessControlsPage() {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/role/${values.roleId}/add/${user.userId}`
)
.post<
AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`)
.catch((e) => {
toast({
variant: "destructive",
@ -96,7 +105,7 @@ export default function AccessControlsPage() {
description: formatAxiosError(
e,
"An error occurred while adding user to the role."
),
)
});
});
@ -104,7 +113,7 @@ export default function AccessControlsPage() {
toast({
variant: "default",
title: "User saved",
description: "The user has been updated.",
description: "The user has been updated."
});
}
@ -112,18 +121,23 @@ export default function AccessControlsPage() {
}
return (
<>
<div className="space-y-8">
<SettingsSectionTitle
title="Access Controls"
description="Manage what this user can access and do in the organization"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
<SettingsSectionDescription>
Manage what this user can access and do in the
organization
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="access-controls-form"
>
<FormField
control={form.control}
@ -155,16 +169,22 @@ export default function AccessControlsPage() {
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loading}
disabled={loading}
form="access-controls-form"
>
Save Changes
Save Access Controls
</Button>
</form>
</Form>
</div>
</>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -21,7 +21,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AlertTriangle, Trash2 } from "lucide-react";
import {
Card,
@ -33,6 +33,16 @@ import {
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { redirect, useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
const GeneralFormSchema = z.object({
name: z.string()
@ -80,10 +90,7 @@ export default function GeneralPage() {
async function pickNewOrgAndNavigate() {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`
);
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
if (res.status === 200) {
if (res.data.data.orgs.length > 0) {
@ -126,7 +133,7 @@ export default function GeneralPage() {
}
return (
<>
<SettingsContainer>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
@ -138,12 +145,10 @@ export default function GeneralPage() {
Are you sure you want to delete the organization{" "}
<b>{org?.org.name}?</b>
</p>
<p className="mb-2">
This action is irreversible and will delete all
associated data.
</p>
<p>
To confirm, type the name of the organization below.
</p>
@ -155,11 +160,23 @@ export default function GeneralPage() {
title="Delete Organization"
/>
<section className="space-y-8 max-w-lg">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Organization Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Manage your organization details and configuration
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="org-settings-form"
>
<FormField
control={form.control}
@ -171,41 +188,47 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the org
This is the display name of the
org
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<SettingsSectionFooter>
<Button type="submit" form="org-settings-form">
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">
Once you delete this org, there is no going back.
Please be certain.
</p>
</CardContent>
<CardFooter className="flex justify-end gap-2">
</SettingsSectionTitle>
<SettingsSectionDescription>
Once you delete this org, there is no going back. Please
be certain.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Organization Data
</Button>
</CardFooter>
</Card>
</section>
</>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -36,7 +36,7 @@ const topNavItems = [
icon: <Users className="h-4 w-4" />
},
{
title: "Sharable Links",
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
},
@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>

View file

@ -6,7 +6,7 @@ type OrgPageProps = {
export default async function SettingsPage(props: OrgPageProps) {
const params = await props.params;
redirect(`/${params.orgId}/settings/resources`);
redirect(`/${params.orgId}/settings/sites`);
return <></>;
}

View file

@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@ -89,7 +90,7 @@ export function ResourcesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Resource
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function ResourcesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View file

@ -0,0 +1,68 @@
"use client";
import React, { useState, useEffect } from "react";
import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
export const ResourcesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(false);
const key = "resources-splash-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Server className="text-blue-500" />
Resources
</h3>
<p className="text-sm">
Resources are proxies to applications running on your private network. Create a resource for any HTTP or HTTPS app on your private network.
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Lock className="text-green-500 w-4 h-4" />
Secure connectivity with WireGuard encryption
</li>
<li className="flex items-center gap-2">
<Key className="text-yellow-500 w-4 h-4" />
Configure multiple authentication methods
</li>
<li className="flex items-center gap-2">
<Users className="text-purple-500 w-4 h-4" />
User and role-based access control
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ResourcesSplashCard;

View file

@ -210,7 +210,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -60,9 +60,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<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.
Anyone can access this resource.
</span>
</div>
)}

View file

@ -28,16 +28,26 @@ import {
FormMessage
} from "@app/components/ui/form";
import { TagInput } from "emblor";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListUsersResponse } from "@server/routers/user";
import { Switch } from "@app/components/ui/switch";
import { Label } from "@app/components/ui/label";
import { Binary, Key, ShieldCheck } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import { Separator } from "@app/components/ui/separator";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
SettingsContainer,
SettingsSection,
SettingsSectionTitle,
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
const UsersRolesFormSchema = z.object({
roles: z.array(
@ -382,34 +392,32 @@ export default function ResourceAuthenticationPage() {
/>
)}
<div className="space-y-12">
<section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle
title="Users & Roles"
description="Configure which users and roles can visit this resource"
size="1xl"
/>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Users & Roles
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure which users and roles can visit this
resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="sso-toggle"
label="Use Platform SSO"
description="Existing users will only have to login once for all resources that have this enabled."
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Label htmlFor="sso-toggle">Use Platform SSO</Label>
</div>
<span className="text-muted-foreground text-sm">
Existing users will only have to login once for all
resources that have this enabled.
</span>
</div>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
@ -435,7 +443,9 @@ export default function ResourceAuthenticationPage() {
usersRolesForm.getValues()
.roles
}
setTags={(newRoles) => {
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
@ -450,7 +460,9 @@ export default function ResourceAuthenticationPage() {
autocompleteOptions={
allRoles
}
allowDuplicates={false}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
@ -466,10 +478,10 @@ export default function ResourceAuthenticationPage() {
/>
</FormControl>
<FormDescription>
These roles will be able to
access this resource. Admins
can always access this
resource.
These roles will be able
to access this resource.
Admins can always access
this resource.
</FormDescription>
<FormMessage />
</FormItem>
@ -496,7 +508,9 @@ export default function ResourceAuthenticationPage() {
usersRolesForm.getValues()
.users
}
setTags={(newUsers) => {
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
@ -511,7 +525,9 @@ export default function ResourceAuthenticationPage() {
autocompleteOptions={
allUsers
}
allowDuplicates={false}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
@ -529,10 +545,11 @@ export default function ResourceAuthenticationPage() {
<FormDescription>
Users added here will be
able to access this
resource. A user will always
have access to a resource if
they have a role that has
access to it.
resource. A user will
always have access to a
resource if they have a
role that has access to
it.
</FormDescription>
<FormMessage />
</FormItem>
@ -540,132 +557,121 @@ export default function ResourceAuthenticationPage() {
/>
</>
)}
</form>
</Form>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={loadingSaveUsersRoles}
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
Save Users & Roles
</Button>
</form>
</Form>
</section>
</SettingsSectionFooter>
</SettingsSection>
<Separator />
<section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle
title="Authentication Methods"
description="Allow access to the resource via additional auth methods"
size="1xl"
/>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between space-x-4">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Authentication Methods
</SettingsSectionTitle>
<SettingsSectionDescription>
Allow access to the resource via additional auth
methods
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Password Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.password ? "red" : "green"}-500 space-x-2`}
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
>
<Key />
<span>
Password Protection{" "}
{authInfo?.password
? "Enabled"
: "Disabled"}
{authInfo.password ? "Enabled" : "Disabled"}
</span>
</div>
{authInfo?.password ? (
<Button
variant="gray"
type="button"
variant="outline"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
disabled={loadingRemoveResourcePassword}
onClick={removeResourcePassword}
>
Remove Password
{authInfo.password
? "Remove Password"
: "Add Password"}
</Button>
) : (
<Button
variant="gray"
type="button"
onClick={() => setIsSetPasswordOpen(true)}
>
Add Password
</Button>
)}
</div>
<div className="flex items-center justify-between space-x-4">
{/* PIN Code Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.pincode ? "red" : "green"}-500 space-x-2`}
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
>
<Binary />
<span>
PIN Code Protection{" "}
{authInfo?.pincode ? "Enabled" : "Disabled"}
{authInfo.pincode ? "Enabled" : "Disabled"}
</span>
</div>
{authInfo?.pincode ? (
<Button
variant="gray"
type="button"
variant="outline"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
disabled={loadingRemoveResourcePincode}
onClick={removeResourcePincode}
>
Remove PIN Code
{authInfo.pincode
? "Remove PIN Code"
: "Add PIN Code"}
</Button>
) : (
<Button
variant="gray"
type="button"
onClick={() => setIsSetPincodeOpen(true)}
>
Add PIN Code
</Button>
)}
</div>
</div>
</section>
</SettingsSectionBody>
</SettingsSection>
<Separator />
<section className="space-y-4 lg:max-w-2xl">
{env.EMAIL_ENABLED === "true" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{env.email.emailEnabled && (
<>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
onCheckedChange={setWhitelistEnabled}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</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 && (
<Form {...whitelistForm}>
<form className="space-y-4">
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormItem>
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
{/* @ts-ignore */}
<TagInput
{...field}
@ -680,7 +686,8 @@ export default function ResourceAuthenticationPage() {
.email()
.safeParse(
tag
).success;
)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
@ -721,18 +728,20 @@ export default function ResourceAuthenticationPage() {
</form>
</Form>
)}
</>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</>
)}
</section>
</div>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);
}

View file

@ -40,28 +40,34 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";;
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { GetSiteResponse } from "@server/routers/site";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
const addTargetSchema = z.object({
ip: z.string().ip(),
method: z.string(),
port: z
.string()
.refine((val) => !isNaN(Number(val)), {
message: "Port must be a number"
})
.transform((val) => Number(val))
port: z.coerce.number().int().positive()
// protocol: z.string(),
});
@ -99,7 +105,7 @@ export default function ReverseProxyTargets(props: {
defaultValues: {
ip: "",
method: "http",
port: "80"
port: 80
// protocol: "TCP",
}
});
@ -154,7 +160,7 @@ export default function ReverseProxyTargets(props: {
fetchSite();
}, []);
async function addTarget(data: AddTargetFormValues) {
async function addTarget(data: z.infer<typeof addTargetSchema>) {
// Check if target with same IP, port and method already exists
const isDuplicate = targets.some(
(target) =>
@ -218,16 +224,10 @@ export default function ReverseProxyTargets(props: {
);
}
async function saveAll() {
async function saveTargets() {
try {
setLoading(true);
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: sslEnabled
});
updateResource({ ssl: sslEnabled });
for (let target of targets) {
const data = {
ip: target.ip,
@ -269,8 +269,8 @@ export default function ReverseProxyTargets(props: {
}
toast({
title: "Resource updated",
description: "Resource and targets updated successfully"
title: "Targets updated",
description: "Targets updated successfully"
});
setTargetsToRemove([]);
@ -289,6 +289,20 @@ export default function ReverseProxyTargets(props: {
setLoading(false);
}
async function saveSsl(val: boolean) {
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: val
});
setSslEnabled(val);
updateResource({ ssl: sslEnabled });
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
});
}
const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "method",
@ -410,40 +424,44 @@ export default function ReverseProxyTargets(props: {
}
return (
<>
<div className="space-y-12">
<section className="space-y-4">
<SettingsSectionTitle
title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates"
size="1xl"
/>
<div className="flex items-center space-x-2">
<Switch
<SettingsContainer>
{/* SSL Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with LetsEncrypt
certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
onCheckedChange={async (val) => {
await saveSsl(val);
}}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</section>
</SettingsSectionBody>
</SettingsSection>
<hr />
<section className="space-y-4">
<SettingsSectionTitle
title="Targets"
description="Setup targets to route traffic to your services"
size="1xl"
/>
<div className="space-y-4">
{/* Targets Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Target Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(
addTarget as any
)}
onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
@ -456,9 +474,7 @@ export default function ReverseProxyTargets(props: {
<FormControl>
<Select
{...field}
onValueChange={(
value
) => {
onValueChange={(value) => {
addTargetForm.setValue(
"method",
value
@ -478,10 +494,6 @@ export default function ReverseProxyTargets(props: {
</SelectContent>
</Select>
</FormControl>
{/* <FormDescription> */}
{/* Choose the method for how */}
{/* the target is accessed. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
@ -491,15 +503,10 @@ export default function ReverseProxyTargets(props: {
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>
IP Address
</FormLabel>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
{/* <FormDescription> */}
{/* Use the IP of the resource on your private network if using Newt, or the peer IP if using raw WireGuard. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
@ -518,82 +525,33 @@ export default function ReverseProxyTargets(props: {
required
/>
</FormControl>
{/* <FormDescription> */}
{/* Specify the port number for */}
{/* the target. */}
{/* </FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={addTargetForm.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>Protocol</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
addTargetForm.setValue(
"protocol",
value
);
}}
>
<SelectTrigger id="protocol">
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UDP">
UDP
</SelectItem>
<SelectItem value="TCP">
TCP
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select the protocol used by the
target
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
</div>
<Button type="submit" variant="gray">
<Button type="submit" variant="outline">
Add Target
</Button>
</form>
</Form>
<div className="rounded-md border">
<TableContainer>
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
)
)}
))}
</TableRow>
))}
</TableHeader>
@ -604,13 +562,10 @@ export default function ReverseProxyTargets(props: {
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
>
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef
.cell,
.columnDef.cell,
cell.getContext()
)}
</TableCell>
@ -623,26 +578,26 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using
the form.
No targets. Add a target using the
form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TableContainer>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveAll}
onClick={saveTargets}
loading={loading}
disabled={loading}
>
Save Changes
Save Targets
</Button>
</div>
</section>
</div>
</>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -11,7 +11,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input";
@ -21,13 +21,13 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandList
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverTrigger
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
@ -37,7 +37,16 @@ import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import ResourceInfoBox from "../ResourceInfoBox";
@ -47,7 +56,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
const GeneralFormSchema = z.object({
name: z.string(),
subdomain: subdomainSchema,
subdomain: subdomainSchema
// siteId: z.number(),
});
@ -72,10 +81,10 @@ export default function GeneralForm() {
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource.name,
subdomain: resource.subdomain,
subdomain: resource.subdomain
// siteId: resource.siteId!,
},
mode: "onChange",
mode: "onChange"
});
useEffect(() => {
@ -95,7 +104,7 @@ export default function GeneralForm() {
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.subdomain,
subdomain: data.subdomain
// siteId: data.siteId,
}
)
@ -106,13 +115,13 @@ export default function GeneralForm() {
description: formatAxiosError(
e,
"An error occurred while updating the resource"
),
)
});
})
.then(() => {
toast({
title: "Resource updated",
description: "The resource has been updated successfully",
description: "The resource has been updated successfully"
});
updateResource({ name: data.name, subdomain: data.subdomain });
@ -123,15 +132,19 @@ export default function GeneralForm() {
}
return (
<>
<div className="space-y-12 lg:max-w-2xl">
<section className="space-y-4">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this resource"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@ -175,101 +188,29 @@ export default function GeneralForm() {
/>
</FormControl>
<FormDescription>
This is the subdomain that will be
used to access the resource.
This is the subdomain that will
be used to access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandList>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={
site.name
}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be used in
the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Changes
Save Settings
</Button>
</form>
</Form>
</section>
</div>
</>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import ResourcesSplashCard from "./ResourcesSplashCard";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@ -62,6 +63,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
return (
<>
<ResourcesSplashCard />
<SettingsSectionTitle
title="Manage Resources"
description="Create secure proxies to your private applications"

View file

@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
@ -89,7 +90,7 @@ export function ShareLinksDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Create Share Link
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function ShareLinksDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View file

@ -0,0 +1,70 @@
"use client";
import React, { useState, useEffect } from "react";
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
import { Card, CardContent } from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
export const ShareableLinksSplash = () => {
const [isDismissed, setIsDismissed] = useState(false);
const key = "share-links-splash-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Link className="text-blue-500" />
Shareable Links
</h3>
<p className="text-sm">
Create shareable links to your resources. Links provide
temporary or unlimited access to your resource. You can
configure the expiration duration of the link when you
create one.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Share className="text-green-500 w-4 h-4" />
Easy to create and share
</li>
<li className="flex items-center gap-2">
<Clock className="text-yellow-500 w-4 h-4" />
Configurable expiration duration
</li>
<li className="flex items-center gap-2">
<Lock className="text-red-500 w-4 h-4" />
Secure and revocable
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ShareableLinksSplash;

View file

@ -8,6 +8,7 @@ import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
import ShareableLinksSplash from "./ShareLinksSplash";
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;
@ -52,6 +53,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
return (
<>
<ShareableLinksSplash />
<SettingsSectionTitle
title="Manage Share Links"
description="Create shareable links to grant temporary or permanent access to your resources"

View file

@ -36,6 +36,9 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { SiteRow } from "./SitesTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
const createSiteFormSchema = z.object({
name: z
@ -274,6 +277,24 @@ PersistentKeepalive = 5`
You will only be able to see the configuration once.
</span>
{form.watch("method") === "newt" && (
<>
<br />
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<ArrowUpRight className="w-5 h-5" />
</Link>
</>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="terms"

View file

@ -16,6 +16,7 @@ import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
@ -89,7 +90,7 @@ export function SitesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Site
</Button>
</div>
<div className="border rounded-md">
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function SitesDataTable<TData, TValue>({
)}
</TableBody>
</Table>
</div>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>

View file

@ -0,0 +1,98 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const key = "sites-splash-card-dismissed";
useEffect(() => {
const dismissed = localStorage.getItem(key);
if (dismissed === "true") {
setIsDismissed(true);
} else {
setIsDismissed(false);
}
}, []);
const handleDismiss = () => {
setIsDismissed(true);
localStorage.setItem(key, "true");
};
if (isDismissed) {
return null;
}
return (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label="Dismiss"
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6 sm:grid-cols-2">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Globe className="text-blue-500" />
Newt (Recommended)
</h3>
<p className="text-sm">
For the best user experience, use Newt. It uses
WireGuard under the hood and allows you to address your
private resources by their LAN address on your private
network from within the Pangolin dashboard.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in Docker
</li>
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in shell on macOS, Linux, and Windows
</li>
</ul>
<Button className="w-full" variant="secondary">
<Link
href="https://docs.fossorial.io/Newt/install"
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
Install Newt <ArrowRight className="ml-2 w-4 h-4" />
</Link>
</Button>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
Basic WireGuard
</h3>
<p className="text-sm">
Use any WireGuard client to connect. You will have to
address your internal resources using the peer IP.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Docker className="text-purple-500 w-4 h-4" />
Compatible with all WireGuard clients
</li>
<li className="flex items-center gap-2">
<Server className="text-purple-500 w-4 h-4" />
Manual configuration required
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default SitesSplashCard;

View file

@ -256,7 +256,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"gray"} className="ml-2">
<Button variant={"outline"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -10,20 +10,29 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/api";;
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const GeneralFormSchema = z.object({
name: z.string(),
name: z.string()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -39,15 +48,15 @@ export default function GeneralPage() {
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
name: site?.name
},
mode: "onChange",
mode: "onChange"
});
async function onSubmit(data: GeneralFormValues) {
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
name: data.name
})
.catch((e) => {
toast({
@ -56,7 +65,7 @@ export default function GeneralPage() {
description: formatAxiosError(
e,
"An error occurred while updating the site."
),
)
});
});
@ -66,18 +75,24 @@ export default function GeneralPage() {
}
return (
<>
<div className="space-y-4 max-w-xl">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"
size="1xl"
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
@ -89,16 +104,24 @@ export default function GeneralPage() {
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the site
This is the display name of the
site
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</div>
</>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="general-settings-form">
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View file

@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesSplashCard from "./SitesSplashCard";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
@ -47,6 +48,8 @@ export default async function SitesPage(props: SitesPageProps) {
return (
<>
<SitesSplashCard />
<SettingsSectionTitle
title="Manage Sites"
description="Allow connectivity to your network through secure tunnels"

View file

@ -12,6 +12,7 @@ import LoginForm from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
type DashboardLoginFormProps = {
redirect?: string;
@ -37,10 +38,20 @@ export default function DashboardLoginForm({
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Welcome to Pangolin</CardTitle>
<CardDescription>
Enter your credentials to access your dashboard
</CardDescription>
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt="Pangolin Logo"
width="100"
height="100"
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">Log in to get started</p>
</div>
</CardHeader>
<CardContent>
<LoginForm

View file

@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@ -16,7 +17,9 @@ export default async function Page(props: {
const isInvite = searchParams?.redirect?.includes("/invite");
const signUpDisabled = process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true";
const env = pullEnv();
const signUpDisabled = env.flags.disableSignupWithoutInvite;
if (user) {
redirect("/");

View file

@ -364,8 +364,6 @@ export default function ResetPasswordForm({
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>

View file

@ -38,7 +38,7 @@ export default async function Page(props: {
}
className="underline"
>
Go to login
Go back to log in
</Link>
</p>
</>

View file

@ -16,6 +16,7 @@ import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
import AccessTokenInvalid from "./AccessToken";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>;
@ -27,6 +28,8 @@ export default async function ResourceAuthPage(props: {
const params = await props.params;
const searchParams = await props.searchParams;
const env = pullEnv();
let authInfo: GetResourceAuthInfoResponse | undefined;
try {
const res = await internal.get<
@ -42,7 +45,9 @@ export default async function ResourceAuthPage(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) {
{/* @ts-ignore */} // TODO: fix this
{
/* @ts-ignore */
} // TODO: fix this
return (
<div className="w-full max-w-md">
<ResourceNotFound />
@ -63,11 +68,7 @@ export default async function ResourceAuthPage(props: {
!authInfo.pincode &&
!authInfo.whitelist;
if (
user &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
) {
if (user && !user.emailVerified && env.flags.emailVerificationRequired) {
redirect(
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`
);
@ -75,7 +76,7 @@ export default async function ResourceAuthPage(props: {
const allCookies = await cookies();
const cookieName =
process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`;
env.server.resourceSessionCookieName + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {

View file

@ -12,23 +12,24 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SignUpResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
type SignupFormProps = {
redirect?: string;
@ -40,14 +41,18 @@ const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema,
confirmPassword: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
message: "Passwords do not match"
});
export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFormProps) {
export default function SignupForm({
redirect,
inviteId,
inviteToken
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
@ -60,8 +65,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
defaultValues: {
email: "",
password: "",
confirmPassword: "",
},
confirmPassword: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@ -109,10 +114,22 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>
Enter your details to create an account
</CardDescription>
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
alt="Pangolin Logo"
width="100"
height="100"
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">
Create an account to get started
</p>
</div>
</CardHeader>
<CardContent>
<Form {...form}>

View file

@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
@ -8,7 +9,9 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
const env = pullEnv();
if (!env.flags.emailVerificationRequired) {
redirect("/");
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -15,14 +15,14 @@
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted: 60 4.8% 85.0%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent: 60 4.8% 90%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@ -41,11 +41,11 @@
--popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 25.0%;
--secondary: 12 6.5% 15.0%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 25.0%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 25.0%;
--accent: 12 2.5% 15.0%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;

View file

@ -1,24 +1,28 @@
import type { Metadata } from "next";
import "./globals.css";
import { Figtree } from "next/font/google";
import { Figtree, Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
description: ""
};
const font = Figtree({ subsets: ["latin"] });
// const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] });
export default async function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
const version = process.env.APP_VERSION;
const env = pullEnv();
const version = env.app.version;
return (
<html suppressHydrationWarning>
@ -29,24 +33,12 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<EnvProvider
env={{
NEXT_PORT: process.env.NEXT_PORT as string,
SERVER_EXTERNAL_PORT: process.env
.SERVER_EXTERNAL_PORT as string,
ENVIRONMENT: process.env.ENVIRONMENT as string,
EMAIL_ENABLED: process.env.EMAIL_ENABLED as string,
DISABLE_USER_CREATE_ORG:
process.env.DISABLE_USER_CREATE_ORG,
DISABLE_SIGNUP_WITHOUT_INVITE:
process.env.DISABLE_SIGNUP_WITHOUT_INVITE
}}
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-4">
<footer className="w-full mt-12 py-3 mb-6">
<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 className="whitespace-nowrap">
Pangolin
@ -73,6 +65,16 @@ export default async function RootLayout({
<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>
</a>
<Separator orientation="vertical" />
<a
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Docs</span>
</a>
{version && (
<>
<Separator orientation="vertical" />

View file

@ -10,6 +10,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic";
@ -21,6 +22,8 @@ export default async function Page(props: {
}) {
const params = await props.searchParams; // this is needed to prevent static optimization
const env = pullEnv();
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
@ -34,7 +37,7 @@ export default async function Page(props: {
if (
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
env.flags.emailVerificationRequired
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
@ -57,7 +60,7 @@ export default async function Page(props: {
if (!orgs.length) {
if (
process.env.DISABLE_USER_CREATE_ORG === "false" ||
!env.flags.disableUserCreateOrg ||
user.serverAdmin
) {
redirect("/setup");

View file

@ -1,5 +1,6 @@
import ProfileIcon from "@app/components/ProfileIcon";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import UserProvider from "@app/providers/UserProvider";
import { Metadata } from "next";
import { redirect } from "next/navigation";
@ -20,12 +21,14 @@ export default async function SetupLayout({
const getUser = cache(verifySession);
const user = await getUser();
const env = pullEnv();
if (!user) {
redirect("/?redirect=/setup");
}
if (
!(process.env.DISABLE_USER_CREATE_ORG === "false" || user.serverAdmin)
!(!env.flags.disableUserCreateOrg || user.serverAdmin)
) {
redirect("/");
}

View file

@ -23,7 +23,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
};
return (
<div className="relative w-full border rounded-md">
<div className="relative w-full border rounded-md bg-card">
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${
@ -38,7 +38,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
variant="outline"
size="icon"
type="button"
className="absolute top-1 right-1 z-10"
className="absolute top-1 right-1 z-10 bg-card"
onClick={copyToClipboard}
aria-label="Copy to clipboard"
>

View file

@ -90,14 +90,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaContent = isDesktop ? DialogContent : SheetContent;
return isDesktop ? (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
{...props}
>
{children}
</CredenzaContent>
) : (
return (
<CredenzaContent
className={cn("overflow-y-auto max-h-screen", className)}
{...props}

View file

@ -224,7 +224,9 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
</div>
<Form {...confirmForm}>
<form

View file

@ -48,7 +48,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
<div className="hidden md:block">
<div className="flex items-center gap-4 mr-4">
<Link
href="https://docs.fossorial.io"
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
@ -99,7 +99,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
<CommandEmpty>
No organizations found.
</CommandEmpty>
{(env.DISABLE_USER_CREATE_ORG === "false" ||
{(!env.flags.disableUserCreateOrg ||
user.serverAdmin) && (
<>
<CommandGroup heading="Create">

View file

@ -37,6 +37,7 @@ import {
} from "./ui/input-otp";
import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from 'next/image'
type LoginFormProps = {
redirect?: string;
@ -227,8 +228,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>

View file

@ -38,7 +38,7 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false);
function getInitials() {
return user.email.substring(0, 2).toUpperCase();
return user.email.substring(0, 1).toUpperCase();
}
function handleThemeChange(theme: "light" | "dark" | "system") {
@ -144,8 +144,8 @@ export default function ProfileIcon() {
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>Log Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -0,0 +1,31 @@
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-4">{children}</div>
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-md bg-card p-4">{children}</div>
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-0.5 pb-8">{children}</div>
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
return <div className="max-w-xl">{children}</div>
}
export function SettingsSectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">{children}</h2>
}
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
return <p className="text-muted-foreground">{children}</p>
}
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {
return <div className="space-y-5">{children}</div>
}
export function SettingsSectionFooter({ children }: { children: React.ReactNode }) {
return <div className="flex justify-end space-x-4 mt-8">{children}</div>
}

View file

@ -11,7 +11,7 @@ export default function SettingsSectionTitle({
}: SettingsSectionTitleProps) {
return (
<div
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-6 md:mb-12" : ""}`}
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
>
<h2
className={`text-${

View file

@ -88,7 +88,7 @@ export function SidebarNav({
</div>
<nav
className={cn(
"hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-3",
"hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-3 pr-8",
disabled && "opacity-50 pointer-events-none",
className
)}
@ -102,7 +102,7 @@ export function SidebarNav({
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) &&
!pathname.includes("create")
? "bg-muted hover:bg-muted dark:bg-border dark:hover:bg-border"
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"

View file

@ -21,8 +21,8 @@ export function SidebarSettings({
limitWidth
}: SideBarSettingsProps) {
return (
<div className="space-y-8 pb-16k">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-32 lg:space-y-0">
<div className="space-y-4">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-6 lg:space-y-0">
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>

View file

@ -0,0 +1,37 @@
import React from "react";
import { Switch } from "./ui/switch";
import { Label } from "./ui/label";
interface SwitchComponentProps {
id: string;
label: string;
description?: string;
defaultChecked?: boolean;
onCheckedChange: (checked: boolean) => void;
}
export function SwitchInput({
id,
label,
description,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
return (
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id={id}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
/>
<Label htmlFor={id}>{label}</Label>
</div>
{description && (
<span className="text-muted-foreground text-sm">
{description}
</span>
)}
</div>
);
}

View file

@ -38,7 +38,7 @@ export function TopbarNav({
key={item.href}
href={item.href.replace("{orgId}", orgId || "")}
className={cn(
"relative px-3 py-3 text-md",
"relative md:px-3 px-1 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium",

View file

@ -8,7 +8,7 @@ const alertVariants = cva(
{
variants: {
variant: {
default: "bg-background text-foreground",
default: "bg-card text-foreground",
destructive:
"border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:

View file

@ -6,7 +6,7 @@ import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@ -15,11 +15,10 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border border-input bg-card hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
gray: "bg-accent text-accent-foreground hover:bg-accent/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@ -27,7 +26,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
}
},
defaultVariants: {
variant: "default",

View file

@ -117,8 +117,8 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
"relative flex cursor-default select-none items-center px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>

View file

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}

View file

@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={showPassword ? "text" : "password"}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}

View file

@ -7,7 +7,16 @@ import { cn } from "@app/lib/cn"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<PopoverPrimitive.Trigger
ref={ref}
className={cn(className, "rounded-md")}
{...props}
/>
))
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,

View file

@ -19,8 +19,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
"rounded-md"
)}
{...props}
>

View file

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
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-100 data-[state=open]:duration-300",
"fixed z-50 gap-4 bg-card 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: {
side: {
@ -65,10 +65,10 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> */}
{/* <X className="h-4 w-4" /> */}
{/* <span className="sr-only">Close</span> */}
{/* </SheetPrimitive.Close> */}
</SheetPrimitive.Content>
</SheetPortal>
))
@ -80,7 +80,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col text-center sm:text-left mb-4",
"flex flex-col sm:text-left mb-4",
className
)}
{...props}

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-4 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-3 w-3 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>

View file

@ -2,6 +2,10 @@ import * as React from "react"
import { cn } from "@app/lib/cn"
export function TableContainer({ children }: { children: React.ReactNode }) {
return <div className="border rounded-md bg-card">{children}</div>
}
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>

View file

@ -1,8 +1,8 @@
import { env } from "@app/lib/types/env";
import { Env } from "@app/lib/types/env";
import { createContext } from "react";
interface EnvContextType {
env: env;
env: Env;
}
const EnvContext = createContext<EnvContextType | undefined>(undefined);

View file

@ -1,8 +1,11 @@
import { cookies } from "next/headers";
import { pullEnv } from "../pullEnv";
export async function authCookieHeader() {
const env = pullEnv();
const allCookies = await cookies();
const cookieName = process.env.SESSION_COOKIE_NAME!;
const cookieName = env.server.sessionCookieName;
const sessionId = allCookies.get(cookieName)?.value ?? null;
return {
headers: {

View file

@ -1,9 +1,9 @@
import { env } from "@app/lib/types/env";
import { Env } from "@app/lib/types/env";
import axios, { AxiosInstance } from "axios";
let apiInstance: AxiosInstance | null = null;
export function createApiClient({ env }: { env: env }): AxiosInstance {
export function createApiClient({ env }: { env: Env }): AxiosInstance {
if (apiInstance) {
return apiInstance;
}
@ -16,9 +16,9 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
let baseURL;
const suffix = "api/v1";
if (window.location.port === env.NEXT_PORT) {
if (window.location.port === env.server.nextPort) {
// this means the user is addressing the server directly
baseURL = `${window.location.protocol}//${window.location.hostname}:${env.SERVER_EXTERNAL_PORT}/${suffix}`;
baseURL = `${window.location.protocol}//${window.location.hostname}:${env.server.externalPort}/${suffix}`;
axios.defaults.withCredentials = true;
} else {
// user is accessing through a proxy

View file

@ -2,12 +2,15 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { GetUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { pullEnv } from "../pullEnv";
export async function verifySession({
skipCheckVerifyEmail,
}: {
skipCheckVerifyEmail?: boolean;
} = {}): Promise<GetUserResponse | null> {
const env = pullEnv();
try {
const res = await internal.get<AxiosResponse<GetUserResponse>>(
"/user",
@ -23,7 +26,7 @@ export async function verifySession({
if (
!skipCheckVerifyEmail &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true"
env.flags.emailVerificationRequired
) {
return null;
}

31
src/lib/pullEnv.ts Normal file
View file

@ -0,0 +1,31 @@
import { Env } from "./types/env";
export function pullEnv(): Env {
return {
server: {
nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string
},
app: {
environment: process.env.ENVIRONMENT as string,
version: process.env.APP_VERSION as string
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
},
flags: {
disableUserCreateOrg:
process.env.DISABLE_USER_CREATE_ORG === "true" ? true : false,
disableSignupWithoutInvite:
process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true"
? true
: false,
emailVerificationRequired:
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
? true
: false
}
};
}

View file

@ -1,8 +1,20 @@
export type env = {
SERVER_EXTERNAL_PORT: string;
NEXT_PORT: string;
ENVIRONMENT: string;
EMAIL_ENABLED: string;
DISABLE_SIGNUP_WITHOUT_INVITE?: string;
DISABLE_USER_CREATE_ORG?: string;
export type Env = {
app: {
environment: string;
version: string;
},
server: {
externalPort: string;
nextPort: string;
sessionCookieName: string;
resourceSessionCookieName: string;
},
email: {
emailEnabled: boolean;
},
flags: {
disableSignupWithoutInvite: boolean;
disableUserCreateOrg: boolean;
emailVerificationRequired: boolean;
}
};

View file

@ -1,11 +1,11 @@
"use client";
import EnvContext from "@app/contexts/envContext";
import { env } from "@app/lib/types/env";
import { Env } from "@app/lib/types/env";
interface ApiProviderProps {
children: React.ReactNode;
env: env;
env: Env;
}
export function EnvProvider({ children, env }: ApiProviderProps) {