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