diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..802c003f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + groups: + dev-patch-updates: + dependency-type: "development" + update-types: + - "patch" + dev-minor-updates: + dependency-type: "development" + update-types: + - "minor" + prod-patch-updates: + dependency-type: "production" + update-types: + - "patch" + prod-minor-updates: + dependency-type: "production" + update-types: + - "minor" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + groups: + patch-updates: + update-types: + - "patch" + minor-updates: + update-types: + - "minor" diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 00000000..750f9ecc --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,34 @@ +name: ESLint + +on: + pull_request: + paths: + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '.eslintrc*' + - 'package.json' + - 'yarn.lock' + - 'pnpm-lock.yaml' + - 'package-lock.json' + +jobs: + Linter: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: | + npm ci + + - name: Run ESLint + run: | + npx eslint . --ext .js,.jsx,.ts,.tsx \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6ec9e23d..adfe2597 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,11 @@ RUN npm install COPY . . -RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/schemas/ --out init +RUN echo 'export * from "./sqlite";' > server/db/index.ts -RUN npm run build +RUN npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema.ts --out init + +RUN npm run build:sqlite FROM node:20-alpine AS runner @@ -32,4 +34,4 @@ COPY server/db/names.json ./dist/names.json COPY public ./public -CMD ["npm", "start"] +CMD ["npm", "run", "start:sqlite"] diff --git a/Dockerfile.pg b/Dockerfile.pg new file mode 100644 index 00000000..58c54d8c --- /dev/null +++ b/Dockerfile.pg @@ -0,0 +1,37 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# COPY package.json package-lock.json ./ +COPY package.json ./ +RUN npm install + +COPY . . + +RUN echo 'export * from "./pg";' > server/db/index.ts + +RUN npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema.ts --out init + +RUN npm run build:pg + +FROM node:20-alpine AS runner + +WORKDIR /app + +# Curl used for the health checks +RUN apk add --no-cache curl + +# COPY package.json package-lock.json ./ +COPY package.json ./ +RUN npm install --only=production && npm cache clean --force + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/init ./dist/init + +COPY server/db/names.json ./dist/names.json + +COPY public ./public + +CMD ["npm", "run", "start:pg"] diff --git a/Makefile b/Makefile index 793a3481..fdf5daa1 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ build-release: fi docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push . docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push . + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-latest -f Dockerfile.pg --push . + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:postgresql-$(tag) -f Dockerfile.pg --push . build-arm: docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . diff --git a/README.md b/README.md index 2d5fb7a7..8723542c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@
- Are you sure you want to remove the invitation for{" "} - {selectedInvitation?.email}? + {t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})}
- Once removed, this invitation will no longer be - valid. You can always re-invite the user later. + {t('inviteMessageRemove')}
- To confirm, please type the email address of the - invitation below. + {t('inviteMessageConfirm')}
- Are you sure you want to regenerate the - invitation for {invitation?.email}? This - will revoke the previous invitation. + {t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
- Are you sure you want to delete the organization{" "} - {org?.org.name}? + {t('orgQuestionRemove', {selectedOrg: org?.org.name})}
- This action is irreversible and will delete all - associated data. + {t('orgMessageRemove')}
- To confirm, type the name of the organization below. + {t('orgMessageConfirm')}
- Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network. - Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel. + {t('resourcesDescription')}
- Are you sure you want to remove the resource{" "} - - {selectedResource?.name || - selectedResource?.id} - {" "} - from the organization? + {t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
- Once removed, the resource will no longer be - accessible. All targets attached to the resource - will be removed. + {t('resourceMessageRemove')}
- To confirm, please type the name of the resource - below. + {t('resourceMessageConfirm')}
- Rules allow you to control access to your resource - based on a set of criteria. You can create rules to - allow or deny access based on IP address or URL - path. + {t('rulesAboutDescription')}
- Your access token can be passed in two ways: as a query - parameter or in the request headers. These must be passed - from the client on every request for authenticated access. + {t('shareTokenDescription')}
- Expiration time is how long the - link will be usable and provide - access to the resource. After - this time, the link will no - longer work, and users who used - this link will lose access to - the resource. + {t('shareExpireDescription')}
- You will only be able to see this link - once. Make sure to copy it. + {t('shareSeeOnce')}
- Anyone with this link can access the - resource. Share it with care. + {t('shareAccessHint')}
- 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. + {t('shareDescription2')}
Loading WireGuard configuration...
+{t('siteLoadWGConfig')}
) : form.watch("method") === "newt" && siteDefaults ? ( <>- 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. + {t('siteNewtDescription')}
- Use any WireGuard client to connect. You will have to - address your internal resources using the peer IP. + {t('siteWgAnyClients')}
- Are you sure you want to remove the site{" "} - {selectedSite?.name || selectedSite?.id}{" "} - from the organization? + {t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})} +
+ ++ {t('siteMessageRemove')}
- Once removed, the site will no longer be - accessible.{" "} - - All resources and targets associated with - the site will also be removed. - -
- -- To confirm, please type the name of the site - below. + {t('siteMessageConfirm')}
- Operating System + {t('operatingSystem')}
- Commands + {t('commands')}
- Are you sure you want to remove the API key{" "} - {selected?.name || selected?.id}? + {t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
- Once removed, the API key will no longer be - able to be used. + {t('apiKeysMessageRemove')}
- To confirm, please type the name of the API key - below. + {t('apiKeysMessageConfirm')}
- Are you sure you want to permanently delete the - identity provider {selectedIdp.name}? + {t('idpQuestionRemove', {name: selectedIdp.name})}
- This will remove the identity provider and - all associated configurations. Users who - authenticate through this provider will no - longer be able to log in. + {t('idpMessageRemove')}
- To confirm, please type the name of the identity - provider below. + {t('idpMessageConfirm')}
- For the most up-to-date pricing and discounts, - please visit the{" "} + {t('licensePricingPage')} - pricing page + {t('pricingPage')} .
@@ -120,10 +119,10 @@ export function SitePriceCalculator({- Are you sure you want to delete the license key{" "} - - {obfuscateLicenseKey( - selectedLicenseKey.licenseKey - )} - - ? + {t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
- This will remove the license key and all - associated permissions granted by it. + {t('licenseMessageRemove')}
- To confirm, please type the license key below. + {t('licenseMessageConfirm')}
- There is no limit on the number of sites - using an unlicensed host. + {t('licenseNoSiteLimit')}
)} {licenseStatus?.maxSites && (- Are you sure you want to permanently delete{" "} - - {selected?.email || - selected?.name || - selected?.username} - {" "} - from the server? + {t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
- The user will be removed from all - organizations and be completely removed from - the server. + {t('userMessageRemove')}
- To confirm, please type the name of the user - below. + {t('userMessageConfirm')}
- Don't have an account?{" "} + {t('authNoAccount')}{" "} - Sign up + {t('signup')}
)} diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 7ddac325..8262c738 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -44,27 +44,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { passwordSchema } from "@server/auth/passwordSchema"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { useTranslations } from "next-intl"; const requestSchema = z.object({ email: z.string().email() }); -const formSchema = z - .object({ - email: z.string().email({ message: "Invalid email address" }), - token: z.string().min(8, { message: "Invalid token" }), - password: passwordSchema, - confirmPassword: passwordSchema - }) - .refine((data) => data.password === data.confirmPassword, { - path: ["confirmPassword"], - message: "Passwords do not match" - }); - -const mfaSchema = z.object({ - code: z.string().length(6, { message: "Invalid code" }) -}); - export type ResetPasswordFormProps = { emailParam?: string; tokenParam?: string; @@ -81,6 +66,7 @@ export default function ResetPasswordForm({ const [error, setError] = useState- Create an account to get started + {t('authCreateAccount')}
- To accept the invite, you must log in or create an - account. + {t('inviteAlreadyDescription')}
- Already have an account?{" "} + {t('signupQuestion')}{" "} - Log in + {t('login')}
> diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index 7d68263e..cbe1e5fb 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -37,13 +37,7 @@ import { formatAxiosError } from "@app/lib/api";; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cleanRedirect } from "@app/lib/cleanRedirect"; - -const FormSchema = z.object({ - email: z.string().email({ message: "Invalid email address" }), - pin: z.string().min(8, { - message: "Your verification code must be 8 characters.", - }), -}); +import { useTranslations } from "next-intl"; export type VerifyEmailFormProps = { email: string; @@ -55,6 +49,7 @@ export default function VerifyEmailForm({ redirect, }: VerifyEmailFormProps) { const router = useRouter(); + const t = useTranslations(); const [error, setError] = useState- Invalid or expired license keys detected. Follow license - terms to continue using all features. + {t('componentsInvalidKey')}
- License Violation: This server is using{" "} - {licenseStatus.usedSites} sites which exceeds its - licensed limit of {licenseStatus.maxSites} sites. Follow - license terms to continue using all features. + {t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})}
- You are not currently a member of any organizations. + t('componentsErrorNoMember')
) : ( @@ -64,7 +66,7 @@ export default function OrganizationLanding({ size="lg" >- We're sorry, but it looks like the invite you're trying - to access has not been accepted or is no longer valid. + {t('inviteErrorNotValid')}
- We're sorry, but it looks like the invite you're trying - to access is not for this user. + {t('inviteErrorUser')}
- Please make sure you're logged in as the correct user. + {t('inviteLoginUser')}
- We're sorry, but it looks like the invite you're trying - to access is not for a user that exists. + {t('inviteErrorNoUser')}
- Please create an account first. + {t('inviteCreateUser')}
The invite link is invalid.
+{t('inviteInvalidDescription')}
> ); } @@ -52,15 +54,13 @@ export default async function InvitePage(props: { } function cardType() { - if (error.includes("Invite is not for this user")) { + if (error.includes(t('inviteErrorWrongUser'))) { return "wrong_user"; } else if ( - error.includes( - "User does not exist. Please create an account first." - ) + error.includes(t('inviteErrorUserNotExists')) ) { return "user_does_not_exist"; - } else if (error.includes("You must be logged in to accept an invite")) { + } else if (error.includes(t('inviteErrorLoginRequired'))) { return "not_logged_in"; } else { return "rejected"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 22b478be..dd02c489 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,8 @@ import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; import { GetLicenseStatusResponse } from "@server/routers/license"; import LicenseViolation from "./components/LicenseViolation"; import { cache } from "react"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale } from "next-intl/server"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -30,6 +32,7 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const env = pullEnv(); + const locale = await getLocale(); let supporterData = { visible: true @@ -50,31 +53,33 @@ export default async function RootLayout({ const licenseStatus = licenseStatusRes.data.data; return ( - + -- Oops! The page you're looking for doesn't exist. + {t('pageNotFoundDescription')}
- This feature is only available in the Professional - Edition. + {t('licenseTierProfessionalRequiredDescription')}
- Signed in as + {t('signingAs')}
{user.email || user.name || user.username} @@ -100,11 +105,11 @@ export default function ProfileIcon() {
- Server Admin + {t('serverAdmin')}
) : (- {user.idpName || "Internal"} + {user.idpName || t('idpNameInternal')}
)}- Purchase a supporter key to help us continue - developing Pangolin for the community. Your - contribution allows us to commit more time to - maintain and add new features to the application for - everyone. We will never use this to paywall - features. This is separate from any Commercial - Edition. + {t('supportKeyDescription')}
- You will also get to adopt and meet your very own - pet Pangolin! + {t('supportKeyPet')}
- Payments are processed via GitHub. Afterward, you - can retrieve your key on{" "} + {t('supportKeyPurchase')}{" "} - our website + {t('supportKeyPurchaseLink')} {" "} - and redeem it here.{" "} + {t('supportKeyPurchase2')}{" "} - Learn more. + {t('supportKeyLearnMore')}
- Please select the option that best suits you. + {t('supportKeyOptions')}
$95
@@ -239,19 +232,19 @@ export default function SupporterStatus() {$25
@@ -282,19 +275,19 @@ export default function SupporterStatus() {- These are the tags you've entered. + {t('tagsEnteredDescription')}