major ui tweaks and refactoring
|
@ -29,4 +29,6 @@ COPY --from=builder /app/init ./dist/init
|
|||
COPY config.example.yml ./dist/config.example.yml
|
||||
COPY server/db/names.json ./dist/names.json
|
||||
|
||||
COPY public ./public
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
|
@ -9,7 +9,7 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
|
|||
|
||||
## Preview
|
||||
|
||||
<img src="public/screenshots/preview.png" alt="Preview"/>
|
||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||
|
||||
_Sites page of Pangolin showing multiple site-to-site tunnels connected to the central server._
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"db:push": "npx tsx server/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
|
||||
"start": "NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"start": "NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'",
|
||||
"email": "email dev --dir server/emails/templates --port 3005"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
38
public/logo/pangolin_black.svg
Normal 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 |
39
public/logo/pangolin_orange.svg
Normal 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 |
Before Width: | Height: | Size: 525 KiB After Width: | Height: | Size: 577 KiB |
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 447 KiB |
Before Width: | Height: | Size: 251 KiB |
BIN
public/screenshots/roles.png
Normal file
After Width: | Height: | Size: 415 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 484 KiB |
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 437 KiB |
Before Width: | Height: | Size: 306 KiB |
|
@ -9,7 +9,7 @@ import { __DIRNAME } from "@server/lib/consts";
|
|||
const dev = process.env.ENVIRONMENT !== "prod";
|
||||
let file;
|
||||
if (!dev) {
|
||||
file = join("names.json");
|
||||
file = join(__DIRNAME, "names.json");
|
||||
} else {
|
||||
file = join("server/db/names.json");
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ export async function sendEmail(
|
|||
|
||||
await emailClient.sendMail({
|
||||
from: {
|
||||
name: opts.name || "Pangolin Proxy",
|
||||
name: opts.name || "Pangolin",
|
||||
address: opts.from,
|
||||
},
|
||||
to: opts.to,
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -23,41 +27,31 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#16A34A"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans relative">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Password Reset Confirmation
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
<EmailHeading>Password Reset Confirmation</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
This email confirms that your password has just been
|
||||
reset. If you made this change, no further action is
|
||||
required.
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
Thank you for keeping your account secure.
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -25,50 +31,39 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Password Reset Request
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
<EmailHeading>Password Reset Request</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested to reset your password. Please{" "}
|
||||
<a href={link} className="text-primary">
|
||||
click here
|
||||
</a>{" "}
|
||||
and follow the instructions to reset your password,
|
||||
or manually enter the following code:
|
||||
</Text>
|
||||
<Section className="text-center">
|
||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||
{code}
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<CopyCodeBox text={code} />
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you didn’t request this, you can safely ignore
|
||||
this email.
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailLetterHead,
|
||||
EmailHeading,
|
||||
EmailText,
|
||||
EmailFooter,
|
||||
EmailSection,
|
||||
EmailGreeting
|
||||
} from "./components/Email";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
|
||||
interface ResourceOTPCodeProps {
|
||||
email?: string;
|
||||
|
@ -31,44 +37,34 @@ export const ResourceOTPCode = ({
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
<EmailHeading>
|
||||
Your One-Time Password for {resourceName}
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested a one-time password to access{" "}
|
||||
<strong>{resourceName}</strong> in{" "}
|
||||
<strong>{organizationName}</strong>. Use the code
|
||||
below to complete your authentication:
|
||||
</Text>
|
||||
<Section className="text-center">
|
||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||
{otp}
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<CopyCodeBox text={otp} />
|
||||
</EmailSection>
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
Button
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
import ButtonLink from "./components/ButtonLink";
|
||||
|
||||
interface SendInviteLinkProps {
|
||||
email: string;
|
||||
|
@ -34,55 +39,42 @@ export const SendInviteLink = ({
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Invited to Join {orgName}
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
<EmailHeading>Invited to Join {orgName}</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve been invited to join the organization{" "}
|
||||
{orgName}
|
||||
<strong>{orgName}</strong>
|
||||
{inviterName ? ` by ${inviterName}.` : "."} Please
|
||||
access the link below to accept the invite.
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
This invite will expire in{" "}
|
||||
<b>
|
||||
<strong>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays === "1" ? "day" : "days"}.
|
||||
</b>
|
||||
</Text>
|
||||
<Section className="text-center">
|
||||
<Button
|
||||
href={inviteLink}
|
||||
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl"
|
||||
>
|
||||
Accept Invite to {orgName}
|
||||
</Button>
|
||||
</Section>
|
||||
</strong>
|
||||
</EmailText>
|
||||
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
<EmailSection>
|
||||
<ButtonLink href={inviteLink}>
|
||||
Accept Invite to {orgName}
|
||||
</ButtonLink>
|
||||
</EmailSection>
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
|
@ -24,52 +28,44 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#16A34A"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
<EmailHeading>
|
||||
Two-Factor Authentication{" "}
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {email || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
This email confirms that Two-Factor Authentication
|
||||
has been successfully{" "}
|
||||
{enabled ? "enabled" : "disabled"} on your account.
|
||||
</Text>
|
||||
</EmailText>
|
||||
|
||||
{enabled ? (
|
||||
<Text className="text-base text-gray-700">
|
||||
<EmailText>
|
||||
With Two-Factor Authentication enabled, your
|
||||
account is now more secure. Please ensure you
|
||||
keep your authentication method safe.
|
||||
</Text>
|
||||
</EmailText>
|
||||
) : (
|
||||
<Text className="text-base text-gray-700">
|
||||
<EmailText>
|
||||
With Two-Factor Authentication disabled, your
|
||||
account may be less secure. We recommend
|
||||
enabling it to protect your account.
|
||||
</Text>
|
||||
</EmailText>
|
||||
)}
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
import LetterHead from "./components/LetterHead";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
|
||||
interface VerifyEmailProps {
|
||||
username?: string;
|
||||
|
@ -29,47 +35,36 @@ export const VerifyEmail = ({
|
|||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans">
|
||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||
<LetterHead />
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||
Please Verify Your Email
|
||||
</Heading>
|
||||
<Text className="text-base text-gray-700 mt-4">
|
||||
Hi {username || "there"},
|
||||
</Text>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
<EmailHeading>Please Verify Your Email</EmailHeading>
|
||||
|
||||
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
You’ve requested to verify your email. Please use
|
||||
the code below to complete the verification process
|
||||
upon logging in.
|
||||
</Text>
|
||||
<Section className="text-center">
|
||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||
{verificationCode}
|
||||
</Text>
|
||||
</Section>
|
||||
<Text className="text-base text-gray-700 mt-2">
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<CopyCodeBox text={verificationCode} />
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you didn’t request this, you can safely ignore
|
||||
this email.
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 mt-6">
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
Best regards,
|
||||
<br />
|
||||
Fossorial
|
||||
</Text>
|
||||
</Container>
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
|
|
18
server/emails/templates/components/ButtonLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
server/emails/templates/components/CopyCodeBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
91
server/emails/templates/components/Email.tsx
Normal 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>;
|
||||
}
|
|
@ -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;
|
9
server/emails/templates/lib/theme.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export const themeColors = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#F97317"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -15,6 +15,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
|
@ -88,7 +89,7 @@ export function RolesDataTable<TData, TValue>({
|
|||
<Plus className="mr-2 h-4 w-4" /> Add Role
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
@ -141,7 +142,7 @@ export function RolesDataTable<TData, TValue>({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
|
|
@ -67,7 +67,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
|||
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
|
||||
const [sendEmail, setSendEmail] = useState(env.EMAIL_ENABLED === "true");
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
|
@ -205,7 +205,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
|||
)}
|
||||
/>
|
||||
|
||||
{env.EMAIL_ENABLED === "true" && (
|
||||
{env.email.emailEnabled && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
|
@ -88,7 +89,7 @@ export function UsersDataTable<TData, TValue>({
|
|||
<Plus className="mr-2 h-4 w-4" /> Invite User
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
@ -141,7 +142,7 @@ export function UsersDataTable<TData, TValue>({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
|
|
@ -159,7 +159,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
const userRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{userRow.isOwner && (
|
||||
<Crown className="w-4 h-4 text-yellow-600" />
|
||||
)}
|
||||
|
@ -186,7 +186,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button variant={"gray"} className="ml-2">
|
||||
<Button variant={"outline"} className="ml-2">
|
||||
Manage
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
|
@ -14,7 +14,7 @@ import {
|
|||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
@ -27,14 +27,23 @@ import { ListRolesResponse } from "@server/routers/role";
|
|||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: "Please enter a valid email" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
|
@ -52,8 +61,8 @@ export default function AccessControlsPage() {
|
|||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: user.email!,
|
||||
roleId: user.roleId?.toString(),
|
||||
},
|
||||
roleId: user.roleId?.toString()
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -68,7 +77,7 @@ export default function AccessControlsPage() {
|
|||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
),
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -86,9 +95,9 @@ export default function AccessControlsPage() {
|
|||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/role/${values.roleId}/add/${user.userId}`
|
||||
)
|
||||
.post<
|
||||
AxiosResponse<InviteUserResponse>
|
||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
|
@ -96,7 +105,7 @@ export default function AccessControlsPage() {
|
|||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while adding user to the role."
|
||||
),
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -104,7 +113,7 @@ export default function AccessControlsPage() {
|
|||
toast({
|
||||
variant: "default",
|
||||
title: "User saved",
|
||||
description: "The user has been updated.",
|
||||
description: "The user has been updated."
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -112,18 +121,23 @@ export default function AccessControlsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<SettingsSectionTitle
|
||||
title="Access Controls"
|
||||
description="Manage what this user can access and do in the organization"
|
||||
size="1xl"
|
||||
/>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage what this user can access and do in the
|
||||
organization
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
@ -155,16 +169,22 @@ export default function AccessControlsPage() {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
form="access-controls-form"
|
||||
>
|
||||
Save Changes
|
||||
Save Access Controls
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import { useForm } from "react-hook-form";
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
|
@ -33,6 +33,16 @@ import {
|
|||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string()
|
||||
|
@ -80,10 +90,7 @@ export default function GeneralPage() {
|
|||
|
||||
async function pickNewOrgAndNavigate() {
|
||||
try {
|
||||
|
||||
const res = await api.get<AxiosResponse<ListOrgsResponse>>(
|
||||
`/orgs`
|
||||
);
|
||||
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
|
||||
|
||||
if (res.status === 200) {
|
||||
if (res.data.data.orgs.length > 0) {
|
||||
|
@ -126,7 +133,7 @@ export default function GeneralPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
|
@ -138,12 +145,10 @@ export default function GeneralPage() {
|
|||
Are you sure you want to delete the organization{" "}
|
||||
<b>{org?.org.name}?</b>
|
||||
</p>
|
||||
|
||||
<p className="mb-2">
|
||||
This action is irreversible and will delete all
|
||||
associated data.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, type the name of the organization below.
|
||||
</p>
|
||||
|
@ -155,11 +160,23 @@ export default function GeneralPage() {
|
|||
title="Delete Organization"
|
||||
/>
|
||||
|
||||
<section className="space-y-8 max-w-lg">
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Organization Settings
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage your organization details and configuration
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="org-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
@ -171,41 +188,47 @@ export default function GeneralPage() {
|
|||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the display name of the org
|
||||
This is the display name of the
|
||||
org
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||
<SettingsSectionFooter>
|
||||
<Button type="submit" form="org-settings-form">
|
||||
Save Settings
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Once you delete this org, there is no going back.
|
||||
Please be certain.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2">
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Once you delete this org, there is no going back. Please
|
||||
be certain.
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Organization Data
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ const topNavItems = [
|
|||
icon: <Users className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Sharable Links",
|
||||
title: "Shareable Links",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <Link className="h-4 w-4" />
|
||||
},
|
||||
|
@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
|
||||
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4">
|
||||
<UserProvider user={user}>
|
||||
|
|
|
@ -6,7 +6,7 @@ type OrgPageProps = {
|
|||
|
||||
export default async function SettingsPage(props: OrgPageProps) {
|
||||
const params = await props.params;
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
redirect(`/${params.orgId}/settings/sites`);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
|
@ -89,7 +90,7 @@ export function ResourcesDataTable<TData, TValue>({
|
|||
<Plus className="mr-2 h-4 w-4" /> Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
@ -141,7 +142,7 @@ export function ResourcesDataTable<TData, TValue>({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
|
68
src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx
Normal 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;
|
|
@ -210,7 +210,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Button variant={"gray"} className="ml-2">
|
||||
<Button variant={"outline"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
@ -60,9 +60,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>
|
||||
This resource is not protected with any
|
||||
auth method. Anyone can access this
|
||||
resource.
|
||||
Anyone can access this resource.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -28,16 +28,26 @@ import {
|
|||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { TagInput } from "emblor";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
// import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { Binary, Key, ShieldCheck } from "lucide-react";
|
||||
import SetResourcePasswordForm from "./SetResourcePasswordForm";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import SetResourcePincodeForm from "./SetResourcePincodeForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
|
@ -382,34 +392,32 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-12">
|
||||
<section className="space-y-4 lg:max-w-2xl">
|
||||
<SettingsSectionTitle
|
||||
title="Users & Roles"
|
||||
description="Configure which users and roles can visit this resource"
|
||||
size="1xl"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Switch
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Users & Roles
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure which users and roles can visit this
|
||||
resource
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label="Use Platform SSO"
|
||||
description="Existing users will only have to login once for all resources that have this enabled."
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
<Label htmlFor="sso-toggle">Use Platform SSO</Label>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Existing users will only have to login once for all
|
||||
resources that have this enabled.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form {...usersRolesForm}>
|
||||
<form
|
||||
onSubmit={usersRolesForm.handleSubmit(
|
||||
onSubmitUsersRoles
|
||||
)}
|
||||
id="users-roles-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
{ssoEnabled && (
|
||||
|
@ -435,7 +443,9 @@ export default function ResourceAuthenticationPage() {
|
|||
usersRolesForm.getValues()
|
||||
.roles
|
||||
}
|
||||
setTags={(newRoles) => {
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"roles",
|
||||
newRoles as [
|
||||
|
@ -450,7 +460,9 @@ export default function ResourceAuthenticationPage() {
|
|||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={false}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
|
@ -466,10 +478,10 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
These roles will be able to
|
||||
access this resource. Admins
|
||||
can always access this
|
||||
resource.
|
||||
These roles will be able
|
||||
to access this resource.
|
||||
Admins can always access
|
||||
this resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -496,7 +508,9 @@ export default function ResourceAuthenticationPage() {
|
|||
usersRolesForm.getValues()
|
||||
.users
|
||||
}
|
||||
setTags={(newUsers) => {
|
||||
setTags={(
|
||||
newUsers
|
||||
) => {
|
||||
usersRolesForm.setValue(
|
||||
"users",
|
||||
newUsers as [
|
||||
|
@ -511,7 +525,9 @@ export default function ResourceAuthenticationPage() {
|
|||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={false}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
|
@ -529,10 +545,11 @@ export default function ResourceAuthenticationPage() {
|
|||
<FormDescription>
|
||||
Users added here will be
|
||||
able to access this
|
||||
resource. A user will always
|
||||
have access to a resource if
|
||||
they have a role that has
|
||||
access to it.
|
||||
resource. A user will
|
||||
always have access to a
|
||||
resource if they have a
|
||||
role that has access to
|
||||
it.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -540,132 +557,121 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loadingSaveUsersRoles}
|
||||
disabled={loadingSaveUsersRoles}
|
||||
form="users-roles-form"
|
||||
>
|
||||
Save Users & Roles
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</section>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4 lg:max-w-2xl">
|
||||
<SettingsSectionTitle
|
||||
title="Authentication Methods"
|
||||
description="Allow access to the resource via additional auth methods"
|
||||
size="1xl"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Authentication Methods
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Allow access to the resource via additional auth
|
||||
methods
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{/* Password Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`flex items-center text-${!authInfo.password ? "red" : "green"}-500 space-x-2`}
|
||||
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
|
||||
>
|
||||
<Key />
|
||||
<span>
|
||||
Password Protection{" "}
|
||||
{authInfo?.password
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
{authInfo.password ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
{authInfo?.password ? (
|
||||
<Button
|
||||
variant="gray"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={
|
||||
authInfo.password
|
||||
? removeResourcePassword
|
||||
: () => setIsSetPasswordOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePassword}
|
||||
disabled={loadingRemoveResourcePassword}
|
||||
onClick={removeResourcePassword}
|
||||
>
|
||||
Remove Password
|
||||
{authInfo.password
|
||||
? "Remove Password"
|
||||
: "Add Password"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="gray"
|
||||
type="button"
|
||||
onClick={() => setIsSetPasswordOpen(true)}
|
||||
>
|
||||
Add Password
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
{/* PIN Code Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`flex items-center text-${!authInfo.pincode ? "red" : "green"}-500 space-x-2`}
|
||||
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
|
||||
>
|
||||
<Binary />
|
||||
<span>
|
||||
PIN Code Protection{" "}
|
||||
{authInfo?.pincode ? "Enabled" : "Disabled"}
|
||||
{authInfo.pincode ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
{authInfo?.pincode ? (
|
||||
<Button
|
||||
variant="gray"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={
|
||||
authInfo.pincode
|
||||
? removeResourcePincode
|
||||
: () => setIsSetPincodeOpen(true)
|
||||
}
|
||||
loading={loadingRemoveResourcePincode}
|
||||
disabled={loadingRemoveResourcePincode}
|
||||
onClick={removeResourcePincode}
|
||||
>
|
||||
Remove PIN Code
|
||||
{authInfo.pincode
|
||||
? "Remove PIN Code"
|
||||
: "Add PIN Code"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="gray"
|
||||
type="button"
|
||||
onClick={() => setIsSetPincodeOpen(true)}
|
||||
>
|
||||
Add PIN Code
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4 lg:max-w-2xl">
|
||||
{env.EMAIL_ENABLED === "true" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
One-time Passwords
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Require email-based authentication for resource
|
||||
access
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{env.email.emailEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Switch
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label="Email Whitelist"
|
||||
defaultChecked={
|
||||
resource.emailWhitelistEnabled
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
setWhitelistEnabled(val)
|
||||
}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
/>
|
||||
<Label htmlFor="whitelist-toggle">
|
||||
Email Whitelist
|
||||
</Label>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Enable resource whitelist to require
|
||||
email-based authentication (one-time
|
||||
passwords) for resource access.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{whitelistEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form className="space-y-4">
|
||||
<form id="whitelist-form">
|
||||
<FormField
|
||||
control={whitelistForm.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Whitelisted Emails
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
|
@ -680,7 +686,8 @@ export default function ResourceAuthenticationPage() {
|
|||
.email()
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
)
|
||||
.success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
|
@ -721,18 +728,20 @@ export default function ResourceAuthenticationPage() {
|
|||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
onClick={saveWhitelist}
|
||||
form="whitelist-form"
|
||||
loading={loadingSaveWhitelist}
|
||||
>
|
||||
Save Whitelist
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,28 +40,34 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";;
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().ip(),
|
||||
method: z.string(),
|
||||
port: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Number(val)), {
|
||||
message: "Port must be a number"
|
||||
})
|
||||
.transform((val) => Number(val))
|
||||
port: z.coerce.number().int().positive()
|
||||
// protocol: z.string(),
|
||||
});
|
||||
|
||||
|
@ -99,7 +105,7 @@ export default function ReverseProxyTargets(props: {
|
|||
defaultValues: {
|
||||
ip: "",
|
||||
method: "http",
|
||||
port: "80"
|
||||
port: 80
|
||||
// protocol: "TCP",
|
||||
}
|
||||
});
|
||||
|
@ -154,7 +160,7 @@ export default function ReverseProxyTargets(props: {
|
|||
fetchSite();
|
||||
}, []);
|
||||
|
||||
async function addTarget(data: AddTargetFormValues) {
|
||||
async function addTarget(data: z.infer<typeof addTargetSchema>) {
|
||||
// Check if target with same IP, port and method already exists
|
||||
const isDuplicate = targets.some(
|
||||
(target) =>
|
||||
|
@ -218,16 +224,10 @@ export default function ReverseProxyTargets(props: {
|
|||
);
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
async function saveTargets() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api.post(`/resource/${params.resourceId}`, {
|
||||
ssl: sslEnabled
|
||||
});
|
||||
|
||||
updateResource({ ssl: sslEnabled });
|
||||
|
||||
for (let target of targets) {
|
||||
const data = {
|
||||
ip: target.ip,
|
||||
|
@ -269,8 +269,8 @@ export default function ReverseProxyTargets(props: {
|
|||
}
|
||||
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "Resource and targets updated successfully"
|
||||
title: "Targets updated",
|
||||
description: "Targets updated successfully"
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
|
@ -289,6 +289,20 @@ export default function ReverseProxyTargets(props: {
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
async function saveSsl(val: boolean) {
|
||||
const res = await api.post(`/resource/${params.resourceId}`, {
|
||||
ssl: val
|
||||
});
|
||||
|
||||
setSslEnabled(val);
|
||||
updateResource({ ssl: sslEnabled });
|
||||
|
||||
toast({
|
||||
title: "SSL Configuration",
|
||||
description: "SSL configuration updated successfully"
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "method",
|
||||
|
@ -410,40 +424,44 @@ export default function ReverseProxyTargets(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-12">
|
||||
<section className="space-y-4">
|
||||
<SettingsSectionTitle
|
||||
title="SSL"
|
||||
description="Setup SSL to secure your connections with LetsEncrypt certificates"
|
||||
size="1xl"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
<SettingsContainer>
|
||||
{/* SSL Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
SSL Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Setup SSL to secure your connections with LetsEncrypt
|
||||
certificates
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label="Enable SSL (https)"
|
||||
defaultChecked={resource.ssl}
|
||||
onCheckedChange={(val) => setSslEnabled(val)}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveSsl(val);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<hr />
|
||||
|
||||
<section className="space-y-4">
|
||||
<SettingsSectionTitle
|
||||
title="Targets"
|
||||
description="Setup targets to route traffic to your services"
|
||||
size="1xl"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Targets Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Target Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Setup targets to route traffic to your services
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...addTargetForm}>
|
||||
<form
|
||||
onSubmit={addTargetForm.handleSubmit(
|
||||
addTarget as any
|
||||
)}
|
||||
onSubmit={addTargetForm.handleSubmit(addTarget)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
|
@ -456,9 +474,7 @@ export default function ReverseProxyTargets(props: {
|
|||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(
|
||||
value
|
||||
) => {
|
||||
onValueChange={(value) => {
|
||||
addTargetForm.setValue(
|
||||
"method",
|
||||
value
|
||||
|
@ -478,10 +494,6 @@ export default function ReverseProxyTargets(props: {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{/* <FormDescription> */}
|
||||
{/* Choose the method for how */}
|
||||
{/* the target is accessed. */}
|
||||
{/* </FormDescription> */}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -491,15 +503,10 @@ export default function ReverseProxyTargets(props: {
|
|||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
IP Address
|
||||
</FormLabel>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
{/* <FormDescription> */}
|
||||
{/* Use the IP of the resource on your private network if using Newt, or the peer IP if using raw WireGuard. */}
|
||||
{/* </FormDescription> */}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -518,82 +525,33 @@ export default function ReverseProxyTargets(props: {
|
|||
required
|
||||
/>
|
||||
</FormControl>
|
||||
{/* <FormDescription> */}
|
||||
{/* Specify the port number for */}
|
||||
{/* the target. */}
|
||||
{/* </FormDescription> */}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* <FormField
|
||||
control={addTargetForm.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Protocol</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
addTargetForm.setValue(
|
||||
"protocol",
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="protocol">
|
||||
<SelectValue placeholder="Select protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="UDP">
|
||||
UDP
|
||||
</SelectItem>
|
||||
<SelectItem value="TCP">
|
||||
TCP
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select the protocol used by the
|
||||
target
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
</div>
|
||||
<Button type="submit" variant="gray">
|
||||
<Button type="submit" variant="outline">
|
||||
Add Target
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.column
|
||||
.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
@ -604,13 +562,10 @@ export default function ReverseProxyTargets(props: {
|
|||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
>
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
|
@ -623,26 +578,26 @@ export default function ReverseProxyTargets(props: {
|
|||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No targets. Add a target using
|
||||
the form.
|
||||
No targets. Add a target using the
|
||||
form.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
</TableContainer>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={saveAll}
|
||||
onClick={saveTargets}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Changes
|
||||
Save Targets
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
@ -21,13 +21,13 @@ import {
|
|||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandList
|
||||
} from "@/components/ui/command";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
|
@ -37,7 +37,16 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import CustomDomainInput from "../CustomDomainInput";
|
||||
import ResourceInfoBox from "../ResourceInfoBox";
|
||||
|
@ -47,7 +56,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string(),
|
||||
subdomain: subdomainSchema,
|
||||
subdomain: subdomainSchema
|
||||
// siteId: z.number(),
|
||||
});
|
||||
|
||||
|
@ -72,10 +81,10 @@ export default function GeneralForm() {
|
|||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: resource.name,
|
||||
subdomain: resource.subdomain,
|
||||
subdomain: resource.subdomain
|
||||
// siteId: resource.siteId!,
|
||||
},
|
||||
mode: "onChange",
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -95,7 +104,7 @@ export default function GeneralForm() {
|
|||
`resource/${resource?.resourceId}`,
|
||||
{
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
subdomain: data.subdomain
|
||||
// siteId: data.siteId,
|
||||
}
|
||||
)
|
||||
|
@ -106,13 +115,13 @@ export default function GeneralForm() {
|
|||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
),
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "The resource has been updated successfully",
|
||||
description: "The resource has been updated successfully"
|
||||
});
|
||||
|
||||
updateResource({ name: data.name, subdomain: data.subdomain });
|
||||
|
@ -123,15 +132,19 @@ export default function GeneralForm() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-12 lg:max-w-2xl">
|
||||
<section className="space-y-4">
|
||||
<SettingsSectionTitle
|
||||
title="General Settings"
|
||||
description="Configure the general settings for this resource"
|
||||
size="1xl"
|
||||
/>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this resource
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
|
@ -175,101 +188,29 @@ export default function GeneralForm() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the subdomain that will be
|
||||
used to access the resource.
|
||||
This is the subdomain that will
|
||||
be used to access the resource.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Site</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-[350px] justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(site) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select site"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search sites" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No sites found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
value={
|
||||
site.name
|
||||
}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
This is the site that will be used in
|
||||
the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
form="general-settings-form"
|
||||
>
|
||||
Save Changes
|
||||
Save Settings
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
|
|||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import ResourcesSplashCard from "./ResourcesSplashCard";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -62,6 +63,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ResourcesSplashCard />
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Resources"
|
||||
description="Create secure proxies to your private applications"
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
|
@ -89,7 +90,7 @@ export function ShareLinksDataTable<TData, TValue>({
|
|||
<Plus className="mr-2 h-4 w-4" /> Create Share Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
@ -141,7 +142,7 @@ export function ShareLinksDataTable<TData, TValue>({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
|
70
src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx
Normal 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;
|
|
@ -8,6 +8,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
|
||||
import ShareableLinksSplash from "./ShareLinksSplash";
|
||||
|
||||
type ShareLinksPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -52,6 +53,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ShareableLinksSplash />
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Share Links"
|
||||
description="Create shareable links to grant temporary or permanent access to your resources"
|
||||
|
|
|
@ -36,6 +36,9 @@ import { createApiClient } from "@app/lib/api";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { SiteRow } from "./SitesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
|
@ -274,6 +277,24 @@ PersistentKeepalive = 5`
|
|||
You will only be able to see the configuration once.
|
||||
</span>
|
||||
|
||||
{form.watch("method") === "newt" && (
|
||||
<>
|
||||
<br />
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Newt/install"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
{" "}
|
||||
Learn how to install Newt on your system
|
||||
</span>
|
||||
<ArrowUpRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
|
@ -89,7 +90,7 @@ export function SitesDataTable<TData, TValue>({
|
|||
<Plus className="mr-2 h-4 w-4" /> Add Site
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
@ -141,7 +142,7 @@ export function SitesDataTable<TData, TValue>({
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
|
98
src/app/[orgId]/settings/sites/SitesSplashCard.tsx
Normal 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;
|
|
@ -256,7 +256,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
<Link
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<Button variant={"gray"} className="ml-2">
|
||||
<Button variant={"outline"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
|
|
@ -10,20 +10,29 @@ import {
|
|||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string(),
|
||||
name: z.string()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
@ -39,15 +48,15 @@ export default function GeneralPage() {
|
|||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: site?.name,
|
||||
name: site?.name
|
||||
},
|
||||
mode: "onChange",
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
async function onSubmit(data: GeneralFormValues) {
|
||||
await api
|
||||
.post(`/site/${site?.siteId}`, {
|
||||
name: data.name,
|
||||
name: data.name
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
|
@ -56,7 +65,7 @@ export default function GeneralPage() {
|
|||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the site."
|
||||
),
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -66,18 +75,24 @@ export default function GeneralPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<SettingsSectionTitle
|
||||
title="General Settings"
|
||||
description="Configure the general settings for this site"
|
||||
size="1xl"
|
||||
/>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this site
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
@ -89,16 +104,24 @@ export default function GeneralPage() {
|
|||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the display name of the site
|
||||
This is the display name of the
|
||||
site
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button type="submit" form="general-settings-form">
|
||||
Save Settings
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site";
|
|||
import { AxiosResponse } from "axios";
|
||||
import SitesTable, { SiteRow } from "./SitesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SitesSplashCard from "./SitesSplashCard";
|
||||
|
||||
type SitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -47,6 +48,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SitesSplashCard />
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Sites"
|
||||
description="Allow connectivity to your network through secure tunnels"
|
||||
|
|
|
@ -12,6 +12,7 @@ import LoginForm from "@app/components/LoginForm";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -37,10 +38,20 @@ export default function DashboardLoginForm({
|
|||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to Pangolin</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access your dashboard
|
||||
</CardDescription>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
alt="Pangolin Logo"
|
||||
width="100"
|
||||
height="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Log in to get started</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm
|
||||
|
|
|
@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
|||
import { cache } from "react";
|
||||
import DashboardLoginForm from "./DashboardLoginForm";
|
||||
import { Mail } from "lucide-react";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -16,7 +17,9 @@ export default async function Page(props: {
|
|||
|
||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||
|
||||
const signUpDisabled = process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true";
|
||||
const env = pullEnv();
|
||||
|
||||
const signUpDisabled = env.flags.disableSignupWithoutInvite;
|
||||
|
||||
if (user) {
|
||||
redirect("/");
|
||||
|
|
|
@ -364,8 +364,6 @@ export default function ResetPasswordForm({
|
|||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
|
|
|
@ -38,7 +38,7 @@ export default async function Page(props: {
|
|||
}
|
||||
className="underline"
|
||||
>
|
||||
Go to login
|
||||
Go back to log in
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { cookies } from "next/headers";
|
|||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
||||
import AccessTokenInvalid from "./AccessToken";
|
||||
import AccessToken from "./AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
|
@ -27,6 +28,8 @@ export default async function ResourceAuthPage(props: {
|
|||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
let authInfo: GetResourceAuthInfoResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
|
@ -42,7 +45,9 @@ export default async function ResourceAuthPage(props: {
|
|||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!authInfo) {
|
||||
{/* @ts-ignore */} // TODO: fix this
|
||||
{
|
||||
/* @ts-ignore */
|
||||
} // TODO: fix this
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<ResourceNotFound />
|
||||
|
@ -63,11 +68,7 @@ export default async function ResourceAuthPage(props: {
|
|||
!authInfo.pincode &&
|
||||
!authInfo.whitelist;
|
||||
|
||||
if (
|
||||
user &&
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
) {
|
||||
if (user && !user.emailVerified && env.flags.emailVerificationRequired) {
|
||||
redirect(
|
||||
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`
|
||||
);
|
||||
|
@ -75,7 +76,7 @@ export default async function ResourceAuthPage(props: {
|
|||
|
||||
const allCookies = await cookies();
|
||||
const cookieName =
|
||||
process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`;
|
||||
env.server.resourceSessionCookieName + `_${params.resourceId}`;
|
||||
const sessionId = allCookies.get(cookieName)?.value ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
|
|
|
@ -12,23 +12,24 @@ import {
|
|||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { SignUpResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import Image from "next/image";
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -40,14 +41,18 @@ const formSchema = z
|
|||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
message: "Passwords do not match"
|
||||
});
|
||||
|
||||
export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFormProps) {
|
||||
export default function SignupForm({
|
||||
redirect,
|
||||
inviteId,
|
||||
inviteToken
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
@ -60,8 +65,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
|
|||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
confirmPassword: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
|
@ -109,10 +114,22 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo
|
|||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Create Account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your details to create an account
|
||||
</CardDescription>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
alt="Pangolin Logo"
|
||||
width="100"
|
||||
height="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an account to get started
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
|
@ -8,7 +9,9 @@ export const dynamic = "force-dynamic";
|
|||
export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
|
||||
const env = pullEnv();
|
||||
|
||||
if (!env.flags.emailVerificationRequired) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
|
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
|
@ -15,14 +15,14 @@
|
|||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted: 60 4.8% 85.0%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent: 60 4.8% 90%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--border: 20 5.9% 85%;
|
||||
--input: 20 5.9% 85%;
|
||||
--ring: 24.6 95% 53.1%;
|
||||
--radius: 0.75rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
|
@ -41,11 +41,11 @@
|
|||
--popover-foreground: 60 9.1% 97.8%;
|
||||
--primary: 20.5 90.2% 48.2%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 12 6.5% 25.0%;
|
||||
--secondary: 12 6.5% 15.0%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 25.0%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 25.0%;
|
||||
--accent: 12 2.5% 15.0%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
|
|
@ -1,24 +1,28 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Figtree } from "next/font/google";
|
||||
import { Figtree, Inter } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
description: ""
|
||||
};
|
||||
|
||||
const font = Figtree({ subsets: ["latin"] });
|
||||
// const font = Figtree({ subsets: ["latin"] });
|
||||
const font = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const version = process.env.APP_VERSION;
|
||||
const env = pullEnv();
|
||||
|
||||
const version = env.app.version;
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
|
@ -29,24 +33,12 @@ export default async function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<EnvProvider
|
||||
env={{
|
||||
NEXT_PORT: process.env.NEXT_PORT as string,
|
||||
SERVER_EXTERNAL_PORT: process.env
|
||||
.SERVER_EXTERNAL_PORT as string,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT as string,
|
||||
EMAIL_ENABLED: process.env.EMAIL_ENABLED as string,
|
||||
DISABLE_USER_CREATE_ORG:
|
||||
process.env.DISABLE_USER_CREATE_ORG,
|
||||
DISABLE_SIGNUP_WITHOUT_INVITE:
|
||||
process.env.DISABLE_SIGNUP_WITHOUT_INVITE
|
||||
}}
|
||||
>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
{/* Main content */}
|
||||
<div className="flex-grow">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full mt-12 py-3 mb-4">
|
||||
<footer className="w-full mt-12 py-3 mb-6">
|
||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
|
||||
<div className="whitespace-nowrap">
|
||||
Pangolin
|
||||
|
@ -73,6 +65,16 @@ export default async function RootLayout({
|
|||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://docs.fossorial.io/Pangolin/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
|
|
|
@ -10,6 +10,7 @@ import Link from "next/link";
|
|||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import OrganizationLanding from "./components/OrganizationLanding";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -21,6 +22,8 @@ export default async function Page(props: {
|
|||
}) {
|
||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
|
@ -34,7 +37,7 @@ export default async function Page(props: {
|
|||
|
||||
if (
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
env.flags.emailVerificationRequired
|
||||
) {
|
||||
if (params.redirect) {
|
||||
redirect(`/auth/verify-email?redirect=${params.redirect}`);
|
||||
|
@ -57,7 +60,7 @@ export default async function Page(props: {
|
|||
|
||||
if (!orgs.length) {
|
||||
if (
|
||||
process.env.DISABLE_USER_CREATE_ORG === "false" ||
|
||||
!env.flags.disableUserCreateOrg ||
|
||||
user.serverAdmin
|
||||
) {
|
||||
redirect("/setup");
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
@ -20,12 +21,14 @@ export default async function SetupLayout({
|
|||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
if (!user) {
|
||||
redirect("/?redirect=/setup");
|
||||
}
|
||||
|
||||
if (
|
||||
!(process.env.DISABLE_USER_CREATE_ORG === "false" || user.serverAdmin)
|
||||
!(!env.flags.disableUserCreateOrg || user.serverAdmin)
|
||||
) {
|
||||
redirect("/");
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full border rounded-md">
|
||||
<div className="relative w-full border rounded-md bg-card">
|
||||
<pre
|
||||
ref={textRef}
|
||||
className={`p-4 pr-16 text-sm w-full ${
|
||||
|
@ -38,7 +38,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
|
|||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="absolute top-1 right-1 z-10"
|
||||
className="absolute top-1 right-1 z-10 bg-card"
|
||||
onClick={copyToClipboard}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
|
|
|
@ -90,14 +90,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||
|
||||
const CredenzaContent = isDesktop ? DialogContent : SheetContent;
|
||||
|
||||
return isDesktop ? (
|
||||
<CredenzaContent
|
||||
className={cn("overflow-y-auto max-h-screen", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CredenzaContent>
|
||||
) : (
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn("overflow-y-auto max-h-screen", className)}
|
||||
{...props}
|
||||
|
|
|
@ -224,7 +224,9 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
|||
<div className="h-[250px] mx-auto flex items-center justify-center">
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
<CopyTextBox text={secretUri} wrapText={false} />
|
||||
</div>
|
||||
|
||||
<Form {...confirmForm}>
|
||||
<form
|
||||
|
|
|
@ -48,7 +48,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
|||
<div className="hidden md:block">
|
||||
<div className="flex items-center gap-4 mr-4">
|
||||
<Link
|
||||
href="https://docs.fossorial.io"
|
||||
href="https://docs.fossorial.io/Pangolin/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
|
@ -99,7 +99,7 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
|||
<CommandEmpty>
|
||||
No organizations found.
|
||||
</CommandEmpty>
|
||||
{(env.DISABLE_USER_CREATE_ORG === "false" ||
|
||||
{(!env.flags.disableUserCreateOrg ||
|
||||
user.serverAdmin) && (
|
||||
<>
|
||||
<CommandGroup heading="Create">
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "./ui/input-otp";
|
||||
import Link from "next/link";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import Image from 'next/image'
|
||||
|
||||
type LoginFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -227,8 +228,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
|
|
|
@ -38,7 +38,7 @@ export default function ProfileIcon() {
|
|||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||
|
||||
function getInitials() {
|
||||
return user.email.substring(0, 2).toUpperCase();
|
||||
return user.email.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function handleThemeChange(theme: "light" | "dark" | "system") {
|
||||
|
@ -144,8 +144,8 @@ export default function ProfileIcon() {
|
|||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||
<span>Log Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
31
src/components/Settings.tsx
Normal 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>
|
||||
}
|
|
@ -11,7 +11,7 @@ export default function SettingsSectionTitle({
|
|||
}: SettingsSectionTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-6 md:mb-12" : ""}`}
|
||||
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
|
||||
>
|
||||
<h2
|
||||
className={`text-${
|
||||
|
|
|
@ -88,7 +88,7 @@ export function SidebarNav({
|
|||
</div>
|
||||
<nav
|
||||
className={cn(
|
||||
"hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-3",
|
||||
"hidden lg:flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-3 pr-8",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
|
@ -102,7 +102,7 @@ export function SidebarNav({
|
|||
buttonVariants({ variant: "ghost" }),
|
||||
pathname === hydrateHref(item.href) &&
|
||||
!pathname.includes("create")
|
||||
? "bg-muted hover:bg-muted dark:bg-border dark:hover:bg-border"
|
||||
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
|
||||
: "hover:bg-transparent hover:underline",
|
||||
"justify-start",
|
||||
disabled && "cursor-not-allowed"
|
||||
|
|
|
@ -21,8 +21,8 @@ export function SidebarSettings({
|
|||
limitWidth
|
||||
}: SideBarSettingsProps) {
|
||||
return (
|
||||
<div className="space-y-8 pb-16k">
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-32 lg:space-y-0">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-6 lg:space-y-0">
|
||||
<aside className="lg:w-1/5">
|
||||
<SidebarNav items={sidebarNavItems} disabled={disabled} />
|
||||
</aside>
|
||||
|
|
37
src/components/SwitchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -38,7 +38,7 @@ export function TopbarNav({
|
|||
key={item.href}
|
||||
href={item.href.replace("{orgId}", orgId || "")}
|
||||
className={cn(
|
||||
"relative px-3 py-3 text-md",
|
||||
"relative md:px-3 px-1 py-3 text-md",
|
||||
pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
|
||||
? "border-b-2 border-primary text-primary font-medium"
|
||||
: "hover:text-primary text-muted-foreground font-medium",
|
||||
|
|
|
@ -8,7 +8,7 @@ const alertVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
default: "bg-card text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
|
|
|
@ -6,7 +6,7 @@ import { cn } from "@app/lib/cn";
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
@ -15,11 +15,10 @@ const buttonVariants = cva(
|
|||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-card hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary border border-input text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
gray: "bg-accent text-accent-foreground hover:bg-accent/90",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
@ -27,7 +26,7 @@ const buttonVariants = cva(
|
|||
sm: "h-8 rounded-md px-3",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
|
|
@ -117,8 +117,8 @@ const CommandItem = React.forwardRef<
|
|||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className
|
||||
"relative flex cursor-default select-none items-center px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-base md:file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -7,7 +7,16 @@ import { cn } from "@app/lib/cn"
|
|||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<PopoverPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(className, "rounded-md")}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
|
|
|
@ -19,8 +19,9 @@ const SelectTrigger = React.forwardRef<
|
|||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
"rounded-md"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
|||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
|
||||
"fixed z-50 gap-4 bg-card p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
@ -65,10 +65,10 @@ const SheetContent = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> */}
|
||||
{/* <X className="h-4 w-4" /> */}
|
||||
{/* <span className="sr-only">Close</span> */}
|
||||
{/* </SheetPrimitive.Close> */}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
|
@ -80,7 +80,7 @@ const SheetHeader = ({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-center sm:text-left mb-4",
|
||||
"flex flex-col sm:text-left mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-4 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
|||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block h-3 w-3 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
|
|
@ -2,6 +2,10 @@ import * as React from "react"
|
|||
|
||||
import { cn } from "@app/lib/cn"
|
||||
|
||||
export function TableContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-md bg-card">{children}</div>
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { env } from "@app/lib/types/env";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { createContext } from "react";
|
||||
|
||||
interface EnvContextType {
|
||||
env: env;
|
||||
env: Env;
|
||||
}
|
||||
|
||||
const EnvContext = createContext<EnvContextType | undefined>(undefined);
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { pullEnv } from "../pullEnv";
|
||||
|
||||
export async function authCookieHeader() {
|
||||
const env = pullEnv();
|
||||
|
||||
const allCookies = await cookies();
|
||||
const cookieName = process.env.SESSION_COOKIE_NAME!;
|
||||
const cookieName = env.server.sessionCookieName;
|
||||
const sessionId = allCookies.get(cookieName)?.value ?? null;
|
||||
return {
|
||||
headers: {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { env } from "@app/lib/types/env";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
let apiInstance: AxiosInstance | null = null;
|
||||
|
||||
export function createApiClient({ env }: { env: env }): AxiosInstance {
|
||||
export function createApiClient({ env }: { env: Env }): AxiosInstance {
|
||||
if (apiInstance) {
|
||||
return apiInstance;
|
||||
}
|
||||
|
@ -16,9 +16,9 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
|
|||
let baseURL;
|
||||
const suffix = "api/v1";
|
||||
|
||||
if (window.location.port === env.NEXT_PORT) {
|
||||
if (window.location.port === env.server.nextPort) {
|
||||
// this means the user is addressing the server directly
|
||||
baseURL = `${window.location.protocol}//${window.location.hostname}:${env.SERVER_EXTERNAL_PORT}/${suffix}`;
|
||||
baseURL = `${window.location.protocol}//${window.location.hostname}:${env.server.externalPort}/${suffix}`;
|
||||
axios.defaults.withCredentials = true;
|
||||
} else {
|
||||
// user is accessing through a proxy
|
||||
|
|
|
@ -2,12 +2,15 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { pullEnv } from "../pullEnv";
|
||||
|
||||
export async function verifySession({
|
||||
skipCheckVerifyEmail,
|
||||
}: {
|
||||
skipCheckVerifyEmail?: boolean;
|
||||
} = {}): Promise<GetUserResponse | null> {
|
||||
const env = pullEnv();
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
||||
"/user",
|
||||
|
@ -23,7 +26,7 @@ export async function verifySession({
|
|||
if (
|
||||
!skipCheckVerifyEmail &&
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true"
|
||||
env.flags.emailVerificationRequired
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
31
src/lib/pullEnv.ts
Normal file
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,8 +1,20 @@
|
|||
export type env = {
|
||||
SERVER_EXTERNAL_PORT: string;
|
||||
NEXT_PORT: string;
|
||||
ENVIRONMENT: string;
|
||||
EMAIL_ENABLED: string;
|
||||
DISABLE_SIGNUP_WITHOUT_INVITE?: string;
|
||||
DISABLE_USER_CREATE_ORG?: string;
|
||||
export type Env = {
|
||||
app: {
|
||||
environment: string;
|
||||
version: string;
|
||||
},
|
||||
server: {
|
||||
externalPort: string;
|
||||
nextPort: string;
|
||||
sessionCookieName: string;
|
||||
resourceSessionCookieName: string;
|
||||
},
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
},
|
||||
flags: {
|
||||
disableSignupWithoutInvite: boolean;
|
||||
disableUserCreateOrg: boolean;
|
||||
emailVerificationRequired: boolean;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import EnvContext from "@app/contexts/envContext";
|
||||
import { env } from "@app/lib/types/env";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
|
||||
interface ApiProviderProps {
|
||||
children: React.ReactNode;
|
||||
env: env;
|
||||
env: Env;
|
||||
}
|
||||
|
||||
export function EnvProvider({ children, env }: ApiProviderProps) {
|
||||
|
|