Localize layout, credential components, email page (#992)

This commit is contained in:
Leendert de Borst
2025-07-08 10:53:15 +02:00
committed by Leendert de Borst
parent 28c1869048
commit e715454acb
18 changed files with 192 additions and 98 deletions

View File

@@ -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 },
];

View File

@@ -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 ?? ''}
/>
)}

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 };
};

View File

@@ -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

View File

@@ -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 (

View File

@@ -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) => (

View File

@@ -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..."
}

View File

@@ -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",

View File

@@ -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."
}
}

View File

@@ -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..."
}

View File

@@ -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",

View File

@@ -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."
}
}