diff --git a/Dockerfile b/Dockerfile index 98d3cfc5..2f49d091 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,4 +29,6 @@ COPY --from=builder /app/init ./dist/init COPY config.example.yml ./dist/config.example.yml COPY server/db/names.json ./dist/names.json +COPY public ./public + CMD ["npm", "start"] diff --git a/README.md b/README.md index 8a5d8b5e..694a25cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pangolin -Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and Wireguard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. +Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. ### Installation and Documentation @@ -9,7 +9,7 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity ## Preview -Preview +Preview _Sites page of Pangolin showing multiple site-to-site tunnels connected to the central server._ @@ -25,6 +25,7 @@ _Sites page of Pangolin showing multiple site-to-site tunnels connected to the c ### Identity & Access Management - Centralized authentication system using platform SSO. **Users will only have to manage one login.** +- Totp with backup codes for two-factor authentication. - Create organizations, each with multiple sites, users, and roles. - **Role-based access control** to manage resource access permissions. - Additional authentication options include: @@ -38,6 +39,7 @@ _Sites page of Pangolin showing multiple site-to-site tunnels connected to the c - Manage sites, users, and roles with a clean and intuitive UI. - Monitor site usage and connectivity. - Light and dark mode options. +- Mobile friendly. ### Easy Deployment diff --git a/package.json b/package.json index 483d5e19..14beebbd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "db:push": "npx tsx server/db/migrate.ts", "db:studio": "drizzle-kit studio", "build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs", - "start": "NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", + "start": "NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", "email": "email dev --dir server/emails/templates --port 3005" }, "dependencies": { diff --git a/public/logo/pangolin_black.svg b/public/logo/pangolin_black.svg new file mode 100644 index 00000000..fd2b02ac --- /dev/null +++ b/public/logo/pangolin_black.svg @@ -0,0 +1,38 @@ + + diff --git a/public/logo/pangolin_orange.svg b/public/logo/pangolin_orange.svg new file mode 100644 index 00000000..a8823c9d --- /dev/null +++ b/public/logo/pangolin_orange.svg @@ -0,0 +1,39 @@ + + diff --git a/public/screenshots/auth.png b/public/screenshots/auth.png index 4c7f5889..1bcc35e6 100644 Binary files a/public/screenshots/auth.png and b/public/screenshots/auth.png differ diff --git a/public/screenshots/connectivity.png b/public/screenshots/connectivity.png index 7130d19b..7b6ca88d 100644 Binary files a/public/screenshots/connectivity.png and b/public/screenshots/connectivity.png differ diff --git a/public/screenshots/preview.png b/public/screenshots/preview.png deleted file mode 100644 index 1c780df3..00000000 Binary files a/public/screenshots/preview.png and /dev/null differ diff --git a/public/screenshots/share-link.png b/public/screenshots/share-link.png index b5027e88..7515c8fe 100644 Binary files a/public/screenshots/share-link.png and b/public/screenshots/share-link.png differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png index 0ca84065..eb82212f 100644 Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ diff --git a/public/screenshots/users.png b/public/screenshots/users.png index a6ee97c0..08a8f591 100644 Binary files a/public/screenshots/users.png and b/public/screenshots/users.png differ diff --git a/server/db/names.ts b/server/db/names.ts index a0b37ef7..6976d64a 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -9,7 +9,7 @@ import { __DIRNAME } from "@server/lib/consts"; const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { - file = join("names.json"); + file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index 9a993b24..d7a59608 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -26,7 +26,7 @@ export async function sendEmail( await emailClient.sendMail({ from: { - name: opts.name || "Pangolin Proxy", + name: opts.name || "Pangolin", address: opts.from, }, to: opts.to, diff --git a/server/emails/templates/NotifyResetPassword.tsx b/server/emails/templates/NotifyResetPassword.tsx index 98f2522e..9b136924 100644 --- a/server/emails/templates/NotifyResetPassword.tsx +++ b/server/emails/templates/NotifyResetPassword.tsx @@ -1,16 +1,20 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailText +} from "./components/Email"; interface Props { email: string; @@ -23,41 +27,31 @@ export const ConfirmPasswordReset = ({ email }: Props) => { {previewText} - + - - + + - - Password Reset Confirmation - - - Hi {email || "there"}, - - + Password Reset Confirmation + + Hi {email || "there"}, + + This email confirms that your password has just been reset. If you made this change, no further action is required. - - + + + Thank you for keeping your account secure. - - + + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx index 48669625..a5adf6cf 100644 --- a/server/emails/templates/ResetPasswordCode.tsx +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -1,16 +1,22 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSection, + EmailText +} from "./components/Email"; +import CopyCodeBox from "./components/CopyCodeBox"; interface Props { email: string; @@ -25,50 +31,39 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => { {previewText} - + - - + + - - Password Reset Request - - - Hi {email || "there"}, - - + Password Reset Request + + Hi {email || "there"}, + + You’ve requested to reset your password. Please{" "} click here {" "} and follow the instructions to reset your password, or manually enter the following code: - -
- - {code} - -
- + + + + + + + If you didn’t request this, you can safely ignore this email. - - + + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index 38808f32..9ba5d1b3 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -1,16 +1,22 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { + EmailContainer, + EmailLetterHead, + EmailHeading, + EmailText, + EmailFooter, + EmailSection, + EmailGreeting +} from "./components/Email"; +import { themeColors } from "./lib/theme"; +import CopyCodeBox from "./components/CopyCodeBox"; interface ResourceOTPCodeProps { email?: string; @@ -31,44 +37,34 @@ export const ResourceOTPCode = ({ {previewText} - + - - + + - + Your One-Time Password for {resourceName} - - - Hi {email || "there"}, - - + + + Hi {email || "there"}, + + You’ve requested a one-time password to access{" "} {resourceName} in{" "} {organizationName}. Use the code below to complete your authentication: - -
- - {otp} - -
- + + + + + + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index b41b7214..51138930 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -1,17 +1,22 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind, - Button } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSection, + EmailText +} from "./components/Email"; +import ButtonLink from "./components/ButtonLink"; interface SendInviteLinkProps { email: string; @@ -34,55 +39,42 @@ export const SendInviteLink = ({ {previewText} - + - - + + - - Invited to Join {orgName} - - - Hi {email || "there"}, - - + Invited to Join {orgName} + + Hi {email || "there"}, + + You’ve been invited to join the organization{" "} - {orgName} + {orgName} {inviterName ? ` by ${inviterName}.` : "."} Please access the link below to accept the invite. - - + + + This invite will expire in{" "} - + {expiresInDays}{" "} {expiresInDays === "1" ? "day" : "days"}. - - -
- -
+
+ - + + + Accept Invite to {orgName} + + + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/TwoFactorAuthNotification.tsx b/server/emails/templates/TwoFactorAuthNotification.tsx index b3ff38eb..8a3d72ce 100644 --- a/server/emails/templates/TwoFactorAuthNotification.tsx +++ b/server/emails/templates/TwoFactorAuthNotification.tsx @@ -1,16 +1,20 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailText +} from "./components/Email"; interface Props { email: string; @@ -24,52 +28,44 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { {previewText} - + - - + + - + Two-Factor Authentication{" "} {enabled ? "Enabled" : "Disabled"} - - - Hi {email || "there"}, - - + + + Hi {email || "there"}, + + This email confirms that Two-Factor Authentication has been successfully{" "} {enabled ? "enabled" : "disabled"} on your account. - + + {enabled ? ( - + With Two-Factor Authentication enabled, your account is now more secure. Please ensure you keep your authentication method safe. - + ) : ( - + With Two-Factor Authentication disabled, your account may be less secure. We recommend enabling it to protect your account. - + )} - + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index d197c30e..14a016c3 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -1,16 +1,22 @@ import { Body, - Container, Head, - Heading, Html, Preview, - Section, - Text, Tailwind } from "@react-email/components"; import * as React from "react"; -import LetterHead from "./components/LetterHead"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSection, + EmailText +} from "./components/Email"; +import CopyCodeBox from "./components/CopyCodeBox"; interface VerifyEmailProps { username?: string; @@ -29,47 +35,36 @@ export const VerifyEmail = ({ {previewText} - + - - + + - - Please Verify Your Email - - - Hi {username || "there"}, - - + Please Verify Your Email + + Hi {username || "there"}, + + You’ve requested to verify your email. Please use the code below to complete the verification process upon logging in. - -
- - {verificationCode} - -
- + + + + + + + If you didn’t request this, you can safely ignore this email. - - + + + Best regards,
Fossorial -
-
+ +
diff --git a/server/emails/templates/components/ButtonLink.tsx b/server/emails/templates/components/ButtonLink.tsx new file mode 100644 index 00000000..849c99c0 --- /dev/null +++ b/server/emails/templates/components/ButtonLink.tsx @@ -0,0 +1,18 @@ +export default function ButtonLink({ + href, + children, + className = "" +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx new file mode 100644 index 00000000..686a6444 --- /dev/null +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +export default function CopyCodeBox({ text }: { text: string }) { + return ( +
+ + {text} + +
+ ); +} diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx new file mode 100644 index 00000000..c28ae888 --- /dev/null +++ b/server/emails/templates/components/Email.tsx @@ -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 ( + + {children} + + ); +} + +// EmailLetterHead: For branding or logo at the top +export function EmailLetterHead() { + return ( +
+ + + + + +
+ Pangolin + + {new Date().getFullYear()} +
+
+ ); +} + +// EmailHeading: For the primary message or headline +export function EmailHeading({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function EmailGreeting({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +// EmailText: For general text content +export function EmailText({ + children, + className +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} + +// EmailSection: For visually distinct sections (like OTP) +export function EmailSection({ + children, + className +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +// EmailFooter: For closing or signature +export function EmailFooter({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/server/emails/templates/components/LetterHead.tsx b/server/emails/templates/components/LetterHead.tsx deleted file mode 100644 index 6829b8df..00000000 --- a/server/emails/templates/components/LetterHead.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; - -export function LetterHead() { - return ( - - - - - -
- Pangolin - - {new Date().getFullYear()} -
- ); -} - -export default LetterHead; diff --git a/server/emails/templates/lib/theme.ts b/server/emails/templates/lib/theme.ts new file mode 100644 index 00000000..ada77fd2 --- /dev/null +++ b/server/emails/templates/lib/theme.ts @@ -0,0 +1,9 @@ +export const themeColors = { + theme: { + extend: { + colors: { + primary: "#F97317" + } + } + } +}; diff --git a/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx b/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx index 315d0bcc..16eb73b2 100644 --- a/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx +++ b/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableHeader, TableRow, @@ -88,7 +89,7 @@ export function RolesDataTable({ Add Role -
+ {table.getHeaderGroups().map((headerGroup) => ( @@ -141,7 +142,7 @@ export function RolesDataTable({ )}
-
+
diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index be52f188..346b9fad 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -67,7 +67,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [sendEmail, setSendEmail] = useState(env.EMAIL_ENABLED === "true"); + const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const validFor = [ { hours: 24, name: "1 day" }, @@ -205,7 +205,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { )} /> - {env.EMAIL_ENABLED === "true" && ( + {env.email.emailEnabled && (
({ Invite User
-
+ {table.getHeaderGroups().map((headerGroup) => ( @@ -141,7 +142,7 @@ export function UsersDataTable({ )}
-
+
diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index dbb243ed..5b365e8d 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -159,7 +159,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { const userRow = row.original; return ( -
+
{userRow.isOwner && ( )} @@ -186,7 +186,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { - diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 5df8248c..4305399b 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -6,7 +6,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { @@ -14,7 +14,7 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from "@app/components/ui/select"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -27,14 +27,23 @@ import { ListRolesResponse } from "@server/routers/role"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { useParams } from "next/navigation"; import { Button } from "@app/components/ui/button"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { formatAxiosError } from "@app/lib/api";; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; const formSchema = z.object({ email: z.string().email({ message: "Please enter a valid email" }), - roleId: z.string().min(1, { message: "Please select a role" }), + roleId: z.string().min(1, { message: "Please select a role" }) }); export default function AccessControlsPage() { @@ -52,8 +61,8 @@ export default function AccessControlsPage() { resolver: zodResolver(formSchema), defaultValues: { email: user.email!, - roleId: user.roleId?.toString(), - }, + roleId: user.roleId?.toString() + } }); useEffect(() => { @@ -68,7 +77,7 @@ export default function AccessControlsPage() { description: formatAxiosError( e, "An error occurred while fetching the roles" - ), + ) }); }); @@ -86,9 +95,9 @@ export default function AccessControlsPage() { setLoading(true); const res = await api - .post>( - `/role/${values.roleId}/add/${user.userId}` - ) + .post< + AxiosResponse + >(`/role/${values.roleId}/add/${user.userId}`) .catch((e) => { toast({ variant: "destructive", @@ -96,7 +105,7 @@ export default function AccessControlsPage() { description: formatAxiosError( e, "An error occurred while adding user to the role." - ), + ) }); }); @@ -104,7 +113,7 @@ export default function AccessControlsPage() { toast({ variant: "default", title: "User saved", - description: "The user has been updated.", + description: "The user has been updated." }); } @@ -112,59 +121,70 @@ export default function AccessControlsPage() { } return ( - <> -
- + + + + Access Controls + + Manage what this user can access and do in the + organization + + -
- + + + + ( + + Role + + + + )} + /> + + +
+ + + + - - -
- + Save Access Controls + + + + ); } diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 36cf887e..e71dd337 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -21,7 +21,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { AlertTriangle, Trash2 } from "lucide-react"; import { Card, @@ -33,6 +33,16 @@ import { import { AxiosResponse } from "axios"; import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org"; import { redirect, useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; const GeneralFormSchema = z.object({ name: z.string() @@ -80,10 +90,7 @@ export default function GeneralPage() { async function pickNewOrgAndNavigate() { try { - - const res = await api.get>( - `/orgs` - ); + const res = await api.get>(`/orgs`); if (res.status === 200) { if (res.data.data.orgs.length > 0) { @@ -126,7 +133,7 @@ export default function GeneralPage() { } return ( - <> + { @@ -138,12 +145,10 @@ export default function GeneralPage() { Are you sure you want to delete the organization{" "} {org?.org.name}?

-

This action is irreversible and will delete all associated data.

-

To confirm, type the name of the organization below.

@@ -155,57 +160,75 @@ export default function GeneralPage() { title="Delete Organization" /> -
-
- - ( - - Name - - - - - This is the display name of the org - - - - )} - /> - - - + + + + Organization Settings + + + Manage your organization details and configuration + + - - - - - Danger Zone - - - -

- Once you delete this org, there is no going back. - Please be certain. -

-
- - - -
-
- + + +
+ + ( + + Name + + + + + This is the display name of the + org + + + + )} + /> + + +
+
+ + + + + + + + + + + Danger Zone + + + Once you delete this org, there is no going back. Please + be certain. + + + + + + + +
); } diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 49cbeea8..95a6cc00 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -36,7 +36,7 @@ const topNavItems = [ icon: }, { - title: "Sharable Links", + title: "Shareable Links", href: "/{orgId}/settings/share-links", icon: }, @@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( <> -
+
diff --git a/src/app/[orgId]/settings/page.tsx b/src/app/[orgId]/settings/page.tsx index 142e73ae..9956bc85 100644 --- a/src/app/[orgId]/settings/page.tsx +++ b/src/app/[orgId]/settings/page.tsx @@ -6,7 +6,7 @@ type OrgPageProps = { export default async function SettingsPage(props: OrgPageProps) { const params = await props.params; - redirect(`/${params.orgId}/settings/resources`); + redirect(`/${params.orgId}/settings/sites`); return <>; } diff --git a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx index 2ffad8c1..fdd92fad 100644 --- a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx @@ -16,6 +16,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableHeader, TableRow, @@ -89,7 +90,7 @@ export function ResourcesDataTable({ Add Resource
-
+ {table.getHeaderGroups().map((headerGroup) => ( @@ -141,7 +142,7 @@ export function ResourcesDataTable({ )}
-
+
diff --git a/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx b/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx new file mode 100644 index 00000000..9f857080 --- /dev/null +++ b/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx @@ -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 ( + + + +
+

+ + Resources +

+

+ 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. +

+
    +
  • + + Secure connectivity with WireGuard encryption +
  • +
  • + + Configure multiple authentication methods +
  • +
  • + + User and role-based access control +
  • +
+
+
+
+ ); +}; + +export default ResourcesSplashCard; diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index b9df72b9..e80fa39e 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -210,7 +210,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 73060f2a..a4d8289f 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -60,9 +60,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
- This resource is not protected with any - auth method. Anyone can access this - resource. + Anyone can access this resource.
)} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index ac525207..0f5ff963 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -28,16 +28,26 @@ import { FormMessage } from "@app/components/ui/form"; import { TagInput } from "emblor"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +// import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListUsersResponse } from "@server/routers/user"; import { Switch } from "@app/components/ui/switch"; import { Label } from "@app/components/ui/label"; import { Binary, Key, ShieldCheck } from "lucide-react"; import SetResourcePasswordForm from "./SetResourcePasswordForm"; -import { Separator } from "@app/components/ui/separator"; import SetResourcePincodeForm from "./SetResourcePincodeForm"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionTitle, + SettingsSectionHeader, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -382,328 +392,80 @@ export default function ResourceAuthenticationPage() { /> )} -
-
- + + + + + Users & Roles + + + Configure which users and roles can visit this + resource + + + + setSsoEnabled(val)} + /> -
-
- setSsoEnabled(val)} - /> - -
- - Existing users will only have to login once for all - resources that have this enabled. - -
- -
- - {ssoEnabled && ( - <> - ( - - Roles - - {/* @ts-ignore */} - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - styleClasses={{ - tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" - }, - input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", - inlineTagsContainer: - "bg-transparent p-2" - }} - /> - - - These roles will be able to - access this resource. Admins - can always access this - resource. - - - - )} - /> - ( - - Users - - {/* @ts-ignore */} - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - styleClasses={{ - tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" - }, - input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", - inlineTagsContainer: - "bg-transparent p-2" - }} - /> - - - Users added here will be - able to access this - resource. A user will always - have access to a resource if - they have a role that has - access to it. - - - - )} - /> - - )} - - - -
- - - -
- - -
-
-
- - - Password Protection{" "} - {authInfo?.password - ? "Enabled" - : "Disabled"} - -
- {authInfo?.password ? ( - - ) : ( - - )} -
- -
-
- - - PIN Code Protection{" "} - {authInfo?.pincode ? "Enabled" : "Disabled"} - -
- {authInfo?.pincode ? ( - - ) : ( - - )} -
-
-
- - - -
- {env.EMAIL_ENABLED === "true" && ( - <> -
-
- - setWhitelistEnabled(val) - } - /> - -
- - Enable resource whitelist to require - email-based authentication (one-time - passwords) for resource access. - -
- - {whitelistEnabled && ( -
- + {ssoEnabled && ( + <> ( - - Whitelisted Emails - + Roles {/* @ts-ignore */} { - return z - .string() - .email() - .safeParse( - tag - ).success; - }} setActiveTagIndex={ - setActiveEmailTagIndex + setActiveRolesTagIndex } - placeholder="Enter an email" + placeholder="Enter a role" tags={ - whitelistForm.getValues() - .emails + usersRolesForm.getValues() + .roles } setTags={( newRoles ) => { - whitelistForm.setValue( - "emails", + usersRolesForm.setValue( + "roles", newRoles as [ Tag, ...Tag[] ] ); }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } allowDuplicates={ false } + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} styleClasses={{ tag: { @@ -715,24 +477,271 @@ export default function ResourceAuthenticationPage() { }} /> + + These roles will be able + to access this resource. + Admins can always access + this resource. + + )} /> - - - )} + ( + + Users + + {/* @ts-ignore */} + { + usersRolesForm.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + styleClasses={{ + tag: { + body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" + }, + input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", + inlineTagsContainer: + "bg-transparent p-2" + }} + /> + + + Users added here will be + able to access this + resource. A user will + always have access to a + resource if they have a + role that has access to + it. + + + + )} + /> + + )} + + + + + + + - - - )} -
-
+
+ + {/* PIN Code Protection */} +
+
+ + + PIN Code Protection{" "} + {authInfo.pincode ? "Enabled" : "Disabled"} + +
+ +
+ + + + + + + One-time Passwords + + + Require email-based authentication for resource + access + + + + {env.email.emailEnabled && ( + <> + + + {whitelistEnabled && ( +
+ + ( + + + Whitelisted Emails + + + {/* @ts-ignore */} + {/* @ts-ignore */} + { + return z + .string() + .email() + .safeParse( + tag + ) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder="Enter an email" + tags={ + whitelistForm.getValues() + .emails + } + setTags={( + newRoles + ) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={ + false + } + sortTags={true} + styleClasses={{ + tag: { + body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" + }, + input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", + inlineTagsContainer: + "bg-transparent p-2" + }} + /> + + + )} + /> + + + )} + + )} +
+ + + +
+ ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 14adbec1..c01cbd5d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -40,28 +40,34 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { useToast } from "@app/hooks/useToast"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ArrayElement } from "@server/types/ArrayElement"; -import { formatAxiosError } from "@app/lib/api/formatAxiosError";; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { GetSiteResponse } from "@server/routers/site"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; const addTargetSchema = z.object({ ip: z.string().ip(), method: z.string(), - port: z - .string() - .refine((val) => !isNaN(Number(val)), { - message: "Port must be a number" - }) - .transform((val) => Number(val)) + port: z.coerce.number().int().positive() // protocol: z.string(), }); @@ -99,7 +105,7 @@ export default function ReverseProxyTargets(props: { defaultValues: { ip: "", method: "http", - port: "80" + port: 80 // protocol: "TCP", } }); @@ -154,7 +160,7 @@ export default function ReverseProxyTargets(props: { fetchSite(); }, []); - async function addTarget(data: AddTargetFormValues) { + async function addTarget(data: z.infer) { // Check if target with same IP, port and method already exists const isDuplicate = targets.some( (target) => @@ -218,16 +224,10 @@ export default function ReverseProxyTargets(props: { ); } - async function saveAll() { + async function saveTargets() { try { setLoading(true); - const res = await api.post(`/resource/${params.resourceId}`, { - ssl: sslEnabled - }); - - updateResource({ ssl: sslEnabled }); - for (let target of targets) { const data = { ip: target.ip, @@ -269,8 +269,8 @@ export default function ReverseProxyTargets(props: { } toast({ - title: "Resource updated", - description: "Resource and targets updated successfully" + title: "Targets updated", + description: "Targets updated successfully" }); setTargetsToRemove([]); @@ -289,6 +289,20 @@ export default function ReverseProxyTargets(props: { setLoading(false); } + async function saveSsl(val: boolean) { + const res = await api.post(`/resource/${params.resourceId}`, { + ssl: val + }); + + setSslEnabled(val); + updateResource({ ssl: sslEnabled }); + + toast({ + title: "SSL Configuration", + description: "SSL configuration updated successfully" + }); + } + const columns: ColumnDef[] = [ { accessorKey: "method", @@ -410,239 +424,180 @@ export default function ReverseProxyTargets(props: { } return ( - <> -
-
- + {/* SSL Section */} + + + + SSL Configuration + + + Setup SSL to secure your connections with LetsEncrypt + certificates + + + + { + await saveSsl(val); + }} /> + + -
- setSslEnabled(val)} - /> - -
-
- -
- -
- - -
-
- -
- ( - - Method - - - - {/* */} - {/* Choose the method for how */} - {/* the target is accessed. */} - {/* */} - - - )} - /> - ( - - - IP Address - - - - - {/* */} - {/* Use the IP of the resource on your private network if using Newt, or the peer IP if using raw WireGuard. */} - {/* */} - - - )} - /> - ( - - Port - - - - {/* */} - {/* Specify the port number for */} - {/* the target. */} - {/* */} - - - )} - /> - {/* + + + Target Configuration + + + Setup targets to route traffic to your services + + + + + +
+ ( - Protocol + Method - - Select the protocol used by the - target - )} - /> */} -
- - - - -
- - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ) - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - No targets. Add a target using - the form. - - + /> + ( + + IP Address + + + + + )} - -
-
+ /> + ( + + Port + + + + + + )} + /> +
+ + + - -
-
-
- + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No targets. Add a target using the + form. + + + )} + +
+
+ + + + + + ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 2b848447..aee39f54 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -11,7 +11,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { Input } from "@/components/ui/input"; @@ -21,13 +21,13 @@ import { CommandGroup, CommandInput, CommandItem, - CommandList, + CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, - PopoverTrigger, + PopoverTrigger } from "@/components/ui/popover"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ListSitesResponse } from "@server/routers/site"; @@ -37,7 +37,16 @@ import { useParams, useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { useToast } from "@app/hooks/useToast"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; import { useOrgContext } from "@app/hooks/useOrgContext"; import CustomDomainInput from "../CustomDomainInput"; import ResourceInfoBox from "../ResourceInfoBox"; @@ -47,7 +56,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; const GeneralFormSchema = z.object({ name: z.string(), - subdomain: subdomainSchema, + subdomain: subdomainSchema // siteId: z.number(), }); @@ -72,10 +81,10 @@ export default function GeneralForm() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: resource.name, - subdomain: resource.subdomain, + subdomain: resource.subdomain // siteId: resource.siteId!, }, - mode: "onChange", + mode: "onChange" }); useEffect(() => { @@ -95,7 +104,7 @@ export default function GeneralForm() { `resource/${resource?.resourceId}`, { name: data.name, - subdomain: data.subdomain, + subdomain: data.subdomain // siteId: data.siteId, } ) @@ -106,13 +115,13 @@ export default function GeneralForm() { description: formatAxiosError( e, "An error occurred while updating the resource" - ), + ) }); }) .then(() => { toast({ title: "Resource updated", - description: "The resource has been updated successfully", + description: "The resource has been updated successfully" }); updateResource({ name: data.name, subdomain: data.subdomain }); @@ -123,153 +132,85 @@ export default function GeneralForm() { } return ( - <> -
-
- + + + + + General Settings + + + Configure the general settings for this resource + + -
- - ( - - Name - - - - - This is the display name of the - resource. - - - - )} - /> - - ( - - Subdomain - - - form.setValue( - "subdomain", - value - ) - } - /> - - - This is the subdomain that will be - used to access the resource. - - - - )} - /> - {/* ( - - Site - - - - - - - - - - - - No sites found. - - - {sites.map((site) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - {site.name} - - ))} - - - - - - - This is the site that will be used in - the dashboard. - - - - )} - /> */} - - - -
-
- + ( + + Name + + + + + This is the display name of the + resource. + + + + )} + /> + + ( + + Subdomain + + + form.setValue( + "subdomain", + value + ) + } + /> + + + This is the subdomain that will + be used to access the resource. + + + + )} + /> + + + + + + + + + + ); } diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 23251494..c8348b5d 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -8,6 +8,7 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; +import ResourcesSplashCard from "./ResourcesSplashCard"; type ResourcesPageProps = { params: Promise<{ orgId: string }>; @@ -62,6 +63,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) { return ( <> + + ({ Create Share Link
-
+ {table.getHeaderGroups().map((headerGroup) => ( @@ -141,7 +142,7 @@ export function ShareLinksDataTable({ )}
-
+
diff --git a/src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx b/src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx new file mode 100644 index 00000000..decaafdf --- /dev/null +++ b/src/app/[orgId]/settings/share-links/ShareLinksSplash.tsx @@ -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 ( + + + +
+

+ + Shareable Links +

+

+ 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. +

+
    +
  • + + Easy to create and share +
  • +
  • + + Configurable expiration duration +
  • +
  • + + Secure and revocable +
  • +
+
+
+
+ ); +}; + +export default ShareableLinksSplash; diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index 6e74afc4..e09a6b54 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -8,6 +8,7 @@ import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable"; +import ShareableLinksSplash from "./ShareLinksSplash"; type ShareLinksPageProps = { params: Promise<{ orgId: string }>; @@ -52,6 +53,8 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) { return ( <> + + + {form.watch("method") === "newt" && ( + <> +
+ + + {" "} + Learn how to install Newt on your system + + + + + )} +
({ Add Site
-
+ {table.getHeaderGroups().map((headerGroup) => ( @@ -141,7 +142,7 @@ export function SitesDataTable({ )}
-
+
diff --git a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx new file mode 100644 index 00000000..6b66f615 --- /dev/null +++ b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx @@ -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 ( + + + +
+

+ + Newt (Recommended) +

+

+ 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. +

+
    +
  • + + Runs in Docker +
  • +
  • + + Runs in shell on macOS, Linux, and Windows +
  • +
+ +
+
+

+ Basic WireGuard +

+

+ Use any WireGuard client to connect. You will have to + address your internal resources using the peer IP. +

+
    +
  • + + Compatible with all WireGuard clients +
  • +
  • + + Manual configuration required +
  • +
+
+
+
+ ); +}; + +export default SitesSplashCard; diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 7f431645..f4361177 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -256,7 +256,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { - diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 97b115dc..95f025cf 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -10,20 +10,29 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { useForm } from "react-hook-form"; import { useToast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { formatAxiosError } from "@app/lib/api";; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; const GeneralFormSchema = z.object({ - name: z.string(), + name: z.string() }); type GeneralFormValues = z.infer; @@ -39,15 +48,15 @@ export default function GeneralPage() { const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: site?.name, + name: site?.name }, - mode: "onChange", + mode: "onChange" }); async function onSubmit(data: GeneralFormValues) { await api .post(`/site/${site?.siteId}`, { - name: data.name, + name: data.name }) .catch((e) => { toast({ @@ -56,7 +65,7 @@ export default function GeneralPage() { description: formatAxiosError( e, "An error occurred while updating the site." - ), + ) }); }); @@ -66,39 +75,53 @@ export default function GeneralPage() { } return ( - <> -
- + + + + + General Settings + + + Configure the general settings for this site + + -
- - ( - - Name - - - - - This is the display name of the site - - - - )} - /> - - - -
- + + +
+ + ( + + Name + + + + + This is the display name of the + site + + + + )} + /> + + +
+
+ + + + + + ); } diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 87aa2ad2..c5d83ce2 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "./SitesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import SitesSplashCard from "./SitesSplashCard"; type SitesPageProps = { params: Promise<{ orgId: string }>; @@ -47,6 +48,8 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> + + - Welcome to Pangolin - - Enter your credentials to access your dashboard - +
+ Pangolin Logo +
+
+

+ Welcome to Pangolin +

+

Log in to get started

+
- - diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index 64d0c1b0..b5636c42 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -38,7 +38,7 @@ export default async function Page(props: { } className="underline" > - Go to login + Go back to log in

diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 79d57b83..886801c7 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -16,6 +16,7 @@ import { cookies } from "next/headers"; import { CheckResourceSessionResponse } from "@server/routers/auth"; import AccessTokenInvalid from "./AccessToken"; import AccessToken from "./AccessToken"; +import { pullEnv } from "@app/lib/pullEnv"; export default async function ResourceAuthPage(props: { params: Promise<{ resourceId: number }>; @@ -27,6 +28,8 @@ export default async function ResourceAuthPage(props: { const params = await props.params; const searchParams = await props.searchParams; + const env = pullEnv(); + let authInfo: GetResourceAuthInfoResponse | undefined; try { const res = await internal.get< @@ -42,7 +45,9 @@ export default async function ResourceAuthPage(props: { const user = await getUser({ skipCheckVerifyEmail: true }); if (!authInfo) { - {/* @ts-ignore */} // TODO: fix this + { + /* @ts-ignore */ + } // TODO: fix this return (
@@ -63,11 +68,7 @@ export default async function ResourceAuthPage(props: { !authInfo.pincode && !authInfo.whitelist; - if ( - user && - !user.emailVerified && - process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true" - ) { + if (user && !user.emailVerified && env.flags.emailVerificationRequired) { redirect( `/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}` ); @@ -75,7 +76,7 @@ export default async function ResourceAuthPage(props: { const allCookies = await cookies(); const cookieName = - process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`; + env.server.resourceSessionCookieName + `_${params.resourceId}`; const sessionId = allCookies.get(cookieName)?.value ?? null; if (sessionId) { diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index ae503dd6..9630d907 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -12,23 +12,24 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { Card, CardContent, CardDescription, CardHeader, - CardTitle, + CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { SignUpResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { passwordSchema } from "@server/auth/passwordSchema"; import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import Image from "next/image"; type SignupFormProps = { redirect?: string; @@ -40,14 +41,18 @@ const formSchema = z .object({ email: z.string().email({ message: "Invalid email address" }), password: passwordSchema, - confirmPassword: passwordSchema, + confirmPassword: passwordSchema }) .refine((data) => data.password === data.confirmPassword, { path: ["confirmPassword"], - message: "Passwords do not match", + message: "Passwords do not match" }); -export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFormProps) { +export default function SignupForm({ + redirect, + inviteId, + inviteToken +}: SignupFormProps) { const router = useRouter(); const api = createApiClient(useEnvContext()); @@ -60,8 +65,8 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo defaultValues: { email: "", password: "", - confirmPassword: "", - }, + confirmPassword: "" + } }); async function onSubmit(values: z.infer) { @@ -109,10 +114,22 @@ export default function SignupForm({ redirect, inviteId, inviteToken }: SignupFo return ( - Create Account - - Enter your details to create an account - +
+ Pangolin Logo +
+
+

+ Welcome to Pangolin +

+

+ Create an account to get started +

+
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index e3e8fe98..f53ff2c8 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,5 +1,6 @@ import SignupForm from "@app/app/auth/signup/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; import { Mail } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -14,9 +15,11 @@ export default async function Page(props: { const getUser = cache(verifySession); const user = await getUser(); + const env = pullEnv(); + const isInvite = searchParams?.redirect?.includes("/invite"); - if (process.env.DISABLE_SIGNUP_WITHOUT_INVITE === "true" && !isInvite) { + if (env.flags.disableSignupWithoutInvite && !isInvite) { redirect("/"); } diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 2413b003..3452df69 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,5 +1,6 @@ import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; import { redirect } from "next/navigation"; import { cache } from "react"; @@ -8,7 +9,9 @@ export const dynamic = "force-dynamic"; export default async function Page(props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") { + const env = pullEnv(); + + if (!env.flags.emailVerificationRequired) { redirect("/"); } diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fea..0ffb1c54 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css index bd45057d..bcb47510 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -15,14 +15,14 @@ --primary-foreground: 60 9.1% 97.8%; --secondary: 60 4.8% 95.9%; --secondary-foreground: 24 9.8% 10%; - --muted: 60 4.8% 95.9%; + --muted: 60 4.8% 85.0%; --muted-foreground: 25 5.3% 44.7%; - --accent: 60 4.8% 95.9%; + --accent: 60 4.8% 90%; --accent-foreground: 24 9.8% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 60 9.1% 97.8%; - --border: 20 5.9% 90%; - --input: 20 5.9% 90%; + --border: 20 5.9% 85%; + --input: 20 5.9% 85%; --ring: 24.6 95% 53.1%; --radius: 0.75rem; --chart-1: 12 76% 61%; @@ -41,11 +41,11 @@ --popover-foreground: 60 9.1% 97.8%; --primary: 20.5 90.2% 48.2%; --primary-foreground: 60 9.1% 97.8%; - --secondary: 12 6.5% 25.0%; + --secondary: 12 6.5% 15.0%; --secondary-foreground: 60 9.1% 97.8%; --muted: 12 6.5% 25.0%; --muted-foreground: 24 5.4% 63.9%; - --accent: 12 6.5% 25.0%; + --accent: 12 2.5% 15.0%; --accent-foreground: 60 9.1% 97.8%; --destructive: 0 72.2% 50.6%; --destructive-foreground: 60 9.1% 97.8%; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5e52d184..4c2f6e2a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,24 +1,28 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Figtree } from "next/font/google"; +import { Figtree, Inter } from "next/font/google"; import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { Separator } from "@app/components/ui/separator"; +import { pullEnv } from "@app/lib/pullEnv"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, description: "" }; -const font = Figtree({ subsets: ["latin"] }); +// const font = Figtree({ subsets: ["latin"] }); +const font = Inter({ subsets: ["latin"] }); export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode; }>) { - const version = process.env.APP_VERSION; + const env = pullEnv(); + + const version = env.app.version; return ( @@ -29,24 +33,12 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > - + {/* Main content */}
{children}
{/* Footer */} -