I18n admin (#22)

* 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

* Add translation keys in app/admin

* Fix build

---------

Co-authored-by: Lokowitz <marvinlokowitz@gmail.com>
This commit is contained in:
vlalx
2025-05-17 19:04:19 +03:00
committed by GitHub
parent 96bfc3cf36
commit d2d84be99a
27 changed files with 1028 additions and 306 deletions

View File

@@ -45,10 +45,8 @@ import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
const t = useTranslations();
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
name: z.string().min(2, "Name must be at least 2 characters."),
clientId: z.string().min(1, { message: "Client ID is required." }),
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
@@ -91,6 +89,8 @@ export default function GeneralPage() {
}
});
const t = useTranslations();
useEffect(() => {
const loadIdp = async () => {
try {
@@ -112,7 +112,7 @@ export default function GeneralPage() {
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -172,18 +172,17 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
{t('idpTitle')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
{t('idpSettingsDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
Redirect URL
{t('redirectUrl')}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
@@ -194,13 +193,10 @@ export default function GeneralPage() {
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Redirect URL
{t('redirectUrlAbout')}
</AlertTitle>
<AlertDescription>
This is the URL to which users will be
redirected after authentication. You need to
configure this URL in your identity provider
settings.
{t('redirectUrlAboutDescription')}
</AlertDescription>
</Alert>
<SettingsSectionForm>
@@ -215,13 +211,12 @@ export default function GeneralPage() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this
identity provider
{t('idpDisplayName')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -231,7 +226,7 @@ export default function GeneralPage() {
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
label={t('idpAutoProvisionUsers')}
defaultChecked={form.getValues(
"autoProvision"
)}
@@ -244,10 +239,7 @@ export default function GeneralPage() {
/>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login with the ability to map
users to roles and organizations.
{t('idpAutoProvisionUsersDescription')}
</span>
</form>
</Form>
@@ -259,11 +251,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
{t('idpOidcConfigure')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints and
credentials
{t('idpOidcConfigureDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -280,15 +271,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Client ID
{t('idpClientId')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID
from your identity
provider
{t('idpClientIdDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -301,7 +290,7 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
{t('idpClientSecret')}
</FormLabel>
<FormControl>
<Input
@@ -310,9 +299,7 @@ export default function GeneralPage() {
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity
provider
{t('idpClientSecretDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -325,14 +312,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
{t('idpAuthUrl')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
{t('idpAuthUrlDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -345,14 +331,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Token URL
{t('idpTokenUrl')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token
endpoint URL
{t('idpTokenUrlDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -367,11 +352,10 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
{t('idpToken')}
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from
the ID token
{t('idpTokenDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -385,19 +369,18 @@ export default function GeneralPage() {
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
{t('idpJmespathAbout')}
</AlertTitle>
<AlertDescription>
The paths below use JMESPath
syntax to extract values from
the ID token.
{/*TODO(vlalx): Validate replacing */}
{t('idpJmespathAboutDescription')}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about JMESPath{" "}
{t('idpJmespathAboutDescriptionLink')}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
@@ -409,15 +392,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
{t('idpJmespathLabel')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID
token
{t('idpJmespathLabelDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -430,15 +411,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
{t('idpJmespathEmailPathOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's email in the ID
token
{t('idpJmespathEmailPathOptionalDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -451,15 +430,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
{t('idpJmespathNamePathOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's name in the ID
token
{t('idpJmespathNamePathOptionalDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -472,14 +449,13 @@ export default function GeneralPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Scopes
{t('idpOidcConfigureScopes')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
{t('idpOidcConfigureScopesDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -500,7 +476,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save General Settings
{t('saveGeneralSettings')}
</Button>
</div>
</>

View File

@@ -15,6 +15,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { useTranslations } from "next-intl";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -36,13 +37,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect("/admin/idp");
}
const t = useTranslations();
const navItems: HorizontalTabs = [
{
title: "General",
title: t('general'),
href: `/admin/idp/${params.idpId}/general`
},
{
title: "Organization Policies",
title: t('orgPolicies'),
href: `/admin/idp/${params.idpId}/policies`
}
];
@@ -50,8 +53,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<SettingsSectionTitle
title={`${idp.idp.name} Settings`}
description="Configure the settings for your identity provider"
title={t('idpSettings', { idpName: idp?.idp.name })}
description={t('idpSettingsDescription')}
/>
<div className="space-y-6">

View File

@@ -2,6 +2,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>[];
@@ -14,14 +15,15 @@ export function PolicyDataTable<TData, TValue>({
data,
onAdd
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title="Organization Policies"
searchPlaceholder="Search organization policies..."
title={t('orgPolicies')}
searchPlaceholder={t('orgPoliciesSearch')}
searchColumn="orgId"
addButtonText="Add Organization Policy"
addButtonText={t('orgPoliciesAdd')}
onAdd={onAdd}
/>
);

View File

@@ -19,6 +19,7 @@ import {
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useTranslations } from "next-intl";
export interface PolicyRow {
orgId: string;
@@ -34,6 +35,7 @@ interface Props {
}
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const t = useTranslations();
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
@@ -44,7 +46,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
<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>
@@ -54,7 +56,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
onDelete(r.orgId);
}}
>
<span className="text-red-500">Delete</span>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -71,7 +73,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization ID
{t('orgId')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -87,7 +89,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Role Mapping
{t('roleMapping')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -114,7 +116,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Mapping
{t('orgMapping')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@@ -142,7 +144,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
className="ml-2"
onClick={() => onEdit(policy)}
>
Edit
{t('edit')}
</Button>
</div>
);

View File

@@ -63,6 +63,7 @@ import {
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
type Organization = {
orgId: string;
@@ -117,6 +118,8 @@ export default function PoliciesPage() {
}
});
const t = useTranslations();
const loadIdp = async () => {
try {
const res = await api.get<AxiosResponse<GetIdpResponse>>(
@@ -131,7 +134,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -146,7 +149,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -165,7 +168,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -200,15 +203,15 @@ export default function PoliciesPage() {
};
setPolicies([...policies, newPolicy]);
toast({
title: "Success",
description: "Policy added successfully"
title: t('success'),
description: t('orgPolicyAddedDescription')
});
setShowAddDialog(false);
form.reset();
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -242,8 +245,8 @@ export default function PoliciesPage() {
)
);
toast({
title: "Success",
description: "Policy updated successfully"
title: t('success'),
description: t('orgPolicyUpdatedDescription')
});
setShowAddDialog(false);
setEditingPolicy(null);
@@ -251,7 +254,7 @@ export default function PoliciesPage() {
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -269,13 +272,13 @@ export default function PoliciesPage() {
policies.filter((policy) => policy.orgId !== orgId)
);
toast({
title: "Success",
description: "Policy deleted successfully"
title: t('success'),
description: t('orgPolicyDeletedDescription')
});
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -293,13 +296,13 @@ export default function PoliciesPage() {
});
if (res.status === 200) {
toast({
title: "Success",
description: "Default mappings updated successfully"
title: t('success'),
description: t('defaultMappingsUpdatedDescription')
});
}
} catch (e) {
toast({
title: "Error",
title: t('error'),
description: formatAxiosError(e),
variant: "destructive"
});
@@ -318,21 +321,18 @@ export default function PoliciesPage() {
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Organization Policies
{t('orgPoliciesAbout')}
</AlertTitle>
<AlertDescription>
Organization policies are used to control access to
organizations based on the user's ID token. You can
specify JMESPath expressions to extract role and
organization information from the ID token. For more
information, see{" "}
{/*TODO(vlalx): Validate replacing */}
{t('orgPoliciesAboutDescription')}{" "}
<Link
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
the documentation
{t('orgPoliciesAboutDescriptionLink')}
<ExternalLink className="ml-1 h-4 w-4 inline" />
</Link>
</AlertDescription>
@@ -341,13 +341,10 @@ export default function PoliciesPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Default Mappings (Optional)
{t('defaultMappingsOptional')}
</SettingsSectionTitle>
<SettingsSectionDescription>
The default mappings are used when when there is not
an organization policy defined for an organization.
You can specify the default role and organization
mappings to fall back to here.
{t('defaultMappingsOptionalDescription')}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -366,16 +363,13 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Default Role Mapping
{t('defaultMappingsRole')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The result of this
expression must return the
role name as defined in the
organization as a string.
{t('defaultMappingsRoleDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -388,16 +382,13 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Default Organization Mapping
{t('defaultMappingsOrg')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This expression must return
thr org ID or true for the
user to be allowed to access
the organization.
{t('defaultMappingsOrgDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -412,7 +403,7 @@ export default function PoliciesPage() {
form="policy-default-mappings-form"
loading={updateDefaultMappingsLoading}
>
Save Default Mappings
{t('defaultMappingsSubmit')}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
@@ -455,8 +446,8 @@ export default function PoliciesPage() {
<CredenzaHeader>
<CredenzaTitle>
{editingPolicy
? "Edit Organization Policy"
: "Add Organization Policy"}
? t('orgPoliciesEdit')
: t('orgPoliciesAdd')}
</CredenzaTitle>
<CredenzaDescription>
Configure access for an organization
@@ -476,7 +467,7 @@ export default function PoliciesPage() {
name="orgId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Organization</FormLabel>
<FormLabel>{t('org')}</FormLabel>
{editingPolicy ? (
<Input {...field} disabled />
) : (
@@ -500,18 +491,17 @@ export default function PoliciesPage() {
org.orgId ===
field.value
)?.name
: "Select organization"}
: t('orgSelect')}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search org" />
<CommandInput placeholder={t('orgSearch')} />
<CommandList>
<CommandEmpty>
No org
found.
{t('orgNotFound')}
</CommandEmpty>
<CommandGroup>
{organizations.map(
@@ -562,16 +552,13 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Role Mapping Path (Optional)
{t('roleMappingPathOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The result of this expression
must return the role name as
defined in the organization as a
string.
{t('defaultMappingsRoleDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -584,17 +571,13 @@ export default function PoliciesPage() {
render={({ field }) => (
<FormItem>
<FormLabel>
Organization Mapping Path
(Optional)
{t('orgMappingPathOptional')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This expression must return the
org ID or true for the user to
be allowed to access the
organization.
{t('defaultMappingsOrgDescription')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -605,7 +588,7 @@ export default function PoliciesPage() {
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
type="submit"
@@ -621,7 +604,7 @@ export default function PoliciesPage() {
: addPolicyLoading
}
>
{editingPolicy ? "Update Policy" : "Add Policy"}
{editingPolicy ? t('orgPolicyUpdate') : t('orgPolicyAdd')}
</Button>
</CredenzaFooter>
</CredenzaContent>