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 33bc90b1..056cc162 100644 --- a/README.md +++ b/README.md @@ -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._ diff --git a/package.json b/package.json index 0b5efeef..3a3380e4 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/roles.png b/public/screenshots/roles.png new file mode 100644 index 00000000..08a8f591 Binary files /dev/null and b/public/screenshots/roles.png 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..c31ed223 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 deleted file mode 100644 index a6ee97c0..00000000 Binary files a/public/screenshots/users.png and /dev/null 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/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 */} -