I18n components (#27)

* New translation keys in en-US locale

* New translation keys in de-DE locale

* New translation keys in fr-FR locale

* New translation keys in it-IT locale

* New translation keys in pl-PL locale

* New translation keys in pt-PT locale

* New translation keys in tr-TR locale

* Move into function

* Replace string matching to boolean check

* Add FIXIT in UsersTable

* Use localization for size units

* Missed and restored translation keys

* fixup! New translation keys in tr-TR locale

* Add translation keys in components
This commit is contained in:
vlalx 2025-05-25 17:41:38 +03:00 committed by GitHub
parent af3694da34
commit ea24759bb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1419 additions and 329 deletions

View file

@ -43,6 +43,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type InviteUserFormProps = {
open: boolean;
@ -67,9 +68,11 @@ export default function InviteUserForm({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation"
message: t('inviteErrorInvalidConfirmation')
})
});
@ -129,7 +132,7 @@ export default function InviteUserForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"

View file

@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
import { useTranslations } from "next-intl";
type CopyTextBoxProps = {
text?: string;
@ -19,6 +20,7 @@ export default function CopyTextBox({
}: CopyTextBoxProps) {
const [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const t = useTranslations();
const copyToClipboard = async () => {
if (textRef.current) {
@ -27,7 +29,7 @@ export default function CopyTextBox({
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
console.error(t('copyTextFailed'), err);
}
}
};
@ -52,7 +54,7 @@ export default function CopyTextBox({
type="button"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
aria-label="Copy to clipboard"
aria-label={t('copyTextClipboard')}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" />

View file

@ -1,6 +1,7 @@
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslations } from "next-intl";
type CopyToClipboardProps = {
text: string;
@ -22,6 +23,8 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
const displayValue = displayText ?? text;
const t = useTranslations();
return (
<div className="flex items-center space-x-2 max-w-full">
{isLink ? (
@ -60,7 +63,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">Copy text</span>
<span className="sr-only">{t('copyText')}</span>
</button>
</div>
);

View file

@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@ -22,6 +23,8 @@ interface DataTablePaginationProps<TData> {
export function DataTablePagination<TData>({
table
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
@ -48,8 +51,7 @@ export function DataTablePagination<TData>({
<div className="flex items-center space-x-3 lg:space-x-8">
<div className="flex items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
{t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})}
</div>
<div className="flex items-center space-x-2">
<Button
@ -58,7 +60,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<span className="sr-only">{t('paginatorToFirst')}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
@ -67,7 +69,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<span className="sr-only">{t('paginatorToPrevious')}</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
@ -76,7 +78,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<span className="sr-only">{t('paginatorToNext')}</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
@ -87,7 +89,7 @@ export function DataTablePagination<TData>({
}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<span className="sr-only">{t('paginatorToLast')}</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>

View file

@ -32,6 +32,7 @@ import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { useUserContext } from "@app/hooks/useUserContext";
import { CheckCircle2 } from "lucide-react";
import { useTranslations } from "next-intl";
const disableSchema = z.object({
password: z.string().min(1, { message: "Password is required" }),
@ -60,6 +61,8 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
}
});
const t = useTranslations();
const request2fa = async (values: z.infer<typeof disableSchema>) => {
setLoading(true);
@ -70,10 +73,10 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
} as Disable2faBody)
.catch((e) => {
toast({
title: "Unable to disable 2FA",
title: t('otpErrorDisable'),
description: formatAxiosError(
e,
"An error occurred while disabling 2FA"
t('otpErrorDisableDescription')
),
variant: "destructive"
});
@ -109,10 +112,10 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
Disable Two-factor Authentication
{t('otpRemove')}
</CredenzaTitle>
<CredenzaDescription>
Disable two-factor authentication for your account
{t('otpRemoveDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -129,7 +132,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -147,7 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
Authenticator Code
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input {...field} />
@ -168,19 +171,17 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
size={48}
/>
<p className="font-semibold text-lg">
Two-Factor Authentication Disabled
{t('otpRemoveSuccess')}
</p>
<p>
Two-factor authentication has been disabled for
your account. You can enable it again at any
time.
{t('otpRemoveSuccessMessage')}
</p>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
{step === "password" && (
<Button
@ -189,7 +190,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
loading={loading}
disabled={loading}
>
Disable 2FA
{t('otpRemoveSubmit')}
</Button>
)}
</CredenzaFooter>

View file

@ -40,6 +40,7 @@ import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
const enableSchema = z.object({
password: z.string().min(1, { message: "Password is required" })
@ -82,6 +83,8 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
}
});
const t = useTranslations();
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
@ -94,10 +97,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
t('otpErrorEnableDescription')
),
variant: "destructive"
});
@ -121,10 +124,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
} as VerifyTotpBody)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
t('otpErrorEnableDescription')
),
variant: "destructive"
});
@ -141,14 +144,14 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const handleVerify = () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
setError(t('otpSetupCheckCode'));
return;
}
if (verificationCode === "123456") {
setSuccess(true);
setStep(3);
} else {
setError("Invalid code. Please try again.");
setError(t('otpSetupCheckCodeRetry'));
}
};
@ -176,10 +179,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
Enable Two-factor Authentication
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
Secure your account with an extra layer of protection
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -196,7 +199,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -215,8 +218,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
{step === 2 && (
<div className="space-y-4">
<p>
Scan this QR code with your authenticator app or
enter the secret key manually:
{t('otpSetupScanQr')}
</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
@ -243,7 +245,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
Authenticator Code
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
@ -268,11 +270,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
size={48}
/>
<p className="font-semibold text-lg">
Two-Factor Authentication Enabled
{t('otpSetupSuccess')}
</p>
<p>
Your account is now more secure. Don't forget to
save your backup codes.
{t('otpSetupSuccessStoreBackupCodes')}
</p>
<div className="max-w-md mx-auto">
@ -298,7 +299,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
}
}}
>
Submit
{t('submit')}
</Button>
)}
</CredenzaFooter>

View file

@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
export type HorizontalTabs = Array<{
title: string;
@ -29,6 +30,7 @@ export function HorizontalTabs({
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
function hydrateHref(href: string) {
return href
@ -86,7 +88,7 @@ export function HorizontalTabs({
variant="outlinePrimary"
className="ml-2"
>
Professional
{t('licenseBadge')}
</Badge>
)}
</div>

View file

@ -23,6 +23,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
interface LayoutProps {
children: React.ReactNode;
@ -60,6 +61,7 @@ export function Layout({
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
return (
<div className="flex flex-col h-screen overflow-hidden">
@ -84,11 +86,10 @@ export function Layout({
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
Navigation Menu
{t('navbar')}
</SheetTitle>
<SheetDescription className="sr-only">
Main navigation menu for the
application
{t('navbarDescription')}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
@ -114,7 +115,7 @@ export function Layout({
}
>
<Server className="h-4 w-4" />
Server Admin
{t('serverAdmin')}
</Link>
</div>
)}
@ -161,7 +162,7 @@ export function Layout({
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Documentation
{t('navbarDocsLink')}
</Link>
</div>
<div>
@ -193,7 +194,7 @@ export function Layout({
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
>
<Server className="h-4 w-4" />
Server Admin
{t('serverAdmin')}
</Link>
</div>
)}
@ -210,8 +211,8 @@ export function Layout({
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? "Community Edition"
: "Commercial Edition"}
? t('communityEdition')
: t('commercialEdition')}
<ExternalLink size={12} />
</Link>
</div>

View file

@ -40,6 +40,7 @@ import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
export type LoginFormIDP = {
idpId: number;
@ -91,6 +92,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
});
const t = useTranslations();
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
@ -106,7 +109,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, "An error occurred while logging in")
formatAxiosError(e, t('loginError'))
);
});
@ -151,7 +154,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res);
if (!res) {
setError("An error occurred while logging in");
setError(t('loginError'));
return;
}
@ -177,7 +180,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -192,7 +195,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -209,7 +212,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
{t('passwordForgot')}
</Link>
</div>
</div>
@ -222,11 +225,10 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
<>
<div className="text-center">
<h3 className="text-lg font-medium">
Two-Factor Authentication
{t('otpAuth')}
</h3>
<p className="text-sm text-muted-foreground">
Enter the code from your authenticator app or one of
your single-use backup codes.
{t('otpAuthDescription')}
</p>
</div>
<Form {...mfaForm}>
@ -302,7 +304,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading}
disabled={loading}
>
Submit Code
{t('otpAuthSubmit')}
</Button>
)}
@ -316,7 +318,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
disabled={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Log In
{t('login')}
</Button>
{hasIdp && (
@ -327,7 +329,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
Or continue with
{t('idpContinue')}
</span>
</div>
</div>
@ -360,7 +362,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset();
}}
>
Back to Log In
{t('otpAuthBack')}
</Button>
)}
</div>

View file

@ -22,6 +22,7 @@ import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
interface OrgSelectorProps {
orgId?: string;
@ -33,6 +34,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
return (
<Popover open={open} onOpenChange={setOpen}>
@ -47,7 +49,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
Organization
{t('org')}
</span>
<span className="text-sm text-muted-foreground">
{orgId
@ -56,7 +58,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
org.orgId ===
orgId
)?.name
: "None selected"}
: t('noneSelected')}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
@ -65,14 +67,14 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandInput placeholder={t('searchProgress')} />
<CommandEmpty>
No organizations found.
{t('orgNotFound2')}
</CommandEmpty>
{(!env.flags.disableUserCreateOrg ||
user.serverAdmin) && (
<>
<CommandGroup heading="Create">
<CommandGroup heading={t('create')}>
<CommandList>
<CommandItem
onSelect={(
@ -84,14 +86,14 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
}}
>
<Plus className="mr-2 h-4 w-4" />
New Organization
{t('setupNewOrg')}
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Organizations">
<CommandGroup heading={t('orgs')}>
<CommandList>
{orgs?.map((org) => (
<CommandItem

View file

@ -7,6 +7,7 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type PermissionsSelectBoxProps = {
root?: boolean;
@ -15,101 +16,103 @@ type PermissionsSelectBoxProps = {
};
function getActionsCategories(root: boolean) {
const t = useTranslations();
const actionsByCategory: Record<string, Record<string, string>> = {
Organization: {
"Get Organization": "getOrg",
"Update Organization": "updateOrg",
"Get Organization User": "getOrgUser",
"List Organization Domains": "listOrgDomains",
[t('actionGetOrg')]: "getOrg",
[t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser",
[t('actionListOrgDomains')]: "listOrgDomains",
},
Site: {
"Create Site": "createSite",
"Delete Site": "deleteSite",
"Get Site": "getSite",
"List Sites": "listSites",
"Update Site": "updateSite",
"List Allowed Site Roles": "listSiteRoles"
[t('actionCreateSite')]: "createSite",
[t('actionDeleteSite')]: "deleteSite",
[t('actionGetSite')]: "getSite",
[t('actionListSites')]: "listSites",
[t('actionUpdateSite')]: "updateSite",
[t('actionListSiteRoles')]: "listSiteRoles"
},
Resource: {
"Create Resource": "createResource",
"Delete Resource": "deleteResource",
"Get Resource": "getResource",
"List Resources": "listResources",
"Update Resource": "updateResource",
"List Resource Users": "listResourceUsers",
"Set Resource Users": "setResourceUsers",
"Set Allowed Resource Roles": "setResourceRoles",
"List Allowed Resource Roles": "listResourceRoles",
"Set Resource Password": "setResourcePassword",
"Set Resource Pincode": "setResourcePincode",
"Set Resource Email Whitelist": "setResourceWhitelist",
"Get Resource Email Whitelist": "getResourceWhitelist"
[t('actionCreateResource')]: "createResource",
[t('actionDeleteResource')]: "deleteResource",
[t('actionGetResource')]: "getResource",
[t('actionListResource')]: "listResources",
[t('actionUpdateResource')]: "updateResource",
[t('actionListResourceUsers')]: "listResourceUsers",
[t('actionSetResourceUsers')]: "setResourceUsers",
[t('actionSetAllowedResourceRoles')]: "setResourceRoles",
[t('actionListAllowedResourceRoles')]: "listResourceRoles",
[t('actionSetResourcePassword')]: "setResourcePassword",
[t('actionSetResourcePincode')]: "setResourcePincode",
[t('actionSetResourceEmailWhitelist')]: "setResourceWhitelist",
[t('actionGetResourceEmailWhitelist')]: "getResourceWhitelist"
},
Target: {
"Create Target": "createTarget",
"Delete Target": "deleteTarget",
"Get Target": "getTarget",
"List Targets": "listTargets",
"Update Target": "updateTarget"
[t('actionCreateTarget')]: "createTarget",
[t('actionDeleteTarget')]: "deleteTarget",
[t('actionGetTarget')]: "getTarget",
[t('actionListTargets')]: "listTargets",
[t('actionUpdateTarget')]: "updateTarget"
},
Role: {
"Create Role": "createRole",
"Delete Role": "deleteRole",
"Get Role": "getRole",
"List Roles": "listRoles",
"Update Role": "updateRole",
"List Allowed Role Resources": "listRoleResources"
[t('actionCreateRole')]: "createRole",
[t('actionDeleteRole')]: "deleteRole",
[t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources"
},
User: {
"Invite User": "inviteUser",
"Remove User": "removeUser",
"List Users": "listUsers",
"Add User Role": "addUserRole"
[t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionAddUserRole')]: "addUserRole"
},
"Access Token": {
"Generate Access Token": "generateAccessToken",
"Delete Access Token": "deleteAcessToken",
"List Access Tokens": "listAccessTokens"
[t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken",
[t('actionListAccessTokens')]: "listAccessTokens"
},
"Resource Rule": {
"Create Resource Rule": "createResourceRule",
"Delete Resource Rule": "deleteResourceRule",
"List Resource Rules": "listResourceRules",
"Update Resource Rule": "updateResourceRule"
[t('actionCreateResourceRule')]: "createResourceRule",
[t('actionDeleteResourceRule')]: "deleteResourceRule",
[t('actionListResourceRules')]: "listResourceRules",
[t('actionUpdateResourceRule')]: "updateResourceRule"
}
};
if (root) {
actionsByCategory["Organization"] = {
"List Organizations": "listOrgs",
"Check ID": "checkOrgId",
"Create Organization": "createOrg",
"Delete Organization": "deleteOrg",
"List API Keys": "listApiKeys",
"List API Key Actions": "listApiKeyActions",
"Set API Key Allowed Actions": "setApiKeyActions",
"Create API Key": "createApiKey",
"Delete API Key": "deleteApiKey",
[t('actionListOrgs')]: "listOrgs",
[t('actionCheckOrgId')]: "checkOrgId",
[t('actionCreateOrg')]: "createOrg",
[t('actionDeleteOrg')]: "deleteOrg",
[t('actionListApiKeys')]: "listApiKeys",
[t('actionListApiKeyActions')]: "listApiKeyActions",
[t('actionSetApiKeyActions')]: "setApiKeyActions",
[t('actionCreateApiKey')]: "createApiKey",
[t('actionDeleteApiKey')]: "deleteApiKey",
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
"Create IDP": "createIdp",
"Update IDP": "updateIdp",
"Delete IDP": "deleteIdp",
"List IDP": "listIdps",
"Get IDP": "getIdp",
"Create IDP Org Policy": "createIdpOrg",
"Delete IDP Org Policy": "deleteIdpOrg",
"List IDP Orgs": "listIdpOrgs",
"Update IDP Org": "updateIdpOrg"
[t('actionCreateIdp')]: "createIdp",
[t('actionUpdateIdp')]: "updateIdp",
[t('actionDeleteIdp')]: "deleteIdp",
[t('actionListIdps')]: "listIdps",
[t('actionGetIdp')]: "getIdp",
[t('actionCreateIdpOrg')]: "createIdpOrg",
[t('actionDeleteIdpOrg')]: "deleteIdpOrg",
[t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
};
}

View file

@ -1,6 +1,7 @@
"use client";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
type ProfessionalContentOverlayProps = {
children: React.ReactNode;
@ -11,6 +12,8 @@ export function ProfessionalContentOverlay({
children,
isProfessional = false
}: ProfessionalContentOverlayProps) {
const t = useTranslations();
return (
<div
className={cn(
@ -22,11 +25,10 @@ export function ProfessionalContentOverlay({
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
<div className="text-center p-6 bg-primary/10 rounded-lg">
<h3 className="text-lg font-semibold mb-2">
Professional Edition Required
{t('licenseTierProfessionalRequired')}
</h3>
<p className="text-muted-foreground">
This feature is only available in the Professional
Edition.
{t('licenseTierProfessionalRequiredDescription')}
</p>
</div>
</div>

View file

@ -24,6 +24,7 @@ import Enable2FaForm from "./Enable2FaForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
import { useTranslations } from "next-intl";
export default function ProfileIcon() {
@ -40,6 +41,8 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const t = useTranslations();
function getInitials() {
return (user.email || user.name || user.username)
.substring(0, 1)
@ -54,10 +57,10 @@ export default function ProfileIcon() {
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error("Error logging out", e);
console.error(t('logoutError'), e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out")
title: t('logoutError'),
description: formatAxiosError(e, t('logoutError'))
});
})
.then(() => {
@ -94,7 +97,7 @@ export default function ProfileIcon() {
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
Signed in as
{t('signingAs')}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email || user.name || user.username}
@ -102,11 +105,11 @@ export default function ProfileIcon() {
</div>
{user.serverAdmin ? (
<p className="text-xs leading-none text-muted-foreground mt-2">
Server Admin
{t('serverAdmin')}
</p>
) : (
<p className="text-xs leading-none text-muted-foreground mt-2">
{user.idpName || "Internal"}
{user.idpName || t('idpNameInternal')}
</p>
)}
</DropdownMenuLabel>
@ -117,14 +120,14 @@ export default function ProfileIcon() {
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
>
<span>Enable Two-factor</span>
<span>{t('otpEnable')}</span>
</DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenDisable2fa(true)}
>
<span>Disable Two-factor</span>
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@ -166,7 +169,7 @@ export default function ProfileIcon() {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>Log Out</span>
<span>{t('logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
export interface SidebarNavItem {
href: string;
@ -65,6 +66,8 @@ export function SidebarNav({
const { user } = useUserContext();
const t = useTranslations();
function hydrateHref(val: string): string {
return val
.replace("{orgId}", orgId)
@ -144,7 +147,7 @@ export function SidebarNav({
variant="outlinePrimary"
className="ml-2"
>
Professional
{t('licenseBadge')}
</Badge>
)}
</Link>

View file

@ -3,7 +3,7 @@
import Image from "next/image";
import { Separator } from "@app/components/ui/separator";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState } from "react";
import { useState, useTransition } from "react";
import {
Popover,
PopoverContent,
@ -48,6 +48,7 @@ import {
} from "./ui/card";
import { Check, ExternalLink } from "lucide-react";
import confetti from "canvas-confetti";
import { useTranslations } from "next-intl";
const formSchema = z.object({
githubUsername: z
@ -73,6 +74,8 @@ export default function SupporterStatus() {
}
});
const t = useTranslations();
async function hide() {
await api.post("/supporter-key/hide");
@ -95,8 +98,8 @@ export default function SupporterStatus() {
if (!data || !data.valid) {
toast({
variant: "destructive",
title: "Invalid Key",
description: "Your supporter key is invalid."
title: t('supportKeyInvalid'),
description: t('supportKeyInvalidDescription')
});
return;
}
@ -104,9 +107,8 @@ export default function SupporterStatus() {
// Trigger the toast
toast({
variant: "default",
title: "Valid Key",
description:
"Your supporter key has been validated. Thank you for your support!"
title: t('supportKeyValid'),
description: t('supportKeyValidDescription')
});
// Fireworks-style confetti
@ -162,7 +164,7 @@ export default function SupporterStatus() {
} catch (error) {
toast({
variant: "destructive",
title: "Error",
title: t('error'),
description: formatAxiosError(
error,
"Failed to validate supporter key."
@ -183,55 +185,47 @@ export default function SupporterStatus() {
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
Support Development and Adopt a Pangolin!
{t('supportKey')}
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<p>
Purchase a supporter key to help us continue
developing Pangolin for the community. Your
contribution allows us to commit more time to
maintain and add new features to the application for
everyone. We will never use this to paywall
features. This is separate from any Commercial
Edition.
{t('supportKeyDescription')}
</p>
<p>
You will also get to adopt and meet your very own
pet Pangolin!
{t('supportKeyPet')}
</p>
<p>
Payments are processed via GitHub. Afterward, you
can retrieve your key on{" "}
{t('supportKeyPurchase')}{" "}
<Link
href="https://supporters.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
our website
{t('supportKeyPurchaseLink')}
</Link>{" "}
and redeem it here.{" "}
{t('supportKeyPurchase2')}{" "}
<Link
href="https://docs.fossorial.io/supporter-program"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more.
{t('supportKeyLearnMore')}
</Link>
</p>
<div className="py-6">
<p className="mb-3 text-center">
Please select the option that best suits you.
{t('supportKeyOptions')}
</p>
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
<Card>
<CardHeader>
<CardTitle>Full Supporter</CardTitle>
<CardTitle>{t('supportKetOptionFull')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$95</p>
@ -239,19 +233,19 @@ export default function SupporterStatus() {
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For the whole server
{t('forWholeServer')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
{t('lifetimePurchase')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
{t('supporterStatus')}
</span>
</li>
</ul>
@ -264,7 +258,7 @@ export default function SupporterStatus() {
className="w-full"
>
<Button className="w-full">
Buy
{t('buy')}
</Button>
</Link>
</CardFooter>
@ -274,7 +268,7 @@ export default function SupporterStatus() {
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
>
<CardHeader>
<CardTitle>Limited Supporter</CardTitle>
<CardTitle>{t('supportKeyOptionLimited')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$25</p>
@ -282,19 +276,19 @@ export default function SupporterStatus() {
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For 5 or less users
{t('forFiveUsers')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
{t('lifetimePurchase')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
{t('supporterStatus')}
</span>
</li>
</ul>
@ -309,7 +303,7 @@ export default function SupporterStatus() {
className="w-full"
>
<Button className="w-full">
Buy
{t('buy')}
</Button>
</Link>
) : (
@ -320,7 +314,7 @@ export default function SupporterStatus() {
"Limited Supporter"
}
>
Buy
{t('buy')}
</Button>
)}
</CardFooter>
@ -336,20 +330,20 @@ export default function SupporterStatus() {
setKeyOpen(true);
}}
>
Redeem Supporter Key
{t('supportKeyRedeem')}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => hide()}
>
Hide for 7 days
{t('supportKeyHideSevenDays')}
</Button>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
@ -363,9 +357,9 @@ export default function SupporterStatus() {
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Enter Supporter Key</CredenzaTitle>
<CredenzaTitle>{t('supportKeyEnter')}</CredenzaTitle>
<CredenzaDescription>
Meet your very own pet Pangolin!
{t('supportKeyEnterDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -381,7 +375,7 @@ export default function SupporterStatus() {
render={({ field }) => (
<FormItem>
<FormLabel>
GitHub Username
{t('githubUsername')}
</FormLabel>
<FormControl>
<Input {...field} />
@ -395,7 +389,7 @@ export default function SupporterStatus() {
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Supporter Key</FormLabel>
<FormLabel>{t('supportKeyInput')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -408,10 +402,10 @@ export default function SupporterStatus() {
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button type="submit" form="form">
Submit
{t('submit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
@ -426,7 +420,7 @@ export default function SupporterStatus() {
setPurchaseOptionsOpen(true);
}}
>
Buy Supporter Key
{t('supportKeyBuy')}
</Button>
) : null}
</>

View file

@ -10,6 +10,7 @@ import { TagList } from "./tag-list";
import { tagVariants } from "./tag";
import { Autocomplete } from "./autocomplete";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
export enum Delimiter {
Comma = ",",
@ -166,11 +167,13 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
);
const inputRef = React.useRef<HTMLInputElement>(null);
const t = useTranslations();
if (
(maxTags !== undefined && maxTags < 0) ||
(props.minTags !== undefined && props.minTags < 0)
) {
console.warn("maxTags and minTags cannot be less than 0");
console.warn(t('tagsWarnCannotBeLessThanZero'));
// error
return null;
}
@ -194,24 +197,22 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(option) => option.text === newTagText
)
) {
console.warn(
"Tag not allowed as per autocomplete options"
);
console.warn(t('tagsWarnNotAllowedAutocompleteOptions'));
return;
}
if (validateTag && !validateTag(newTagText)) {
console.warn("Invalid tag as per validateTag");
console.warn(t('tagsWarnInvalid'));
return;
}
if (minLength && newTagText.length < minLength) {
console.warn(`Tag "${newTagText}" is too short`);
console.warn(t('tagWarnTooShort', {tagText: newTagText}));
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn(`Tag "${newTagText}" is too long`);
console.warn(t('tagWarnTooLong', {tagText: newTagText}));
return;
}
@ -228,12 +229,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
setTags((prevTags) => [...prevTags, newTag]);
onTagAdd?.(newTagText);
} else {
console.warn(
"Reached the maximum number of tags allowed"
);
console.warn(t('tagsWarnReachedMaxNumber'));
}
} else {
console.warn(`Duplicate tag "${newTagText}" not added`);
console.warn(t('tagWarnDuplicate', {tagText: newTagText}));
}
});
setInputValue("");
@ -259,12 +258,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
console.warn(t('tagWarnTooShort'));
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn("Tag is too long");
console.warn(t('tagWarnTooLong'));
return;
}
@ -309,7 +308,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
console.warn(t('tagWarnTooShort'));
// error
return;
}
@ -317,7 +316,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// Validate maxLength
if (maxLength && newTagText.length > maxLength) {
// error
console.warn("Tag is too long");
console.warn(t('tagWarnTooLong'));
return;
}

View file

@ -4,6 +4,7 @@ import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
type TagPopoverProps = {
children: React.ReactNode;
@ -41,6 +42,8 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
const t = useTranslations();
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) {
@ -183,10 +186,10 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
>
<div className="space-y-1">
<h4 className="text-sm font-medium leading-none">
Entered Tags
{t('tagsEntered')}
</h4>
<p className="text-sm text-muted-foregrounsd text-left">
These are the tags you&apos;ve entered.
{t('tagsEnteredDescription')}
</p>
</div>
<TagList