mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-31 08:04:54 +02:00
Add first i18n stuff
This commit is contained in:
parent
21f1326045
commit
7eb08474ff
35 changed files with 2629 additions and 759 deletions
3
crowdin.yml
Normal file
3
crowdin.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
files:
|
||||
- source: /messages/en-US.json
|
||||
translation: /messages/%locale%.json
|
22
messages/de-DE.json
Normal file
22
messages/de-DE.json
Normal 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
154
messages/en-US.json
Normal 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"
|
||||
}
|
|
@ -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
2522
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -118,3 +118,8 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
word-break: keep-all;
|
||||
white-space: normal;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
24
src/components/LocaleSwitcher.tsx
Normal file
24
src/components/LocaleSwitcher.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
}
|
72
src/components/LocaleSwitcherSelect.tsx
Normal file
72
src/components/LocaleSwitcherSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
4
src/i18n/config.ts
Normal 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
11
src/i18n/request.ts
Normal 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
16
src/services/locale.ts
Normal 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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue