refactor: flatten translation tree

This commit is contained in:
Nicolas Meienberger 2024-01-18 19:12:59 +01:00 committed by Nicolas Meienberger
parent 934dd8fa18
commit 3d55537923
50 changed files with 685 additions and 319 deletions

View File

@ -1,5 +1,5 @@
module.exports = {
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom', 'drizzle'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom', 'jsonc', 'drizzle'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
@ -8,9 +8,10 @@ module.exports = {
'airbnb-typescript',
'eslint:recommended',
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
'plugin:jsx-a11y/recommended',
'plugin:jsonc/recommended-with-jsonc',
'prettier',
'plugin:drizzle/recommended',
],
parser: '@typescript-eslint/parser',
@ -70,6 +71,10 @@ module.exports = {
files: ['*.test.ts', '*.test.tsx'],
extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'],
},
{
files: ['*.json', '*.json5', '*.jsonc'],
parser: 'jsonc-eslint-parser',
},
],
globals: {
JSX: true,

View File

@ -58,7 +58,7 @@
"lodash.merge": "^4.6.2",
"next": "14.0.4",
"next-client-cookies": "^1.1.0",
"next-intl": "^2.22.1",
"next-intl": "^3.4.4",
"next-safe-action": "^5.0.2",
"pg": "^8.11.3",
"qrcode.react": "^3.1.0",
@ -122,6 +122,7 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-jest-dom": "^5.1.0",
"eslint-plugin-jsonc": "^2.11.2",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",

View File

@ -90,8 +90,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0(next@14.0.4)(react@18.2.0)
next-intl:
specifier: ^2.22.1
version: 2.22.1(next@14.0.4)(react@18.2.0)
specifier: ^3.4.4
version: 3.4.4(next@14.0.4)(react@18.2.0)
next-safe-action:
specifier: ^5.0.2
version: 5.0.2(next@14.0.4)(react@18.2.0)(zod@3.22.4)
@ -276,6 +276,9 @@ importers:
eslint-plugin-jest-dom:
specifier: ^5.1.0
version: 5.1.0(@testing-library/dom@9.3.3)(eslint@8.55.0)
eslint-plugin-jsonc:
specifier: ^2.11.2
version: 2.11.2(eslint@8.55.0)
eslint-plugin-jsx-a11y:
specifier: ^6.8.0
version: 6.8.0(eslint@8.55.0)
@ -6011,6 +6014,15 @@ packages:
source-map: 0.6.1
dev: true
/eslint-compat-utils@0.1.2(eslint@8.55.0):
resolution: {integrity: sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==}
engines: {node: '>=12'}
peerDependencies:
eslint: '>=6.0.0'
dependencies:
eslint: 8.55.0
dev: true
/eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.29.1)(eslint@8.55.0):
resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -6239,6 +6251,21 @@ packages:
- typescript
dev: true
/eslint-plugin-jsonc@2.11.2(eslint@8.55.0):
resolution: {integrity: sha512-F6A0MZhIGRBPOswzzn4tJFXXkPLiLwJaMlQwz/Qj1qx+bV5MCn79vBeJh2ynMmtqqHloi54KDCnsT/KWrcCcnQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@8.55.0)
eslint: 8.55.0
eslint-compat-utils: 0.1.2(eslint@8.55.0)
espree: 9.6.1
graphemer: 1.4.0
jsonc-eslint-parser: 2.4.0
natural-compare: 1.4.0
dev: true
/eslint-plugin-jsx-a11y@6.8.0(eslint@8.55.0):
resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==}
engines: {node: '>=4.0'}
@ -8294,6 +8321,16 @@ packages:
engines: {node: '>=6'}
hasBin: true
/jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
acorn: 8.10.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.5.4
dev: true
/jsonc-parser@3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
@ -9318,9 +9355,8 @@ packages:
react: 18.2.0
dev: false
/next-intl@2.22.1(next@14.0.4)(react@18.2.0):
resolution: {integrity: sha512-mg3CXfQRtG0lTS2zyRyTsYIl6ujQi2mAN0Xdmprce9ecUVKNOmHaMMsXOIPgFv1l+O80CavWo7m9rUfj7Zbr8w==}
engines: {node: '>=10'}
/next-intl@3.4.4(next@14.0.4)(react@18.2.0):
resolution: {integrity: sha512-18mCwuhzp+te4Q2q30yHqjPJKf6adxxaWxFSwfW0SsBXz+4jPCulOsmjhmql+DqlZhA48+/gRPI/I343gUuWRA==}
peerDependencies:
next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -9329,7 +9365,7 @@ packages:
negotiator: 0.6.3
next: 14.0.4(@babel/core@7.23.6)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5)
react: 18.2.0
use-intl: 2.22.1(react@18.2.0)
use-intl: 3.4.4(react@18.2.0)
dev: false
/next-router-mock@0.9.10(next@14.0.4)(react@18.2.0):
@ -11934,9 +11970,8 @@ packages:
tslib: 2.6.2
dev: false
/use-intl@2.22.1(react@18.2.0):
resolution: {integrity: sha512-vTMJZrqVjErn421Ry4mxS2GuVUTWkY5zyIwu+3XvrAXH3PsPDAfWBMvVhip3kx914/IRs/4QnZ2ZaOehiZSXng==}
engines: {node: '>=10'}
/use-intl@3.4.4(react@18.2.0):
resolution: {integrity: sha512-6+NwnmmgYWkK6W+sw1qXh4QGWsp/xtUJi021eCGirnKIrb8a3S8MdkVDpM6k0eaOmklrw56HbZc6IYJEQpDspQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:

View File

@ -20,7 +20,7 @@ interface IProps {
}
export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
const t = useTranslations('auth');
const t = useTranslations();
const {
register,
handleSubmit,
@ -37,23 +37,32 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
return (
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
<h2 className="h2 text-center mb-3">{t('login.title')}</h2>
<Input {...register('email')} name="email" label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
<h2 className="h2 text-center mb-3">{t('AUTH_LOGIN_TITLE')}</h2>
<Input
{...register('email')}
name="email"
label={t('AUTH_FORM_EMAIL')}
error={errors.email?.message}
disabled={loading}
type="email"
className="mb-3"
placeholder={t('AUTH_FORM_EMAIL_PLACEHOLDER')}
/>
<span className="form-label-description">
<Link href="/reset-password">{t('form.forgot')}</Link>
<Link href="/reset-password">{t('AUTH_FORM_FORGOT')}</Link>
</span>
<Input
{...register('password')}
name="password"
label={t('form.password')}
label={t('AUTH_FORM_PASSWORD')}
error={errors.password?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder={t('form.password-placeholder')}
placeholder={t('AUTH_FORM_PASSWORD_PLACEHOLDER')}
/>
<Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
{t('login.submit')}
{t('AUTH_LOGIN_SUBMIT')}
</Button>
</form>
);

View File

@ -10,7 +10,7 @@ type Props = {
export const TotpForm = (props: Props) => {
const { onSubmit, loading } = props;
const t = useTranslations('auth');
const t = useTranslations();
const [totpCode, setTotpCode] = React.useState('');
return (
@ -22,11 +22,11 @@ export const TotpForm = (props: Props) => {
}}
>
<div className="flex items-center justify-center">
<h3 className="">{t('totp.title')}</h3>
<p className="text-sm text-gray-500">{t('totp.instructions')}</p>
<h3 className="">{t('AUTH_TOTP_TITLE')}</h3>
<p className="text-sm text-gray-500">{t('AUTH_TOTP_INSTRUCTIONS')}</p>
<OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
{t('totp.submit')}
{t('AUTH_TOTP_SUBMIT')}
</Button>
</div>
</form>

View File

@ -14,18 +14,18 @@ interface IProps {
type FormValues = { email: string; password: string; passwordConfirm: string };
export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
const t = useTranslations('auth');
const t = useTranslations();
const schema = z
.object({
email: z.string().email(),
password: z.string().min(8, t('form.errors.password.minlength')),
passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
password: z.string().min(8, t('AUTH_ERROR_INVALID_PASSWORD_LENGTH')),
passwordConfirm: z.string().min(8, t('AUTH_ERROR_INVALID_PASSWORD_LENGTH')),
})
.superRefine((data, ctx) => {
if (data.password !== data.passwordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('form.errors.password-confirmation.match'),
message: t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_MATCH'),
path: ['passwordConfirm'],
});
}
@ -40,20 +40,36 @@ export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
return (
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
<h2 className="h2 text-center mb-3">{t('register.title')}</h2>
<Input {...register('email')} label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
<Input {...register('password')} label={t('form.password')} error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder={t('form.password-placeholder')} />
<h2 className="h2 text-center mb-3">{t('AUTH_REGISTER_TITLE')}</h2>
<Input
{...register('email')}
label={t('AUTH_FORM_EMAIL')}
error={errors.email?.message}
disabled={loading}
type="email"
className="mb-3"
placeholder={t('AUTH_FORM_EMAIL_PLACEHOLDER')}
/>
<Input
{...register('password')}
label={t('AUTH_FORM_PASSWORD')}
error={errors.password?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder={t('AUTH_FORM_PASSWORD_PLACEHOLDER')}
/>
<Input
{...register('passwordConfirm')}
label={t('form.password-confirmation')}
label={t('AUTH_FORM_PASSWORD_CONFIRMATION')}
error={errors.passwordConfirm?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder={t('form.password-confirmation-placeholder')}
placeholder={t('AUTH_FORM_PASSWORD_CONFIRMATION_PLACEHOLDER')}
/>
<Button loading={loading} type="submit" className="btn btn-primary w-100">
{t('register.submit')}
{t('AUTH_REGISTER_SUBMIT')}
</Button>
</form>
);

View File

@ -29,10 +29,10 @@ export const ResetPasswordContainer: React.FC = () => {
if (resetPasswordMutation.result.data?.success && resetPasswordMutation.result.data?.email) {
return (
<>
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
<p>{t('auth.reset-password.success', { email: resetPasswordMutation.result.data.email })}</p>
<h2 className="h2 text-center mb-3">{t('AUTH_RESET_PASSWORD_SUCCESS_TITLE')}</h2>
<p>{t('AUTH_RESET_PASSWORD_SUCCESS', { email: resetPasswordMutation.result.data.email })}</p>
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
{t('auth.reset-password.back-to-login')}
{t('AUTH_RESET_PASSWORD_BACK_TO_LOGIN')}
</Button>
</>
);

View File

@ -15,17 +15,17 @@ interface IProps {
type FormValues = { password: string; passwordConfirm: string };
export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCancel }) => {
const t = useTranslations('auth');
const t = useTranslations();
const schema = z
.object({
password: z.string().min(8, t('form.errors.password.minlength')),
passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
password: z.string().min(8, t('AUTH_FORM_ERROR_PASSWORD_LENGTH')),
passwordConfirm: z.string().min(8, t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_LENGTH')),
})
.superRefine((data, ctx) => {
if (data.password !== data.passwordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('form.errors.password-confirmation.match'),
message: t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_MATCH'),
path: ['passwordConfirm'],
});
}
@ -41,30 +41,30 @@ export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCance
return (
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
<h2 className="h2 text-center mb-3">{t('reset-password.title')}</h2>
<h2 className="h2 text-center mb-3">{t('AUTH_RESET_PASSWORD_TITLE')}</h2>
<Input
{...register('password')}
label={t('form.password')}
label={t('AUTH_FORM_PASSWORD')}
error={errors.password?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder={t('form.new-password-placeholder')}
placeholder={t('AUTH_FORM_PASSWORD_PLACEHOLDER')}
/>
<Input
{...register('passwordConfirm')}
label={t('form.password-confirmation')}
label={t('AUTH_FORM_PASSWORD_CONFIRMATION')}
error={errors.passwordConfirm?.message}
disabled={loading}
type="password"
className="mb-3"
placeholder={t('form.new-password-confirmation-placeholder')}
placeholder={t('AUTH_FORM_PASSWORD_CONFIRMATION_PLACEHOLDER')}
/>
<Button loading={loading} type="submit" className="btn btn-primary w-100">
{t('reset-password.submit')}
{t('AUTH_RESET_PASSWORD_SUBMIT')}
</Button>
<Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
{t('reset-password.cancel')}
{t('AUTH_RESET_PASSWORD_CANCEL')}
</Button>
</form>
);

View File

@ -13,8 +13,8 @@ export default async function ResetPasswordPage() {
return (
<>
<h2 className="h2 text-center mb-3">{translator('auth.reset-password.title')}</h2>
<p>{translator('auth.reset-password.instructions')}</p>
<h2 className="h2 text-center mb-3">{translator('AUTH_RESET_PASSWORD_TITLE')}</h2>
<p>{translator('AUTH_RESET_PASSWORD_INSTRUCTIONS')}</p>
<pre>
<code>./runtipi-cli reset-password</code>
</pre>

View File

@ -80,34 +80,34 @@ export const AppActions: React.FC<IProps> = ({
onUpdateSettings,
}) => {
const { info } = app;
const t = useTranslations('apps.app-details');
const t = useTranslations();
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
const buttons: JSX.Element[] = [];
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('actions.remove')} color="danger" />;
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('actions.settings')} />;
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('actions.stop')} color="danger" />;
const LoadingButtion = <ActionButton key="loading" loading color="success" title={t('actions.loading')} />;
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('actions.cancel')} />;
const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('actions.install')} color="success" />;
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('APP_ACTION_START')} color="success" />;
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('APP_ACTION_REMOVE')} color="danger" />;
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('APP_ACTION_SETTINGS')} />;
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('APP_ACTION_STOP')} color="danger" />;
const LoadingButtion = <ActionButton key="loading" loading color="success" title={t('APP_ACTION_LOADING')} />;
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('APP_ACTION_CANCEL')} />;
const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('APP_ACTION_INSTALL')} color="success" />;
const UpdateButton = (
<ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('actions.update')} color="success" />
<ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('APP_ACTION_UPDATE')} color="success" />
);
const OpenButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button width={140} className={clsx('me-2 px-4 mt-2')}>
{t('actions.open')}
{t('APP_ACTION_OPEN')}
<IconExternalLink className="ms-1" size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('choose-open-method')}</DropdownMenuLabel>
<DropdownMenuLabel>{t('APP_DETAILS_CHOOSE_OPEN_METHOD')}</DropdownMenuLabel>
<DropdownMenuGroup>
{app.exposed && app.domain && (
<DropdownMenuItem onClick={() => onOpen('domain')}>

View File

@ -100,7 +100,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
updateSettingsDisclosure.close();
},
onSuccess: () => {
toast.success(t('apps.app-details.update-config-success'));
toast.success(t('APP_UPDATE_CONFIG_SUCCESS'));
},
});
@ -192,7 +192,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
<AppLogo id={app.id} size={130} alt={app.info.name} />
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
<div>
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="mt-1 me-1">{t('APP_DETAILS_VERSION')}: </span>
<span className="badge bg-muted mt-2 text-white">{app.info.version}</span>
</div>
<span className="mt-1 text-muted text-center text-md-start mb-2">{app.info.short_desc}</span>

View File

@ -11,13 +11,13 @@ interface IProps {
}
export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
const t = useTranslations('apps.app-details');
const t = useTranslations();
return (
<Tabs defaultValue="description" orientation="vertical" style={{ marginTop: -1 }}>
<TabsList>
<TabsTrigger value="description">{t('description')}</TabsTrigger>
<TabsTrigger value="info">{t('base-info')}</TabsTrigger>
<TabsTrigger value="description">{t('APP_DETAILS_DESCRIPTION')}</TabsTrigger>
<TabsTrigger value="info">{t('APP_DETAILS_BASE_INFO')}</TabsTrigger>
</TabsList>
<TabsContent value="description">
{info.deprecated && (
@ -27,8 +27,8 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
<IconAlertCircle />
</div>
<div className="ms-2">
<h4 className="alert-title">{t('deprecated-alert-title')}</h4>
<div className="text-secondary">{t('deprecated-alert-subtitle')} </div>
<h4 className="alert-title">{t('APP_DETAILS_DEPRECATED_ALERT_TITLE')}</h4>
<div className="text-secondary">{t('APP_DETAILS_DEPRECATED_ALERT_SUBTITLE')} </div>
</div>
</div>
</div>
@ -37,26 +37,26 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
</TabsContent>
<TabsContent value="info">
<DataGrid>
<DataGridItem title={t('source-code')}>
<DataGridItem title={t('APP_DETAILS_SOURCE_CODE')}>
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
{t('link')}
{t('APP_DETAILS_LINK')}
<IconExternalLink size={15} className="ms-1 mb-1" />
</a>
</DataGridItem>
<DataGridItem title={t('author')}>{info.author}</DataGridItem>
<DataGridItem title={t('port')}>
<DataGridItem title={t('APP_DETAILS_AUTHOR')}>{info.author}</DataGridItem>
<DataGridItem title={t('APP_DETAILS_PORT')}>
<b>{info.port}</b>
</DataGridItem>
<DataGridItem title={t('categories-title')}>
<DataGridItem title={t('APP_DETAILS_CATEGORIES_TITLE')}>
{info.categories.map((c) => (
<div key={c} className="badge text-white bg-green me-1">
{t(`categories.${c}`)}
{t(`APP_CATEGORY_${c.toUpperCase() as Uppercase<typeof c>}`)}
</div>
))}
</DataGridItem>
<DataGridItem title={t('version')}>{info.version}</DataGridItem>
<DataGridItem title={t('APP_DETAILS_VERSION')}>{info.version}</DataGridItem>
{info.supported_architectures && (
<DataGridItem title={t('supported-arch')}>
<DataGridItem title={t('APP_DETAILS_SUPPORTED_ARCH')}>
{info.supported_architectures.map((a) => (
<div key={a} className="badge text-white bg-red me-1">
{a.toLowerCase()}
@ -65,7 +65,7 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
</DataGridItem>
)}
{info.website && (
<DataGridItem title={t('website')}>
<DataGridItem title={t('APP_DETAILS_WEBSITE')}>
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.website}>
{info.website}
<IconExternalLink size={15} className="ms-1 mb-1" />

View File

@ -33,7 +33,7 @@ const hiddenTypes = ['random'];
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading, onReset, status }) => {
const t = useTranslations('apps.app-details.install-form');
const t = useTranslations();
const {
register,
handleSubmit,
@ -102,7 +102,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
render={({ field: { onChange, value, ref, ...props } }) => (
<Select value={value as string} defaultValue={field.default as string} onValueChange={onChange} {...props}>
<SelectTrigger className="mb-3" error={errors[field.env_variable]?.message} label={label}>
<SelectValue placeholder={t('choose-option')} />
<SelectValue placeholder={t('APP_INSTALL_FORM_CHOOSE_OPTION')} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
@ -144,14 +144,20 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
checked={value}
onCheckedChange={onChange}
disabled={info.force_expose}
label={t('expose-app')}
label={t('APP_INSTALL_FORM_EXPOSE_APP')}
/>
)}
/>
{watchExposed && (
<div className="mb-3">
<Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} disabled={loading} placeholder={t('domain-name')} />
<span className="text-muted">{t('domain-name-hint')}</span>
<Input
{...register('domain')}
label={t('APP_INSTALL_FORM_DOMAIN_NAME')}
error={errors.domain?.message}
disabled={loading}
placeholder={t('APP_INSTALL_FORM_DOMAIN_NAME')}
/>
<span className="text-muted">{t('APP_INSTALL_FORM_DOMAIN_NAME_HINT')}</span>
</div>
)}
</>
@ -186,17 +192,17 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
checked={value}
onCheckedChange={onChange}
{...props}
label={t('display-on-guest-dashboard')}
label={t('APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD')}
/>
)}
/>
{info.exposable && renderExposeForm()}
<Button loading={loading} type="submit" className="btn-success">
{initalValues ? t('submit-update') : t('sumbit-install')}
{initalValues ? t('APP_INSTALL_FORM_SUBMIT_UPDATE') : t('APP_INSTALL_FORM_SUBMIT_INSTALL')}
</Button>
{initalValues && onReset && (
<Button loading={status === 'stopping'} onClick={onClickReset} className="btn-danger ms-2">
{t('reset')}
{t('APP_INSTALL_FORM_RESET')}
</Button>
)}
</form>

View File

@ -13,13 +13,13 @@ interface IProps {
}
export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => {
const t = useTranslations('apps.app-details.install-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_INSTALL_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<ScrollArea maxHeight={500}>
<DialogDescription>

View File

@ -14,22 +14,22 @@ interface IProps {
}
export const ResetAppModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm, isLoading }) => {
const t = useTranslations('apps.app-details.reset-app-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_RESET_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>{t('warning')}</h3>
<div className="text-muted">{t('subtitle')}</div>
<h3>{t('APP_RESET_FORM_WARNING')}</h3>
<div className="text-muted">{t('APP_RESET_FORM_SUBTITLE')}</div>
</DialogDescription>
<DialogFooter>
<Button loading={isLoading} onClick={onConfirm} className="btn-danger">
{t('submit')}
{t('APP_RESET_FORM_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -12,20 +12,20 @@ interface IProps {
}
export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.stop-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_STOP_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">{t('subtitle')}</div>
<div className="text-muted">{t('APP_STOP_FORM_SUBTITLE')}</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
{t('submit')}
{t('APP_STOP_FORM_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -13,22 +13,22 @@ interface IProps {
}
export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.uninstall-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_UNINSTALL_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>{t('warning')}</h3>
<div className="text-muted">{t('subtitle')}</div>
<h3>{t('APP_UNINSTALL_FORM_WARNING')}</h3>
<div className="text-muted">{t('APP_UNINSTALL_FORM_SUBTITLE')}</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
{t('submit')}
{t('APP_UNINSTALL_FORM_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -13,23 +13,23 @@ interface IProps {
}
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.update-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_UPDATE_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">
{t('subtitle1')} <b>{newVersion}</b> ?<br />
{t('subtitle2')}
{t('APP_UPDATE_FORM_SUBTITLE_1')} <b>{newVersion}</b> ?<br />
{t('APP_UPDATE_FORM_SUBTITLE_2')}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
{t('submit')}
{t('APP_UPDATE_FORM_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -17,17 +17,24 @@ interface IProps {
}
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, onReset, status }) => {
const t = useTranslations('apps.app-details.update-settings-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
<h5 className="modal-title">{t('APP_UPDATE_SETTINGS_FORM_TITLE', { name: info.name })}</h5>
</DialogHeader>
<ScrollArea maxHeight={500}>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} onReset={onReset} status={status} />
<InstallForm
onSubmit={onSubmit}
formFields={info.form_fields}
info={info}
initalValues={{ ...config }}
onReset={onReset}
status={status}
/>
</DialogDescription>
</ScrollArea>
</DialogContent>

View File

@ -6,7 +6,7 @@ export const validateField = (field: FormField, value: string | undefined | bool
const { translator } = useUIStore.getState();
if (field.required && !value && typeof value !== 'boolean') {
return translator('apps.app-details.install-form.errors.required', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_REQUIRED', { label: field.label });
}
if (!value || typeof value !== 'string') {
@ -14,51 +14,51 @@ export const validateField = (field: FormField, value: string | undefined | bool
}
if (field.regex && !validator.matches(value, field.regex)) {
return field.pattern_error || translator('apps.app-details.install-form.errors.regex', { label: field.label, pattern: field.regex });
return field.pattern_error || translator('APP_INSTALL_FORM_ERROR_REGEX', { label: field.label, pattern: field.regex });
}
switch (field.type) {
case 'text':
if (field.max && value.length > field.max) {
return translator('apps.app-details.install-form.errors.max-length', { label: field.label, max: field.max });
return translator('APP_INSTALL_FORM_ERROR_MAX_LENGTH', { label: field.label, max: field.max });
}
if (field.min && value.length < field.min) {
return translator('apps.app-details.install-form.errors.min-length', { label: field.label, min: field.min });
return translator('APP_INSTALL_FORM_ERROR_MIN_LENGTH', { label: field.label, min: field.min });
}
break;
case 'password':
if (!validator.isLength(value, { min: field.min || 0, max: field.max || 100 })) {
return translator('apps.app-details.install-form.errors.between-length', { label: field.label, min: field.min, max: field.max });
return translator('APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH', { label: field.label, min: field.min, max: field.max });
}
break;
case 'email':
if (!validator.isEmail(value)) {
return translator('apps.app-details.install-form.errors.invalid-email', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_INVALID_EMAIL', { label: field.label });
}
break;
case 'number':
if (!validator.isNumeric(value)) {
return translator('apps.app-details.install-form.errors.number', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_NUMBER', { label: field.label });
}
break;
case 'fqdn':
if (!validator.isFQDN(value)) {
return translator('apps.app-details.install-form.errors.fqdn', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_FQDN', { label: field.label });
}
break;
case 'ip':
if (!validator.isIP(value)) {
return translator('apps.app-details.install-form.errors.ip', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_IP', { label: field.label });
}
break;
case 'fqdnip':
if (!validator.isFQDN(value || '') && !validator.isIP(value)) {
return translator('apps.app-details.install-form.errors.fqdnip', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_FQDNIP', { label: field.label });
}
break;
case 'url':
if (!validator.isURL(value)) {
return translator('apps.app-details.install-form.errors.url', { label: field.label });
return translator('APP_INSTALL_FORM_ERROR_URL', { label: field.label });
}
break;
default:
@ -71,7 +71,7 @@ export const validateField = (field: FormField, value: string | undefined | bool
const validateDomain = (domain?: string | boolean): string | undefined => {
if (typeof domain !== 'string' || !validator.isFQDN(domain || '')) {
const { translator } = useUIStore.getState();
return translator('apps.app-details.install-form.errors.fqdn', { label: String(domain) });
return translator('APP_INSTALL_FORM_ERROR_FQDN', { label: String(domain) });
}
return undefined;

View File

@ -14,10 +14,13 @@ interface IProps {
export const AppStoreTable: React.FC<IProps> = ({ data }) => {
const { category, search, sort, sortDirection } = useAppStoreState();
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
const tableData = React.useMemo(
() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }),
[data, sort, sortDirection, category, search],
);
if (!tableData.length) {
return <EmptyPage title="apps.app-store.no-results" subtitle="apps.app-store.no-results-subtitle" />;
return <EmptyPage title="APP_STORE_NO_RESULTS" subtitle="APP_STORE_NO_RESULTS_SUBTITLE" />;
}
return (

View File

@ -10,11 +10,16 @@ import { CategorySelector } from '../CategorySelector';
export const AppStoreTableActions = () => {
const { setCategory, category, search, setSearch } = useAppStoreState();
const t = useTranslations('apps.app-store');
const t = useTranslations();
return (
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('APP_STORE_SEARCH_PLACEHOLDER')}
className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)}
/>
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
</div>
);

View File

@ -18,7 +18,7 @@ type App = {
};
export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
const t = useTranslations('apps.app-details');
const t = useTranslations();
return (
<Link aria-label={app.name} className={clsx('cursor-pointer col-sm-6 col-lg-4 p-2 mt-4', styles.appTile)} href={`/app-store/${app.id}`} passHref>
@ -29,7 +29,7 @@ export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
<p className="text-muted text-nowrap mb-2">{limitText(app.short_desc, 30)}</p>
{app.categories?.map((category) => (
<div className={`text-white badge me-1 bg-${colorSchemeForCategory[category]}`} key={`${app.id}-${category}`}>
{t(`categories.${category}`)}
{t(`APP_CATEGORY_${category.toUpperCase() as Uppercase<typeof category>}`)}
</div>
))}
</div>

View File

@ -48,11 +48,11 @@ const ControlComponent = (props: ControlProps<OptionsType>) => {
};
export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
const t = useTranslations('apps');
const t = useTranslations();
const { darkMode } = useUIStore();
const options: OptionsType[] = iconForCategory.map((category) => ({
value: category.id,
label: t(`app-details.categories.${category.id}`),
label: t(`APP_CATEGORY_${category.id.toUpperCase() as Uppercase<typeof category.id>}`),
icon: category.icon,
}));
@ -108,7 +108,7 @@ export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initia
defaultValue={[]}
name="categories"
options={options}
placeholder={t('app-store.category-placeholder')}
placeholder={t('APP_STORE_CATEGORY_PLACEHOLDER')}
/>
);
};

View File

@ -8,7 +8,7 @@ export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.app-store.title')} - Tipi`,
title: `${translator('APP_STORE_TITLE')} - Tipi`,
};
}

View File

@ -11,11 +11,11 @@ import { UpdateAllModal } from '../UpdateAllModal';
export const UpdateAllButton: React.FC = () => {
const updateDisclosure = useDisclosure();
const t = useTranslations('apps.my-apps.update-all-form');
const t = useTranslations();
const updateAllMutation = useAction(updateAllAppsAction, {
onSuccess: () => {
toast.loading(t('in-progress'), { duration: 3000 });
toast.loading(t('MY_APPS_UPDATE_ALL_IN_PROGRESS'), { duration: 3000 });
},
onError: (e) => {
if (e.serverError) toast.error(e.serverError);

View File

@ -10,25 +10,25 @@ interface IProps {
}
export const UpdateAllModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.my-apps.update-all-form');
const t = useTranslations();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title')}</h5>
<h5 className="modal-title">{t('MY_APPS_UPDATE_ALL_FORM_TITLE')}</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">
{t('subtitle1')}
{t('MY_APPS_UPDATE_ALL_FORM_SUBTITLE_1')}
<br />
<br />
{t('subtitle2')}
{t('MY_APPS_UPDATE_ALL_FORM_SUBTITLE_2')}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
{t('submit')}
{t('MY_APPS_UPDATE_ALL_FORM_SUBMIT')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -13,7 +13,7 @@ export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.my-apps.title')} - Tipi`,
title: `${translator('MY_APPS_TITLE')} - Tipi`,
};
}
@ -36,7 +36,9 @@ export default async function Page() {
return (
<>
{installedApps.length === 0 && <EmptyPage title="apps.my-apps.empty-title" subtitle="apps.my-apps.empty-subtitle" redirectPath="/app-store" actionLabel="apps.my-apps.empty-action" />}
{installedApps.length === 0 && (
<EmptyPage title="MY_APPS_EMPTY_TITLE" subtitle="MY_APPS_EMPTY_SUBTITLE" redirectPath="/app-store" actionLabel="MY_APPS_EMPTY_ACTION" />
)}
<div className="row row-cards " data-testid="apps-list">
{installedApps?.map(renderApp)}
</div>

View File

@ -24,7 +24,7 @@ interface IProps {
export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = true }) => {
const { setDarkMode } = useUIStore();
const t = useTranslations('header');
const t = useTranslations();
const { allowAutoThemes = false } = useClientSettings();
const router = useRouter();
@ -73,17 +73,17 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = tr
<div className="btn-list">
<a href="https://github.com/runtipi/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
<IconBrandGithub data-testid="icon-github" className="me-1 icon" size={24} />
{t('source-code')}
{t('HEADER_SOURCE_CODE')}
</a>
<a href="https://github.com/runtipi/runtipi?sponsor=1" target="_blank" rel="noreferrer" className="btn btn-dark">
<IconHeart className="me-1 icon text-pink" size={24} />
{t('sponsor')}
{t('HEADER_SPONSOR')}
</a>
</div>
</div>
<div style={{ zIndex: 1 }} className="d-flex">
<Tooltip className="tooltip" anchorSelect=".darkMode">
{t('dark-mode')}
{t('HEADER_DARK_MODE')}
</Tooltip>
<div
onClick={() => setDarkMode(true)}
@ -95,7 +95,7 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = tr
<IconMoon data-testid="icon-moon" size={20} />
</div>
<Tooltip className="tooltip" anchorSelect=".lightMode">
{t('light-mode')}
{t('HEADER_LIGHT_MODE')}
</Tooltip>
<div
onClick={() => setDarkMode(false)}
@ -106,7 +106,7 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = tr
<IconSun data-testid="icon-sun" size={20} />
</div>
<Tooltip className="tooltip" anchorSelect=".logOut">
{authenticated ? t('logout') : t('login')}
{authenticated ? t('HEADER_LOGOUT') : t('HEADER_LOGIN')}
</Tooltip>
<div
onClick={() => logHandler()}

View File

@ -10,7 +10,7 @@ interface IProps {
}
export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
const t = useTranslations('header');
const t = useTranslations();
const path = usePathname()?.split('/')[1];
const renderItem = (title: string, name: string, IconComponent: Icon) => {
@ -33,12 +33,12 @@ export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
<div id="navbar-menu" className="collapse navbar-collapse">
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul className="navbar-nav">
{renderItem(t('dashboard'), 'dashboard', IconHome)}
{renderItem(t('my-apps'), 'apps', IconApps)}
{renderItem(t('app-store'), 'app-store', IconBrandAppstore)}
{renderItem(t('settings'), 'settings', IconSettings)}
{renderItem(t('HEADER_DASHBOARD'), 'dashboard', IconHome)}
{renderItem(t('HEADER_APPS'), 'apps', IconApps)}
{renderItem(t('HEADER_APP_STORE'), 'app-store', IconBrandAppstore)}
{renderItem(t('HEADER_SETTINGS'), 'settings', IconSettings)}
</ul>
{Boolean(isUpdateAvailable) && <span className="ms-2 badge text-white bg-green d-none d-lg-block">{t('update-available')}</span>}
{Boolean(isUpdateAvailable) && <span className="ms-2 badge text-white bg-green d-none d-lg-block">{t('HEADER_UPDATE_AVAILABLE')}</span>}
</div>
</div>
);

View File

@ -21,7 +21,7 @@ export const DashboardContainer: React.FC<IProps> = (props) => {
selector: { type: 'system_info' },
});
const t = useTranslations('dashboard');
const t = useTranslations();
if (!lastData) {
return null;
@ -30,21 +30,21 @@ export const DashboardContainer: React.FC<IProps> = (props) => {
return (
<div className="row row-deck row-cards">
<SystemStat
title={t('cards.disk.title')}
title={t('DASHBOARD_DISK_SPACE_TITLE')}
metric={`${lastData.diskUsed} GB`}
subtitle={t('cards.disk.subtitle', { total: lastData.diskSize })}
subtitle={t('DASHBOARD_DISK_SPACE_SUBTITLE', { total: lastData.diskSize })}
icon={IconDatabase}
progress={lastData.percentUsed}
/>
<SystemStat
title={t('cards.cpu.title')}
title={t('DASHBOARD_CPU_TITLE')}
metric={`${lastData.cpuLoad.toFixed(2)}%`}
subtitle={t('cards.cpu.subtitle')}
subtitle={t('DASHBOARD_CPU_SUBTITLE')}
icon={IconCpu}
progress={lastData.cpuLoad}
/>
<SystemStat
title={t('cards.memory.title')}
title={t('DASHBOARD_MEMORY_TITLE')}
metric={`${lastData.percentUsedMemory || 0}%`}
subtitle={`${lastData.memoryTotal} GB`}
icon={IconCircuitResistor}

View File

@ -8,7 +8,7 @@ export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('dashboard.title')} - Tipi`,
title: `${translator('DASHBOARD_TITLE')} - Tipi`,
};
}

View File

@ -11,19 +11,19 @@ import { useAction } from 'next-safe-action/hook';
import { changePasswordAction } from '@/actions/settings/change-password';
export const ChangePasswordForm = () => {
const t = useTranslations('settings.security');
const t = useTranslations();
const schema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, t('form.password-length')),
newPasswordConfirm: z.string().min(8, t('form.password-length')),
newPassword: z.string().min(8, t('SETTINGS_SECURITY_FORM_PASSWORD_LENGTH')),
newPasswordConfirm: z.string().min(8, t('SETTINGS_SECURITY_FORM_PASSWORD_LENGTH')),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.newPasswordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('form.password-match'),
message: t('SETTINGS_SECURITY_FORM_PASSWORD_MATCH'),
path: ['newPasswordConfirm'],
});
}
@ -38,7 +38,7 @@ export const ChangePasswordForm = () => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('password-change-success'));
toast.success(t('SETTINGS_SECURITY_PASSWORD_CHANGE_SUCCESS'));
router.push('/');
},
});
@ -62,7 +62,7 @@ export const ChangePasswordForm = () => {
{...register('currentPassword')}
error={errors.currentPassword?.message}
type="password"
placeholder={t('form.current-password')}
placeholder={t('SETTINGS_SECURITY_FORM_CURRENT_PASSWORD')}
/>
<Input
disabled={changePasswordMutation.status === 'executing'}
@ -70,7 +70,7 @@ export const ChangePasswordForm = () => {
error={errors.newPassword?.message}
className="mt-2"
type="password"
placeholder={t('form.new-password')}
placeholder={t('SETTINGS_SECURITY_FORM_NEW_PASSWORD')}
/>
<Input
disabled={changePasswordMutation.status === 'executing'}
@ -78,10 +78,10 @@ export const ChangePasswordForm = () => {
error={errors.newPasswordConfirm?.message}
className="mt-2"
type="password"
placeholder={t('form.confirm-password')}
placeholder={t('SETTINGS_SECURITY_FORM_CONFIRM_PASSWORD')}
/>
<Button disabled={changePasswordMutation.status === 'executing'} className="mt-3" type="submit">
{t('form.change-password')}
{t('SETTINGS_SECURITY_FORM_CHANGE_PASSWORD_SUBMIT')}
</Button>
</form>
);

View File

@ -19,9 +19,9 @@ type Props = {
export const ChangeUsernameForm = ({ username }: Props) => {
const router = useRouter();
const changeUsernameDisclosure = useDisclosure();
const t = useTranslations('settings.security');
const t = useTranslations();
const schema = z.object({
newUsername: z.string().email(t('change-username.form.invalid-username')),
newUsername: z.string().email(t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_INVALID_USERNAME')),
password: z.string().min(1),
});
type FormValues = z.infer<typeof schema>;
@ -31,7 +31,7 @@ export const ChangeUsernameForm = ({ username }: Props) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('change-username.success'));
toast.success(t('SETTINGS_SECURITY_CHANGE_USERNAME_SUCCESS'));
router.push('/');
},
});
@ -48,21 +48,21 @@ export const ChangeUsernameForm = ({ username }: Props) => {
<div className="mb-4">
<Input disabled type="email" value={username} />
<Button className="mt-3" onClick={() => changeUsernameDisclosure.open()}>
{t('change-username.form.submit')}
{t('SETTINGS_SECURITY_CHANGE_USERNAME_TITLE')}
</Button>
<Dialog open={changeUsernameDisclosure.isOpen} onOpenChange={changeUsernameDisclosure.toggle}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{t('password-needed')}</DialogTitle>
<DialogTitle>{t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_PASSWORD')}</DialogTitle>
</DialogHeader>
<DialogDescription className="d-flex flex-column">
<form onSubmit={handleSubmit(onSubmit)} className="w-100">
<p className="text-muted">{t('change-username.form.password-needed-hint')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_PASSWORD_NEEDED_HINT')}</p>
<Input
error={formState.errors.newUsername?.message}
disabled={changeUsernameMutation.status === 'executing'}
type="email"
placeholder={t('change-username.form.new-username')}
placeholder={t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_NEW_USERNAME')}
{...register('newUsername')}
/>
<Input
@ -70,11 +70,11 @@ export const ChangeUsernameForm = ({ username }: Props) => {
error={formState.errors.password?.message}
disabled={changeUsernameMutation.status === 'executing'}
type="password"
placeholder={t('form.password')}
placeholder={t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_PASSWORD')}
{...register('password')}
/>
<Button loading={changeUsernameMutation.status === 'executing'} type="submit" className="btn-success mt-3">
{t('change-username.form.submit')}
{t('SETTINGS_SECURITY_CHANGE_USERNAME_FORM_SUBMIT')}
</Button>
</form>
</DialogDescription>

View File

@ -18,7 +18,7 @@ export const GeneralActions = (props: Props) => {
const renderUpdate = () => {
if (isLatest) {
return <Button disabled>{t('settings.actions.already-latest')}</Button>;
return <Button disabled>{t('SETTINGS_ACTIONS_ALREADY_LATEST')}</Button>;
}
return (
@ -41,9 +41,11 @@ export const GeneralActions = (props: Props) => {
return (
<div className="card-body">
<h2 className="mb-4">{t('settings.actions.title')}</h2>
<h3 className="card-title mt-4">{t('settings.actions.current-version', { version: version.current })}</h3>
<p className="card-subtitle">{isLatest ? t('settings.actions.stay-up-to-date') : t('settings.actions.new-version', { version: version.latest })}</p>
<h2 className="mb-4">{t('SETTINGS_ACTIONS_TITLE')}</h2>
<h3 className="card-title mt-4">{t('SETTINGS_ACTIONS_CURRENT_VERSION', { version: version.current })}</h3>
<p className="card-subtitle">
{isLatest ? t('SETTINGS_ACTIONS_STAY_UP_TO_DATE') : t('SETTINGS_ACTIONS_NEW_VERSION', { version: version.latest })}
</p>
{renderUpdate()}
</div>
);

View File

@ -15,7 +15,7 @@ import { disableTotpAction } from '@/actions/settings/disable-totp';
export const OtpForm = (props: { totpEnabled: boolean }) => {
const { totpEnabled } = props;
const t = useTranslations('settings.security');
const t = useTranslations();
const [password, setPassword] = React.useState('');
const [key, setKey] = React.useState('');
const [uri, setUri] = React.useState('');
@ -48,7 +48,7 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
setTotpCode('');
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
toast.success(t('SETTINGS_SECURITY_2FA_ENABLE_SUCCESS'));
},
});
@ -61,7 +61,7 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('2fa-disable-success'));
toast.success(t('SETTINGS_SECURITY_2FA_DISABLE_SUCCESS'));
},
});
@ -71,18 +71,18 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
return (
<div className="mt-4">
<div className="mb-4">
<p className="text-muted">{t('scan-qr-code')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_SCAN_QR_CODE')}</p>
<QRCodeSVG value={uri} />
</div>
<div className="mb-4">
<p className="text-muted">{t('enter-key-manually')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_ENTER_KEY_MANUALLY')}</p>
<Input name="secret key" value={key} readOnly />
</div>
<div className="mb-4">
<p className="text-muted">{t('enter-2fa-code')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_ENTER_2FA_CODE')}</p>
<OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
<Button disabled={totpCode.trim().length < 6} onClick={() => setupTotpMutation.execute({ totpCode })} className="mt-3 btn-success">
{t('enable-2fa')}
{t('SETTINGS_SECURITY_ENABLE_2FA')}
</Button>
</div>
</div>
@ -99,7 +99,7 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
return (
<>
{!key && <Switch onCheckedChange={handleTotp} checked={totpEnabled} label={t('enable-2fa')} />}
{!key && <Switch onCheckedChange={handleTotp} checked={totpEnabled} label={t('SETTINGS_SECURITY_ENABLE_2FA')} />}
{getTotpUriMutation.status === 'executing' && (
<div className="progress w-50">
<div className="progress-bar progress-bar-indeterminate bg-green" />
@ -109,7 +109,7 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
<Dialog open={setupOtpDisclosure.isOpen} onOpenChange={(o: boolean) => setupOtpDisclosure.toggle(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{t('password-needed')}</DialogTitle>
<DialogTitle>{t('SETTINGS_SECURITY_PASSWORD_NEEDED')}</DialogTitle>
</DialogHeader>
<DialogDescription className="d-flex flex-column">
<form
@ -118,10 +118,15 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
getTotpUriMutation.execute({ password });
}}
>
<p className="text-muted">{t('password-needed-hint')}</p>
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
<p className="text-muted">{t('SETTINGS_SECURITY_PASSWORD_NEEDED_HINT')}</p>
<Input
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder={t('SETTINGS_SECURITY_PASSWORD_NEEDED')}
/>
<Button loading={getTotpUriMutation.status === 'executing'} type="submit" className="btn-success mt-3">
{t('enable-2fa')}
{t('SETTINGS_SECURITY_ENABLE_2FA')}
</Button>
</form>
</DialogDescription>
@ -130,7 +135,7 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
<Dialog open={disableOtpDisclosure.isOpen} onOpenChange={(o: boolean) => disableOtpDisclosure.toggle(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{t('password-needed')}</DialogTitle>
<DialogTitle>{t('SETTINGS_SECURITY_PASSWORD_NEEDED')}</DialogTitle>
</DialogHeader>
<DialogDescription className="d-flex flex-column">
<form
@ -139,10 +144,15 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
disableTotpMutation.execute({ password });
}}
>
<p className="text-muted">{t('password-needed-hint')}</p>
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
<p className="text-muted">{t('SETTINGS_SECURITY_PASSWORD_NEEDED_HINT')}</p>
<Input
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder={t('SETTINGS_SECURITY_PASSWORD_NEEDED')}
/>
<Button loading={disableTotpMutation.status === 'executing'} type="submit" className="btn-danger mt-3">
{t('disable-2fa')}
{t('SETTINGS_SECURITY_DISABLE_2FA')}
</Button>
</form>
</DialogDescription>

View File

@ -9,30 +9,30 @@ import { ChangeUsernameForm } from '../ChangeUsernameForm';
export const SecurityContainer = (props: { totpEnabled: boolean; username?: string }) => {
const { totpEnabled, username } = props;
const t = useTranslations('settings.security');
const t = useTranslations();
return (
<div className="card-body">
<div className="d-flex">
<IconUser className="me-2" />
<h2>{t('change-username.title')}</h2>
<h2>{t('SETTINGS_SECURITY_CHANGE_USERNAME_TITLE')}</h2>
</div>
<p className="text-muted">{t('change-username.subtitle')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_CHANGE_USERNAME_SUBTITLE')}</p>
<ChangeUsernameForm username={username} />
<div className="d-flex">
<IconKey className="me-2" />
<h2>{t('change-password-title')}</h2>
<h2>{t('SETTINGS_SECURITY_CHANGE_PASSWORD_TITLE')}</h2>
</div>
<p className="text-muted">{t('change-password-subtitle')}</p>
<p className="text-muted">{t('SETTINGS_SECURITY_CHANGE_PASSWORD_SUBTITLE')}</p>
<ChangePasswordForm />
<div className="d-flex">
<IconLock className="me-2" />
<h2>{t('2fa-title')}</h2>
<h2>{t('SETTINGS_SECURITY_2FA_TITLE')}</h2>
</div>
<p className="text-muted">
{t('2fa-subtitle')}
{t('SETTINGS_SECURITY_2FA_SUBTITLE')}
<br />
{t('2fa-subtitle-2')}
{t('SETTINGS_SECURITY_2FA_SUBTITLE_2')}
</p>
<OtpForm totpEnabled={totpEnabled} />
</div>

View File

@ -24,7 +24,7 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('settings.settings.settings-updated'));
toast.success(t('SETTINGS_GENERAL_SETTINGS_UPDATED'));
router.refresh();
},
});

View File

@ -33,29 +33,29 @@ interface IProps {
export const SettingsForm = (props: IProps) => {
const { onSubmit, initalValues, loading, currentLocale = 'en-US', submitErrors } = props;
const t = useTranslations('settings.settings');
const t = useTranslations();
const validateFields = (values: SettingsFormValues) => {
const errors: { [K in keyof SettingsFormValues]?: string } = {};
if (values.localDomain && !validator.isFQDN(values.localDomain)) {
errors.localDomain = t('invalid-domain');
errors.localDomain = t('SETTINGS_GENERAL_INVALID_DOMAIN');
}
if (values.dnsIp && !validator.isIP(values.dnsIp)) {
errors.dnsIp = t('invalid-ip');
errors.dnsIp = t('SETTINGS_GENERAL_INVALID_IP');
}
if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
errors.internalIp = t('invalid-ip');
errors.internalIp = t('SETTINGS_GENERAL_INVALID_IP');
}
if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
errors.appsRepoUrl = t('invalid-url');
errors.appsRepoUrl = t('SETTINGS_GENERAL_INVALID_URL');
}
if (values.domain && !validator.isFQDN(values.domain)) {
errors.domain = t('invalid-domain');
errors.domain = t('SETTINGS_GENERAL_INVALID_DOMAIN');
}
return errors;
@ -109,15 +109,15 @@ export const SettingsForm = (props: IProps) => {
<>
<div className="d-flex">
<IconUser className="me-2" />
<h2 className="text-2xl font-bold">{t('user-settings-title')}</h2>
<h2 className="text-2xl font-bold">{t('SETTINGS_GENERAL_USER_SETTINGS')}</h2>
</div>
<LanguageSelector showLabel locale={currentLocale} />
<form className="flex flex-col mt-2" onSubmit={handleSubmit(validate)}>
<div className="d-flex">
<IconAdjustmentsAlt className="me-2" />
<h2 className="text-2xl font-bold">{t('title')}</h2>
<h2 className="text-2xl font-bold">{t('SETTINGS_GENERAL_TITLE')}</h2>
</div>
<p className="mb-4">{t('subtitle')}</p>
<p className="mb-4">{t('SETTINGS_GENERAL_SUBTITLE')}</p>
<div className="mb-3">
<Controller
control={control}
@ -132,9 +132,9 @@ export const SettingsForm = (props: IProps) => {
{...rest}
label={
<>
{t('guest-dashboard')}
{t('SETTINGS_GENERAL_GUEST_DASHBOARD')}
<Tooltip className="tooltip" anchorSelect=".guest-dashboard-hint">
{t('guest-dashboard-hint')}
{t('SETTINGS_GENERAL_GUEST_DASHBOARD_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help guest-dashboard-hint')}>?</span>
</>
@ -157,9 +157,9 @@ export const SettingsForm = (props: IProps) => {
{...rest}
label={
<>
{t('allow-error-monitoring')}
{t('SETTINGS_GENERAL_ALLOW_ERROR_MONITORING')}
<Tooltip className="tooltip" anchorSelect=".allow-errors-hint">
{t('allow-error-monitoring-hint')}
{t('SETTINGS_GENERAL_ALLOW_ERROR_MONITORING_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help allow-errors-hint')}>?</span>
</>
@ -182,9 +182,9 @@ export const SettingsForm = (props: IProps) => {
{...rest}
label={
<>
{t('allow-auto-themes')}
{t('SETTINGS_GENERAL_ALLOW_AUTO_THEMES')}
<Tooltip className="tooltip" anchorSelect=".allow-auto-themes-hint">
{t('allow-auto-themes-hint')}
{t('SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help allow-auto-themes-hint')}>?</span>
</>
@ -198,9 +198,9 @@ export const SettingsForm = (props: IProps) => {
{...register('domain')}
label={
<>
{t('domain-name')}
{t('SETTINGS_GENERAL_DOMAIN_NAME')}
<Tooltip className="tooltip" anchorSelect=".domain-name-hint">
{t('domain-name-hint')}
{t('SETTINGS_GENERAL_DOMAIN_NAME_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help domain-name-hint')}>?</span>
</>
@ -210,16 +210,16 @@ export const SettingsForm = (props: IProps) => {
/>
</div>
<div className="mb-3">
<Input {...register('dnsIp')} label={t('dns-ip')} error={errors.dnsIp?.message} placeholder="9.9.9.9" />
<Input {...register('dnsIp')} label={t('SETTINGS_GENERAL_DNS_IP')} error={errors.dnsIp?.message} placeholder="9.9.9.9" />
</div>
<div className="mb-3">
<Input
{...register('internalIp')}
label={
<>
{t('internal-ip')}
{t('SETTINGS_GENERAL_INTERNAL_IP')}
<Tooltip className="tooltip" anchorSelect=".internal-ip-hint">
{t('internal-ip-hint')}
{t('SETTINGS_GENERAL_INTERNAL_IP_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help internal-ip-hint')}>?</span>
</>
@ -233,9 +233,9 @@ export const SettingsForm = (props: IProps) => {
{...register('appsRepoUrl')}
label={
<>
{t('apps-repo')}
{t('SETTINGS_GENERAL_APPS_REPO')}
<Tooltip className="tooltip" anchorSelect=".apps-repo-hint">
{t('apps-repo-hint')}
{t('SETTINGS_GENERAL_APPS_REPO_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help apps-repo-hint')}>?</span>
</>
@ -249,15 +249,15 @@ export const SettingsForm = (props: IProps) => {
{...register('storagePath')}
label={
<>
{t('storage-path')}
{t('SETTINGS_GENERAL_STORAGE_PATH')}
<Tooltip className="tooltip" anchorSelect=".storage-path-hint">
{t('storage-path-hint')}
{t('SETTINGS_GENERAL_STORAGE_PATH_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help storage-path-hint')}>?</span>
</>
}
error={errors.storagePath?.message}
placeholder={t('storage-path')}
placeholder={t('SETTINGS_GENERAL_STORAGE_PATH')}
/>
</div>
<div className="mb-3">
@ -265,9 +265,9 @@ export const SettingsForm = (props: IProps) => {
{...register('localDomain')}
label={
<>
{t('local-domain')}
{t('SETTINGS_GENERAL_LOCAL_DOMAIN')}
<Tooltip className="tooltip" anchorSelect=".local-domain-hint">
{t('local-domain-hint')}
{t('SETTINGS_GENERAL_LOCAL_DOMAIN_HINT')}
</Tooltip>
<span className={clsx('ms-1 form-help local-domain-hint')}>?</span>
</>
@ -276,11 +276,11 @@ export const SettingsForm = (props: IProps) => {
placeholder="tipi.lan"
/>
<Button className="mt-2" onClick={downloadCertificate}>
{t('download-certificate')}
{t('SETTINGS_GENERAL_DOWNLOAD_CERTIFICATE')}
</Button>
</div>
<Button loading={loading} type="submit" className="btn-success">
{t('submit')}
{t('SETTINGS_GENERAL_SUBMIT')}
</Button>
</form>
</>

View File

@ -16,13 +16,13 @@ export const SettingsTabTriggers = () => {
return (
<TabsList>
<TabsTrigger onClick={() => handleTabChange('actions')} value="actions">
{t('settings.actions.tab-title')}
{t('SETTINGS_ACTIONS_TAB_TITLE')}
</TabsTrigger>
<TabsTrigger onClick={() => handleTabChange('settings')} value="settings">
{t('settings.settings.tab-title')}
{t('SETTINGS_GENERAL_TAB_TITLE')}
</TabsTrigger>
<TabsTrigger onClick={() => handleTabChange('security')} value="security">
{t('settings.security.tab-title')}
{t('SETTINGS_SECURITY_TAB_TITLE')}
</TabsTrigger>
</TabsList>
);

View File

@ -15,7 +15,7 @@ export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('settings.title')} - Tipi`,
title: `${translator('SETTINGS_TITLE')} - Tipi`,
};
}

View File

@ -25,40 +25,40 @@ export const ClientProviders = ({ children, initialTheme, cookies }: Props) => {
switch (event) {
case 'install_success':
toast.success(t('apps.app-details.install-success', { id: data.appId }));
toast.success(t('APP_INSTALL_SUCCESS', { id: data.appId }));
break;
case 'install_error':
toast.error(t('server-messages.errors.app-failed-to-install', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_INSTALL', { id: data.appId }));
break;
case 'start_success':
toast.success(t('apps.app-details.start-success', { id: data.appId }));
toast.success(t('APP_START_SUCCESS', { id: data.appId }));
break;
case 'start_error':
toast.error(t('server-messages.errors.app-failed-to-start', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_START', { id: data.appId }));
break;
case 'stop_success':
toast.success(t('apps.app-details.stop-success', { id: data.appId }));
toast.success(t('APP_STOP_SUCCESS', { id: data.appId }));
break;
case 'stop_error':
toast.error(t('server-messages.errors.app-failed-to-stop', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_STOP', { id: data.appId }));
break;
case 'uninstall_success':
toast.success(t('apps.app-details.uninstall-success', { id: data.appId }));
toast.success(t('APP_UNINSTALL_SUCCESS', { id: data.appId }));
break;
case 'uninstall_error':
toast.error(t('server-messages.errors.app-failed-to-uninstall', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_UNINSTALL', { id: data.appId }));
break;
case 'update_success':
toast.success(t('apps.app-details.update-success', { id: data.appId }));
toast.success(t('APP_UPDATE_SUCCESS', { id: data.appId }));
break;
case 'update_error':
toast.error(t('server-messages.errors.app-failed-to-update', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_UPDATE', { id: data.appId }));
break;
case 'reset_success':
toast.success(t('apps.app-details.reset-success', { id: data.appId }));
toast.success(t('APP_RESET_SUCCESS', { id: data.appId }));
break;
case 'reset_error':
toast.error(t('server-messages.errors.app-failed-to-reset', { id: data.appId }));
toast.error(t('APP_ERROR_APP_FAILED_TO_RESET', { id: data.appId }));
break;
default:
break;

View File

@ -3,13 +3,13 @@ import { IconExternalLink } from '@tabler/icons-react';
import { useTranslations } from 'next-intl';
export const LanguageSelectorLabel = () => {
const t = useTranslations('settings.settings');
const t = useTranslations();
return (
<span>
{t('language')}&nbsp;
{t('SETTINGS_GENERAL_LANGUAGE')}&nbsp;
<a href="https://crowdin.com/project/runtipi/invite?h=ae594e86cd807bc075310cab20a4aa921693663" target="_blank" rel="noreferrer">
{t('help-translate')}
{t('SETTINGS_GENERAL_LANGUAGE_HELP_TRANSLATE')}
<IconExternalLink className="ms-1 mb-1" size={16} />
</a>
</span>

View File

@ -24,9 +24,9 @@ export default async function RootPage() {
const apps = await appService.getGuestDashboardApps();
return (
<UnauthenticatedPage title="guest-dashboard" subtitle="runtipi">
<UnauthenticatedPage title="GUEST_DASHBOARD" subtitle="RUNTIPI">
{apps.length === 0 ? (
<EmptyPage title="guest-dashboard-no-apps" subtitle="guest-dashboard-no-apps-subtitle" />
<EmptyPage title="GUEST_DASHBOARD_NO_APPS" subtitle="GUEST_DASHBOARD_NO_APPS_SUBTITLE" />
) : (
<div className="row row-cards">
<GuestDashboardApps apps={apps} hostname={hostname} />

View File

@ -6,8 +6,8 @@ import { useTranslations } from 'next-intl';
import styles from './AppStatus.module.scss';
export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
const t = useTranslations('apps');
const formattedStatus = t(`status-${status}`);
const t = useTranslations();
const formattedStatus = t(`APP_STATUS_${status.toUpperCase() as Uppercase<typeof status>}`);
const classes = clsx('status-dot status-gray', {
'status-dot-animated status-green': status === 'running',

View File

@ -14,7 +14,7 @@ import styles from './AppTile.module.scss';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc' | 'deprecated'>;
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
const t = useTranslations('apps');
const t = useTranslations();
return (
<div data-testid={`app-tile-${app.id}`}>
@ -38,7 +38,7 @@ export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; update
{updateAvailable && (
<>
<Tooltip className="tooltip" anchorSelect=".updateAvailable">
{t('update-available')}
{t('MY_APPS_UPDATE_AVAILABLE')}
</Tooltip>
<div className="updateAvailable ribbon bg-green ribbon-top">
<IconDownload size={20} />
@ -48,7 +48,7 @@ export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; update
{app.deprecated && (
<>
<Tooltip className="tooltip" anchorSelect=".deprecated">
{t('deprecated')}
{t('MY_APPS_DEPRECATED')}
</Tooltip>
<div className="deprecated ribbon bg-red">
<IconAlertCircle />

View File

@ -1,4 +1,268 @@
{
"AUTH_ERROR_INVALID_CREDENTIALS": "Invalid credentials",
"AUTH_ERROR_ADMIN_ALREADY_EXISTS": "There is already an admin user. Please login to create a new user from the admin panel.",
"AUTH_ERROR_MISSING_EMAIL_OR_PASSWORD": "Missing email or password",
"AUTH_ERROR_INVALID_USERNAME": "Invalid username",
"AUTH_ERROR_USER_ALREADY_EXISTS": "User already exists",
"AUTH_ERROR_ERROR_CREATING_USER": "Error creating user",
"AUTH_ERROR_NO_CHANGE_PASSWORD_REQUEST": "No change password request found",
"AUTH_ERROR_OPERATOR_NOT_FOUND": "Operator user not found",
"AUTH_ERROR_USER_NOT_FOUND": "User not found",
"SERVER_ERROR_NOT_ALLOWED_IN_DEMO": "Not allowed in demo mode",
"SERVER_ERROR_NOT_ALLOWED_IN_DEV": "Not allowed in dev mode",
"AUTH_ERROR_INVALID_PASSWORD": "Invalid password",
"AUTH_ERROR_INVALID_PASSWORD_LENGTH": "Password must be at least 8 characters long",
"SERVER_ERROR_INVALID_LOCALE": "Invalid locale",
"AUTH_ERROR_TOTP_SESSION_NOT_FOUND": "2FA session not found",
"AUTH_ERROR_TOTP_NOT_ENABLED": "2FA is not enabled for this user",
"AUTH_ERROR_TOTP_INVALID_CODE": "Invalid 2FA code",
"AUTH_ERROR_TOTP_ALREADY_ENABLED": "2FA is already enabled for this user",
"APP_ERROR_APP_NOT_FOUND": "App {id} not found",
"APP_ERROR_APP_FAILED_TO_START": "Failed to start app {id}, see logs for more details",
"APP_ERROR_APP_FAILED_TO_INSTALL": "Failed to install app {id}, see logs for more details",
"APP_ERROR_APP_FAILED_TO_STOP": "Failed to stop app {id}, see logs for more details",
"APP_ERROR_APP_FAILED_TO_UNINSTALL": "Failed to uninstall app {id}, see logs for more details",
"APP_ERROR_APP_FAILED_TO_UPDATE": "Failed to update app {id}, see logs for more details",
"APP_ERROR_APP_FAILED_TO_RESET": "Failed to reset app {id}, see logs for more details",
"APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP": "Domain is required if app is exposed",
"APP_ERROR_DOMAIN_NOT_VALID": "Domain {domain} is not a valid domain",
"APP_ERROR_INVALID_CONFIG": "App {id} has an invalid config.json file",
"APP_ERROR_APP_NOT_EXPOSABLE": "App {id} is not exposable",
"APP_ERROR_APP_FORCE_EXPOSED": "App {id} works only with exposed domain",
"APP_ERROR_DOMAIN_ALREADY_IN_USE": "Domain {domain} is already in use by app {id}",
"SYSTEM_ERROR_COULD_NOT_GET_LATEST_VERSION": "Could not get latest version",
"SYSTEM_ERROR_CURRENT_VERSION_IS_LATEST": "Current version is already up to date",
"SYSTEM_ERROR_MAJOR_VERSION_UPDATE": "The major version has changed. Please update manually (instructions in release notes)",
"SYSTEM_ERROR_DEMO_MODE_LIMIT": "Only 6 apps can be installed in the demo mode. Please uninstall an other app to install a new one.",
"AUTH_LOGIN_TITLE": "Login to your account",
"AUTH_LOGIN_SUBMIT": "Login",
"AUTH_TOTP_TITLE": "Two-factor authentication",
"AUTH_TOTP_INSTRUCTIONS": "Enter the code from your authenticator app",
"AUTH_TOTP_SUBMIT": "Confirm",
"AUTH_REGISTER_TITLE": "Register your account",
"AUTH_REGISTER_SUBMIT": "Register",
"AUTH_RESET_PASSWORD_TITLE": "Reset your password",
"AUTH_RESET_PASSWORD_SUBMIT": "Reset password",
"AUTH_RESET_PASSWORD_CANCEL": "Cancel password change request",
"AUTH_RESET_PASSWORD_INSTRUCTIONS": "Run this command on your server and then refresh this page",
"AUTH_RESET_PASSWORD_SUCCESS_TITLE": "Password reset",
"AUTH_RESET_PASSWORD_SUCCESS": "Your password has been reset. You can now login with your new password. And your email {email}",
"AUTH_RESET_PASSWORD_BACK_TO_LOGIN": "Back to login",
"AUTH_FORM_EMAIL": "Email address",
"AUTH_FORM_EMAIL_PLACEHOLDER": "you@example.com",
"AUTH_FORM_PASSWORD": "Password",
"AUTH_FORM_PASSWORD_PLACEHOLDER": "Enter your password",
"AUTH_FORM_PASSWORD_CONFIRMATION": "Confirm password",
"AUTH_FORM_PASSWORD_CONFIRMATION_PLACEHOLDER": "Confirm your password",
"AUTH_FORM_FORGOT": "Forgot password?",
"AUTH_FORM_NEW_PASSWORD_PLACEHOLDER": "Your new password",
"AUTH_FORM_NEW_PASSWORD_CONFIRMATION_PLACEHOLDER": "Confirm your new password",
"AUTH_FORM_ERROR_EMAIL_REQUIRED": "Email address is required",
"AUTH_FORM_ERROR_EMAIL_EMAIL": "Email address is invalid",
"AUTH_FORM_ERROR_EMAIL_INVALID": "Email address is invalid",
"AUTH_FORM_ERROR_PASSWORD_REQUIRED": "Password is required",
"AUTH_FORM_ERROR_PASSWORD_LENGTH": "Password must be at least 8 characters",
"AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_REQUIRED": "Password confirmation is required",
"AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_LENGTH": "Password confirmation must be at least 8 characters",
"AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_MATCH": "Passwords do not match",
"DASHBOARD_TITLE": "Dashboard",
"DASHBOARD_DISK_SPACE_TITLE": "Disk space",
"DASHBOARD_DISK_SPACE_SUBTITLE": "Used out of {total} GB",
"DASHBOARD_MEMORY_TITLE": "Memory used",
"DASHBOARD_CPU_TITLE": "CPU load",
"DASHBOARD_CPU_SUBTITLE": "Uninstall apps to reduce load",
"APP_STATUS_RUNNING": "Running",
"APP_STATUS_STOPPED": "Stopped",
"APP_STATUS_RESETTING": "Resetting",
"APP_STATUS_STARTING": "Starting",
"APP_STATUS_STOPPING": "Stopping",
"APP_STATUS_UPDATING": "Updating",
"APP_STATUS_MISSING": "Missing",
"APP_STATUS_INSTALLING": "Installing",
"APP_STATUS_UNINSTALLING": "Uninstalling",
"MY_APPS_UPDATE_AVAILABLE": "Update available",
"MY_APPS_DEPRECATED": "This app is deprecated",
"MY_APPS_TITLE": "My Apps",
"MY_APPS_EMPTY_TITLE": "No app installed",
"MY_APPS_EMPTY_SUBTITLE": "Install an app from the app store to get started",
"MY_APPS_EMPTY_ACTION": "Go to app store",
"MY_APPS_UPDATE_ALL_FORM_TITLE": "Update all apps",
"MY_APPS_UPDATE_ALL_FORM_SUBTITLE_1": "Do you want to update all your apps to the latest version?",
"MY_APPS_UPDATE_ALL_FORM_SUBTITLE_2": "This will update all your apps to the latest version. Make sure you've read the release notes of the apps and you've backed up your app data.",
"MY_APPS_UPDATE_ALL_FORM_SUBMIT": "Update all",
"MY_APPS_UPDATE_ALL_IN_PROGRESS": "Updating all apps",
"APP_STORE_TITLE": "App Store",
"APP_STORE_SEARCH_PLACEHOLDER": "Search apps",
"APP_STORE_CATEGORY_PLACEHOLDER": "Select a category",
"APP_STORE_NO_RESULTS": "No app found",
"APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search",
"APP_DETAILS_TITLE": "App details",
"APP_INSTALL_SUCCESS": "App {id} installed successfully",
"APP_UNINSTALL_SUCCESS": "App {id} uninstalled successfully",
"APP_STOP_SUCCESS": "App {id} stopped successfully",
"APP_UPDATE_SUCCESS": "App {id} updated successfully",
"APP_START_SUCCESS": "App {id} started successfully",
"APP_UPDATE_CONFIG_SUCCESS": "App config updated successfully. Restart the app to apply the changes",
"APP_DETAILS_VERSION": "Version",
"APP_RESET_SUCCESS": "App {id} reset successfully",
"APP_DETAILS_DESCRIPTION": "Description",
"APP_DETAILS_BASE_INFO": "Base info",
"APP_DETAILS_SOURCE_CODE": "Source code",
"APP_DETAILS_AUTHOR": "Author",
"APP_DETAILS_PORT": "Port",
"APP_DETAILS_CATEGORIES_TITLE": "Categories",
"APP_DETAILS_LINK": "Link",
"APP_DETAILS_WEBSITE": "Website",
"APP_DETAILS_SUPPORTED_ARCH": "Supported architectures",
"APP_DETAILS_CHOOSE_OPEN_METHOD": "Choose open method",
"APP_DETAILS_DEPRECATED_ALERT_TITLE": "This app is deprecated",
"APP_DETAILS_DEPRECATED_ALERT_SUBTITLE": "A breaking change in this app prevents it from being updated automatically. You can still use this version and update it manually, but it is recommended to switch to a newer version and migrate your data. You can find an updated version in the app store under the same name.",
"APP_CATEGORY_DATA": "Data",
"APP_CATEGORY_NETWORK": "Network",
"APP_CATEGORY_MEDIA": "Media",
"APP_CATEGORY_DEVELOPMENT": "Development",
"APP_CATEGORY_AUTOMATION": "Automation",
"APP_CATEGORY_SOCIAL": "Social",
"APP_CATEGORY_UTILITIES": "Utilities",
"APP_CATEGORY_SECURITY": "Security",
"APP_CATEGORY_PHOTOGRAPHY": "Photography",
"APP_CATEGORY_FEATURED": "Featured",
"APP_CATEGORY_BOOKS": "Books",
"APP_CATEGORY_MUSIC": "Music",
"APP_CATEGORY_FINANCE": "Finance",
"APP_CATEGORY_GAMING": "Gaming",
"APP_CATEGORY_AI": "AI",
"APP_ACTION_START": "Start",
"APP_ACTION_REMOVE": "Remove",
"APP_ACTION_SETTINGS": "Settings",
"APP_ACTION_STOP": "Stop",
"APP_ACTION_OPEN": "Open",
"APP_ACTION_LOADING": "Loading",
"APP_ACTION_CANCEL": "Cancel",
"APP_ACTION_INSTALL": "Install",
"APP_ACTION_UPDATE": "Update",
"APP_INSTALL_FORM_TITLE": "Install {name}",
"APP_INSTALL_FORM_EXPOSE_APP": "Expose app",
"APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD": "Display on guest dashboard",
"APP_INSTALL_FORM_DOMAIN_NAME": "Domain name",
"APP_INSTALL_FORM_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.",
"APP_INSTALL_FORM_CHOOSE_OPTION": "Choose an option...",
"APP_INSTALL_FORM_SUBMIT_INSTALL": "Install",
"APP_INSTALL_FORM_SUBMIT_UPDATE": "Update",
"APP_INSTALL_FORM_RESET": "Reset app",
"APP_INSTALL_FORM_ERROR_REQUIRED": "{label} is required",
"APP_INSTALL_FORM_ERROR_REGEX": "{label} must match the pattern {pattern}",
"APP_INSTALL_FORM_ERROR_MAX_LENGTH": "{label} must be less than {max} characters",
"APP_INSTALL_FORM_ERROR_MIN_LENGTH": "{label} must be at least {min} characters",
"APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH": "{label} must be between {min} and {max} characters",
"APP_INSTALL_FORM_ERROR_INVALID_EMAIL": "{label} must be a valid email address",
"APP_INSTALL_FORM_ERROR_NUMBER": "{label} must be a number",
"APP_INSTALL_FORM_ERROR_FQDN": "{label} must be a valid domain",
"APP_INSTALL_FORM_ERROR_IP": "{label} must be a valid IP address",
"APP_INSTALL_FORM_ERROR_FQDNIP": "{label} must be a valid domain or IP address",
"APP_INSTALL_FORM_ERROR_URL": "{label} must be a valid URL",
"APP_STOP_FORM_TITLE": "Stop {name} ?",
"APP_STOP_FORM_SUBTITLE": "All data will be retained",
"APP_STOP_FORM_SUBMIT": "Stop",
"APP_UNINSTALL_FORM_TITLE": "Uninstall {name} ?",
"APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.",
"APP_UNINSTALL_FORM_WARNING": "Are you sure? This action cannot be undone.",
"APP_UNINSTALL_FORM_SUBMIT": "Uninstall",
"APP_RESET_FORM_TITLE": "Reset {name} ?",
"APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.",
"APP_RESET_FORM_WARNING": "Are you sure? This action cannot be undone.",
"APP_RESET_FORM_SUBMIT": "Reset",
"APP_UPDATE_FORM_TITLE": "Update {name} ?",
"APP_UPDATE_FORM_SUBTITLE_1": "Update app to latest verion :",
"APP_UPDATE_FORM_SUBTITLE_2": "Make sure you've read the release notes of the app and you've backed up your app data.",
"APP_UPDATE_FORM_SUBMIT": "Update",
"APP_UPDATE_SETTINGS_FORM_TITLE": "Update {name} config",
"SETTINGS_TITLE": "Settings",
"SETTINGS_ACTIONS_TAB_TITLE": "Actions",
"SETTINGS_ACTIONS_TITLE": "Actions",
"SETTINGS_ACTIONS_CURRENT_VERSION": "Current version: {version}",
"SETTINGS_ACTIONS_STAY_UP_TO_DATE": "Stay up to date with the latest version of Tipi",
"SETTINGS_ACTIONS_NEW_VERSION": "A new version ({version}) of Tipi is available",
"SETTINGS_ACTIONS_MAINTENANCE_TITLE": "Maintenance",
"SETTINGS_ACTIONS_MAINTENANCE_SUBTITLE": "Common actions to perform on your instance",
"SETTINGS_ACTIONS_RESTART": "Restart",
"SETTINGS_ACTIONS_ALREADY_LATEST": "Already up to date",
"SETTINGS_GENERAL_TITLE": "Settings",
"SETTINGS_GENERAL_USER_SETTINGS": "Settings",
"SETTINGS_GENERAL_TAB_TITLE": "Settings",
"SETTINGS_GENERAL_TITLE": "General settings",
"SETTINGS_GENERAL_SUBTITLE": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
"SETTINGS_GENERAL_SETTINGS_UPDATED": "Settings updated. Restart your instance to apply new settings.",
"SETTINGS_GENERAL_INVALID_IP": "Invalid IP address",
"SETTINGS_GENERAL_INVALID_URL": "Invalid URL",
"SETTINGS_GENERAL_INVALID_DOMAIN": "Invalid domain",
"SETTINGS_GENERAL_GUEST_DASHBOARD": "Enable guest dashboard",
"SETTINGS_GENERAL_GUEST_DASHBOARD_HINT": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.",
"SETTINGS_GENERAL_ALLOW_ERROR_MONITORING": "Allow anonymous error monitoring",
"SETTINGS_GENERAL_ALLOW_ERROR_MONITORING_HINT": "Error monitoring is used to track errors and improve Tipi. Keep this option enabled to help us improve Tipi.",
"SETTINGS_GENERAL_ALLOW_AUTO_THEMES": "Allow auto themes",
"SETTINGS_GENERAL_ALLOW_AUTO_THEMES_HINT": "Be surprised by themes that change automatically based on the time of the year.",
"SETTINGS_GENERAL_DOMAIN_NAME": "Domain name",
"SETTINGS_GENERAL_DOMAIN_NAME_HINT": "Make sure this exact domain contains an A record pointing to your IP.",
"SETTINGS_GENERAL_DNS_IP": "DNS IP",
"SETTINGS_GENERAL_INTERNAL_IP": "Internal IP",
"SETTINGS_GENERAL_INTERNAL_IP_HINT": "IP address your server is listening on.",
"SETTINGS_GENERAL_APPS_REPO": "Apps repo URL",
"SETTINGS_GENERAL_APPS_REPO_HINT": "URL to the apps repository.",
"SETTINGS_GENERAL_STORAGE_PATH": "Storage path",
"SETTINGS_GENERAL_STORAGE_PATH_HINT": "Path to the storage directory. Make sure it is an absolute path and that it exists.",
"SETTINGS_GENERAL_LOCAL_DOMAIN": "Local domain",
"SETTINGS_GENERAL_LOCAL_DOMAIN_HINT": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"SETTINGS_GENERAL_LANGUAGE": "Language",
"SETTINGS_GENERAL_LANGUAGE_HELP_TRANSLATE": "Help translate Tipi",
"SETTINGS_GENERAL_DOWNLOAD_CERTIFICATE": "Download certificate",
"SETTINGS_GENERAL_SUBMIT": "Update settings",
"SETTINGS_SECURITY_TAB_TITLE": "Security",
"SETTINGS_SECURITY_CHANGE_PASSWORD_TITLE": "Change password",
"SETTINGS_SECURITY_CHANGE_PASSWORD_SUBTITLE": "Changing your password will log you out of all devices.",
"SETTINGS_SECURITY_PASSWORD_CHANGE_SUCCESS": "Password changed successfully",
"SETTINGS_SECURITY_2FA_TITLE": "Two-factor authentication",
"SETTINGS_SECURITY_2FA_SUBTITLE": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
"SETTINGS_SECURITY_2FA_SUBTITLE_2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
"SETTINGS_SECURITY_2FA_ENABLE_SUCCESS": "Two-factor authentication enabled",
"SETTINGS_SECURITY_2FA_DISABLE_SUCCESS": "Two-factor authentication disabled",
"SETTINGS_SECURITY_SCAN_QR_CODE": "Scan this QR code with your authenticator app.",
"SETTINGS_SECURITY_ENTER_KEY_MANUALLY": "Or enter this key manually.",
"SETTINGS_SECURITY_ENTER_2FA_CODE": "Enter the 6-digit code from your authenticator app",
"SETTINGS_SECURITY_ENABLE_2FA": "Enable two-factor authentication",
"SETTINGS_SECURITY_DISABLE_2FA": "Disable two-factor authentication",
"SETTINGS_SECURITY_PASSWORD_NEEDED": "Password needed",
"SETTINGS_SECURITY_PASSWORD_NEEDED_HINT": "Your password is required to change two-factor authentication settings.",
"SETTINGS_SECURITY_FORM_PASSWORD_LENGTH": "Password must be at least 8 characters",
"SETTINGS_SECURITY_FORM_PASSWORD_MATCH": "Passwords do not match",
"SETTINGS_SECURITY_FORM_CURRENT_PASSWORD": "Current password",
"SETTINGS_SECURITY_FORM_NEW_PASSWORD": "New password",
"SETTINGS_SECURITY_FORM_CONFIRM_PASSWORD": "Confirm new password",
"SETTINGS_SECURITY_FORM_CHANGE_PASSWORD_SUBMIT": "Change password",
"SETTINGS_SECURITY_FORM_PASSWORD": "Password",
"SETTINGS_SECURITY_CHANGE_USERNAME_TITLE": "Change username",
"SETTINGS_SECURITY_CHANGE_USERNAME_SUBTITLE": "Changing your username will log you out of all devices.",
"SETTINGS_SECURITY_CHANGE_USERNAME_SUCCESS": "Username changed successfully",
"SETTINGS_SECURITY_CHANGE_USERNAME_FORM_NEW_USERNAME": "New username",
"SETTINGS_SECURITY_CHANGE_USERNAME_FORM_INVALID_USERNAME": "Must be a valid email address",
"SETTINGS_SECURITY_CHANGE_USERNAME_FORM_PASSWORD": "Password",
"SETTINGS_SECURITY_CHANGE_USERNAME_FORM_PASSWORD_NEEDED_HINT": "Your password is required to change your username.",
"SETTINGS_SECURITY_CHANGE_USERNAME_FORM_SUBMIT": "Change username",
"HEADER_DASHBOARD": "Dashboard",
"HEADER_APPS": "My Apps",
"HEADER_APP_STORE": "App Store",
"HEADER_SETTINGS": "Settings",
"HEADER_LOGOUT": "Logout",
"HEADER_LOGIN": "Login",
"HEADER_DARK_MODE": "Dark Mode",
"HEADER_LIGHT_MODE": "Light Mode",
"HEADER_SPONSOR": "Sponsor",
"HEADER_SOURCE_CODE": "Source code",
"HEADER_UPDATE_AVAILABLE": "Update available",
"RUNTIPI": "Runtipi",
"GUEST_DASHBOARD": "Guest dashboard",
"GUEST_DASHBOARD_NO_APPS": "No apps to display",
"GUEST_DASHBOARD_NO_APPS_SUBTITLE": "Ask your administrator to add apps to the guest dashboard or login to see your apps.",
"server-messages": {
"errors": {
"invalid-credentials": "Invalid credentials",

View File

@ -84,7 +84,7 @@ export class AppServiceClass {
public startApp = async (appName: string) => {
const app = await this.queries.getApp(appName);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id: appName });
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id: appName });
}
await this.queries.updateApp(appName, { status: 'starting' });
@ -128,15 +128,15 @@ export class AppServiceClass {
const apps = await this.queries.getApps();
if (apps.length >= 6 && TipiConfig.getConfig().demoMode) {
throw new TranslatedError('server-messages.errors.demo-mode-limit');
throw new TranslatedError('SYSTEM_ERROR_DEMO_MODE_LIMIT');
}
if (exposed && !domain) {
throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
throw new TranslatedError('APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP');
}
if (domain && !validator.isFQDN(domain)) {
throw new TranslatedError('server-messages.errors.domain-not-valid', { domain });
throw new TranslatedError('APP_ERROR_DOMAIN_NOT_VALID', { domain });
}
checkAppRequirements(id);
@ -144,22 +144,22 @@ export class AppServiceClass {
const appInfo = getAppInfo(id);
if (!appInfo) {
throw new TranslatedError('server-messages.errors.invalid-config', { id });
throw new TranslatedError('APP_ERROR_INVALID_CONFIG', { id });
}
if (!appInfo.exposable && exposed) {
throw new TranslatedError('server-messages.errors.app-not-exposable', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_EXPOSABLE', { id });
}
if ((appInfo.force_expose && !exposed) || (appInfo.force_expose && !domain)) {
throw new TranslatedError('server-messages.errors.app-force-exposed', { id });
throw new TranslatedError('APP_ERROR_APP_FORCE_EXPOSED', { id });
}
if (exposed && domain) {
const appsWithSameDomain = await this.queries.getAppsByDomain(domain, id);
if (appsWithSameDomain.length > 0) {
throw new TranslatedError('server-messages.errors.domain-already-in-use', { domain, id: appsWithSameDomain[0]?.id });
throw new TranslatedError('APP_ERROR_DOMAIN_ALREADY_IN_USE', { domain, id: appsWithSameDomain[0]?.id });
}
}
@ -208,38 +208,38 @@ export class AppServiceClass {
const { exposed, domain } = form;
if (exposed && !domain) {
throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
throw new TranslatedError('APP_ERROR_DOMAIN_REQUIRED_IF_EXPOSE_APP');
}
if (domain && !validator.isFQDN(domain)) {
throw new TranslatedError('server-messages.errors.domain-not-valid');
throw new TranslatedError('APP_ERROR_DOMAIN_NOT_VALID');
}
const app = await this.queries.getApp(id);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id });
}
const appInfo = getAppInfo(app.id, app.status);
if (!appInfo) {
throw new TranslatedError('server-messages.errors.invalid-config', { id });
throw new TranslatedError('APP_ERROR_INVALID_CONFIG', { id });
}
if (!appInfo.exposable && exposed) {
throw new TranslatedError('server-messages.errors.app-not-exposable', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_EXPOSABLE', { id });
}
if ((appInfo.force_expose && !exposed) || (appInfo.force_expose && !domain)) {
throw new TranslatedError('server-messages.errors.app-force-exposed', { id });
throw new TranslatedError('APP_ERROR_APP_FORCE_EXPOSED', { id });
}
if (exposed && domain) {
const appsWithSameDomain = await this.queries.getAppsByDomain(domain, id);
if (appsWithSameDomain.length > 0) {
throw new TranslatedError('server-messages.errors.domain-already-in-use', { domain, id: appsWithSameDomain[0]?.id });
throw new TranslatedError('APP_ERROR_DOMAIN_ALREADY_IN_USE', { domain, id: appsWithSameDomain[0]?.id });
}
}
@ -257,7 +257,7 @@ export class AppServiceClass {
return updatedApp;
}
throw new TranslatedError('server-messages.errors.app-failed-to-update', { id });
throw new TranslatedError('APP_ERROR_APP_FAILED_TO_UPDATE', { id });
};
/**
@ -270,7 +270,7 @@ export class AppServiceClass {
const app = await this.queries.getApp(id);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id });
}
// Run script
@ -302,7 +302,7 @@ export class AppServiceClass {
const app = await this.queries.getApp(id);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id });
}
if (app.status === 'running') {
await this.stopApp(id);
@ -367,7 +367,7 @@ export class AppServiceClass {
return { ...app, ...updateInfo, info };
}
throw new TranslatedError('server-messages.errors.invalid-config', { id });
throw new TranslatedError('APP_ERROR_INVALID_CONFIG', { id });
};
/**
@ -381,7 +381,7 @@ export class AppServiceClass {
const appStatusBeforeUpdate = app?.status;
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
throw new TranslatedError('APP_ERROR_APP_NOT_FOUND', { id });
}
await this.queries.updateApp(id, { status: 'updating' });

View File

@ -35,13 +35,13 @@ export class AuthServiceClass {
const user = await this.queries.getUserByUsername(username);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new TranslatedError('server-messages.errors.invalid-credentials');
throw new TranslatedError('AUTH_ERROR_INVALID_CREDENTIALS');
}
if (user.totpEnabled) {
@ -72,24 +72,24 @@ export class AuthServiceClass {
await cache.close();
if (!userId) {
throw new TranslatedError('server-messages.errors.totp-session-not-found');
throw new TranslatedError('AUTH_ERROR_TOTP_SESSION_NOT_FOUND');
}
const user = await this.queries.getUserById(Number(userId));
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
if (!user.totpEnabled || !user.totpSecret || !user.salt) {
throw new TranslatedError('server-messages.errors.totp-not-enabled');
throw new TranslatedError('AUTH_ERROR_TOTP_NOT_ENABLED');
}
const totpSecret = decrypt(user.totpSecret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new TranslatedError('server-messages.errors.totp-invalid-code');
throw new TranslatedError('AUTH_ERROR_TOTP_INVALID_CODE');
}
const sessionId = uuidv4();
@ -107,7 +107,7 @@ export class AuthServiceClass {
*/
public getTotpUri = async (params: { userId: number; password: string }) => {
if (TipiConfig.getConfig().demoMode) {
throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
throw new TranslatedError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO');
}
const { userId, password } = params;
@ -115,16 +115,16 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new TranslatedError('server-messages.errors.invalid-password');
throw new TranslatedError('AUTH_ERROR_INVALID_PASSWORD');
}
if (user.totpEnabled) {
throw new TranslatedError('server-messages.errors.totp-already-enabled');
throw new TranslatedError('AUTH_ERROR_TOTP_ALREADY_ENABLED');
}
let { salt } = user;
@ -145,25 +145,25 @@ export class AuthServiceClass {
public setupTotp = async (params: { userId: number; totpCode: string }) => {
if (TipiConfig.getConfig().demoMode) {
throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
throw new TranslatedError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO');
}
const { userId, totpCode } = params;
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
if (user.totpEnabled || !user.totpSecret || !user.salt) {
throw new TranslatedError('server-messages.errors.totp-already-enabled');
throw new TranslatedError('AUTH_ERROR_TOTP_ALREADY_ENABLED');
}
const totpSecret = decrypt(user.totpSecret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new TranslatedError('server-messages.errors.totp-invalid-code');
throw new TranslatedError('AUTH_ERROR_TOTP_INVALID_CODE');
}
await this.queries.updateUser(userId, { totpEnabled: true });
@ -177,16 +177,16 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
if (!user.totpEnabled) {
throw new TranslatedError('server-messages.errors.totp-not-enabled');
throw new TranslatedError('AUTH_ERROR_TOTP_NOT_ENABLED');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new TranslatedError('server-messages.errors.invalid-password');
throw new TranslatedError('AUTH_ERROR_INVALID_PASSWORD');
}
await this.queries.updateUser(userId, { totpEnabled: false, totpSecret: null });
@ -203,24 +203,24 @@ export class AuthServiceClass {
const operators = await this.queries.getOperators();
if (operators.length > 0) {
throw new TranslatedError('server-messages.errors.admin-already-exists');
throw new TranslatedError('AUTH_ERROR_ADMIN_ALREADY_EXISTS');
}
const { password, username } = input;
const email = username.trim().toLowerCase();
if (!username || !password) {
throw new TranslatedError('server-messages.errors.missing-email-or-password');
throw new TranslatedError('AUTH_ERROR_MISSING_EMAIL_OR_PASSWORD');
}
if (username.length < 3 || !validator.isEmail(email)) {
throw new TranslatedError('server-messages.errors.invalid-username');
throw new TranslatedError('AUTH_ERROR_INVALID_USERNAME');
}
const user = await this.queries.getUserByUsername(email);
if (user) {
throw new TranslatedError('server-messages.errors.user-already-exists');
throw new TranslatedError('AUTH_ERROR_USER_ALREADY_EXISTS');
}
const hash = await argon2.hash(password);
@ -228,7 +228,7 @@ export class AuthServiceClass {
const newUser = await this.queries.createUser({ username: email, password: hash, operator: true, locale: getLocaleFromString(input.locale) });
if (!newUser) {
throw new TranslatedError('server-messages.errors.error-creating-user');
throw new TranslatedError('AUTH_ERROR_ERROR_CREATING_USER');
}
const sessionId = uuidv4();
@ -285,7 +285,7 @@ export class AuthServiceClass {
*/
public changeOperatorPassword = async (params: { newPassword: string }) => {
if (!AuthServiceClass.checkPasswordChangeRequest()) {
throw new TranslatedError('server-messages.errors.no-change-password-request');
throw new TranslatedError('AUTH_ERROR_NO_CHANGE_PASSWORD_REQUEST');
}
const { newPassword } = params;
@ -293,7 +293,7 @@ export class AuthServiceClass {
const user = await this.queries.getFirstOperator();
if (!user) {
throw new TranslatedError('server-messages.errors.operator-not-found');
throw new TranslatedError('AUTH_ERROR_OPERATOR_NOT_FOUND');
}
const hash = await argon2.hash(newPassword);
@ -352,7 +352,7 @@ export class AuthServiceClass {
public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
if (TipiConfig.getConfig().demoMode) {
throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
throw new TranslatedError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO');
}
const { currentPassword, newPassword, userId } = params;
@ -360,17 +360,17 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
const valid = await argon2.verify(user.password, currentPassword);
if (!valid) {
throw new TranslatedError('server-messages.errors.invalid-password');
throw new TranslatedError('AUTH_ERROR_INVALID_PASSWORD');
}
if (newPassword.length < 8) {
throw new TranslatedError('server-messages.errors.invalid-password-length');
throw new TranslatedError('AUTH_ERROR_INVALID_PASSWORD_LENGTH');
}
const hash = await argon2.hash(newPassword);
@ -384,7 +384,7 @@ export class AuthServiceClass {
public changeUsername = async (params: { newUsername: string; password: string; userId: number }) => {
if (TipiConfig.getConfig().demoMode) {
throw new TranslatedError('server-messages.errors.not-allowed-in-demo');
throw new TranslatedError('SERVER_ERROR_NOT_ALLOWED_IN_DEMO');
}
const { newUsername, password, userId } = params;
@ -392,25 +392,25 @@ export class AuthServiceClass {
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
const valid = await argon2.verify(user.password, password);
if (!valid) {
throw new TranslatedError('server-messages.errors.invalid-password');
throw new TranslatedError('AUTH_ERROR_INVALID_PASSWORD');
}
const email = newUsername.trim().toLowerCase();
if (!validator.isEmail(email)) {
throw new TranslatedError('server-messages.errors.invalid-username');
throw new TranslatedError('AUTH_ERROR_INVALID_USERNAME');
}
const existingUser = await this.queries.getUserByUsername(email);
if (existingUser) {
throw new TranslatedError('server-messages.errors.user-already-exists');
throw new TranslatedError('AUTH_ERROR_USER_ALREADY_EXISTS');
}
await this.queries.updateUser(user.id, { username: email });
@ -434,13 +434,13 @@ export class AuthServiceClass {
const isLocaleValid = Locales.includes(locale);
if (!isLocaleValid) {
throw new TranslatedError('server-messages.errors.invalid-locale');
throw new TranslatedError('SERVER_ERROR_INVALID_LOCALE');
}
const user = await this.queries.getUserById(userId);
if (!user) {
throw new TranslatedError('server-messages.errors.user-not-found');
throw new TranslatedError('AUTH_ERROR_USER_NOT_FOUND');
}
await this.queries.updateUser(user.id, { locale });

View File

@ -1,21 +1,22 @@
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions, renderHook } from '@testing-library/react';
import { Toaster } from 'react-hot-toast';
import { NextIntlProvider } from 'next-intl';
import { IntlProvider } from 'next-intl';
import ue from '@testing-library/user-event';
import messages from '../src/client/messages/en.json';
const userEvent = ue.setup();
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
<NextIntlProvider locale="en" messages={messages}>
<IntlProvider locale="en" messages={messages}>
{children}
<Toaster />
</NextIntlProvider>
</IntlProvider>
);
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
const customRenderHook = <Props, Result>(callback: (props: Props) => Result, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
const customRenderHook = <Props, Result>(callback: (props: Props) => Result, options?: Omit<RenderOptions, 'wrapper'>) =>
renderHook(callback, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };