diff --git a/web/package.json b/web/package.json index 2dba5c9..8f4500c 100644 --- a/web/package.json +++ b/web/package.json @@ -50,6 +50,7 @@ "date-fns": "^3.6.0", "file-saver": "^2.0.5", "handlebars": "^4.7.8", + "i18next": "^25.6.3", "js-cookie": "^3.0.5", "lucide-react": "^0.468.0", "react": "^18.3.1", @@ -57,6 +58,7 @@ "react-day-picker": "8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.0", + "react-i18next": "^16.3.5", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.7", "recharts": "^2.14.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8d26575..4cb83d1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: handlebars: specifier: ^4.7.8 version: 4.7.8 + i18next: + specifier: ^25.6.3 + version: 25.6.3(typescript@5.7.2) js-cookie: specifier: ^3.0.5 version: 3.0.5 @@ -140,6 +143,9 @@ importers: react-hook-form: specifier: ^7.54.0 version: 7.54.0(react@18.3.1) + react-i18next: + specifier: ^16.3.5 + version: 16.3.5(i18next@25.6.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.18)(react@18.3.1) @@ -166,7 +172,7 @@ importers: version: 3.23.8 zustand: specifier: ^5.0.2 - version: 5.0.2(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) + version: 5.0.2(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -348,6 +354,10 @@ packages: resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -2721,9 +2731,20 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + i18next@25.6.3: + resolution: {integrity: sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3334,6 +3355,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@16.3.5: + resolution: {integrity: sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3740,6 +3777,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3798,6 +3840,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -4007,6 +4053,8 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -6302,8 +6350,18 @@ snapshots: dependencies: '@types/hast': 3.0.4 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} + i18next@25.6.3(typescript@5.7.2): + dependencies: + '@babel/runtime': 7.28.4 + optionalDependencies: + typescript: 5.7.2 + ignore@5.3.2: {} immutable@5.1.3: @@ -7025,6 +7083,17 @@ snapshots: dependencies: react: 18.3.1 + react-i18next@16.3.5(i18next@25.6.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2): + dependencies: + '@babel/runtime': 7.28.4 + html-parse-stringify: 3.0.1 + i18next: 25.6.3(typescript@5.7.2) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.7.2 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -7504,6 +7573,10 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} vaul@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -7556,6 +7629,8 @@ snapshots: tsx: 4.19.2 yaml: 2.6.0 + void-elements@3.1.0: {} + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -7636,10 +7711,10 @@ snapshots: zod@3.23.8: {} - zustand@5.0.2(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): + zustand@5.0.2(@types/react@18.3.18)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.18 react: 18.3.1 - use-sync-external-store: 1.2.2(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) zwitch@2.0.4: {} diff --git a/web/src/api/oauth2/api.ts b/web/src/api/oauth2/api.ts index bd1288d..257f479 100644 --- a/web/src/api/oauth2/api.ts +++ b/web/src/api/oauth2/api.ts @@ -51,7 +51,7 @@ export interface OAuth2Tokens { access_token: string; account_id: string; created_at: number; - oauth2_name: string; + oauth2_id: number; refresh_token: string; updated_at: number; } diff --git a/web/src/components/api-docs.tsx b/web/src/components/api-docs.tsx index 559bd45..f246679 100644 --- a/web/src/components/api-docs.tsx +++ b/web/src/components/api-docs.tsx @@ -21,16 +21,18 @@ import { Card } from "@/components/ui/card"; import { FixedHeader } from "./layout/fixed-header"; import { Main } from "./layout/main"; import Logo from '@/assets/logo.svg' - -const docsOptions = [ - { name: "Swagger UI", path: "/api-docs/swagger" }, - { name: "ReDoc", path: "/api-docs/redoc" }, - { name: "OpenAPI Explorer", path: "/api-docs/explorer" }, - { name: "Scalar", path: "/api-docs/scalar" }, - { name: "Download Spec YAML", path: "/api-docs/spec.yaml" } -]; +import { useTranslation } from 'react-i18next' export default function APIDocs() { + const { t } = useTranslation() + + const docsOptions = [ + { name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" }, + { name: t('apiDocs.reDoc'), path: "/api-docs/redoc" }, + { name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" }, + { name: t('apiDocs.scalar'), path: "/api-docs/scalar" }, + { name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" } + ]; const handleCardClick = (path: string) => { // Open in new tab window.open(path, '_blank', 'noopener,noreferrer'); @@ -43,9 +45,9 @@ export default function APIDocs() {
-

API Documentation

+

{t('navigation.apiDocs')}

- Choose your preferred API documentation type + {t('apiDocs.choosePreferredType')}

@@ -62,7 +64,7 @@ export default function APIDocs() { RustMailer Logo

{option.name}

diff --git a/web/src/components/command-menu.tsx b/web/src/components/command-menu.tsx index f1253a6..0ba095b 100644 --- a/web/src/components/command-menu.tsx +++ b/web/src/components/command-menu.tsx @@ -35,13 +35,16 @@ import { CommandList, CommandSeparator, } from '@/components/ui/command' -import { sidebarData } from './layout/data/sidebar-data' +import { useSidebarData } from './layout/data/sidebar-data' import { ScrollArea } from './ui/scroll-area' +import { useTranslation } from 'react-i18next' export function CommandMenu() { const navigate = useNavigate() const { setTheme } = useTheme() const { open, setOpen } = useSearch() + const sidebarData = useSidebarData() + const { t } = useTranslation() const runCommand = React.useCallback( (command: () => unknown) => { @@ -53,10 +56,10 @@ export function CommandMenu() { return ( - + - No results found. + {t('command.noResultsFound')} {sidebarData.navGroups.map((group) => ( {group.items.map((navItem, i) => { @@ -94,13 +97,13 @@ export function CommandMenu() { ))} - + runCommand(() => setTheme('light'))}> - Light + {t('command.light')} runCommand(() => setTheme('dark'))}> - Dark + {t('command.dark')} diff --git a/web/src/components/confirm-dialog.tsx b/web/src/components/confirm-dialog.tsx index fd9a147..7d1a4e9 100644 --- a/web/src/components/confirm-dialog.tsx +++ b/web/src/components/confirm-dialog.tsx @@ -28,6 +28,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' +import { useTranslation } from 'react-i18next' interface ConfirmDialogProps { open: boolean @@ -45,6 +46,7 @@ interface ConfirmDialogProps { } export function ConfirmDialog(props: ConfirmDialogProps) { + const { t } = useTranslation() const { title, desc, @@ -70,14 +72,14 @@ export function ConfirmDialog(props: ConfirmDialogProps) { {children} - {cancelBtnText ?? 'Cancel'} + {cancelBtnText ?? t('common.cancel')} diff --git a/web/src/components/language-switch.tsx b/web/src/components/language-switch.tsx new file mode 100644 index 0000000..9c6d4cc --- /dev/null +++ b/web/src/components/language-switch.tsx @@ -0,0 +1,77 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useTranslation } from 'react-i18next' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Languages } from 'lucide-react' + +const LANGUAGES = [ + { code: 'ar', label: 'العربية' }, + { code: 'da', label: 'Dansk' }, + { code: 'de', label: 'Deutsch' }, + { code: 'en', label: 'English' }, + { code: 'es', label: 'Español' }, + { code: 'fi', label: 'Suomi' }, + { code: 'fr', label: 'Français' }, + { code: 'it', label: 'Italiano' }, + { code: 'jp', label: '日本語' }, + { code: 'ko', label: '한국어' }, + { code: 'nl', label: 'Nederlands' }, + { code: 'no', label: 'Norsk' }, + { code: 'pt', label: 'Português' }, + { code: 'ru', label: 'Русский' }, + { code: 'sv', label: 'Svenska' }, + { code: 'zh', label: '中文' }, + { code: 'zh-tw', label: '繁體中文' }, +] + + +export function LanguageSwitch() { + const { i18n } = useTranslation() + + const changeLanguage = (lng: string) => { + i18n.changeLanguage(lng) + } + + return ( + + + + + + {LANGUAGES.map((lang) => ( + changeLanguage(lang.code)} + > + {lang.label} + + ))} + + + ) +} diff --git a/web/src/components/layout/app-sidebar.tsx b/web/src/components/layout/app-sidebar.tsx index e38a795..ca600a3 100644 --- a/web/src/components/layout/app-sidebar.tsx +++ b/web/src/components/layout/app-sidebar.tsx @@ -26,10 +26,11 @@ import { } from '@/components/ui/sidebar' import { NavGroup } from '@/components/layout/nav-group' import Logo from '@/assets/logo.svg' -import { sidebarData } from './data/sidebar-data' +import { useSidebarData } from './data/sidebar-data' export function AppSidebar({ ...props }: React.ComponentProps) { const { open } = useSidebar(); + const sidebarData = useSidebarData(); return ( diff --git a/web/src/components/layout/data/sidebar-data.ts b/web/src/components/layout/data/sidebar-data.ts index 5c81644..65fdcc5 100644 --- a/web/src/components/layout/data/sidebar-data.ts +++ b/web/src/components/layout/data/sidebar-data.ts @@ -25,68 +25,73 @@ import { } from '@tabler/icons-react' import { IdCard, Inbox, Mailbox, Search } from 'lucide-react' import { type SidebarData } from '../types' +import { useTranslation } from 'react-i18next' -export const sidebarData: SidebarData = { - navGroups: [ - { - title: 'General', - items: [ - { - title: 'Dashboard', - url: '/', - icon: IconLayoutDashboard, - } - ], - }, - { - title: 'Accounts', - items: [ - { - title: 'Accounts', - url: '/accounts', - icon: Inbox, - }, - { - title: 'MailBox', - url: '/mailboxes', - icon: Mailbox, - }, - { - title: 'Search', - url: '/search', - icon: Search, - } - ], - }, - { - title: 'Auth', - items: [ - { - title: 'OAuth2', - url: '/oauth2', - icon: IdCard, - }, - { - title: 'Access Tokens', - url: '/access-tokens', - icon: IconLockAccess, - } - ] - }, - { - title: 'Other', - items: [ - { - title: 'Settings', - url: '/settings', - icon: IconSettings, - }, - { - title: 'API Documentation', - url: '/api-docs', - icon: IconHelp, - }, - ], - }, - ], +export function useSidebarData(): SidebarData { + const { t } = useTranslation() + + return { + navGroups: [ + { + title: t('navigation.general'), + items: [ + { + title: t('navigation.dashboard'), + url: '/', + icon: IconLayoutDashboard, + } + ], + }, + { + title: t('navigation.accounts'), + items: [ + { + title: t('navigation.accounts'), + url: '/accounts', + icon: Inbox, + }, + { + title: t('navigation.mailbox'), + url: '/mailboxes', + icon: Mailbox, + }, + { + title: t('common.search'), + url: '/search', + icon: Search, + } + ], + }, + { + title: t('navigation.auth'), + items: [ + { + title: t('navigation.oauth2'), + url: '/oauth2', + icon: IdCard, + }, + { + title: t('navigation.accessTokens'), + url: '/access-tokens', + icon: IconLockAccess, + } + ] + }, + { + title: t('navigation.other'), + items: [ + { + title: t('navigation.settings'), + url: '/settings', + icon: IconSettings, + }, + { + title: t('navigation.apiDocs'), + url: '/api-docs', + icon: IconHelp, + }, + ], + }, + ], + } } diff --git a/web/src/components/layout/fixed-header.tsx b/web/src/components/layout/fixed-header.tsx index a30ec9e..9fdeaa4 100644 --- a/web/src/components/layout/fixed-header.tsx +++ b/web/src/components/layout/fixed-header.tsx @@ -22,6 +22,7 @@ import { ProfileDropdown } from "../profile-dropdown"; import { Header } from "./header"; import { NotificationPopover } from "./notification"; import { GithubLinkButton } from "./github"; +import { LanguageSwitch } from "../language-switch"; export const FixedHeader = () => { return ( @@ -30,6 +31,7 @@ export const FixedHeader = () => {
+
diff --git a/web/src/components/multi-select.tsx b/web/src/components/multi-select.tsx index 78b4d52..995bb0b 100644 --- a/web/src/components/multi-select.tsx +++ b/web/src/components/multi-select.tsx @@ -45,6 +45,7 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; +import { useTranslation } from "react-i18next"; /** * Variants for the multi-select component to handle different styles. @@ -75,7 +76,7 @@ const multiSelectVariants = cva( */ interface MultiSelectProps extends React.ButtonHTMLAttributes, - VariantProps { + VariantProps { /** * An array of option objects to be displayed in the multi-select component. * Each option object has a label, value, and an optional icon. @@ -156,6 +157,7 @@ export const MultiSelect = React.forwardRef< }, ref ) => { + const { t } = useTranslation() const [selectedValues, setSelectedValues] = React.useState(defaultValue); const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); @@ -308,7 +310,7 @@ export const MultiSelect = React.forwardRef< onKeyDown={handleInputKeyDown} /> - No results found. + {t('common.table.noResults')} { @@ -67,11 +69,11 @@ export function EnvelopeListPagination({ return (
- {totalItems} total items + {totalItems} {t("table.results")}
-

Rows per page

+

{t("table.rowsPerPage")}

{ table.setGlobalFilter(event.target.value); diff --git a/web/src/features/access-tokens/components/accounts-detail-columns.tsx b/web/src/features/access-tokens/components/accounts-detail-columns.tsx index 0814b0f..05ea8e0 100644 --- a/web/src/features/access-tokens/components/accounts-detail-columns.tsx +++ b/web/src/features/access-tokens/components/accounts-detail-columns.tsx @@ -22,11 +22,11 @@ import LongText from '@/components/long-text' import { AccountInfo } from '../data/schema' import { DataTableColumnHeader } from './data-table-column-header' -export const columns: ColumnDef[] = [ +export const getColumns = (t: (key: string) => string): ColumnDef[] => [ { accessorKey: 'id', header: ({ column }) => ( - + ), cell: ({ row }) => ( {row.original.id} @@ -38,7 +38,7 @@ export const columns: ColumnDef[] = [ { accessorKey: 'email', header: ({ column }) => ( - + ), cell: ({ row }) => ( {row.getValue('email')} diff --git a/web/src/features/access-tokens/components/accounts-detail-dialog.tsx b/web/src/features/access-tokens/components/accounts-detail-dialog.tsx index ba648e3..e09d36b 100644 --- a/web/src/features/access-tokens/components/accounts-detail-dialog.tsx +++ b/web/src/features/access-tokens/components/accounts-detail-dialog.tsx @@ -29,7 +29,8 @@ import { import { AccessToken } from '../data/schema' import { Button } from '@/components/ui/button' import { AccountsDetailTable } from './accounts-detail-table' -import { columns } from './accounts-detail-columns' +import { getColumns } from './accounts-detail-columns' +import { useTranslation } from 'react-i18next' interface Props { currentRow: AccessToken @@ -38,6 +39,8 @@ interface Props { } export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) { + const { t } = useTranslation() + const columns = getColumns(t) return ( - Accounts + {t('settings.accounts')} - The list of accounts that can be queried using an access token. + {t('accessTokens.theListOfAccountsThatCanBeQueried')}
@@ -57,7 +60,7 @@ export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
- +
diff --git a/web/src/features/access-tokens/components/accounts-detail-table.tsx b/web/src/features/access-tokens/components/accounts-detail-table.tsx index 585863c..b6d9648 100644 --- a/web/src/features/access-tokens/components/accounts-detail-table.tsx +++ b/web/src/features/access-tokens/components/accounts-detail-table.tsx @@ -43,6 +43,7 @@ import { import { AccountInfo } from '../data/schema' import { DataTablePagination } from './data-table-pagination' import { AccountDetailTableToolbar } from './account-detail-table-toolbar' +import { useTranslation } from 'react-i18next' declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -57,6 +58,7 @@ interface DataTableProps { } export function AccountsDetailTable({ columns, data }: DataTableProps) { + const { t } = useTranslation() const [columnFilters, setColumnFilters] = useState([]) const [sorting, setSorting] = useState([]) @@ -132,7 +134,7 @@ export function AccountsDetailTable({ columns, data }: DataTableProps) { colSpan={columns.length} className='h-24 text-center' > - No results. + {t('common.table.noResults')} )} diff --git a/web/src/features/access-tokens/components/acl-detail-dialog.tsx b/web/src/features/access-tokens/components/acl-detail-dialog.tsx index 570982c..6bc1c61 100644 --- a/web/src/features/access-tokens/components/acl-detail-dialog.tsx +++ b/web/src/features/access-tokens/components/acl-detail-dialog.tsx @@ -32,6 +32,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' +import { useTranslation } from 'react-i18next' interface Props { currentRow: AccessToken @@ -40,6 +41,7 @@ interface Props { } export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) { + const { t } = useTranslation() return ( - Acl + {t('settings.acl')} - ACL rules for access tokens include IP whitelist verification and rate limit enforcement. + {t('accessTokens.aclRulesForAccessTokens')} diff --git a/web/src/features/access-tokens/components/action-dialog.tsx b/web/src/features/access-tokens/components/action-dialog.tsx index 1f957ed..66019c4 100644 --- a/web/src/features/access-tokens/components/action-dialog.tsx +++ b/web/src/features/access-tokens/components/action-dialog.tsx @@ -10,11 +10,11 @@ // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . +// along with this program.  If not, see . import { z } from 'zod' @@ -51,6 +51,7 @@ import { ToastAction } from '@/components/ui/toast' import { AxiosError } from 'axios' import { VirtualizedSelect } from '@/components/virtualized-select' import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' const isValidIP = (ip: string) => { const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/; @@ -58,14 +59,31 @@ const isValidIP = (ip: string) => { return ipv4Regex.test(ip) || ipv6Regex.test(ip); }; -const rateLimitSchema = z.object({ - quota: z.optional(z.number().int().positive({ message: "Quota must be a positive integer." })), - interval: z.optional(z.number().int().positive({ message: "Interval must be a positive integer." })), +const RateLimitBaseSchema = z.object({ + quota: z.optional(z.number()), + interval: z.optional(z.number()), }); -const accessControlSchema = z.object({ +const AccessControlBaseSchema = z.object({ ip_whitelist: z.string().optional(), - rate_limit: rateLimitSchema.optional(), + rate_limit: z.optional(RateLimitBaseSchema), +}); + +const AccessTokenBaseSchema = z.object({ + accounts: z.array(z.number()), + description: z.optional(z.string()), + acl: z.optional(AccessControlBaseSchema), +}); + +export type AccessTokenForm = z.infer; + +const getRateLimitSchema = (t: (key: string) => string) => z.object({ + quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })), + interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })), +}); + +const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({ + rate_limit: getRateLimitSchema(t).optional(), }).transform((data) => { if (data.ip_whitelist) { const ips = data.ip_whitelist @@ -89,7 +107,7 @@ const accessControlSchema = z.object({ return true; }, { - message: 'Invalid IP addresses found. Please enter valid IPv4 or IPv6 addresses.', + message: t('accessTokens.invalidIpAddressesFound'), path: ['ip_whitelist'], } ).transform((data) => { @@ -108,17 +126,15 @@ const accessControlSchema = z.object({ return data; }); -const accessTokenFormSchema = z.object({ +const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({ accounts: z .array(z.number()) - .min(1, { message: "At least one account is required." }), + .min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }), description: z - .optional(z.string().max(255, { message: "Description must not exceed 255 characters." })), - acl: z.optional(accessControlSchema), + .optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })), + acl: z.optional(getAccessControlSchema(t)), }); -export type AccessTokenForm = z.infer; - interface Props { currentRow?: AccessToken open: boolean @@ -134,14 +150,15 @@ const defaultValues = { export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { + const { t } = useTranslation() const isEdit = !!currentRow const queryClient = useQueryClient(); const form = useForm({ - resolver: zodResolver(accessTokenFormSchema), + resolver: zodResolver(getAccessTokenFormSchema(t)), defaultValues: isEdit ? { accounts: currentRow.accounts.map(value => value.id), - description: currentRow.description, + description: currentRow.description ?? undefined, acl: currentRow.acl ? { ip_whitelist: currentRow.acl.ip_whitelist @@ -168,9 +185,9 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { function handleSuccess() { toast({ - title: `Access token ${isEdit ? 'Updated' : 'Created'}`, - description: `Your access token has been successfully ${isEdit ? 'updated' : 'created'}.`, - action: Close, + title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`, + description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }), + action: {t('common.close')}, }); queryClient.invalidateQueries({ queryKey: ['access-tokens'] }); @@ -181,13 +198,13 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { function handleError(error: AxiosError) { const errorMessage = (error.response?.data as { message?: string })?.message || error.message || - `${isEdit ? 'Update' : 'Creation'} failed, please try again later`; + t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') }); toast({ variant: "destructive", - title: `Access token ${isEdit ? 'Update' : 'Creation'} Failed`, + title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`, description: errorMessage as string, - action: Try again, + action: {t('common.tryAgain')}, }); console.error(error); } @@ -233,10 +250,10 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { > - {isEdit ? 'Edit Token' : 'Add New'} + {isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')} - {isEdit ? 'Update the access token here. ' : 'Create new access token here. '} - Click save when you're done. + {isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')} + {t('accounts.clickSaveWhenDone')} @@ -251,7 +268,7 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { name='accounts' render={({ field }) => ( - Accounts: + {t('accessTokens.accounts')}: parseInt(v, 10)); return field.onChange(numberArray); }} - defaultValue={`${field.value}`} - placeholder="Select accounts" + value={field.value.map(String)} + placeholder={t('accessTokens.selectAccounts')} /> - Select multiple accounts for the access token's authorization scope. + {t('accessTokens.selectMultipleAccountsForTheAccessToken')} )} @@ -278,16 +295,16 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) { name="acl.ip_whitelist" render={({ field }) => ( - IP Whitelist: + {t('settings.acl')}: