mirror of
https://github.com/meienberger/runtipi.git
synced 2024-10-26 20:19:56 +03:00
refactor: flatten translation tree
This commit is contained in:
parent
934dd8fa18
commit
3d55537923
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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')}>
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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()}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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')}
|
||||
{t('SETTINGS_GENERAL_LANGUAGE')}
|
||||
<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>
|
||||
|
@ -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} />
|
||||
|
@ -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',
|
||||
|
@ -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 />
|
||||
|
@ -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",
|
||||
|
@ -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' });
|
||||
|
@ -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 });
|
||||
|
@ -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 };
|
||||
|
Loading…
Reference in New Issue
Block a user