mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Localize layout, credential components, email page (#992)
This commit is contained in:
committed by
Leendert de Borst
parent
28c1869048
commit
e715454acb
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
|
||||
import BottomNav from '@/entrypoints/popup/components/Layout/BottomNav';
|
||||
@@ -41,6 +42,7 @@ type RouteConfig = {
|
||||
* App component.
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation(['common', 'credentials', 'emails', 'settings']);
|
||||
const authContext = useAuth();
|
||||
const { isInitialLoading } = useLoading();
|
||||
const [isLoading, setIsLoading] = useMinDurationLoading(true, 150);
|
||||
@@ -55,13 +57,13 @@ const App: React.FC = () => {
|
||||
{ path: '/unlock', element: <Unlock />, showBackButton: false },
|
||||
{ path: '/unlock-success', element: <UnlockSuccess />, showBackButton: false },
|
||||
{ path: '/upgrade', element: <Upgrade />, showBackButton: false },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: 'Settings' },
|
||||
{ path: '/auth-settings', element: <AuthSettings />, showBackButton: true, title: t('settings:title') },
|
||||
{ path: '/credentials', element: <CredentialsList />, showBackButton: false },
|
||||
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: 'Add credential' },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: 'Credential details' },
|
||||
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: 'Edit credential' },
|
||||
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials:addCredential') },
|
||||
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials:credentialDetails') },
|
||||
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials:editCredential') },
|
||||
{ path: '/emails', element: <EmailsList />, showBackButton: false },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: 'Email details' },
|
||||
{ path: '/emails/:id', element: <EmailDetails />, showBackButton: true, title: t('emails:title') },
|
||||
{ path: '/settings', element: <Settings />, showBackButton: false },
|
||||
{ path: '/logout', element: <Logout />, showBackButton: false },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
|
||||
|
||||
@@ -13,6 +14,7 @@ type AliasBlockProps = {
|
||||
* Render the alias block.
|
||||
*/
|
||||
const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const hasFirstName = Boolean(credential.Alias?.FirstName?.trim());
|
||||
const hasLastName = Boolean(credential.Alias?.LastName?.trim());
|
||||
const hasNickName = Boolean(credential.Alias?.NickName?.trim());
|
||||
@@ -24,39 +26,39 @@ const AliasBlock: React.FC<AliasBlockProps> = ({ credential }) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Alias</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('alias')}</h2>
|
||||
{(hasFirstName || hasLastName) && (
|
||||
<FormInputCopyToClipboard
|
||||
id="fullName"
|
||||
label="Full Name"
|
||||
label={t('fullName')}
|
||||
value={[credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ')}
|
||||
/>
|
||||
)}
|
||||
{hasFirstName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
label={t('firstName')}
|
||||
value={credential.Alias?.FirstName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{hasLastName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
label={t('lastName')}
|
||||
value={credential.Alias?.LastName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{hasBirthDate && (
|
||||
<FormInputCopyToClipboard
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
label={t('birthDate')}
|
||||
value={IdentityHelperUtils.normalizeBirthDateForDisplay(credential.Alias?.BirthDate)}
|
||||
/>
|
||||
)}
|
||||
{hasNickName && (
|
||||
<FormInputCopyToClipboard
|
||||
id="nickName"
|
||||
label="Nickname"
|
||||
label={t('nickname')}
|
||||
value={credential.Alias?.NickName ?? ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/FormInputCopyToClipboard';
|
||||
|
||||
@@ -12,6 +13,7 @@ type LoginCredentialsBlockProps = {
|
||||
* Render the login credentials block.
|
||||
*/
|
||||
const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credential }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const email = credential.Alias?.Email?.trim();
|
||||
const username = credential.Username?.trim();
|
||||
const password = credential.Password?.trim();
|
||||
@@ -22,25 +24,25 @@ const LoginCredentialsBlock: React.FC<LoginCredentialsBlockProps> = ({ credentia
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Login credentials</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('loginCredentials')}</h2>
|
||||
{email && (
|
||||
<FormInputCopyToClipboard
|
||||
id="email"
|
||||
label="Email"
|
||||
label={t('email')}
|
||||
value={email}
|
||||
/>
|
||||
)}
|
||||
{username && (
|
||||
<FormInputCopyToClipboard
|
||||
id="username"
|
||||
label="Username"
|
||||
label={t('username')}
|
||||
value={username}
|
||||
/>
|
||||
)}
|
||||
{password && (
|
||||
<FormInputCopyToClipboard
|
||||
id="password"
|
||||
label="Password"
|
||||
label={t('password')}
|
||||
value={password}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type NotesBlockProps = {
|
||||
notes: string | undefined;
|
||||
@@ -20,6 +21,7 @@ const convertUrlsToLinks = (text: string): string => {
|
||||
* Render the notes block.
|
||||
*/
|
||||
const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
|
||||
const { t } = useTranslation('common');
|
||||
if (!notes) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +30,7 @@ const NotesBlock: React.FC<NotesBlockProps> = ({ notes }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Notes</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">{t('notes')}</h2>
|
||||
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
|
||||
<p
|
||||
className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
|
||||
@@ -13,6 +14,7 @@ type TotpBlockProps = {
|
||||
* This component shows TOTP codes for a credential.
|
||||
*/
|
||||
const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentCodes, setCurrentCodes] = useState<Record<string, string>>({});
|
||||
@@ -138,8 +140,8 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Two-factor authentication</h2>
|
||||
Loading TOTP codes...
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('twoFactorAuthentication')}</h2>
|
||||
{t('loadingTotpCodes')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,7 +153,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Two-factor authentication</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('twoFactorAuthentication')}</h2>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{totpCodes.map(totpCode => (
|
||||
<button
|
||||
@@ -171,7 +173,7 @@ const TotpBlock: React.FC<TotpBlockProps> = ({ credentialId }) => {
|
||||
</span>
|
||||
<div className="text-xs">
|
||||
{copiedId === totpCode.Id ? (
|
||||
<span className="text-green-600 dark:text-green-400">Copied!</span>
|
||||
<span className="text-green-600 dark:text-green-400">{t('copied')}</span>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400">{getRemainingSeconds()}s</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
@@ -18,6 +19,7 @@ type EmailPreviewProps = {
|
||||
* This component shows a preview of the latest emails in the inbox.
|
||||
*/
|
||||
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const { t } = useTranslation(['common', 'emails']);
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastEmailId, setLastEmailId] = useState<number>(0);
|
||||
@@ -74,7 +76,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError('An error occurred while loading emails. Please try again later.');
|
||||
setError(t('emails:errors.emailLoadError'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,23 +127,23 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const apiErrorResponse = response as ApiErrorResponse;
|
||||
|
||||
if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_MATCH_USER') {
|
||||
setError('The current chosen email address is already in use. Please change the email address by editing this credential.');
|
||||
setError(t('emails:errors.emailInUse'));
|
||||
} else if (apiErrorResponse?.code === 'CLAIM_DOES_NOT_EXIST') {
|
||||
setError('An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again.');
|
||||
setError(t('emails:errors.emailSyncError'));
|
||||
} else {
|
||||
setError('An error occurred while loading emails. Please try again later.');
|
||||
setError(t('emails:errors.emailLoadError'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('An error occurred while loading emails. Please try again later.');
|
||||
setError(t('emails:errors.emailLoadError'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading emails:', err);
|
||||
setError('An unexpected error occurred while loading emails. Please try again later.');
|
||||
setError(t('emails:errors.emailUnexpectedError'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -150,7 +152,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
// Set up auto-refresh interval
|
||||
const interval = setInterval(loadEmails, 2000);
|
||||
return () : void => clearInterval(interval);
|
||||
}, [email, loading, webApi, dbContext]);
|
||||
}, [email, loading, webApi, dbContext, t]);
|
||||
|
||||
// Don't render anything if the domain is not supported
|
||||
if (!isSupportedDomain) {
|
||||
@@ -161,7 +163,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common:recentEmails')}</h2>
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
@@ -174,10 +176,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common:recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
Loading emails...
|
||||
{t('common:loadingEmails')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,10 +187,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
return (
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Recent emails</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('common:recentEmails')}</h2>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
No emails received yet.
|
||||
{t('emails:noEmails')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface IModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,6 +25,7 @@ const Modal: React.FC<IModalProps> = ({
|
||||
cancelText = '',
|
||||
variant = 'default'
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -46,7 +48,7 @@ const Modal: React.FC<IModalProps> = ({
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('close')}</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useDb } from '@/entrypoints/popup/context/DbContext';
|
||||
@@ -22,8 +23,9 @@ export function useVaultMutate() : {
|
||||
isLoading: boolean;
|
||||
syncStatus: string;
|
||||
} {
|
||||
const { t } = useTranslation('common');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState('Syncing vault');
|
||||
const [syncStatus, setSyncStatus] = useState(t('syncingVault'));
|
||||
const dbContext = useDb();
|
||||
const { syncVault } = useVaultSync();
|
||||
|
||||
@@ -34,12 +36,12 @@ export function useVaultMutate() : {
|
||||
operation: () => Promise<void>,
|
||||
options: VaultMutationOptions
|
||||
) : Promise<void> => {
|
||||
setSyncStatus('Saving changes to vault');
|
||||
setSyncStatus(t('savingChangesToVault'));
|
||||
|
||||
// Execute the provided operation (e.g. create/update/delete credential)
|
||||
await operation();
|
||||
|
||||
setSyncStatus('Uploading vault to server');
|
||||
setSyncStatus(t('uploadingVaultToServer'));
|
||||
|
||||
try {
|
||||
// Upload the updated vault to the server.
|
||||
@@ -90,7 +92,7 @@ export function useVaultMutate() : {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [dbContext]);
|
||||
}, [dbContext, t]);
|
||||
|
||||
/**
|
||||
* Hook to execute a vault mutation which uploads a new encrypted vault to the server
|
||||
@@ -101,11 +103,11 @@ export function useVaultMutate() : {
|
||||
) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSyncStatus('Checking for vault updates');
|
||||
setSyncStatus(t('checkingVaultUpdates'));
|
||||
|
||||
// Skip sync check if requested (e.g., during upgrade operations)
|
||||
if (options.skipSyncCheck) {
|
||||
setSyncStatus('Executing operation...');
|
||||
setSyncStatus(t('executingOperation'));
|
||||
await executeMutateOperation(operation, options);
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +156,7 @@ export function useVaultMutate() : {
|
||||
setIsLoading(false);
|
||||
setSyncStatus('');
|
||||
}
|
||||
}, [syncVault, executeMutateOperation]);
|
||||
}, [syncVault, executeMutateOperation, t]);
|
||||
|
||||
return {
|
||||
executeVaultMutation,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
|
||||
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
|
||||
@@ -46,6 +47,7 @@ type VaultSyncOptions = {
|
||||
export const useVaultSync = () : {
|
||||
syncVault: (options?: VaultSyncOptions) => Promise<boolean>;
|
||||
} => {
|
||||
const { t } = useTranslation('common');
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
@@ -65,7 +67,7 @@ export const useVaultSync = () : {
|
||||
}
|
||||
|
||||
// Check app status and vault revision
|
||||
onStatus?.('Checking vault updates');
|
||||
onStatus?.(t('checkingVaultUpdates'));
|
||||
const statusResponse = await withMinimumDelay(() => webApi.getStatus(), 300, enableDelay);
|
||||
|
||||
// Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode.
|
||||
@@ -90,7 +92,7 @@ export const useVaultSync = () : {
|
||||
const vaultRevisionNumber = vaultMetadata?.vaultRevisionNumber ?? 0;
|
||||
|
||||
if (statusResponse.vaultRevision > vaultRevisionNumber) {
|
||||
onStatus?.('Syncing updated vault');
|
||||
onStatus?.(t('syncingUpdatedVault'));
|
||||
const vaultResponseJson = await withMinimumDelay(() => webApi.get<VaultResponse>('Vault'), 1000, enableDelay);
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse);
|
||||
@@ -169,7 +171,7 @@ export const useVaultSync = () : {
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [authContext, dbContext, webApi]);
|
||||
}, [authContext, dbContext, webApi, t]);
|
||||
|
||||
return { syncVault };
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Buffer } from 'buffer';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { sendMessage } from 'webext-bridge/popup';
|
||||
import * as Yup from 'yup';
|
||||
@@ -68,6 +69,7 @@ const credentialSchema = Yup.object().shape({
|
||||
* Add or edit credential page.
|
||||
*/
|
||||
const CredentialAddEdit: React.FC = () => {
|
||||
const { t } = useTranslation(['common', 'credentials']);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
@@ -458,14 +460,14 @@ const CredentialAddEdit: React.FC = () => {
|
||||
{isEditMode && (
|
||||
<HeaderButton
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
title="Delete credential"
|
||||
title={t('credentials:deleteCredential')}
|
||||
iconType={HeaderIconType.DELETE}
|
||||
variant="danger"
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
title="Save credential"
|
||||
title={t('credentials:saveCredential')}
|
||||
iconType={HeaderIconType.SAVE}
|
||||
/>
|
||||
</div>
|
||||
@@ -473,7 +475,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => {};
|
||||
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode]);
|
||||
}, [setHeaderButtons, handleSubmit, onSubmit, isEditMode, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -481,7 +483,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
if (isEditMode && !watch('ServiceName')) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>{t('common:loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -503,10 +505,10 @@ const CredentialAddEdit: React.FC = () => {
|
||||
setShowDeleteModal(false);
|
||||
void handleDelete();
|
||||
}}
|
||||
title="Delete Credential"
|
||||
message="Are you sure you want to delete this credential? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
title={t('credentials:deleteCredentialTitle')}
|
||||
message={t('credentials:deleteCredentialConfirm')}
|
||||
confirmText={t('common:delete')}
|
||||
cancelText={t('common:cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
@@ -527,7 +529,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<circle cx="8" cy="16" r="1"/>
|
||||
<circle cx="16" cy="16" r="1"/>
|
||||
</svg>
|
||||
Random Alias
|
||||
{t('credentials:randomAlias')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -540,18 +542,18 @@ const CredentialAddEdit: React.FC = () => {
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
<path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>
|
||||
</svg>
|
||||
Manual
|
||||
{t('credentials:manual')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Service</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials:service')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="serviceName"
|
||||
label="Service Name"
|
||||
label={t('credentials:serviceName')}
|
||||
ref={serviceNameRef}
|
||||
value={watch('ServiceName') ?? ''}
|
||||
onChange={(value) => setValue('ServiceName', value)}
|
||||
@@ -560,7 +562,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
/>
|
||||
<FormInput
|
||||
id="serviceUrl"
|
||||
label="Service URL"
|
||||
label={t('credentials:serviceUrl')}
|
||||
value={watch('ServiceUrl') ?? ''}
|
||||
onChange={(value) => setValue('ServiceUrl', value)}
|
||||
error={errors.ServiceUrl?.message}
|
||||
@@ -571,11 +573,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
{(mode === 'manual' || isEditMode) && (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Login Credentials</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials:loginCredentials')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="username"
|
||||
label="Username"
|
||||
label={t('common:username')}
|
||||
value={watch('Username') ?? ''}
|
||||
onChange={(value) => setValue('Username', value)}
|
||||
error={errors.Username?.message}
|
||||
@@ -583,13 +585,13 @@ const CredentialAddEdit: React.FC = () => {
|
||||
{
|
||||
icon: 'refresh',
|
||||
onClick: generateRandomUsername,
|
||||
title: 'Generate random username'
|
||||
title: t('credentials:generateRandomUsername')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<FormInput
|
||||
id="password"
|
||||
label="Password"
|
||||
label={t('common:password')}
|
||||
type="password"
|
||||
value={watch('Password') ?? ''}
|
||||
onChange={(value) => setValue('Password', value)}
|
||||
@@ -600,7 +602,7 @@ const CredentialAddEdit: React.FC = () => {
|
||||
{
|
||||
icon: 'refresh',
|
||||
onClick: generateRandomPassword,
|
||||
title: 'Generate random password'
|
||||
title: t('credentials:generateRandomPassword')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -609,11 +611,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
onClick={handleGenerateRandomAlias}
|
||||
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
Generate Random Alias
|
||||
{t('credentials:generateRandomAlias')}
|
||||
</button>
|
||||
<FormInput
|
||||
id="email"
|
||||
label="Email"
|
||||
label={t('common:email')}
|
||||
value={watch('Alias.Email') ?? ''}
|
||||
onChange={(value) => setValue('Alias.Email', value)}
|
||||
error={errors.Alias?.Email?.message}
|
||||
@@ -622,40 +624,40 @@ const CredentialAddEdit: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Alias</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials:alias')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
label={t('credentials:firstName')}
|
||||
value={watch('Alias.FirstName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.FirstName', value)}
|
||||
error={errors.Alias?.FirstName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
label={t('credentials:lastName')}
|
||||
value={watch('Alias.LastName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.LastName', value)}
|
||||
error={errors.Alias?.LastName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="nickName"
|
||||
label="Nick Name"
|
||||
label={t('credentials:nickName')}
|
||||
value={watch('Alias.NickName') ?? ''}
|
||||
onChange={(value) => setValue('Alias.NickName', value)}
|
||||
error={errors.Alias?.NickName?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="gender"
|
||||
label="Gender"
|
||||
label={t('credentials:gender')}
|
||||
value={watch('Alias.Gender') ?? ''}
|
||||
onChange={(value) => setValue('Alias.Gender', value)}
|
||||
error={errors.Alias?.Gender?.message}
|
||||
/>
|
||||
<FormInput
|
||||
id="birthDate"
|
||||
label="Birth Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
label={t('credentials:birthDate')}
|
||||
placeholder={t('credentials:birthDatePlaceholder')}
|
||||
value={watch('Alias.BirthDate') ?? ''}
|
||||
onChange={(value) => setValue('Alias.BirthDate', value)}
|
||||
error={errors.Alias?.BirthDate?.message}
|
||||
@@ -664,11 +666,11 @@ const CredentialAddEdit: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Metadata</h2>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials:metadata')}</h2>
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="notes"
|
||||
label="Notes"
|
||||
label={t('credentials:notes')}
|
||||
value={watch('Notes') ?? ''}
|
||||
onChange={(value) => setValue('Notes', value)}
|
||||
multiline
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import type { Credential } from '@/utils/dist/shared/models/vault';
|
||||
* Credential details page.
|
||||
*/
|
||||
const CredentialDetails: React.FC = (): React.ReactElement => {
|
||||
const { t } = useTranslation(['common', 'credentials']);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
@@ -74,20 +76,20 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={openInNewPopup}
|
||||
title="Open in new window"
|
||||
title={t('common:openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={handleEdit}
|
||||
title="Edit credential"
|
||||
title={t('credentials:editCredential')}
|
||||
iconType={HeaderIconType.EDIT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
setHeaderButtons(headerButtonsJSX);
|
||||
return () => {};
|
||||
}, [setHeaderButtons, handleEdit, openInNewPopup]);
|
||||
}, [setHeaderButtons, handleEdit, openInNewPopup, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -95,7 +97,7 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
|
||||
}, [setHeaderButtons]);
|
||||
|
||||
if (!credential) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>{t('common:loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
|
||||
@@ -22,6 +23,7 @@ import { HeaderIconType } from '../components/Icons/HeaderIcons';
|
||||
* Email details page.
|
||||
*/
|
||||
const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
const { t } = useTranslation(['common', 'emails']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const dbContext = useDb();
|
||||
@@ -149,13 +151,13 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
{!PopoutUtility.isPopup() && (
|
||||
<HeaderButton
|
||||
onClick={openInNewPopup}
|
||||
title="Open in new window"
|
||||
title={t('common:openInNewWindow')}
|
||||
iconType={HeaderIconType.EXPAND}
|
||||
/>
|
||||
)}
|
||||
<HeaderButton
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
title="Delete email"
|
||||
title={t('emails:deleteEmail')}
|
||||
iconType={HeaderIconType.DELETE}
|
||||
variant="danger"
|
||||
/>
|
||||
@@ -166,7 +168,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
setHeaderButtonsConfigured(true);
|
||||
}
|
||||
return () => {};
|
||||
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup]);
|
||||
}, [setHeaderButtons, headerButtonsConfigured, openInNewPopup, t]);
|
||||
|
||||
// Clear header buttons on unmount
|
||||
useEffect((): (() => void) => {
|
||||
@@ -182,11 +184,11 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
return <div className="text-red-500">{t('common:error')} {error}</div>;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return <div className="text-gray-500">Email not found</div>;
|
||||
return <div className="text-gray-500">{t('emails:emailNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -198,10 +200,10 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
setShowDeleteModal(false);
|
||||
void handleDelete();
|
||||
}}
|
||||
title="Delete Email"
|
||||
message="Are you sure you want to delete this email? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
title={t('emails:deleteEmailTitle')}
|
||||
message={t('emails:deleteEmailConfirm')}
|
||||
confirmText={t('common:delete')}
|
||||
cancelText={t('common:cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
@@ -212,9 +214,9 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{email.subject}</h1>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>From: {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>To: {email.toLocal}@{email.toDomain}</p>
|
||||
<p>Date: {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
<p>{t('emails:from')} {email.fromDisplay} ({email.fromLocal}@{email.fromDomain})</p>
|
||||
<p>{t('emails:to')} {email.toLocal}@{email.toDomain}</p>
|
||||
<p>{t('emails:date')} {new Date(email.dateSystem).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +226,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
<iframe
|
||||
srcDoc={ConversionUtility.convertAnchorTagsToOpenInNewTab(email.messageHtml)}
|
||||
className="w-full min-h-[500px] border-0"
|
||||
title="Email content"
|
||||
title={t('emails:emailContent')}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
@@ -237,7 +239,7 @@ const EmailDetails: React.FC = (): React.ReactElement => {
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Attachments
|
||||
{t('emails:attachments')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{email.attachments.map((attachment) => (
|
||||
|
||||
@@ -60,5 +60,11 @@
|
||||
"nickname": "Nickname",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"password": "Password"
|
||||
"password": "Password",
|
||||
"syncingVault": "Syncing vault",
|
||||
"savingChangesToVault": "Saving changes to vault",
|
||||
"uploadingVaultToServer": "Uploading vault to server",
|
||||
"checkingVaultUpdates": "Checking for vault updates",
|
||||
"syncingUpdatedVault": "Syncing updated vault",
|
||||
"executingOperation": "Executing operation..."
|
||||
}
|
||||
@@ -49,6 +49,29 @@
|
||||
"folder": "Folder",
|
||||
"selectFolder": "Select Folder",
|
||||
"createFolder": "Create Folder",
|
||||
"editCredential": "Edit credential",
|
||||
"deleteCredential": "Delete credential",
|
||||
"saveCredential": "Save credential",
|
||||
"deleteCredentialTitle": "Delete Credential",
|
||||
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
|
||||
"randomAlias": "Random Alias",
|
||||
"manual": "Manual",
|
||||
"service": "Service",
|
||||
"serviceName": "Service Name",
|
||||
"serviceUrl": "Service URL",
|
||||
"loginCredentials": "Login Credentials",
|
||||
"generateRandomUsername": "Generate random username",
|
||||
"generateRandomPassword": "Generate random password",
|
||||
"generateRandomAlias": "Generate Random Alias",
|
||||
"alias": "Alias",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"nickName": "Nick Name",
|
||||
"gender": "Gender",
|
||||
"birthDate": "Birth Date",
|
||||
"birthDatePlaceholder": "YYYY-MM-DD",
|
||||
"metadata": "Metadata",
|
||||
"notes": "Notes",
|
||||
"errors": {
|
||||
"serviceNameRequired": "Service name is required",
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Email Aliases",
|
||||
"title": "Email Details",
|
||||
"addAlias": "Add Email Alias",
|
||||
"editAlias": "Edit Email Alias",
|
||||
"deleteAlias": "Delete Email Alias",
|
||||
@@ -63,6 +63,9 @@
|
||||
"custom": "Custom",
|
||||
"deleteConfirm": "Are you sure you want to delete this alias?",
|
||||
"deleteEmailConfirm": "Are you sure you want to permanently delete this email?",
|
||||
"deleteEmailTitle": "Delete Email",
|
||||
"emailNotFound": "Email not found",
|
||||
"emailContent": "Email content",
|
||||
"deleteSuccess": "Alias deleted successfully",
|
||||
"saveSuccess": "Alias saved successfully",
|
||||
"enableSuccess": "Alias enabled successfully",
|
||||
@@ -80,7 +83,10 @@
|
||||
"copyError": "Failed to copy to clipboard",
|
||||
"enableError": "Failed to enable alias",
|
||||
"disableError": "Failed to disable alias",
|
||||
"emailLoadError": "Failed to load emails",
|
||||
"emailDeleteError": "Failed to delete email"
|
||||
"emailLoadError": "An error occurred while loading emails. Please try again later.",
|
||||
"emailDeleteError": "Failed to delete email",
|
||||
"emailInUse": "The current chosen email address is already in use. Please change the email address by editing this credential.",
|
||||
"emailSyncError": "An error occurred while trying to load the emails. Please try to edit and save the credential entry to synchronize the database, then try again.",
|
||||
"emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later."
|
||||
}
|
||||
}
|
||||
@@ -60,5 +60,11 @@
|
||||
"nickname": "Bijnaam",
|
||||
"email": "E-mail",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord"
|
||||
"password": "Wachtwoord",
|
||||
"syncingVault": "Kluis synchroniseren",
|
||||
"savingChangesToVault": "Wijzigingen opslaan in kluis",
|
||||
"uploadingVaultToServer": "Kluis uploaden naar server",
|
||||
"checkingVaultUpdates": "Controleren op kluis-updates",
|
||||
"syncingUpdatedVault": "Bijgewerkte kluis synchroniseren",
|
||||
"executingOperation": "Bewerking uitvoeren..."
|
||||
}
|
||||
@@ -49,6 +49,29 @@
|
||||
"folder": "Map",
|
||||
"selectFolder": "Map Selecteren",
|
||||
"createFolder": "Map Aanmaken",
|
||||
"editCredential": "Inloggegevens bewerken",
|
||||
"deleteCredential": "Inloggegevens verwijderen",
|
||||
"saveCredential": "Inloggegevens opslaan",
|
||||
"deleteCredentialTitle": "Inloggegevens Verwijderen",
|
||||
"deleteCredentialConfirm": "Weet u zeker dat u deze inloggegevens wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"randomAlias": "Willekeurige Alias",
|
||||
"manual": "Handmatig",
|
||||
"service": "Service",
|
||||
"serviceName": "Servicenaam",
|
||||
"serviceUrl": "Service-URL",
|
||||
"loginCredentials": "Inloggegevens",
|
||||
"generateRandomUsername": "Willekeurige gebruikersnaam genereren",
|
||||
"generateRandomPassword": "Willekeurig wachtwoord genereren",
|
||||
"generateRandomAlias": "Willekeurige Alias Genereren",
|
||||
"alias": "Alias",
|
||||
"firstName": "Voornaam",
|
||||
"lastName": "Achternaam",
|
||||
"nickName": "Bijnaam",
|
||||
"gender": "Geslacht",
|
||||
"birthDate": "Geboortedatum",
|
||||
"birthDatePlaceholder": "JJJJ-MM-DD",
|
||||
"metadata": "Metadata",
|
||||
"notes": "Notities",
|
||||
"errors": {
|
||||
"serviceNameRequired": "Servicenaam is verplicht",
|
||||
"invalidUrl": "Voer een geldige URL in",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "E-mail Aliassen",
|
||||
"title": "E-mail Details",
|
||||
"addAlias": "E-mail Alias Toevoegen",
|
||||
"editAlias": "E-mail Alias Bewerken",
|
||||
"deleteAlias": "E-mail Alias Verwijderen",
|
||||
@@ -63,6 +63,9 @@
|
||||
"custom": "Aangepast",
|
||||
"deleteConfirm": "Weet u zeker dat u deze alias wilt verwijderen?",
|
||||
"deleteEmailConfirm": "Weet u zeker dat u deze e-mail definitief wilt verwijderen?",
|
||||
"deleteEmailTitle": "E-mail Verwijderen",
|
||||
"emailNotFound": "E-mail niet gevonden",
|
||||
"emailContent": "E-mailinhoud",
|
||||
"deleteSuccess": "Alias succesvol verwijderd",
|
||||
"saveSuccess": "Alias succesvol opgeslagen",
|
||||
"enableSuccess": "Alias succesvol ingeschakeld",
|
||||
@@ -80,7 +83,10 @@
|
||||
"copyError": "Kopiëren naar klembord mislukt",
|
||||
"enableError": "Inschakelen van alias mislukt",
|
||||
"disableError": "Uitschakelen van alias mislukt",
|
||||
"emailLoadError": "Laden van e-mails mislukt",
|
||||
"emailDeleteError": "Verwijderen van e-mail mislukt"
|
||||
"emailLoadError": "Er is een fout opgetreden bij het laden van e-mails. Probeer het later opnieuw.",
|
||||
"emailDeleteError": "Verwijderen van e-mail mislukt",
|
||||
"emailInUse": "Het momenteel gekozen e-mailadres is al in gebruik. Wijzig het e-mailadres door deze inloggegevens te bewerken.",
|
||||
"emailSyncError": "Er is een fout opgetreden bij het laden van e-mails. Probeer de inloggegevens te bewerken en op te slaan om de database te synchroniseren, en probeer het opnieuw.",
|
||||
"emailUnexpectedError": "Er is een onverwachte fout opgetreden bij het laden van e-mails. Probeer het later opnieuw."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user