Add first i18n stuff

This commit is contained in:
Lokowitz 2025-05-04 15:11:42 +00:00
parent 21f1326045
commit 7eb08474ff
35 changed files with 2629 additions and 759 deletions

3
crowdin.yml Normal file
View file

@ -0,0 +1,3 @@
files:
- source: /messages/en-US.json
translation: /messages/%locale%.json

22
messages/de-DE.json Normal file
View file

@ -0,0 +1,22 @@
{
"locales": {
"label": "Sprache",
"en-US": "Englisch",
"de-DE": "Deutsch"
},
"setupCreate": "Erstelle deine Organisation, Seite und Ressourcen",
"setupNewOrg": "Neue Organisation",
"setupCreateOrg": "Organisation erstellen",
"setupCreateSite": "Seite erstellen",
"setupCreateResources": "Ressource erstellen",
"setupOrgName": "Organisation's Name",
"setupDisplayName": "Dies ist der Anzeigename für Ihre Organisation.",
"setupOrgId": "Organisations-ID",
"setupIdentifierMessage": "Dies ist der eindeutige Bezeichner für Ihre Organisation. Dies ist getrennt vom Anzeigenamen.",
"setupErrorIdentifier": "Organisations-ID ist bereits vergeben. Bitte wählen Sie eine andere.",
"componentsErrorNoMemberCreate": "Sie sind derzeit kein Mitglied einer Organisation. Erstellen Sie eine Organisation, um loszulegen.",
"componentsErrorNoMember": "Du bist aktuell kein Mitglied einer Organisation.",
"welcome": "Willkommen zu Pangolin",
"componentsCreateOrg": "Erstelle eine Organisation",
"componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} =1 {einer Organisation} other {# Organisationen}}."
}

154
messages/en-US.json Normal file
View file

@ -0,0 +1,154 @@
{
"locales": {
"label": "Language",
"en-US": "English",
"de-DE": "German"
},
"setupCreate": "Create your organization, site, and resources",
"setupNewOrg": "New Organization",
"setupCreateOrg": "Create Organization",
"setupCreateSite": "Create Site",
"setupCreateResources": "Create Resources",
"setupOrgName": "Organization Name",
"setupDisplayName": "This is the display name for your organization.",
"setupOrgId": "Organization ID",
"setupIdentifierMessage": "This is the unique identifier for your organization. This is separate from the display name.",
"setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.",
"componentsErrorNoMemberCreate": "You are not currently a member of any organizations. Create an organization to get started.",
"componentsErrorNoMember": "You are not currently a member of any organizations.",
"welcome": "Welcome to Pangolin",
"componentsCreateOrg": "Create an Organization",
"componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.",
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
"dismiss": "Dismiss",
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
"componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!",
"inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.",
"inviteErrorUser": "We're sorry, but it looks like the invite you're trying to access is not for this user.",
"inviteLoginUser": "Please make sure you're logged in as the correct user.",
"inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.",
"inviteCreateUser": "Please create an account first.",
"goHome": "Go Home",
"inviteLogInOtherUser": "Log In as a Different User",
"createAnAccount": "Create an Account",
"inviteNotAccepted": "Invite Not Accepted",
"authCreateAccount": "Create an account to get started",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"createAccount": "Create Account",
"viewSettings": "View settings",
"delete": "Delete",
"name": "Name",
"online": "Online",
"offline": "Offline",
"site": "Site",
"dataIn": "Data In",
"dataOut": "Data Out",
"connectionType": "Connection Type",
"local": "Local",
"edit": "Edit",
"siteConfirmDelete": "Confirm Delete Site",
"siteDelete": "Delete Site",
"siteMessageRemove": "Once removed, the site will no longer be accessible. All resources and targets associated with the site will also be removed.",
"siteMessageConfirm": "To confirm, please type the name of the site below.",
"siteQuestionRemove": "Are you sure you want to remove the site {selectedSite} from the organization?",
"siteManageSites": "Manage Sites",
"siteDescription": "Allow connectivity to your network through secure tunnels",
"siteCreate": "Create Site",
"siteCreateDescription": "Create a new site to start connecting your resources",
"close": "Close",
"siteNameMin": "Name must be at least 2 characters.",
"siteNameMax": "Name must not be longer than 30 characters.",
"siteErrorCreate": "Error creating site",
"siteErrorCreateKeyPair": "Key pair or site defaults not found",
"siteErrorCreateDefaults": "Site defaults not found",
"siteNameDescription": "This is the display name for the site.",
"method": "Method",
"siteMethodDescription": "This is how you will expose connections.",
"siteLearnNewt": "Learn how to install Newt on your system",
"siteSeeConfigOnce": "You will only be able to see the configuration once.",
"siteLoadWGConfig": "Loading WireGuard configuration...",
"siteDocker": "Expand for Docker Deployment Details",
"toggle": "Toggle",
"dockerCompose": "Docker Compose",
"dockerRun": "Docker Run",
"siteLearnLocal": "Local sites do not tunnel, learn more",
"siteConfirmCopy": "I have copied the config",
"searchSites": "Search sites...",
"siteAdd": "Add Site",
"recommended": "Recommended",
"siteNewtDescription": "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.",
"siteRunsInDocker": "Runs in Docker",
"siteRunsInShell": "Runs in shell on macOS, Linux, and Windows",
"siteErrorDelete": "Error deleting site",
"shareTitle": "Manage Share Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to your resources",
"shareSearch": "Search share links...",
"shareCreate": "Create Share Link",
"shareErrorDelete": "Failed to delete link",
"shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted",
"shareDeletedDesciption": "The link has been deleted",
"openMenu": "Open menu",
"resource": "Resource",
"title": "Title",
"created": "Created",
"expires": "Expires",
"never": "Never",
"shareErrorSelectResource": "Please select a resource",
"resourceTitle": "Manage Resources",
"resourceDescription": "Create secure proxies to your private applications",
"resourceSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"authentication": "Authentication",
"protected": "Protected",
"notProtected": "Not Protected",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceMessageConfirm": "To confirm, please type the name of the resource below.",
"resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?",
"resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.",
"resourceRaw": "Raw TCP/UDP Resource",
"resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.",
"resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceSeeAll": "See All Resources",
"resourceInfo": "Resource Information",
"resourceNameDescription": "This is the display name for the resource.",
"siteSelect": "Select site",
"siteSearch": "Search site",
"siteNotFound": "No site found.",
"siteSelectionDescription": "This site will provide connectivity to the resource.",
"resourceType": "Resource Type",
"resourceTypeDescription": "Determine how you want to access your resource",
"resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS",
"domainType": "Domain Type",
"subdomain": "Subdomain",
"baseDomain": "Base Domain",
"subdomnainDescription": "The subdomain where your resource will be accessible.",
"resourceRawSettings": "TCP/UDP Settings",
"resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP",
"protocol": "Protocol",
"protocolSelect": "Select a protocol",
"resourcePortNumber": "Port Number",
"resourcePortNumberDescription": "The external port number to proxy requests.",
"cancle": "Cancle",
"resourceConfig": "Configuration Snippets",
"resourceConfigDescription": "Copy and paste these configuration snippets to set up your TCP/UDP resource",
"resourceAddEntrypoints": "Traefik: Add Entrypoints",
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
"resourceBack": "Back to Resources",
"resourceGoTo": "Go to Resource",
"visibility": "Visibility",
"enabled": "Enabled",
"disabled": "Disabled",
"general": "General",
"proxy": "Proxy",
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on your resource",
"resourceSetting": "{resourceName} Settings"
}

View file

@ -1,9 +1,13 @@
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
ignoreDuringBuilds: true
},
output: "standalone"
output: 'standalone'
};
export default nextConfig;
export default withNextIntl(nextConfig);

2522
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.0",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "3.9.1",
"@node-rs/argon2": "2.0.2",
"@oslojs/crypto": "1.0.1",
@ -74,6 +75,7 @@
"lucide-react": "0.469.0",
"moment": "2.30.1",
"next": "15.2.4",
"next-intl": "^4.1.0",
"next-themes": "0.4.4",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",

View file

@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -16,15 +17,18 @@ export function ResourcesDataTable<TData, TValue>({
data,
createResource
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Resources"
searchPlaceholder="Search resources..."
searchPlaceholder={t('resourceSearch')}
searchColumn="name"
onAdd={createResource}
addButtonText="Add Resource"
addButtonText={t('resourceAdd')}
/>
);
}

View file

@ -31,6 +31,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from 'next-intl';
export type ResourceRow = {
id: number;
@ -53,6 +54,7 @@ type ResourcesTableProps = {
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
@ -63,11 +65,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error("Error deleting resource", e);
console.error(t('resourceErrorDelte'), e);
toast({
variant: "destructive",
title: "Error deleting resource",
description: formatAxiosError(e, "Error deleting resource")
title: t('resourceErrorDelte'),
description: formatAxiosError(e, t('resourceErrorDelte'))
});
})
.then(() => {
@ -108,7 +110,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@ -118,7 +120,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
View settings
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
@ -127,7 +129,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -144,7 +146,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -160,7 +162,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
{t('site')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -219,7 +221,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Authentication
{t('authentication')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -231,12 +233,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>Protected</span>
<span>{t('protected')}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
<span>{t('notProtected')}</span>
</span>
) : (
<span>-</span>
@ -267,7 +269,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@ -289,23 +291,15 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
dialog={
<div>
<p className="mb-2">
Are you sure you want to remove the resource{" "}
<b>
{selectedResource?.name ||
selectedResource?.id}
</b>{" "}
from the organization?
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
</p>
<p className="mb-2">
Once removed, the resource will no longer be
accessible. All targets attached to the resource
will be removed.
{t('resourceMessageRemove')}
</p>
<p>
To confirm, please type the name of the resource
below.
{t('resourceMessageConfirm')}
</p>
</div>
}

View file

@ -13,11 +13,13 @@ import {
} from "@app/components/InfoSection";
import Link from "next/link";
import { Switch } from "@app/components/ui/switch";
import { useTranslations } from 'next-intl';
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext();
const t = useTranslations();
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
@ -25,7 +27,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Resource Information
{t('resourceInfo')}
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
@ -33,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<>
<InfoSection>
<InfoSectionTitle>
Authentication
{t('authentication')}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
@ -42,12 +44,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>Protected</span>
<span>{t('protected')}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
<span>{t('notProtected')}</span>
</div>
)}
</InfoSectionContent>
@ -62,7 +64,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Site</InfoSectionTitle>
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
@ -71,7 +73,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : (
<>
<InfoSection>
<InfoSectionTitle>Protocol</InfoSectionTitle>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
@ -79,7 +81,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>Port</InfoSectionTitle>
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
@ -90,9 +92,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</>
)}
<InfoSection>
<InfoSectionTitle>Visibility</InfoSectionTitle>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
<InfoSectionContent>
<span>{resource.enabled ? "Enabled" : "Disabled"}</span>
<span>{resource.enabled ? t('enabled') : t('disabled')}</span>
</InfoSectionContent>
</InfoSection>
</InfoSections>

View file

@ -22,6 +22,7 @@ import {
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { getTranslations } from 'next-intl/server';
interface ResourceLayoutProps {
children: React.ReactNode;
@ -30,6 +31,7 @@ interface ResourceLayoutProps {
export default async function ResourceLayout(props: ResourceLayoutProps) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
@ -82,22 +84,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const navItems = [
{
title: "General",
title: t('general'),
href: `/{orgId}/settings/resources/{resourceId}/general`
},
{
title: "Proxy",
title: t('proxy'),
href: `/{orgId}/settings/resources/{resourceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: "Authentication",
title: t('authentication'),
href: `/{orgId}/settings/resources/{resourceId}/authentication`
});
navItems.push({
title: "Rules",
title: t('rules'),
href: `/{orgId}/settings/resources/{resourceId}/rules`
});
}
@ -105,8 +107,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${resource?.name} Settings`}
description="Configure the settings on your resource"
title={t('resourceSetting', {resourceName: resource?.name})}
description={t('resourceSettingDescription')}
/>
<OrgProvider org={org}>

View file

@ -62,6 +62,7 @@ import { cn } from "@app/lib/cn";
import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from 'next-intl';
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@ -104,6 +105,7 @@ export default function Page() {
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
@ -117,15 +119,13 @@ export default function Page() {
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
{
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
title: t('resourceHTTP'),
description: t('resourceHTTPDescription')
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number.",
title: t('resourceRaw'),
description: t('resourceRawDescription'),
disabled: !env.flags.allowRawResources
}
];
@ -300,8 +300,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title="Create Resource"
description="Follow the steps below to create a new resource"
title={t('resourceCreate')}
description={t('resourceCreateDescription')}
/>
<Button
variant="outline"
@ -309,7 +309,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`);
}}
>
See All Resources
{t('resourceSeeAll')}
</Button>
</div>
@ -320,7 +320,7 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Information
{t('resourceInfo')}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -336,7 +336,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Name
{t('name')}
</FormLabel>
<FormControl>
<Input
@ -345,9 +345,7 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
This is the
display name for
the resource.
{t('resourceNameDescription')}
</FormDescription>
</FormItem>
)}
@ -359,7 +357,7 @@ export default function Page() {
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Site
{t('site')}
</FormLabel>
<Popover>
<PopoverTrigger
@ -384,19 +382,17 @@ export default function Page() {
field.value
)
?.name
: "Select site"}
: t('siteSelect')}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site" />
<CommandInput placeholder={t('siteSearch')} />
<CommandList>
<CommandEmpty>
No
site
found.
{t('siteNotFound')}
</CommandEmpty>
<CommandGroup>
{sites.map(
@ -437,10 +433,7 @@ export default function Page() {
</Popover>
<FormMessage />
<FormDescription>
This site will
provide
connectivity to
the resource.
{t('siteSelectionDescription')}
</FormDescription>
</FormItem>
)}
@ -454,11 +447,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Type
{t('resourceType')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to access your
resource
{t('resourceTypeDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -480,11 +472,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
HTTPS Settings
{t('resourceHTTPSSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over HTTPS
{t('resourceHTTPSSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -506,8 +497,7 @@ export default function Page() {
}) => (
<FormItem>
<FormLabel>
Domain
Type
{t('domainType')}
</FormLabel>
<Select
value={
@ -531,11 +521,10 @@ export default function Page() {
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
{t('subdomain')}
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
{t('baseDomain')}
</SelectItem>
</SelectContent>
</Select>
@ -550,7 +539,7 @@ export default function Page() {
) && (
<FormItem>
<FormLabel>
Subdomain
{t('subdomain')}
</FormLabel>
<div className="flex space-x-0">
<div className="w-1/2">
@ -629,10 +618,7 @@ export default function Page() {
</div>
</div>
<FormDescription>
The subdomain
where your
resource will be
accessible.
{t('subdomnainDescription')}
</FormDescription>
</FormItem>
)}
@ -650,8 +636,7 @@ export default function Page() {
}) => (
<FormItem>
<FormLabel>
Base
Domain
{t('baseDomain')}
</FormLabel>
<Select
onValueChange={
@ -702,11 +687,10 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
TCP/UDP Settings
{t('resourceRawSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over TCP/UDP
{t('resourceRawSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -724,7 +708,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
{t('protocol')}
</FormLabel>
<Select
onValueChange={
@ -734,7 +718,7 @@ export default function Page() {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
<SelectValue placeholder={t('protocolSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@ -759,7 +743,7 @@ export default function Page() {
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
{t('resourcePortNumber')}
</FormLabel>
<FormControl>
<Input
@ -787,10 +771,7 @@ export default function Page() {
</FormControl>
<FormMessage />
<FormDescription>
The external
port number
to proxy
requests.
{t('resourcePortNumberDescription')}
</FormDescription>
</FormItem>
)}
@ -810,7 +791,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`)
}
>
Cancel
{t('cancle')}
</Button>
<Button
type="button"
@ -827,7 +808,7 @@ export default function Page() {
}}
loading={createLoading}
>
Create Resource
{t('resourceCreate')}
</Button>
</div>
</SettingsContainer>
@ -836,17 +817,17 @@ export default function Page() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Configuration Snippets
{t('resourceConfig')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Copy and paste these configuration snippets to set up your TCP/UDP resource
{t('resourceConfigDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Traefik: Add Entrypoints
{t('resourceAddEntrypoints')}
</h3>
<CopyTextBox
text={`entryPoints:
@ -858,7 +839,7 @@ export default function Page() {
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Gerbil: Expose Ports in Docker Compose
{t('resourceExposePorts')}
</h3>
<CopyTextBox
text={`ports:
@ -874,7 +855,7 @@ export default function Page() {
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP resources
{t('resourceLearnRaw')}
</span>
<SquareArrowOutUpRight size={14} />
</Link>
@ -890,7 +871,7 @@ export default function Page() {
router.push(`/${orgId}/settings/resources`)
}
>
Back to Resources
{t('resourceBack')}
</Button>
<Button
type="button"
@ -900,7 +881,7 @@ export default function Page() {
)
}
>
Go to Resource
{t('resourceGoTo')}
</Button>
</div>
</SettingsContainer>

View file

@ -9,6 +9,7 @@ import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import ResourcesSplashCard from "./ResourcesSplashCard";
import { getTranslations } from 'next-intl/server';
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@ -68,13 +69,15 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
};
});
const t = await getTranslations();
return (
<>
{/* <ResourcesSplashCard /> */}
<SettingsSectionTitle
title="Manage Resources"
description="Create secure proxies to your private applications"
title={t('resourceTitle')}
description={t('resourceDescription')}
/>
<OrgProvider org={org}>

View file

@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -16,15 +17,18 @@ export function ShareLinksDataTable<TData, TValue>({
data,
createShareLink
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Share Links"
searchPlaceholder="Search share links..."
searchPlaceholder={t('shareSearch')}
searchColumn="name"
onAdd={createShareLink}
addButtonText="Create Share Link"
addButtonText={t('shareCreate')}
/>
);
}

View file

@ -33,6 +33,7 @@ import { ListAccessTokensResponse } from "@server/routers/accessToken";
import moment from "moment";
import CreateShareLinkForm from "./CreateShareLinkForm";
import { constructShareLink } from "@app/lib/shareLinks";
import { useTranslations } from 'next-intl';
export type ShareLinkRow = {
accessTokenId: string;
@ -54,6 +55,7 @@ export default function ShareLinksTable({
orgId
}: ShareLinksTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
@ -67,11 +69,8 @@ export default function ShareLinksTable({
async function deleteSharelink(id: string) {
await api.delete(`/access-token/${id}`).catch((e) => {
toast({
title: "Failed to delete link",
description: formatAxiosError(
e,
"An error occurred deleting link"
)
title: t('shareErrorDelete'),
description: formatAxiosError(e,t('shareErrorDeleteMessage'))
});
});
@ -79,8 +78,8 @@ export default function ShareLinksTable({
setRows(newRows);
toast({
title: "Link deleted",
description: "The link has been deleted"
title: t('shareDeleted'),
description: t('shareDeletedDesciption')
});
}
@ -102,7 +101,7 @@ export default function ShareLinksTable({
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
{t('openMenu')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -116,7 +115,7 @@ export default function ShareLinksTable({
}}
>
<button className="text-red-500">
Delete
{t('delete')}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
@ -136,7 +135,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Resource
{t('resource')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -164,7 +163,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Title
{t('title')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -243,7 +242,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Created
{t('created')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -263,7 +262,7 @@ export default function ShareLinksTable({
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Expires
{t('expires')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -273,7 +272,7 @@ export default function ShareLinksTable({
if (r.expiresAt) {
return moment(r.expiresAt).format("lll");
}
return "Never";
return t('never');
}
},
{
@ -286,7 +285,7 @@ export default function ShareLinksTable({
deleteSharelink(row.original.accessTokenId)
}
>
Delete
{t('delete')}
</Button>
</div>
)

View file

@ -9,6 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
import ShareableLinksSplash from "./ShareLinksSplash";
import { getTranslations } from 'next-intl/server';
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;
@ -51,13 +52,15 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
(token) => ({ ...token }) as ShareLinkRow
);
const t = await getTranslations();
return (
<>
{/* <ShareableLinksSplash /> */}
<SettingsSectionTitle
title="Manage Share Links"
description="Create shareable links to grant temporary or permanent access to your resources"
title={t('shareTitle')}
description={t('shareDescription')}
/>
<OrgProvider org={org}>

View file

@ -50,15 +50,18 @@ import {
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { useTranslations } from 'next-intl';
const t = useTranslations();
const createSiteFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
message: {t('siteNameMin')}
})
.max(30, {
message: "Name must not be longer than 30 characters."
message: {t('siteNameMax')}
}),
method: z.enum(["wireguard", "newt", "local"])
});
@ -169,8 +172,8 @@ export default function CreateSiteForm({
if (!keypair || !siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Key pair or site defaults not found"
title: {t('siteErrorCreate')},
description: {t('siteErrorCreateKeyPair')}
});
setLoading?.(false);
setIsLoading(false);
@ -188,8 +191,8 @@ export default function CreateSiteForm({
if (!siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Site defaults not found"
title: {t('siteErrorCreate')},
description: {t('siteErrorCreateDefaults')}
});
setLoading?.(false);
setIsLoading(false);
@ -212,7 +215,7 @@ export default function CreateSiteForm({
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
title: {t('siteErrorCreate')},
description: formatAxiosError(e)
});
});
@ -285,13 +288,13 @@ PersistentKeepalive = 5`
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input autoComplete="off" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name for the site.
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
@ -301,7 +304,7 @@ PersistentKeepalive = 5`
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormLabel>{t('method')}</FormLabel>
<FormControl>
<Select
value={field.value}
@ -331,7 +334,7 @@ PersistentKeepalive = 5`
</FormControl>
<FormMessage />
<FormDescription>
This is how you will expose connections.
{t('siteMethodDescription')}
</FormDescription>
</FormItem>
)}
@ -345,7 +348,7 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
Learn how to install Newt on your system
{t('siteLearnNewt')}
</span>
<SquareArrowOutUpRight size={14} />
</Link>
@ -356,13 +359,12 @@ PersistentKeepalive = 5`
<>
<CopyTextBox text={wgConfig} />
<span className="text-sm text-muted-foreground mt-2">
You will only be able to see the
configuration once.
{t('siteSeeConfigOnce')}
</span>
</>
) : form.watch("method") === "wireguard" &&
isLoading ? (
<p>Loading WireGuard configuration...</p>
<p>{t('siteLoadWGConfig')}</p>
) : form.watch("method") === "newt" && siteDefaults ? (
<>
<div className="mb-2">
@ -378,8 +380,7 @@ PersistentKeepalive = 5`
/>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
{t('siteSeeConfigOnce')}
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
@ -389,13 +390,12 @@ PersistentKeepalive = 5`
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
Expand for Docker
Deployment Details
{t('siteDocker')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
{t('toggle')}
</span>
</div>
</Button>
@ -403,7 +403,7 @@ PersistentKeepalive = 5`
</div>
<CollapsibleContent className="space-y-4">
<div className="space-y-2">
<b>Docker Compose</b>
<b>{t('dockerCompose')}</b>
<CopyTextBox
text={
newtConfigDockerCompose
@ -412,7 +412,7 @@ PersistentKeepalive = 5`
/>
</div>
<div className="space-y-2">
<b>Docker Run</b>
<b>{t('dockerRun')}</b>
<CopyTextBox
text={newtConfigDockerRun}
@ -433,7 +433,7 @@ PersistentKeepalive = 5`
target="_blank"
rel="noopener noreferrer"
>
<span> Local sites do not tunnel, learn more</span>
<span>{t('siteLearnLocal')}</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
@ -450,7 +450,7 @@ PersistentKeepalive = 5`
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
{t('siteConfirmCopy')}
</label>
</div>
)}

View file

@ -14,6 +14,7 @@ import {
} from "@app/components/Credenza";
import { SiteRow } from "./SitesTable";
import CreateSiteForm from "./CreateSiteForm";
import { useTranslations } from 'next-intl';
type CreateSiteFormProps = {
open: boolean;
@ -30,6 +31,7 @@ export default function CreateSiteFormModal({
}: CreateSiteFormProps) {
const [loading, setLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const t = useTranslations();
return (
<>
@ -42,9 +44,9 @@ export default function CreateSiteFormModal({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Site</CredenzaTitle>
<CredenzaTitle>{t('siteCreate')}</CredenzaTitle>
<CredenzaDescription>
Create a new site to start connecting your resources
{t('siteCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -59,7 +61,7 @@ export default function CreateSiteFormModal({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
@ -70,7 +72,7 @@ export default function CreateSiteFormModal({
setOpen(false);
}}
>
Create Site
{t('siteCreate')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View file

@ -4,6 +4,7 @@ import {
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from 'next-intl';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -16,15 +17,18 @@ export function SitesDataTable<TData, TValue>({
data,
createSite
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Sites"
searchPlaceholder="Search sites..."
searchPlaceholder={t('searchSites')}
searchColumn="name"
onAdd={createSite}
addButtonText="Add Site"
addButtonText={t('siteAdd')}
/>
);
}

View file

@ -5,11 +5,13 @@ 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";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const key = "sites-splash-card-dismissed";
const t = useTranslations();
useEffect(() => {
const dismissed = localStorage.getItem(key);
@ -42,22 +44,19 @@ export const SitesSplashCard = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Globe className="text-blue-500" />
Newt (Recommended)
Newt ({t('recommended')})
</h3>
<p className="text-sm">
For the best user experience, use Newt. It uses
WireGuard under the hood and allows you to address your
private resources by their LAN address on your private
network from within the Pangolin dashboard.
{t('siteNewtDescription')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in Docker
{t('siteRunsInDocker')}
</li>
<li className="flex items-center gap-2">
<Server className="text-green-500 w-4 h-4" />
Runs in shell on macOS, Linux, and Windows
{t('siteRunsInShell')}
</li>
</ul>

View file

@ -27,6 +27,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CreateSiteFormModal from "./CreateSiteModal";
import { useTranslations } from 'next-intl';
export type SiteRow = {
id: number;
@ -52,15 +53,16 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
const [rows, setRows] = useState<SiteRow[]>(sites);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error("Error deleting site", e);
console.error(t('siteErrorDelete'), e);
toast({
variant: "destructive",
title: "Error deleting site",
description: formatAxiosError(e, "Error deleting site")
title: t('siteErrorDelete'),
description: formatAxiosError(e, t('siteErrorDelete'))
});
})
.then(() => {
@ -94,7 +96,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
View settings
{t('viewSettings')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
@ -103,7 +105,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -120,7 +122,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
{t('name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -136,7 +138,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Online
{t('online')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -151,14 +153,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
<span>{t('online')}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
<span>{t('offline')}</span>
</span>
);
}
@ -177,7 +179,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
{t('site')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -193,7 +195,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
{t('dataIn')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -209,7 +211,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
{t('dataOut')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -225,7 +227,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connection Type
{t('connectionType')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -252,7 +254,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.type === "local") {
return (
<div className="flex items-center space-x-2">
<span>Local</span>
<span>{t('local')}</span>
</div>
);
}
@ -268,7 +270,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
{t('edit')}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
@ -290,30 +292,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the site{" "}
<b>{selectedSite?.name || selectedSite?.id}</b>{" "}
from the organization?
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})}
</p>
<p>
{t('siteMessageRemove')}
</p>
<p>
Once removed, the site will no longer be
accessible.{" "}
<b>
All resources and targets associated with
the site will also be removed.
</b>
</p>
<p>
To confirm, please type the name of the site
below.
{t('siteMessageConfirm')}
</p>
</div>
}
buttonText="Confirm Delete Site"
buttonText={t('siteConfirmDelete')}
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title="Delete Site"
title={t('siteDelete')}
/>
)}

View file

@ -5,6 +5,7 @@ import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesSplashCard from "./SitesSplashCard";
import { getTranslations } from 'next-intl/server';
type SitesPageProps = {
params: Promise<{ orgId: string }>;
@ -49,13 +50,15 @@ export default async function SitesPage(props: SitesPageProps) {
};
});
const t = await getTranslations();
return (
<>
{/* <SitesSplashCard /> */}
<SettingsSectionTitle
title="Manage Sites"
description="Allow connectivity to your network through secure tunnels"
title={t('siteManageSites')}
description={t('siteDescription')}
/>
<SitesTable sites={siteRows} orgId={params.orgId} />

View file

@ -31,6 +31,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from 'next-intl';
type SignupFormProps = {
redirect?: string;
@ -112,6 +113,8 @@ export default function SignupForm({
setLoading(false);
}
const t = useTranslations();
return (
<Card className="w-full max-w-md">
<CardHeader>
@ -125,10 +128,10 @@ export default function SignupForm({
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
{t('welcome')}
</h1>
<p className="text-sm text-muted-foreground">
Create an account to get started
{t('authCreateAccount')}
</p>
</div>
</CardHeader>
@ -143,7 +146,7 @@ export default function SignupForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -156,7 +159,7 @@ export default function SignupForm({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -172,7 +175,7 @@ export default function SignupForm({
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormControl>
<Input
type="password"
@ -191,7 +194,7 @@ export default function SignupForm({
)}
<Button type="submit" className="w-full">
Create Account
{t('createAccount')}
</Button>
</form>
</Form>

View file

@ -8,10 +8,12 @@
import { Button } from "@app/components/ui/button";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useState } from "react";
import { useTranslations } from 'next-intl';
export default function LicenseViolation() {
const { licenseStatus } = useLicenseStatusContext();
const [isDismissed, setIsDismissed] = useState(false);
const t = useTranslations();
if (!licenseStatus || isDismissed) return null;
@ -21,15 +23,14 @@ export default function LicenseViolation() {
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
Invalid or expired license keys detected. Follow license
terms to continue using all features.
{t('componentsInvalidKey')}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
{t('dismiss')}
</Button>
</div>
</div>
@ -46,17 +47,14 @@ export default function LicenseViolation() {
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
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})}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
{t('dismiss')}
</Button>
</div>
</div>

View file

@ -11,6 +11,8 @@ import {
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight, Plus } from "lucide-react";
import { useTranslations } from 'next-intl';
interface Organization {
id: string;
name: string;
@ -31,31 +33,31 @@ export default function OrganizationLanding({
setSelectedOrg(orgId);
};
const t = useTranslations();
function getDescriptionText() {
if (organizations.length === 0) {
if (!disableCreateOrg) {
return "You are not currently a member of any organizations. Create an organization to get started.";
return t('componentsErrorNoMemberCreate');
} else {
return "You are not currently a member of any organizations.";
return t('componentsErrorNoMember');
}
}
return `You're a member of ${organizations.length} ${
organizations.length === 1 ? "organization" : "organizations"
}.`;
return t('componentsMember', {count: organizations.length});
}
return (
<Card>
<CardHeader>
<CardTitle>Welcome to Pangolin</CardTitle>
<CardTitle>{t('welcome')}</CardTitle>
<CardDescription>{getDescriptionText()}</CardDescription>
</CardHeader>
<CardContent>
{organizations.length === 0 ? (
disableCreateOrg ? (
<p className="text-center text-muted-foreground">
You are not currently a member of any organizations.
t('componentsErrorNoMember')
</p>
) : (
<Link href="/setup">
@ -64,7 +66,7 @@ export default function OrganizationLanding({
size="lg"
>
<Plus className="mr-2 h-5 w-5" />
Create an Organization
{t('componentsCreateOrg')}
</Button>
</Link>
)

View file

@ -3,8 +3,11 @@
import React from "react";
import confetti from "canvas-confetti";
import { Star } from "lucide-react";
import { useTranslations } from 'next-intl';
export default function SupporterMessage({ tier }: { tier: string }) {
const t = useTranslations();
return (
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
<span
@ -31,7 +34,7 @@ export default function SupporterMessage({ tier }: { tier: string }) {
</span>
<Star className="w-3 h-3"/>
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
Thank you for supporting Pangolin as a {tier}!
{t('componentsSupporterMessage', {tier: tier})}
</div>
</div>
);

View file

@ -118,3 +118,8 @@
@apply bg-background text-foreground;
}
}
p {
word-break: keep-all;
white-space: normal;
}

View file

@ -12,6 +12,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { XCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from 'next-intl';
type InviteStatusCardProps = {
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
@ -23,8 +24,8 @@ export default function InviteStatusCard({
token,
}: InviteStatusCardProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
async function goToLogin() {
await api.post("/auth/logout", {});
@ -41,8 +42,7 @@ export default function InviteStatusCard({
return (
<div>
<p className="text-center mb-4">
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')}
</p>
<ul className="list-disc list-inside text-sm space-y-2">
<li>The invite may have expired</li>
@ -55,11 +55,10 @@ export default function InviteStatusCard({
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for this user.
{t('inviteErrorUser')}
</p>
<p className="text-center">
Please make sure you're logged in as the correct user.
{t('inviteLoginUser')}
</p>
</div>
);
@ -67,11 +66,10 @@ export default function InviteStatusCard({
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for a user that exists.
{t('inviteErrorNoUser')}
</p>
<p className="text-center">
Please create an account first.
{t('inviteCreateUser')}
</p>
</div>
);
@ -86,15 +84,15 @@ export default function InviteStatusCard({
router.push("/");
}}
>
Go Home
{t('goHome')}
</Button>
);
} else if (type === "wrong_user") {
return (
<Button onClick={goToLogin}>Log In as a Different User</Button>
<Button onClick={goToLogin}>{t('inviteLogInOtherUser')}</Button>
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>Create an Account</Button>;
return <Button onClick={goToSignup}>{t('createAnAccount')}</Button>;
}
}
@ -109,7 +107,7 @@ export default function InviteStatusCard({
/>
</div> */}
<CardTitle className="text-center text-2xl font-bold">
Invite Not Accepted
{t('inviteNotAccepted')}
</CardTitle>
</CardHeader>
<CardContent>{renderBody()}</CardContent>

View file

@ -12,6 +12,8 @@ import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import LicenseViolation from "./components/LicenseViolation";
import { NextIntlClientProvider } from 'next-intl';
import { getLocale } from 'next-intl/server';
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@ -29,6 +31,7 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const env = pullEnv();
const locale = await getLocale();
let supporterData = {
visible: true
@ -47,31 +50,33 @@ export default async function RootLayout({
const licenseStatus = licenseStatusRes.data.data;
return (
<html suppressHydrationWarning>
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen overflow-hidden`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
</div>
</div>
</div>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</ThemeProvider>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);

View file

@ -33,6 +33,7 @@ import {
} from "@app/components/ui/form";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import CreateSiteForm from "../[orgId]/settings/sites/CreateSiteForm";
import { useTranslations } from 'next-intl';
type Step = "org" | "site" | "resources";
@ -112,13 +113,15 @@ export default function StepperForm() {
setLoading(false);
}
const t = useTranslations();
return (
<>
<Card>
<CardHeader>
<CardTitle>New Organization</CardTitle>
<CardTitle>{t('setupNewOrg')}</CardTitle>
<CardDescription>
Create your organization, site, and resources
{t('setupCreate')}
</CardDescription>
</CardHeader>
<CardContent>
@ -141,7 +144,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
Create Org
{t('setupCreateOrg')}
</span>
</div>
<div className="flex flex-col items-center">
@ -161,7 +164,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
Create Site
{t('setupCreateSite')}
</span>
</div>
<div className="flex flex-col items-center">
@ -181,7 +184,7 @@ export default function StepperForm() {
: "text-muted-foreground"
}`}
>
Create Resources
{t('setupCreateResources')}
</span>
</div>
</div>
@ -200,7 +203,7 @@ export default function StepperForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Organization Name
{t('setupOrgName')}
</FormLabel>
<FormControl>
<Input
@ -228,8 +231,7 @@ export default function StepperForm() {
</FormControl>
<FormMessage />
<FormDescription>
This is the display name for
your organization.
{t('setupDisplayName')}
</FormDescription>
</FormItem>
)}
@ -240,7 +242,7 @@ export default function StepperForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
Organization ID
{t('setupOrgId')}
</FormLabel>
<FormControl>
<Input
@ -250,11 +252,7 @@ export default function StepperForm() {
</FormControl>
<FormMessage />
<FormDescription>
This is the unique
identifier for your
organization. This is
separate from the display
name.
{t('setupIdentifierMessage')}
</FormDescription>
</FormItem>
)}
@ -263,9 +261,7 @@ export default function StepperForm() {
{orgIdTaken && (
<Alert variant="destructive">
<AlertDescription>
Organization ID is already
taken. Please choose a different
one.
{t('setupErrorIdentifier')}
</AlertDescription>
</Alert>
)}
@ -288,7 +284,7 @@ export default function StepperForm() {
orgIdTaken
}
>
Create Organization
{t('setupCreateOrg')}
</Button>
</div>
</form>

View file

@ -0,0 +1,24 @@
import { useLocale, useTranslations } from 'next-intl';
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
export default function LocaleSwitcher() {
const t = useTranslations('locales');
const locale = useLocale();
return (
<LocaleSwitcherSelect
defaultValue={locale}
items={[
{
value: 'en-US',
label: t('en-US')
},
{
value: 'de-DE',
label: t('de-DE')
}
]}
label={t('label')}
/>
);
}

View file

@ -0,0 +1,72 @@
'use client';
import { CheckIcon, LanguageIcon } from '@heroicons/react/24/solid';
import * as Select from '@radix-ui/react-select';
import clsx from 'clsx';
import { useTransition } from 'react';
import { Locale } from '@/i18n/config';
import { setUserLocale } from '@/services/locale';
type Props = {
defaultValue: string;
items: Array<{value: string; label: string}>;
label: string;
};
export default function LocaleSwitcherSelect({
defaultValue,
items,
label
}: Props) {
const [isPending, startTransition] = useTransition();
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
return (
<div className="relative">
<Select.Root defaultValue={defaultValue} onValueChange={onChange}>
<Select.Trigger
aria-label={label}
className={clsx(
'rounded-sm p-2 transition-colors hover:bg-slate-200',
isPending && 'pointer-events-none opacity-60'
)}
>
<Select.Icon>
<LanguageIcon className="h-6 w-6 text-slate-600 transition-colors group-hover:text-slate-900" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
align="end"
className="min-w-[8rem] overflow-hidden rounded-sm bg-white py-1 shadow-md"
position="popper"
>
<Select.Viewport>
{items.map((item) => (
<Select.Item
key={item.value}
className="flex cursor-default items-center px-3 py-2 text-base data-[highlighted]:bg-slate-100"
value={item.value}
>
<div className="mr-2 w-[1rem]">
{item.value === defaultValue && (
<CheckIcon className="h-5 w-5 text-slate-600" />
)}
</div>
<span className="text-slate-900">{item.label}</span>
</Select.Item>
))}
</Select.Viewport>
<Select.Arrow className="fill-white text-white" />
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
);
}

View file

@ -23,6 +23,8 @@ import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
@ -157,6 +159,10 @@ export default function ProfileIcon() {
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<div>
<LocaleSwitcher />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}

4
src/i18n/config.ts Normal file
View file

@ -0,0 +1,4 @@
export type Locale = (typeof locales)[number];
export const locales = ['en-US', 'de-DE'] as const;
export const defaultLocale: Locale = 'en-US';

11
src/i18n/request.ts Normal file
View file

@ -0,0 +1,11 @@
import {getRequestConfig} from 'next-intl/server';
import {getUserLocale} from '../services/locale';
export default getRequestConfig(async () => {
const locale = await getUserLocale();
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});

16
src/services/locale.ts Normal file
View file

@ -0,0 +1,16 @@
'use server';
import {cookies} from 'next/headers';
import {Locale, defaultLocale} from '@/i18n/config';
// In this example the locale is read from a cookie. You could alternatively
// also read it from a database, backend service, or any other source.
const COOKIE_NAME = 'NEXT_LOCALE';
export async function getUserLocale() {
return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale;
}
export async function setUserLocale(locale: Locale) {
(await cookies()).set(COOKIE_NAME, locale);
}