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 config.example.yml ./dist/config.example.yml
COPY server/db/names.json ./dist/names.json COPY server/db/names.json ./dist/names.json
COPY public ./public
CMD ["npm", "start"] CMD ["npm", "start"]

View file

@ -9,7 +9,7 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
## Preview ## 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._ _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:push": "npx tsx server/db/migrate.ts",
"db:studio": "drizzle-kit studio", "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", "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" "email": "email dev --dir server/emails/templates --port 3005"
}, },
"dependencies": { "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"; const dev = process.env.ENVIRONMENT !== "prod";
let file; let file;
if (!dev) { if (!dev) {
file = join("names.json"); file = join(__DIRNAME, "names.json");
} else { } else {
file = join("server/db/names.json"); file = join("server/db/names.json");
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,22 @@
import { import {
Body, Body,
Container,
Head, Head,
Heading,
Html, Html,
Preview, Preview,
Section,
Text,
Tailwind Tailwind
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; 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 { interface VerifyEmailProps {
username?: string; username?: string;
@ -29,47 +35,36 @@ export const VerifyEmail = ({
<Html> <Html>
<Head /> <Head />
<Preview>{previewText}</Preview> <Preview>{previewText}</Preview>
<Tailwind <Tailwind config={themeColors}>
config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}
>
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg"> <EmailContainer>
<LetterHead /> <EmailLetterHead />
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <EmailHeading>Please Verify Your Email</EmailHeading>
Please Verify Your Email
</Heading> <EmailGreeting>Hi {username || "there"},</EmailGreeting>
<Text className="text-base text-gray-700 mt-4">
Hi {username || "there"}, <EmailText>
</Text>
<Text className="text-base text-gray-700 mt-2">
Youve requested to verify your email. Please use Youve requested to verify your email. Please use
the code below to complete the verification process the code below to complete the verification process
upon logging in. upon logging in.
</Text> </EmailText>
<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"> <EmailSection>
{verificationCode} <CopyCodeBox text={verificationCode} />
</Text> </EmailSection>
</Section>
<Text className="text-base text-gray-700 mt-2"> <EmailText>
If you didnt request this, you can safely ignore If you didnt request this, you can safely ignore
this email. this email.
</Text> </EmailText>
<Text className="text-sm text-gray-500 mt-6">
<EmailFooter>
Best regards, Best regards,
<br /> <br />
Fossorial Fossorial
</Text> </EmailFooter>
</Container> </EmailContainer>
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </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, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
@ -88,7 +89,7 @@ export function RolesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Role <Plus className="mr-2 h-4 w-4" /> Add Role
</Button> </Button>
</div> </div>
<div className="border rounded-md"> <TableContainer>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function RolesDataTable<TData, TValue>({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </TableContainer>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </div>

View file

@ -67,7 +67,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); 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 = [ const validFor = [
{ hours: 24, name: "1 day" }, { 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"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="send-email" id="send-email"

View file

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

View file

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

View file

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

View file

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

View file

@ -36,7 +36,7 @@ const topNavItems = [
icon: <Users className="h-4 w-4" /> icon: <Users className="h-4 w-4" />
}, },
{ {
title: "Sharable Links", title: "Shareable Links",
href: "/{orgId}/settings/share-links", href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" /> icon: <Link className="h-4 w-4" />
}, },
@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( 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="container mx-auto flex flex-col content-between">
<div className="my-4"> <div className="my-4">
<UserProvider user={user}> <UserProvider user={user}>

View file

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

View file

@ -16,6 +16,7 @@ import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
@ -89,7 +90,7 @@ export function ResourcesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Resource <Plus className="mr-2 h-4 w-4" /> Add Resource
</Button> </Button>
</div> </div>
<div className="border rounded-md"> <TableContainer>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function ResourcesDataTable<TData, TValue>({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </TableContainer>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </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 <Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`} href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
> >
<Button variant={"gray"} className="ml-2"> <Button variant={"outline"} className="ml-2">
Edit Edit
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>

View file

@ -60,9 +60,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<div className="flex items-center space-x-2 text-yellow-500"> <div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" /> <ShieldOff className="w-4 h-4" />
<span> <span>
This resource is not protected with any Anyone can access this resource.
auth method. Anyone can access this
resource.
</span> </span>
</div> </div>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow TableRow
@ -89,7 +90,7 @@ export function ShareLinksDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Create Share Link <Plus className="mr-2 h-4 w-4" /> Create Share Link
</Button> </Button>
</div> </div>
<div className="border rounded-md"> <TableContainer>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function ShareLinksDataTable<TData, TValue>({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </TableContainer>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </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 OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken"; import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable"; import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
import ShareableLinksSplash from "./ShareLinksSplash";
type ShareLinksPageProps = { type ShareLinksPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -52,6 +53,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
return ( return (
<> <>
<ShareableLinksSplash />
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Share Links" title="Manage Share Links"
description="Create shareable links to grant temporary or permanent access to your resources" 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { SiteRow } from "./SitesTable"; import { SiteRow } from "./SitesTable";
import { AxiosResponse } from "axios"; 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({ const createSiteFormSchema = z.object({
name: z name: z
@ -274,6 +277,24 @@ PersistentKeepalive = 5`
You will only be able to see the configuration once. You will only be able to see the configuration once.
</span> </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"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="terms" id="terms"

View file

@ -16,6 +16,7 @@ import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
@ -89,7 +90,7 @@ export function SitesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Site <Plus className="mr-2 h-4 w-4" /> Add Site
</Button> </Button>
</div> </div>
<div className="border rounded-md"> <TableContainer>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@ -141,7 +142,7 @@ export function SitesDataTable<TData, TValue>({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </TableContainer>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination table={table} /> <DataTablePagination table={table} />
</div> </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 <Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`} href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
> >
<Button variant={"gray"} className="ml-2"> <Button variant={"outline"} className="ml-2">
Edit Edit
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>

View file

@ -10,20 +10,29 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext"; import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import {
import { formatAxiosError } from "@app/lib/api";; 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 { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string(), name: z.string()
}); });
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -39,15 +48,15 @@ export default function GeneralPage() {
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: site?.name, name: site?.name
}, },
mode: "onChange", mode: "onChange"
}); });
async function onSubmit(data: GeneralFormValues) { async function onSubmit(data: GeneralFormValues) {
await api await api
.post(`/site/${site?.siteId}`, { .post(`/site/${site?.siteId}`, {
name: data.name, name: data.name
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -56,7 +65,7 @@ export default function GeneralPage() {
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while updating the site." "An error occurred while updating the site."
), )
}); });
}); });
@ -66,18 +75,24 @@ export default function GeneralPage() {
} }
return ( return (
<> <SettingsContainer>
<div className="space-y-4 max-w-xl"> <SettingsSection>
<SettingsSectionTitle <SettingsSectionHeader>
title="General Settings" <SettingsSectionTitle>
description="Configure the general settings for this site" General Settings
size="1xl" </SettingsSectionTitle>
/> <SettingsSectionDescription>
Configure the general settings for this site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-4"
id="general-settings-form"
> >
<FormField <FormField
control={form.control} control={form.control}
@ -89,16 +104,24 @@ export default function GeneralPage() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
This is the display name of the site This is the display name of the
site
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit">Save Changes</Button>
</form> </form>
</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 { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./SitesTable"; import SitesTable, { SiteRow } from "./SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesSplashCard from "./SitesSplashCard";
type SitesPageProps = { type SitesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -47,6 +48,8 @@ export default async function SitesPage(props: SitesPageProps) {
return ( return (
<> <>
<SitesSplashCard />
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Sites" title="Manage Sites"
description="Allow connectivity to your network through secure tunnels" 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import Image from "next/image";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
@ -37,10 +38,20 @@ export default function DashboardLoginForm({
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>Welcome to Pangolin</CardTitle> <div className="flex flex-row items-center justify-center">
<CardDescription> <Image
Enter your credentials to access your dashboard src={`/logo/pangolin_orange.svg`}
</CardDescription> 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> </CardHeader>
<CardContent> <CardContent>
<LoginForm <LoginForm

View file

@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm"; import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -16,7 +17,9 @@ export default async function Page(props: {
const isInvite = searchParams?.redirect?.includes("/invite"); 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) { if (user) {
redirect("/"); redirect("/");

View file

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

View file

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

View file

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

View file

@ -12,23 +12,24 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { SignUpResponse } from "@server/routers/auth"; import { SignUpResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
type SignupFormProps = { type SignupFormProps = {
redirect?: string; redirect?: string;
@ -40,14 +41,18 @@ const formSchema = z
.object({ .object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema, password: passwordSchema,
confirmPassword: passwordSchema, confirmPassword: passwordSchema
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
path: ["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 router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@ -60,8 +65,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
defaultValues: { defaultValues: {
email: "", email: "",
password: "", password: "",
confirmPassword: "", confirmPassword: ""
}, }
}); });
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
@ -109,10 +114,22 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>Create Account</CardTitle> <div className="flex flex-row items-center justify-center">
<CardDescription> <Image
Enter your details to create an account src={`/logo/pangolin_orange.svg`}
</CardDescription> 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> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>

View file

@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
@ -8,7 +9,9 @@ export const dynamic = "force-dynamic";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") { const env = pullEnv();
if (!env.flags.emailVerificationRequired) {
redirect("/"); 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%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%; --secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%; --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%; --muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%; --accent: 60 4.8% 90%;
--accent-foreground: 24 9.8% 10%; --accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%; --destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%; --border: 20 5.9% 85%;
--input: 20 5.9% 90%; --input: 20 5.9% 85%;
--ring: 24.6 95% 53.1%; --ring: 24.6 95% 53.1%;
--radius: 0.75rem; --radius: 0.75rem;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
@ -41,11 +41,11 @@
--popover-foreground: 60 9.1% 97.8%; --popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%; --primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%; --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%; --secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 25.0%; --muted: 12 6.5% 25.0%;
--muted-foreground: 24 5.4% 63.9%; --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%; --accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%; --destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%; --destructive-foreground: 60 9.1% 97.8%;

View file

@ -1,24 +1,28 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Figtree } from "next/font/google"; import { Figtree, Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider"; import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider"; import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
description: "" description: ""
}; };
const font = Figtree({ subsets: ["latin"] }); // const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] });
export default async function RootLayout({ export default async function RootLayout({
children children
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const version = process.env.APP_VERSION; const env = pullEnv();
const version = env.app.version;
return ( return (
<html suppressHydrationWarning> <html suppressHydrationWarning>
@ -29,24 +33,12 @@ export default async function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<EnvProvider <EnvProvider env={pullEnv()}>
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
}}
>
{/* Main content */} {/* Main content */}
<div className="flex-grow">{children}</div> <div className="flex-grow">{children}</div>
{/* Footer */} {/* 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="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"> <div className="whitespace-nowrap">
Pangolin 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" /> <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg> </svg>
</a> </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 && ( {version && (
<> <>
<Separator orientation="vertical" /> <Separator orientation="vertical" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -88,7 +88,7 @@ export function SidebarNav({
</div> </div>
<nav <nav
className={cn( 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", disabled && "opacity-50 pointer-events-none",
className className
)} )}
@ -102,7 +102,7 @@ export function SidebarNav({
buttonVariants({ variant: "ghost" }), buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) && pathname === hydrateHref(item.href) &&
!pathname.includes("create") !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", : "hover:bg-transparent hover:underline",
"justify-start", "justify-start",
disabled && "cursor-not-allowed" disabled && "cursor-not-allowed"

View file

@ -21,8 +21,8 @@ export function SidebarSettings({
limitWidth limitWidth
}: SideBarSettingsProps) { }: SideBarSettingsProps) {
return ( return (
<div className="space-y-8 pb-16k"> <div className="space-y-4">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-32 lg:space-y-0"> <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"> <aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} /> <SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside> </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} key={item.href}
href={item.href.replace("{orgId}", orgId || "")} href={item.href.replace("{orgId}", orgId || "")}
className={cn( 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 || "")) pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
? "border-b-2 border-primary text-primary font-medium" ? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium", : "hover:text-primary text-muted-foreground font-medium",

View file

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

View file

@ -6,7 +6,7 @@ import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
@ -15,11 +15,10 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: 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: 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", 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", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
@ -27,7 +26,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md px-3", sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-9 w-9", icon: "h-9 w-9",
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",

View file

@ -117,8 +117,8 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />

View file

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className={cn( 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 className
)} )}
ref={ref} ref={ref}
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}

View file

@ -7,7 +7,16 @@ import { cn } from "@app/lib/cn"
const Popover = PopoverPrimitive.Root 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< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,

View file

@ -19,8 +19,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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", "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 className,
"rounded-md"
)} )}
{...props} {...props}
> >

View file

@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-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: { variants: {
side: { side: {
@ -65,10 +65,10 @@ const SheetContent = React.forwardRef<
{...props} {...props}
> >
{children} {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"> {/* <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" /> {/* <X className="h-4 w-4" /> */}
<span className="sr-only">Close</span> {/* <span className="sr-only">Close</span> */}
</SheetPrimitive.Close> {/* </SheetPrimitive.Close> */}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ))
@ -80,7 +80,7 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col text-center sm:text-left mb-4", "flex flex-col sm:text-left mb-4",
className className
)} )}
{...props} {...props}

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( 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 className
)} )}
{...props} {...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( 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> </SwitchPrimitives.Root>

View file

@ -2,6 +2,10 @@ import * as React from "react"
import { cn } from "@app/lib/cn" 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< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<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"; import { createContext } from "react";
interface EnvContextType { interface EnvContextType {
env: env; env: Env;
} }
const EnvContext = createContext<EnvContextType | undefined>(undefined); const EnvContext = createContext<EnvContextType | undefined>(undefined);

View file

@ -1,8 +1,11 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { pullEnv } from "../pullEnv";
export async function authCookieHeader() { export async function authCookieHeader() {
const env = pullEnv();
const allCookies = await cookies(); const allCookies = await cookies();
const cookieName = process.env.SESSION_COOKIE_NAME!; const cookieName = env.server.sessionCookieName;
const sessionId = allCookies.get(cookieName)?.value ?? null; const sessionId = allCookies.get(cookieName)?.value ?? null;
return { return {
headers: { 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"; import axios, { AxiosInstance } from "axios";
let apiInstance: AxiosInstance | null = null; let apiInstance: AxiosInstance | null = null;
export function createApiClient({ env }: { env: env }): AxiosInstance { export function createApiClient({ env }: { env: Env }): AxiosInstance {
if (apiInstance) { if (apiInstance) {
return apiInstance; return apiInstance;
} }
@ -16,9 +16,9 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
let baseURL; let baseURL;
const suffix = "api/v1"; 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 // 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; axios.defaults.withCredentials = true;
} else { } else {
// user is accessing through a proxy // 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 { authCookieHeader } from "@app/lib/api/cookies";
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { pullEnv } from "../pullEnv";
export async function verifySession({ export async function verifySession({
skipCheckVerifyEmail, skipCheckVerifyEmail,
}: { }: {
skipCheckVerifyEmail?: boolean; skipCheckVerifyEmail?: boolean;
} = {}): Promise<GetUserResponse | null> { } = {}): Promise<GetUserResponse | null> {
const env = pullEnv();
try { try {
const res = await internal.get<AxiosResponse<GetUserResponse>>( const res = await internal.get<AxiosResponse<GetUserResponse>>(
"/user", "/user",
@ -23,7 +26,7 @@ export async function verifySession({
if ( if (
!skipCheckVerifyEmail && !skipCheckVerifyEmail &&
!user.emailVerified && !user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true" env.flags.emailVerificationRequired
) { ) {
return null; 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 = { export type Env = {
SERVER_EXTERNAL_PORT: string; app: {
NEXT_PORT: string; environment: string;
ENVIRONMENT: string; version: string;
EMAIL_ENABLED: string; },
DISABLE_SIGNUP_WITHOUT_INVITE?: string; server: {
DISABLE_USER_CREATE_ORG?: string; 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"; "use client";
import EnvContext from "@app/contexts/envContext"; import EnvContext from "@app/contexts/envContext";
import { env } from "@app/lib/types/env"; import { Env } from "@app/lib/types/env";
interface ApiProviderProps { interface ApiProviderProps {
children: React.ReactNode; children: React.ReactNode;
env: env; env: Env;
} }
export function EnvProvider({ children, env }: ApiProviderProps) { export function EnvProvider({ children, env }: ApiProviderProps) {