mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-02 16:19:08 +00:00
Add first i18n stuff
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user