complete share link i18n

This commit is contained in:
Lokowitz
2025-05-05 16:10:08 +00:00
parent 29375385c0
commit fa1997adc1
8 changed files with 109 additions and 82 deletions

View File

@@ -77,6 +77,15 @@
"siteRunsInDocker": "Runs in Docker",
"siteRunsInShell": "Runs in shell on macOS, Linux, and Windows",
"siteErrorDelete": "Error deleting site",
"siteErrorUpdate": "Failed to update site",
"siteErrorUpdateDescription": "An error occurred while updating the site.",
"siteUpdated": "Site updated",
"siteUpdatedDescription": "The site has been updated.",
"siteGeneralDescription": "Configure the general settings for this site",
"siteSettingDescription": "Configure the settings on your site",
"siteSetting": "{siteName} Settings",
"siteInfo": "Site Information",
"status": "Status",
"shareTitle": "Manage Share Links",
"shareDescription": "Create shareable links to grant temporary or permanent access to your resources",
"shareSearch": "Search share links...",
@@ -85,6 +94,31 @@
"shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted",
"shareDeletedDesciption": "The link has been deleted",
"shareTokenDescription": "Your access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
"accessToken": "Access Token",
"usageExamples": "Usage Examples",
"tokenId": "Token ID",
"requestHeades": "Request Headers",
"queryParameter": "Query Parameter",
"importantNote": "Important Note",
"shareImportantDescription": "For security reasons, using headers is recommended over query parameters when possible, as query parameters may be logged in server logs or browser history.",
"token": "Token",
"shareTokenSecurety": "Keep your access token secure. Do not share it in publicly accessible areas or client-side code.",
"shareErrorFetchResource": "Failed to fetch resources",
"shareErrorFetchResourceDescription": "An error occurred while fetching the resources",
"shareErrorCreate": "Failed to create share link",
"shareErrorCreateDescription": "An error occurred while creating the share link",
"shareCreateDescription": "Anyone with this link can access the resource",
"shareTitleOptional": "Title (optional)",
"expireIn": "Expire In",
"neverExpire": "Never expire",
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
"shareSeeOnce": "You will only be able to see this linkonce. Make sure to copy it.",
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
"shareTokenUsage": "See Access Token Usage",
"createLink": "Create Link",
"resourceNotFound": "No resources found",
"resourceSearch": "Search resources",
"openMenu": "Open menu",
"resource": "Resource",
"title": "Title",
@@ -94,7 +128,7 @@
"shareErrorSelectResource": "Please select a resource",
"resourceTitle": "Manage Resources",
"resourceDescription": "Create secure proxies to your private applications",
"resourceSearch": "Search resources...",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"authentication": "Authentication",
@@ -142,6 +176,7 @@
"enabled": "Enabled",
"disabled": "Disabled",
"general": "General",
"generalSettings": "General Settings",
"proxy": "Proxy",
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on your resource",
@@ -151,7 +186,7 @@
"orgSettingsDescription": "Configure your organization's general settings",
"orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
"orgGeneralSave": "Save General Settings",
"saveGeneralSettings": "Save General Settings",
"orgDangerZone": "Danger Zone",
"orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.",
"orgDelete": "Delete Organization",
@@ -186,5 +221,11 @@
"description": "Description",
"inviteTitle": "Open Invitations",
"inviteDescription": "Manage your invitations to other users",
"inviteSearch": "Search invitations..."
"inviteSearch": "Search invitations...",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
}

View File

@@ -225,7 +225,7 @@ export default function GeneralPage() {
loading={loadingSave}
disabled={loadingSave}
>
{t('orgGeneralSave')}
{t('saveGeneralSettings')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -25,7 +25,7 @@ export function ResourcesDataTable<TData, TValue>({
columns={columns}
data={data}
title="Resources"
searchPlaceholder={t('resourceSearch')}
searchPlaceholder={t('resourcesSearch')}
searchColumn="name"
onAdd={createResource}
addButtonText={t('resourceAdd')}

View File

@@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CopyTextBox from "@app/components/CopyTextBox";
import { useTranslations } from 'next-intl';
interface AccessTokenSectionProps {
token: string;
@@ -37,37 +38,37 @@ export default function AccessTokenSection({
setTimeout(() => setCopied(null), 2000);
};
const t = useTranslations();
return (
<>
<div className="flex items-start space-x-2">
<p className="text-sm text-muted-foreground">
Your access token can be passed in two ways: as a query
parameter or in the request headers. These must be passed
from the client on every request for authenticated access.
{t('shareTokenDescription')}
</p>
</div>
<Tabs defaultValue="token" className="w-full mt-4">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="token">Access Token</TabsTrigger>
<TabsTrigger value="usage">Usage Examples</TabsTrigger>
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
</TabsList>
<TabsContent value="token" className="space-y-4">
<div className="space-y-1">
<div className="font-bold">Token ID</div>
<div className="font-bold">{t('tokenId')}</div>
<CopyToClipboard text={tokenId} isLink={false} />
</div>
<div className="space-y-1">
<div className="font-bold">Token</div>
<div className="font-bold">{t('token')}</div>
<CopyToClipboard text={token} isLink={false} />
</div>
</TabsContent>
<TabsContent value="usage" className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Request Headers</h3>
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
<CopyTextBox
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
@@ -75,7 +76,7 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Query Parameter</h3>
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
<CopyTextBox
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
/>
@@ -84,21 +85,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Important Note
{t('importantNote')}
</AlertTitle>
<AlertDescription>
For security reasons, using headers is recommended
over query parameters when possible, as query
parameters may be logged in server logs or browser
history.
{t('shareImportantDescription')}
</AlertDescription>
</Alert>
</TabsContent>
</Tabs>
<div className="text-sm text-muted-foreground mt-4">
Keep your access token secure. Do not share it in publicly
accessible areas or client-side code.
{t('shareTokenSecurety')}
</div>
</>
);

View File

@@ -66,6 +66,7 @@ import {
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import AccessTokenSection from "./AccessTokenUsage";
import { useTranslations } from 'next-intl';
type FormProps = {
open: boolean;
@@ -91,6 +92,7 @@ export default function CreateShareLinkForm({
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [link, setLink] = useState<string | null>(null);
const [accessTokenId, setAccessTokenId] = useState<string | null>(null);
@@ -110,12 +112,12 @@ export default function CreateShareLinkForm({
>([]);
const timeUnits = [
{ unit: "minutes", name: "Minutes" },
{ unit: "hours", name: "Hours" },
{ unit: "days", name: "Days" },
{ unit: "weeks", name: "Weeks" },
{ unit: "months", name: "Months" },
{ unit: "years", name: "Years" }
{ unit: "minutes", name: t('minutes') },
{ unit: "hours", name: t('hours') },
{ unit: "days", name: t('days') },
{ unit: "weeks", name: t('weeks') },
{ unit: "months", name: t('months') },
{ unit: "years", name: t('years') }
];
const form = useForm<z.infer<typeof formSchema>>({
@@ -141,11 +143,8 @@ export default function CreateShareLinkForm({
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch resources",
description: formatAxiosError(
e,
"An error occurred while fetching the resources"
)
title: t('shareErrorFetchResource'),
description: formatAxiosError(e, t('shareErrorFetchResourceDescription'))
});
});
@@ -208,11 +207,8 @@ export default function CreateShareLinkForm({
console.error(e);
toast({
variant: "destructive",
title: "Failed to create share link",
description: formatAxiosError(
e,
"An error occurred while creating the share link"
)
title: t('shareErrorCreate'),
description: formatAxiosError(e, t('shareErrorCreateDescription'))
});
});
@@ -260,9 +256,9 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
<CredenzaDescription>
Anyone with this link can access the resource
{t('shareCreateDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -280,7 +276,7 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Resource
{t('resource')}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
@@ -305,12 +301,10 @@ export default function CreateShareLinkForm({
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search resources" />
<CommandInput placeholder={t('resourceSearch')} />
<CommandList>
<CommandEmpty>
No
resources
found
{t('resourceNotFound')}
</CommandEmpty>
<CommandGroup>
{resources.map(
@@ -366,7 +360,7 @@ export default function CreateShareLinkForm({
render={({ field }) => (
<FormItem>
<FormLabel>
Title (optional)
{t('shareTitleOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
@@ -378,7 +372,7 @@ export default function CreateShareLinkForm({
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<FormLabel>{t('expireIn')}</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -455,18 +449,12 @@ export default function CreateShareLinkForm({
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Never expire
{t('neverExpire')}
</label>
</div>
<p className="text-sm text-muted-foreground">
Expiration time is how long the
link will be usable and provide
access to the resource. After
this time, the link will no
longer work, and users who used
this link will lose access to
the resource.
{t('shareExpireDescription')}
</p>
</div>
</form>
@@ -475,12 +463,10 @@ export default function CreateShareLinkForm({
{link && (
<div className="max-w-md space-y-4">
<p>
You will only be able to see this link
once. Make sure to copy it.
{t('shareSeeOnce')}
</p>
<p>
Anyone with this link can access the
resource. Share it with care.
{t('shareAccessHint')}
</p>
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
@@ -506,7 +492,7 @@ export default function CreateShareLinkForm({
className="p-0 flex items-center justify-between w-full"
>
<h4 className="text-sm font-semibold">
See Access Token Usage
{t('shareTokenUsage')}
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
@@ -549,7 +535,7 @@ export default function CreateShareLinkForm({
loading={loading}
disabled={link !== null || loading}
>
Create Link
{t('createLink')}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -9,11 +9,13 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from 'next-intl';
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
const t = useTranslations();
const getConnectionTypeString = (type: string) => {
if (type === "newt") {
@@ -21,7 +23,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
} else if (type === "wireguard") {
return "WireGuard";
} else if (type === "local") {
return "Local";
return t('local');
} else {
return "Unknown";
}
@@ -30,23 +32,23 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">Site Information</AlertTitle>
<AlertTitle className="font-semibold">{t('siteInfo')}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>
<InfoSectionTitle>Status</InfoSectionTitle>
<InfoSectionTitle>{t('status')}</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div 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>
</div>
) : (
<div 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>
</div>
)}
</InfoSectionContent>
@@ -54,7 +56,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
</>
)}
<InfoSection>
<InfoSectionTitle>Connection Type</InfoSectionTitle>
<InfoSectionTitle>{t('connectionType')}</InfoSectionTitle>
<InfoSectionContent>
{getConnectionTypeString(site.type)}
</InfoSectionContent>

View File

@@ -31,6 +31,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
import { useTranslations } from 'next-intl';
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required")
@@ -46,6 +47,7 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false);
const router = useRouter();
const t = useTranslations();
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
@@ -65,19 +67,16 @@ export default function GeneralPage() {
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
description: formatAxiosError(
e,
"An error occurred while updating the site."
)
title: t('siteErrorUpdate'),
description: formatAxiosError(e,t('siteErrorUpdateDescription'))
});
});
updateSite({ name: data.name });
toast({
title: "Site updated",
description: "The site has been updated."
title: t('siteUpdated'),
description: t('siteUpdatedDescription')
});
setLoading(false);
@@ -90,10 +89,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
{t('generalSettings')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this site
{t('siteGeneralDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -110,14 +109,13 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
site.
{t('siteNameDescription')}
</FormDescription>
</FormItem>
)}
@@ -134,7 +132,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save General Settings
{t('saveGeneralSettings')}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -16,6 +16,7 @@ import {
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import SiteInfoCard from "./SiteInfoCard";
import { getTranslations } from 'next-intl/server';
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -38,9 +39,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}/settings/sites`);
}
const t = await getTranslations();
const navItems = [
{
title: "General",
title: t('general'),
href: "/{orgId}/settings/sites/{niceId}/general"
}
];
@@ -48,8 +51,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${site?.name} Settings`}
description="Configure the settings on your site"
title={t('siteSetting', {siteName: site?.name})}
description={t('siteSettingDescription')}
/>
<SiteProvider site={site}>