diff --git a/.claude/skills/ui-architect/references/core-display.md b/.claude/skills/ui-architect/references/core-display.md index e52241489e..3a30155896 100644 --- a/.claude/skills/ui-architect/references/core-display.md +++ b/.claude/skills/ui-architect/references/core-display.md @@ -16,6 +16,8 @@ Reference for the O2 display and content primitives under `@/lib/core/*`. Each e - [OEmptyState](#oemptystate) - [OIcon](#oicon) - [OSeparator](#oseparator) +- [OSettingRow](#osettingrow) +- [OSettingRowPair](#osettingrowpair) - [OShortcut](#oshortcut) - [OText](#otext) - [OVirtualScroll](#ovirtualscroll) @@ -400,6 +402,61 @@ Note: delete/bin icon names render in the destructive (red) colour by default; o --- +### OSettingRow +**Import:** `@/lib/core/SettingRow/OSettingRow.vue` +**Use when:** One labelled setting inside a settings card — name and one-line description on the left, the control on the right, hairline rule between rows (the last row drops its own). +**Don't use for:** Form fields with a label above the control — use `OFormInput` directly. +**Key props:** +- `label` (`I18nText`, required — the setting's name) +- `description` (`I18nText` — one line saying what the setting does or what a special value means) +- `disabled` (boolean, default `false` — renders the row muted only; the control owns its own disabled state) +- `dataTest` (string) + +**Slots:** `default` — the control on the right +**Emits:** none +**Example:** +```vue + + + +``` +**Family:** Pair with `OFormSection`, which supplies the card. + +--- + +### OSettingRowPair +**Import:** `@/lib/core/SettingRow/OSettingRowPair.vue` +**Use when:** Two related settings — a min/max, a period and its warning — on one row; the pair owns the hairline and padding so the two cells read as one row. +**Don't use for:** More than two controls, or unrelated settings that only happen to be adjacent. +**Key props:** +- `dataTest` (string) + +**Slots:** `default` — exactly two `OSettingRow`; `footer` — a full-width message under both cells (a cross-field validation error) +**Emits:** none +**Example:** +```vue + + + + + + + @@ -33,6 +34,7 @@ import { ConfigProvider } from "reka-ui"; import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue"; import ConfirmDialogProvider from "@/components/ConfirmDialogProvider.vue"; import CreateAlertDialogProvider from "@/components/alerts/CreateAlertDialogProvider.vue"; +import UpdatePasswordDialog from "@/components/common/UpdatePasswordDialog.vue"; export default { components: { @@ -40,6 +42,7 @@ export default { OToastProvider, ConfirmDialogProvider, CreateAlertDialogProvider, + UpdatePasswordDialog, }, setup() { const store = useStore(); diff --git a/web/src/components/common/ConstrainedPage.vue b/web/src/components/common/ConstrainedPage.vue index 00d8ea95df..2f77e0a7f2 100644 --- a/web/src/components/common/ConstrainedPage.vue +++ b/web/src/components/common/ConstrainedPage.vue @@ -43,7 +43,7 @@ import { computed } from "vue"; const props = withDefaults( defineProps<{ /** Reading-column width. `lg` (default) matches the section hubs. */ - size?: "sm" | "md" | "lg" | "xl"; + size?: "sm" | "md" | "lg" | "xl" | "full"; /** Apply the default page gutter (px-6 py-6). Off → child owns padding. */ padded?: boolean; /** Column placement. `center` (default) for hubs; `left` for form pages. */ @@ -62,6 +62,7 @@ const SIZE_CLASS = { md: "max-w-3xl", // settings sections, org params lg: "max-w-5xl", // section hubs (Settings/IAM landing) xl: "max-w-7xl", // wide reading content + full: "max-w-none", // dense settings forms that use the row width } as const; const maxWidthClass = computed(() => SIZE_CLASS[props.size]); diff --git a/web/src/components/common/PasswordExpiryBanner.vue b/web/src/components/common/PasswordExpiryBanner.vue new file mode 100644 index 0000000000..aba9d327b4 --- /dev/null +++ b/web/src/components/common/PasswordExpiryBanner.vue @@ -0,0 +1,61 @@ + + + + + + diff --git a/web/src/components/common/PasswordRequirementList.spec.ts b/web/src/components/common/PasswordRequirementList.spec.ts new file mode 100644 index 0000000000..abae6fc95d --- /dev/null +++ b/web/src/components/common/PasswordRequirementList.spec.ts @@ -0,0 +1,63 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; + +import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue"; +import type { TranslateFn } from "@/types/i18n"; +import { buildPasswordRequirements, DEFAULT_COMPLEXITY } from "@/utils/passwordComplexity"; + +import PasswordRequirementList from "./PasswordRequirementList.vue"; + +// Echoes the key so assertions read against stable ids rather than English copy. +const t = ((key: string) => key) as unknown as TranslateFn; + +// Four rows: min length, uppercase, lowercase, digit. +const requirements = buildPasswordRequirements( + { ...DEFAULT_COMPLEXITY, require_uppercase: true, require_lowercase: true, require_digit: true }, + t, +); + +const mountList = (props: { password?: string; showStrength?: boolean }) => + mount(PasswordRequirementList, { props: { requirements, ...props } }); + +describe("PasswordRequirementList", () => { + it("renders the strength meter as progress over the policy's own requirements", () => { + const wrapper = mountList({ password: "ab", showStrength: true }); + + const bar = wrapper.findComponent(OProgressBar); + expect(bar.exists()).toBe(true); + expect(bar.attributes("data-test")).toBe("password-requirements-strength"); + // One of four rows is met: only lowercase. + expect(bar.props("value")).toBeCloseTo(1 / 4); + expect(bar.props("variant")).toBe("default"); + expect(bar.props("size")).toBe("xs"); + }); + + it("turns the meter green only once every requirement is met", () => { + const wrapper = mountList({ password: "Abcdefg1", showStrength: true }); + + expect(wrapper.findComponent(OProgressBar).props("value")).toBe(1); + expect(wrapper.findComponent(OProgressBar).props("variant")).toBe("success"); + }); + + it("shows no meter unless asked for", () => { + const wrapper = mountList({ password: "Abcdefg1" }); + + expect(wrapper.findComponent(OProgressBar).exists()).toBe(false); + expect(wrapper.find('[data-test="password-requirements-strength"]').exists()).toBe(false); + }); +}); diff --git a/web/src/components/common/PasswordRequirementList.vue b/web/src/components/common/PasswordRequirementList.vue new file mode 100644 index 0000000000..e39664bd6b --- /dev/null +++ b/web/src/components/common/PasswordRequirementList.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/web/src/components/common/UpdatePasswordDialog.schema.ts b/web/src/components/common/UpdatePasswordDialog.schema.ts new file mode 100644 index 0000000000..4490470cd3 --- /dev/null +++ b/web/src/components/common/UpdatePasswordDialog.schema.ts @@ -0,0 +1,78 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { z } from "zod"; + +import type { PasswordComplexity } from "@/services/passwordPolicy"; +import type { TranslateFn } from "@/types/i18n"; +import { validateAgainstComplexity } from "@/utils/passwordComplexity"; + +export const updatePasswordBaseSchema = z.object({ + old_password: z.string().optional().default(""), + new_password: z.string().optional().default(""), + confirm_password: z.string().optional().default(""), +}); + +export type UpdatePasswordForm = z.infer; + +export const updatePasswordDefaults = (): UpdatePasswordForm => ({ + old_password: "", + new_password: "", + confirm_password: "", +}); + +/** `getComplexity` is read on every run, so one schema instance follows a policy landing after mount. */ +export const makeUpdatePasswordSchema = (getComplexity: () => PasswordComplexity, t: TranslateFn) => + updatePasswordBaseSchema.superRefine((val, zctx) => { + // Required-only: it is an existing credential, and may predate the policy being enforced now. + if (!val.old_password) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["old_password"], + message: t("passwordReset.currentPasswordRequired"), + }); + } + + if (!val.new_password) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["new_password"], + message: t("passwordReset.newPasswordRequired"), + }); + } else { + const failure = validateAgainstComplexity(val.new_password, getComplexity(), t); + if (failure) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["new_password"], + message: failure, + }); + } + } + + if (!val.confirm_password) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["confirm_password"], + message: t("passwordReset.confirmPasswordRequired"), + }); + } else if (val.confirm_password !== val.new_password) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["confirm_password"], + message: t("passwordReset.mismatch"), + }); + } + }); diff --git a/web/src/components/common/UpdatePasswordDialog.spec.ts b/web/src/components/common/UpdatePasswordDialog.spec.ts new file mode 100644 index 0000000000..9f06c6e2ce --- /dev/null +++ b/web/src/components/common/UpdatePasswordDialog.spec.ts @@ -0,0 +1,342 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStore } from "vuex"; + +import { resetPasswordComplexityCache } from "@/composables/usePasswordComplexity"; +import { usePasswordReset } from "@/composables/usePasswordReset"; +import i18n from "@/locales"; + +import UpdatePasswordDialog from "./UpdatePasswordDialog.vue"; +import type { UpdatePasswordForm } from "./UpdatePasswordDialog.schema"; + +// Reka portals dialog content into ; rendered inline so the assertions can reach it. +vi.mock("reka-ui", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DialogPortal: actual.DialogContent }; +}); + +vi.mock("@/services/passwordPolicy", () => ({ + default: { getComplexity: vi.fn(), getPolicy: vi.fn(), updatePolicy: vi.fn() }, +})); + +vi.mock("@/services/users", () => ({ + default: { update: vi.fn() }, +})); + +const toastMock = vi.fn(); +vi.mock("@/lib/feedback/Toast/useToast", () => ({ + toast: (...args: unknown[]) => toastMock(...args), +})); + +const pushMock = vi.fn(); +vi.mock("vue-router", () => ({ + useRouter: () => ({ push: pushMock }), +})); + +const invalidateLoginDataMock = vi.fn(); +vi.mock("@/utils/zincutils", async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + invalidateLoginData: (...args: unknown[]) => invalidateLoginDataMock(...args), + useLocalCurrentUser: vi.fn(), + useLocalUserInfo: vi.fn(), + }; +}); + +import passwordPolicyService from "@/services/passwordPolicy"; +import userService from "@/services/users"; + +const STRICT_COMPLEXITY = { + min_length: 12, + max_length: 64, + require_uppercase: true, + require_lowercase: true, + require_digit: true, + require_special: true, + special_char_set: "", +}; + +const store = createStore({ + state: { + userInfo: { email: "maya@acme.io" }, + selectedOrganization: { identifier: "acme" }, + zoConfig: { meta_org: "_meta" }, + }, + actions: { logout: vi.fn() }, +}); + +let wrappers: any[] = []; + +const mountDialog = () => { + const wrapper = mount(UpdatePasswordDialog, { + global: { + plugins: [i18n, store], + stubs: { teleport: true }, + }, + }); + wrappers.push(wrapper); + return wrapper; +}; + +const { open, close, isOpen } = usePasswordReset(); + +describe("UpdatePasswordDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPasswordComplexityCache(); + close(); + (passwordPolicyService.getComplexity as any).mockResolvedValue({ data: STRICT_COMPLEXITY }); + (userService.update as any).mockResolvedValue({ data: {} }); + }); + + afterEach(() => { + wrappers.forEach((wrapper) => wrapper.unmount()); + wrappers = []; + close(); + }); + + it("renders nothing until the user is flagged", async () => { + const wrapper = mountDialog(); + await flushPromises(); + + expect(wrapper.find('[data-test="password-reset-dialog"]').exists()).toBe(false); + }); + + it("reads the complexity when it opens", async () => { + open("policy_tightened"); + mountDialog(); + await flushPromises(); + + expect(passwordPolicyService.getComplexity).toHaveBeenCalledWith("acme"); + }); + + it("still fetches the complexity when no organization is selected", async () => { + // A blocked user's org list is refused too, so no organization is ever selected for them. + store.state.selectedOrganization = undefined as any; + + open("policy_tightened"); + mountDialog(); + await flushPromises(); + + expect(passwordPolicyService.getComplexity).toHaveBeenCalledWith("default"); + + store.state.selectedOrganization = { identifier: "acme" } as any; + }); + + it("submits the password change to a real org when none is selected", async () => { + // /api/undefined/users 401s, and the global 401 handler would make a failed change look like a success. + store.state.selectedOrganization = undefined as any; + + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await (wrapper.vm as any).submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + expect(userService.update).toHaveBeenCalledWith(expect.anything(), "default", "maya@acme.io"); + + store.state.selectedOrganization = { identifier: "acme" } as any; + }); + + it("builds the checklist from the policy, not a hardcoded list", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + for (const key of ["minLength", "maxLength", "uppercase", "lowercase", "digit", "special"]) { + expect(wrapper.find(`[data-test="password-requirement-${key}"]`).exists()).toBe(true); + } + }); + + it("shows one row for a policy that enforces one thing", async () => { + (passwordPolicyService.getComplexity as any).mockResolvedValue({ + data: { + ...STRICT_COMPLEXITY, + max_length: 0, + require_uppercase: false, + require_lowercase: false, + require_digit: false, + require_special: false, + }, + }); + + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + expect(wrapper.findAll('[data-test^="password-requirement-"]')).toHaveLength(1); + expect(wrapper.find('[data-test="password-requirement-minLength"]').exists()).toBe(true); + }); + + it("ticks requirements as the new password is typed", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + const metBefore = wrapper + .find('[data-test="password-requirement-uppercase"]') + .classes() + .join(" "); + expect(metBefore).toContain("text-text-secondary"); + + await wrapper + .find('[data-test="password-reset-dialog-new-password"] input') + .setValue("Abcdefghijk1!"); + await flushPromises(); + + const metAfter = wrapper + .find('[data-test="password-requirement-uppercase"]') + .classes() + .join(" "); + expect(metAfter).toContain("text-status-positive"); + }); + + it("reveals the new password when its toggle is clicked", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + const input = () => wrapper.find('[data-test="password-reset-dialog-new-password"] input'); + expect(input().attributes("type")).toBe("password"); + + await wrapper.find('[data-test="password-reset-dialog-new-password-reveal"]').trigger("click"); + expect(input().attributes("type")).toBe("text"); + }); + + it("never advertises a rule the server does not enforce", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + // The mockup lists it; PasswordPolicy has no such field. + expect(wrapper.text()).not.toContain("email"); + }); + + it("still renders a usable form when the complexity fetch fails", async () => { + (passwordPolicyService.getComplexity as any).mockRejectedValue(new Error("nope")); + + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + expect(wrapper.find('[data-test="password-reset-dialog-new-password"]').exists()).toBe(true); + }); + + it("shows the reason the middleware gave", async () => { + open("rotation_expired"); + const wrapper = mountDialog(); + await flushPromises(); + + expect(wrapper.find('[data-test="password-reset-dialog-banner"]').text()).toContain("expired"); + }); + + it("submits the change_password payload for the signed-in user", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await (wrapper.vm as any).submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + expect(userService.update).toHaveBeenCalledWith( + { change_password: true, old_password: "old-secret", new_password: "New-Secret-1!" }, + "acme", + "maya@acme.io", + ); + }); + + it("signs the user out on success, because the cookie is the old password", async () => { + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await (wrapper.vm as any).submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + expect(invalidateLoginDataMock).toHaveBeenCalled(); + expect(pushMock).toHaveBeenCalledWith("/logout"); + expect(isOpen.value).toBe(false); + }); + + it("clears the reuse error on edit", async () => { + vi.mocked(userService.update).mockRejectedValue({ + response: { data: { message: "New password matches one of your last 5 passwords" } }, + }); + + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await ( + wrapper.vm as unknown as { submit: (values: UpdatePasswordForm) => Promise } + ).submit({ + old_password: "old-secret", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + const fieldError = () => wrapper.find('[data-test="password-reset-dialog-new-password-error"]'); + expect(fieldError().exists()).toBe(true); + expect(fieldError().text()).toContain("last 5 passwords"); + + await wrapper + .find('[data-test="password-reset-dialog-new-password"] input') + .setValue("New-Secret-2!"); + await flushPromises(); + + expect(fieldError().exists()).toBe(false); + }); + + it("keeps the dialog open and does not sign out when the current password is wrong", async () => { + (userService.update as any).mockRejectedValue({ + response: { data: { message: "Invalid old password" } }, + }); + + open("policy_tightened"); + const wrapper = mountDialog(); + await flushPromises(); + + await (wrapper.vm as any).submit({ + old_password: "wrong", + new_password: "New-Secret-1!", + confirm_password: "New-Secret-1!", + }); + await flushPromises(); + + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ variant: "error", message: "Invalid old password" }), + ); + expect(pushMock).not.toHaveBeenCalled(); + expect(isOpen.value).toBe(true); + }); +}); diff --git a/web/src/components/common/UpdatePasswordDialog.vue b/web/src/components/common/UpdatePasswordDialog.vue new file mode 100644 index 0000000000..147ef985f4 --- /dev/null +++ b/web/src/components/common/UpdatePasswordDialog.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/src/components/iam/users/AddUser.schema.ts b/web/src/components/iam/users/AddUser.schema.ts index 53b6b6fc40..8017c6188f 100644 --- a/web/src/components/iam/users/AddUser.schema.ts +++ b/web/src/components/iam/users/AddUser.schema.ts @@ -11,16 +11,9 @@ import { z } from "zod"; -// Password policy (mirrors src/config/src/utils/password.rs): length 8-128 AND -// lower AND upper AND digit AND special. -export const isStrongPassword = (val: string): boolean => { - if (!val || val.length < 8 || val.length > 128) return false; - const hasLower = /[a-z]/.test(val); - const hasUpper = /[A-Z]/.test(val); - const hasDigit = /[0-9]/.test(val); - const hasSpecial = /[^A-Za-z0-9]/.test(val); - return hasLower && hasUpper && hasDigit && hasSpecial; -}; +import type { PasswordComplexity } from "@/services/passwordPolicy"; +import type { TranslateFn } from "@/types/i18n"; +import { validateAgainstComplexity } from "@/utils/passwordComplexity"; export const userEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // other_organization must START WITH A LETTER, then alphanumeric / _ / - . @@ -35,6 +28,8 @@ export interface AddUserSchemaContext { loggedInUserEmail: string; modelEmail: string; organization: string; + /** The instance policy the server will hold this password to. */ + complexity: PasswordComplexity; } // Base fields are optional; the real, mode-dependent requireds are enforced in @@ -54,10 +49,7 @@ export const addUserBaseSchema = z.object({ export type AddUserForm = z.infer; -export const makeAddUserSchema = ( - getCtx: () => AddUserSchemaContext, - t: (_key: string) => string, -) => +export const makeAddUserSchema = (getCtx: () => AddUserSchemaContext, t: TranslateFn) => addUserBaseSchema.superRefine((val, zctx) => { // Read the live context on every run so a single stable schema instance // follows mode flips (e.g. the 422 add-existing → create-new switch) without @@ -90,12 +82,15 @@ export const makeAddUserSchema = ( path: ["password"], message: t("user.passwordRequired"), }); - } else if (!isStrongPassword(val.password)) { - zctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["password"], - message: t("user.passwordPolicyHint"), - }); + } else { + const failure = validateAgainstComplexity(val.password, ctx.complexity, t); + if (failure) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["password"], + message: failure, + }); + } } } @@ -117,12 +112,15 @@ export const makeAddUserSchema = ( path: ["new_password"], message: t("user.newPasswordRequired"), }); - } else if (!isStrongPassword(val.new_password)) { - zctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["new_password"], - message: t("user.passwordPolicyHint"), - }); + } else { + const failure = validateAgainstComplexity(val.new_password, ctx.complexity, t); + if (failure) { + zctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["new_password"], + message: failure, + }); + } } } diff --git a/web/src/components/iam/users/AddUser.spec.ts b/web/src/components/iam/users/AddUser.spec.ts index d27fcfe31c..a135c97edd 100644 --- a/web/src/components/iam/users/AddUser.spec.ts +++ b/web/src/components/iam/users/AddUser.spec.ts @@ -41,6 +41,7 @@ vi.mock("@/services/users", async (importOriginal) => { create: vi.fn(), update: vi.fn(), updateexistinguser: vi.fn(), + get: vi.fn(() => Promise.resolve({ data: {} })), getUserRoles: vi.fn(() => Promise.resolve({ data: [] })), }, }); @@ -50,6 +51,11 @@ vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }), })); +const confirmMock = vi.fn(); +vi.mock("@/composables/useConfirmDialog", () => ({ + useConfirmDialog: () => ({ confirm: confirmMock }), +})); + const { mockToast } = vi.hoisted(() => ({ mockToast: vi.fn(), })); @@ -734,5 +740,35 @@ describe("AddUser", () => { expect(wrapper.find('[data-test="user-custom-role-field"]').exists()).toBe(true); }); + + it("stages an unlock and sends it on save, after confirming", async () => { + vi.mocked(userServiece.get).mockResolvedValue({ + data: { lockout: { locked: true, retry_after_secs: 240 } }, + } as any); + vi.mocked(userServiece.update).mockResolvedValue({ data: {} } as any); + confirmMock.mockResolvedValue(true); + wrapper = mountComp({ + modelValue: { email: "other@example.com", first_name: "Other", org_member_id: "2" }, + }); + await flushPromises(); + + expect(wrapper.find('[data-test="user-lockout-banner"]').exists()).toBe(true); + await wrapper.find('[data-test="user-lockout-unlock-btn"]').trigger("click"); + setField(wrapper, "role", "admin"); + await submitForm(wrapper); + + expect(confirmMock).toHaveBeenCalled(); + const [body] = vi.mocked(userServiece.update).mock.calls[0]; + expect(body.remove_lockout).toBe(true); + }); + + it("never fetches lockout state for your own row", async () => { + wrapper = mountComp({ + modelValue: { email: ADMIN_EMAIL, first_name: "Me", org_member_id: "1" }, + }); + await flushPromises(); + + expect(userServiece.get).not.toHaveBeenCalled(); + }); }); }); diff --git a/web/src/components/iam/users/AddUser.vue b/web/src/components/iam/users/AddUser.vue index 35cefa8f67..55b1de35fd 100644 --- a/web/src/components/iam/users/AddUser.vue +++ b/web/src/components/iam/users/AddUser.vue @@ -71,6 +71,11 @@ along with this program. If not, see . /> + . required class="showLabelOnTop mt-2" data-test="user-new-password-field" + @update:model-value="clearServerErrors" > + + + +
{{ t("user.lockout.locked") }}
+
+ {{ + unlockStaged + ? t("user.lockout.staged") + : t("user.lockout.autoUnlock", { + duration: raw(durationFormatter(lockout.retry_after_secs ?? 0)), + }) + }} +
+ +
{ return { org_member_id: "", @@ -249,7 +299,18 @@ const defaultValue: any = () => { export default defineComponent({ name: "ComponentAddUpdateUser", - components: { ODialog, ODrawer, OIcon, OForm, OFormInput, OFormSelect, OFormSwitch }, + components: { + ODialog, + ODrawer, + OBanner, + OButton, + OIcon, + OForm, + OFormInput, + OFormSelect, + OFormSwitch, + PasswordRequirementList, + }, props: { open: { type: Boolean, @@ -320,6 +381,10 @@ export default defineComponent({ const organization = ref(store.state.selectedOrganization.identifier); const editRecord: any = ref(null); const isExternalUser = ref(false); + // The edited user's lockout state, read once when the dialog opens on someone else's row. + const lockout = ref<{ locked: boolean; retry_after_secs?: number } | null>(null); + const unlockStaged = ref(false); + const { confirm } = useConfirmDialog(); const blankForm = (): AddUserForm => ({ email: "", @@ -381,6 +446,16 @@ export default defineComponent({ delete payload.old_password; delete payload.new_password; } + // A security-relevant action on someone else's account confirms before the write. + if (unlockStaged.value) { + const confirmed = await confirm({ + title: t("user.lockout.confirmTitle", { email: raw(userEmail) }), + message: t("user.lockout.confirmMessage"), + confirmLabel: t("user.lockout.unlock"), + }); + if (!confirmed) return; + payload.remove_lockout = true; + } try { const res: any = await userServiece.update(payload, selectedOrg, userEmail); if ( @@ -393,10 +468,15 @@ export default defineComponent({ emit("update:open", false); } } catch (err: any) { - toast({ - variant: "error", - message: err.response.data.message, - }); + const reused = reuseRejection(err, t); + if (reused) { + setServerFieldErrors(form, { new_password: reused }); + } else { + toast({ + variant: "error", + message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"), + }); + } } track("Button Click", { button: "Update User", page: "Add User" }); } else if (existingUser.value) { @@ -428,7 +508,7 @@ export default defineComponent({ if (err.response?.status != 403 || err?.status != 403) { toast({ variant: "error", - message: err.response.data.message, + message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"), }); } } @@ -452,7 +532,7 @@ export default defineComponent({ } catch (err: any) { toast({ variant: "error", - message: err.response.data.message, + message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"), }); } track("Button Click", { button: "Create User", page: "Add User" }); @@ -464,6 +544,14 @@ export default defineComponent({ // via — ONE source of truth. The schema takes a context // GETTER so a single stable instance follows mode flips (e.g. the 422 // add-existing → create-new switch) with no remount. + + // The instance policy, not a hardcoded rule: a fixed mirror would drift from what the server enforces. + const { + complexity: passwordComplexity, + requirements: passwordRequirements, + load: loadPasswordComplexity, + } = usePasswordComplexity(); + const addUserSchema = makeAddUserSchema( () => ({ existingUser: existingUser.value, @@ -472,6 +560,7 @@ export default defineComponent({ loggedInUserEmail: loggedInUserEmail.value, modelEmail: props.modelValue?.email ?? "", organization: organization.value, + complexity: passwordComplexity.value, }), t, ); @@ -488,6 +577,29 @@ export default defineComponent({ const formEmail = form.useStore((s: any) => s.values.email); const formRole = form.useStore((s: any) => s.values.role); const formCustomRole = form.useStore((s: any) => s.values.custom_role); + // Read live so the requirement rows tick while typing, the same way the reset dialog does. + const formPassword = form.useStore((s: any) => s.values.password); + const formNewPassword = form.useStore((s: any) => s.values.new_password); + + // A server error is not re-validated on change, so it would block every later submit unless cleared. + const clearServerErrors = () => { + setServerFieldErrors(form, {}); + }; + + // Only when administering someone else: a locked-out user must not lift their own lock. + const loadLockoutState = (email: string) => { + lockout.value = null; + unlockStaged.value = false; + if (config.isEnterprise != "true" || email === loggedInUserEmail.value) return; + userServiece + .get(organization.value, email) + .then((response: any) => { + lockout.value = response.data?.lockout ?? null; + }) + .catch(() => { + lockout.value = null; + }); + }; watch( () => props.customRoles, @@ -500,7 +612,9 @@ export default defineComponent({ organization.value = store.state.selectedOrganization.identifier; }); - onBeforeMount(() => setOrganizationOptions()); + onBeforeMount(() => { + setOrganizationOptions(); + }); watch( () => store.state.organizations, @@ -528,6 +642,7 @@ export default defineComponent({ password: "", }; isExternalUser.value = !!newVal.is_external; + loadLockoutState(newVal.email); // Seed the form via reset(values) — never a per-field setFieldValue loop. form.reset({ ...blankForm(), @@ -565,6 +680,7 @@ export default defineComponent({ (isOpen, wasOpen) => { if (isOpen && !wasOpen) { resetFormFromModelValue(props.modelValue); + loadPasswordComplexity(); } }, { immediate: true }, @@ -604,6 +720,14 @@ export default defineComponent({ formEmail, formRole, formCustomRole, + formPassword, + formNewPassword, + passwordRequirements, + clearServerErrors, + lockout, + unlockStaged, + durationFormatter, + raw, isPwd, isNewPwd, isOldPwd, diff --git a/web/src/components/login/Login.spec.ts b/web/src/components/login/Login.spec.ts index 00c6b584c8..6c2b0f5627 100644 --- a/web/src/components/login/Login.spec.ts +++ b/web/src/components/login/Login.spec.ts @@ -499,7 +499,12 @@ describe("Login", () => { wrapper.vm.password = "password123"; await wrapper.vm.onSignIn(); - expect(mockDispatch).toHaveBeenCalledWith("setUserInfo", "encoded-data"); + // The store must hold the decoded object: the dialog and MainLayout read `.email` off it directly. + expect(mockDispatch).toHaveBeenCalledWith( + "setUserInfo", + expect.objectContaining({ email: "testuser", name: "testuser" }), + ); + expect(mockDispatch).not.toHaveBeenCalledWith("setUserInfo", "encoded-data"); }); // Test 21: successful sign in dispatches setCurrentUser diff --git a/web/src/components/login/Login.vue b/web/src/components/login/Login.vue index d2a5d0b92b..ce40a02534 100644 --- a/web/src/components/login/Login.vue +++ b/web/src/components/login/Login.vue @@ -124,6 +124,18 @@ along with this program. If not, see . v-if="!showSSO || (showSSO && loginAsInternalUser && showInternalLogin)" class="login-inputs" > + + . block type="submit" :loading="submitting" + :disabled="lockoutSecondsLeft > 0" > {{ t("login.login") }} @@ -169,11 +182,13 @@ along with this program. If not, see . diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue index 662f3bf9b3..a28c15f30e 100644 --- a/web/src/components/settings/index.vue +++ b/web/src/components/settings/index.vue @@ -36,7 +36,12 @@ along with this program. If not, see . :icon="activeSectionItem?.icon as any" class="border-border-default shrink-0 border-b" /> - + @@ -89,6 +94,7 @@ export default defineComponent({ queryManagement: "queryManagement", query_management: "queryManagement", domainManagement: "domain_management", + passwordPolicy: "password_policy", pipelineDestinations: "pipeline_destinations", alertTemplates: "templates", modelPricing: "model_pricing", @@ -118,6 +124,7 @@ export default defineComponent({ "organization", "license", "domain_management", + "password_policy", ]); const isConstrainedSection = computed(() => CONSTRAINED_SECTIONS.has(activeSection.value)); @@ -130,10 +137,10 @@ export default defineComponent({ query: { org_identifier: store.state.selectedOrganization?.identifier }, }); }; - // Nodes and License are enterprise-only meta-org pages. + // Nodes, License and Password policy are enterprise-only meta-org pages. const notMeta = store.state.zoConfig.meta_org && (!isMetaOrg.value || config.isEnterprise === "false"); - if ((name === "nodes" || name === "license") && notMeta) { + if ((name === "nodes" || name === "license" || name === "passwordPolicy") && notMeta) { toGeneral(); return; } @@ -229,6 +236,16 @@ export default defineComponent({ dataTest: "domain-management-tab", group: "Access & Security", }, + { + key: "password_policy", + label: t("settings.passwordPolicy"), + description: t("settings.passwordPolicyDesc"), + icon: "lock", + to: { name: "passwordPolicy", query: { org_identifier: org } }, + visible: isEnt && meta, + dataTest: "password-policy-tab", + group: "Access & Security", + }, // Notification Destinations and Templates are alerting configuration and // now live under Reliability (/alert-destinations, /alert-templates). // Pipeline Destinations stays here — it belongs to pipelines, not diff --git a/web/src/components/settings/settingsIndex.spec.ts b/web/src/components/settings/settingsIndex.spec.ts index 9db46e50a3..d54ccf19ee 100644 --- a/web/src/components/settings/settingsIndex.spec.ts +++ b/web/src/components/settings/settingsIndex.spec.ts @@ -5,12 +5,15 @@ import { nextTick } from "vue"; import { createStore } from "vuex"; import { createI18n } from "vue-i18n"; import enLocale from "@/locales/languages/en-US.json"; +import config from "@/aws-exports"; import SettingsIndex from "./index.vue"; -// Mock composables and config with factory functions +// Settable rather than a fixed `false`: meta-org-only sections cannot be covered otherwise. +const { isMetaOrgRef } = vi.hoisted(() => ({ isMetaOrgRef: { value: false } })); + vi.mock("@/composables/useIsMetaOrg", () => ({ default: () => ({ - isMetaOrg: { value: false }, + isMetaOrg: isMetaOrgRef, }), })); @@ -393,6 +396,61 @@ describe("SettingsIndex.vue", () => { }); }); + describe("password policy section", () => { + const findEntry = (w: any) => + w.vm.sectionGroups + .flatMap((group: any) => group.items) + .find((item: any) => item.key === "password_policy"); + + afterEach(() => { + isMetaOrgRef.value = false; + config.isEnterprise = "false"; + mockRouter.currentRoute.value.name = "settings"; + }); + + it("is offered in the meta org of an enterprise build", () => { + config.isEnterprise = "true"; + isMetaOrgRef.value = true; + wrapper = createWrapper(); + + expect(findEntry(wrapper)?.visible).toBe(true); + }); + + it("is hidden outside the meta org", () => { + config.isEnterprise = "true"; + isMetaOrgRef.value = false; + wrapper = createWrapper(); + + expect(findEntry(wrapper)?.visible).toBe(false); + }); + + it("is hidden in an OSS build even in the meta org", () => { + // The API only exists behind the enterprise feature, so the entry would lead nowhere. + config.isEnterprise = "false"; + isMetaOrgRef.value = true; + wrapper = createWrapper(); + + expect(findEntry(wrapper)?.visible).toBe(false); + }); + + it("sends an enterprise non-meta org back to General", () => { + config.isEnterprise = "true"; + isMetaOrgRef.value = false; + mockRouter.currentRoute.value.name = "passwordPolicy"; + mockPush.mockClear(); + wrapper = createWrapper({ zoConfig: { meta_org: "_meta" } }); + + expect(mockPush).toHaveBeenCalledWith(expect.objectContaining({ path: "/settings/general" })); + }); + + it("renders in the centered reading column, like the other form sections", () => { + mockRouter.currentRoute.value.name = "passwordPolicy"; + wrapper = createWrapper(); + + expect(wrapper.vm.isConstrainedSection).toBe(true); + }); + }); + describe("Template Rendering", () => { it("should render without errors", () => { mockRouter.currentRoute.value.name = "general"; diff --git a/web/src/composables/shared/useManagementRoutes.spec.ts b/web/src/composables/shared/useManagementRoutes.spec.ts index dd696f8ce0..f40ad560e7 100644 --- a/web/src/composables/shared/useManagementRoutes.spec.ts +++ b/web/src/composables/shared/useManagementRoutes.spec.ts @@ -521,7 +521,7 @@ describe("useManagementRoutes", () => { it("should have exactly 20 children routes when enterprise is enabled", () => { const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(20); // 5 base (incl. alert_sources redirect) + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise + expect(routes[0].children).toHaveLength(21); // 5 base (incl. alert_sources redirect) + syntheticsLocations + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise (incl. passwordPolicy) }); }); @@ -604,7 +604,7 @@ describe("useManagementRoutes", () => { it("should have exactly 21 children routes when both enterprise and cloud are enabled", () => { const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(21); // 5 base (incl. alert_sources redirect) + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise + 1 cloud + expect(routes[0].children).toHaveLength(22); // 5 base (incl. alert_sources redirect) + syntheticsLocations + modelPricing (+ editor) + llmProviders + genAiAgentMapping + 11 enterprise (incl. passwordPolicy) + 1 cloud }); it("should have all enterprise routes when both are enabled", () => { @@ -699,68 +699,101 @@ describe("useManagementRoutes", () => { }); }); + describe("passwordPolicy route", () => { + const findRoute = (routes: any) => + routes[0].children.find((child: any) => child.name === "passwordPolicy"); + + it("is registered in an enterprise build", () => { + config.isEnterprise = "true"; + config.isCloud = "false"; + + expect(findRoute(useManagementRoutes())).toBeDefined(); + }); + + it("is absent in an OSS build, where the API does not exist", () => { + config.isEnterprise = "false"; + config.isCloud = "false"; + + expect(findRoute(useManagementRoutes())).toBeUndefined(); + }); + + it("is registered in enterprise and cloud builds too", () => { + config.isEnterprise = "true"; + config.isCloud = "true"; + + expect(findRoute(useManagementRoutes())).toBeDefined(); + }); + + it("uses the settings path the shell maps to its section key", () => { + config.isEnterprise = "true"; + config.isCloud = "false"; + + expect(findRoute(useManagementRoutes()).path).toBe("password_policy"); + }); + }); + describe("Edge Cases and Error Handling", () => { it("should handle isEnterprise as string 'false'", () => { config.isEnterprise = "false"; config.isCloud = "false"; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle isCloud as string 'false'", () => { config.isEnterprise = "false"; config.isCloud = "false"; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle isEnterprise as undefined", () => { (config as any).isEnterprise = undefined; config.isCloud = "false"; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle isCloud as undefined", () => { config.isEnterprise = "false"; (config as any).isCloud = undefined; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle both config values as undefined", () => { (config as any).isEnterprise = undefined; (config as any).isCloud = undefined; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle isEnterprise as non-string truthy value", () => { (config as any).isEnterprise = true; config.isCloud = "false"; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true" + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true" }); it("should handle isCloud as non-string truthy value", () => { config.isEnterprise = "false"; (config as any).isCloud = true; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true" + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true" }); it("should handle empty string for isEnterprise", () => { config.isEnterprise = ""; config.isCloud = "false"; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should handle empty string for isCloud", () => { config.isEnterprise = "false"; config.isCloud = ""; const routes = useManagementRoutes(); - expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present + expect(routes[0].children).toHaveLength(6); // syntheticsLocations ships in OSS; passwordPolicy is enterprise-only; model_pricing (+ editor), gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present }); it("should return the same structure on multiple calls", () => { diff --git a/web/src/composables/shared/useManagementRoutes.ts b/web/src/composables/shared/useManagementRoutes.ts index 99a1c420ed..e1085671bb 100644 --- a/web/src/composables/shared/useManagementRoutes.ts +++ b/web/src/composables/shared/useManagementRoutes.ts @@ -239,6 +239,18 @@ const useManagementRoutes = () => { routeGuard(to, from, next); }, }, + { + path: "password_policy", + name: "passwordPolicy", + component: () => import("@/components/settings/PasswordPolicy.vue"), + meta: { + keepAlive: true, + titleKey: "routeTitles.passwordPolicy", + }, + beforeEnter(to: any, from: any, next: any) { + routeGuard(to, from, next); + }, + }, { path: "correlation/:tab?", name: "correlationSettings", diff --git a/web/src/composables/usePasswordComplexity.ts b/web/src/composables/usePasswordComplexity.ts new file mode 100644 index 0000000000..f23f5b3ccb --- /dev/null +++ b/web/src/composables/usePasswordComplexity.ts @@ -0,0 +1,70 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { computed, ref } from "vue"; +import { useStore } from "vuex"; + +import { remediationOrg } from "@/composables/usePasswordReset"; +import passwordPolicy, { type PasswordComplexity } from "@/services/passwordPolicy"; +import { useI18nTyped } from "@/types/i18n"; +import { buildPasswordRequirements, DEFAULT_COMPLEXITY } from "@/utils/passwordComplexity"; + +// Module scope so the last answer stays visible while a reopen refetches. +const complexity = ref(null); +let inFlight: Promise | null = null; + +/** `loaded` false must not block submit: the server validates regardless, and a missing hint is recoverable. */ +export function usePasswordComplexity() { + const store = useStore(); + const { t } = useI18nTyped(); + const error = ref(false); + const loading = ref(false); + + // Always refetch: the policy can change in Settings within the same session. + const load = async () => { + if (inFlight) return inFlight; + + loading.value = true; + error.value = false; + const org = remediationOrg(store); + + inFlight = passwordPolicy + .getComplexity(org) + .then((response: any) => { + complexity.value = response.data; + }) + .catch(() => { + error.value = true; + }) + .finally(() => { + loading.value = false; + inFlight = null; + }); + + return inFlight; + }; + + const effective = computed(() => complexity.value ?? DEFAULT_COMPLEXITY); + const loaded = computed(() => complexity.value !== null); + const requirements = computed(() => buildPasswordRequirements(effective.value, t)); + + return { complexity: effective, requirements, loaded, loading, error, load }; +} + +/** Test seam: drops the module-level cache so each case starts from a known state. */ +export const resetPasswordComplexityCache = () => { + complexity.value = null; + inFlight = null; +}; diff --git a/web/src/composables/usePasswordExpiryWarning.ts b/web/src/composables/usePasswordExpiryWarning.ts new file mode 100644 index 0000000000..ab457ff8d9 --- /dev/null +++ b/web/src/composables/usePasswordExpiryWarning.ts @@ -0,0 +1,44 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// sessionStorage rather than a module ref so the sign-in response's count survives a reload. + +import { ref } from "vue"; + +const STORAGE_KEY = "password_rotation_warning"; + +const readStored = (): number | null => { + const stored = Number(sessionStorage.getItem(STORAGE_KEY)); + return stored > 0 ? stored : null; +}; + +const daysRemaining = ref(readStored()); + +export function usePasswordExpiryWarning() { + /** Record what the sign-in response said; absent means there is nothing to warn about. */ + const remember = (days: unknown) => { + const count = Number(days); + if (!(count > 0)) return; + sessionStorage.setItem(STORAGE_KEY, String(count)); + daysRemaining.value = count; + }; + + const dismiss = () => { + sessionStorage.removeItem(STORAGE_KEY); + daysRemaining.value = null; + }; + + return { daysRemaining, remember, dismiss }; +} diff --git a/web/src/composables/usePasswordReset.spec.ts b/web/src/composables/usePasswordReset.spec.ts new file mode 100644 index 0000000000..9b6f6e2a3b --- /dev/null +++ b/web/src/composables/usePasswordReset.spec.ts @@ -0,0 +1,43 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePasswordReset } from "./usePasswordReset"; + +const { open, close, isOpen } = usePasswordReset(); + +describe("usePasswordReset", () => { + beforeEach(() => { + vi.clearAllMocks(); + close(); + }); + + it("opens once however many rejections arrive", () => { + open("policy_tightened"); + open("rotation_expired"); + open("rotation_expired"); + + expect(isOpen.value).toBe(true); + // The first reason wins: six parallel requests must not rewrite the banner under the user. + expect(usePasswordReset().reason.value).toBe("policy_tightened"); + }); + + it("falls back to policy_tightened for an unrecognised reason", () => { + open("something-else"); + + expect(usePasswordReset().reason.value).toBe("policy_tightened"); + }); +}); diff --git a/web/src/composables/usePasswordReset.ts b/web/src/composables/usePasswordReset.ts new file mode 100644 index 0000000000..431522c7c8 --- /dev/null +++ b/web/src/composables/usePasswordReset.ts @@ -0,0 +1,74 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Written by the http interceptor and read by the dialog App.vue mounts, so the state lives in neither. + +import { ref } from "vue"; + +// The two server reasons, plus the advisory one the expiry banner opens the dialog with. +export type PasswordResetReason = "policy_tightened" | "rotation_expired" | "rotation_warning"; + +type OrgStore = { state?: { selectedOrganization?: { identifier?: string } } }; + +/** Mirrors the server's DEFAULT_ORG — the one organization guaranteed to exist. */ +const DEFAULT_ORG = "default"; + +/** A blocked user's org list is refused too, so `selectedOrganization` alone would yield `/api/undefined/...`. */ +export const remediationOrg = (store: OrgStore | undefined): string => + store?.state?.selectedOrganization?.identifier || DEFAULT_ORG; + +// Module scope, not per-call: every consumer must see the same flag. +const isOpen = ref(false); +const reason = ref(null); +// Only the blocked state is a trap; a user who opened the dialog voluntarily may close it. +const dismissible = ref(false); + +const serverReason = (nextReason?: string): PasswordResetReason => + nextReason === "rotation_expired" || nextReason === "policy_tightened" + ? nextReason + : "policy_tightened"; + +export function usePasswordReset() { + /** Enter the blocked state; the already-open guard stops parallel rejections stacking dialogs. */ + const open = (nextReason?: string) => { + if (isOpen.value) return; + reason.value = serverReason(nextReason); + dismissible.value = false; + isOpen.value = true; + }; + + /** Open the same dialog with a way back out, ahead of expiry. */ + const openVoluntarily = (nextReason: PasswordResetReason = "rotation_warning") => { + if (isOpen.value) return; + reason.value = nextReason; + dismissible.value = true; + isOpen.value = true; + }; + + const close = () => { + isOpen.value = false; + reason.value = null; + dismissible.value = false; + }; + + return { + isOpen, + reason, + dismissible, + open, + openVoluntarily, + close, + }; +} diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index f7190014df..b5bdc70d3f 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -45,6 +45,7 @@ along with this program. If not, see . + @@ -275,6 +276,7 @@ import useSearchWebSocket from "@/composables/useSearchWebSocket"; import O2AIChat from "@/components/O2AIChat.vue"; import WebinarBanner from "@/components/WebinarBanner.vue"; import AnnouncementBanner from "@/components/announcements/AnnouncementBanner.vue"; +import PasswordExpiryBanner from "@/components/common/PasswordExpiryBanner.vue"; import useRoutePrefetch from "@/composables/useRoutePrefetch"; import { toast, dismissAll } from "@/lib/feedback/Toast/useToast"; import { purgeOrgQueries, queryClient } from "@/composables/query/queryClient"; @@ -295,6 +297,7 @@ export default defineComponent({ AppHeader, WebinarBanner, AnnouncementBanner, + PasswordExpiryBanner, "keep-alive": KeepAlive, ONavbar, "router-view": RouterView, diff --git a/web/src/lib/core/SettingRow/OSettingRow.spec.ts b/web/src/lib/core/SettingRow/OSettingRow.spec.ts new file mode 100644 index 0000000000..9c7e429b0b --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRow.spec.ts @@ -0,0 +1,65 @@ +// Copyright 2026 OpenObserve Inc. + +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; + +import { raw } from "@/types/i18n"; + +import { SETTING_ROW_PAIR_KEY } from "./OSettingRow.types"; +import OSettingRow from "./OSettingRow.vue"; + +describe("OSettingRow", () => { + it("renders the label and the description", () => { + const wrapper = mount(OSettingRow, { + props: { label: raw("Minimum length"), description: raw("Shortest password accepted") }, + }); + + expect(wrapper.text()).toContain("Minimum length"); + expect(wrapper.text()).toContain("Shortest password accepted"); + }); + + it("omits the description element when there is none", () => { + const wrapper = mount(OSettingRow, { props: { label: raw("Require digit") } }); + + expect(wrapper.text()).toContain("Require digit"); + expect(wrapper.find(".text-text-secondary").exists()).toBe(false); + }); + + it("renders the control in the default slot", () => { + const wrapper = mount(OSettingRow, { + props: { label: raw("Require digit") }, + slots: { default: '' }, + }); + + expect(wrapper.find('[data-test="control"]').exists()).toBe(true); + }); + + it("drops its own rule on the last row, so a card needs no per-row conditionals", () => { + const wrapper = mount(OSettingRow, { props: { label: raw("Require digit") } }); + + expect(wrapper.classes()).toContain("border-b"); + expect(wrapper.classes()).toContain("last:border-b-0"); + expect(wrapper.find(".min-w-0").classes()).toContain("w-[22.5%]"); + }); + + it("forwards data-test for addressing the row in page tests", () => { + const wrapper = mount(OSettingRow, { + props: { label: raw("Require digit"), dataTest: "settings-password-policy-require-digit" }, + }); + + expect(wrapper.attributes("data-test")).toBe("settings-password-policy-require-digit"); + }); + + it("leaves rule and padding to the pair when inside one", () => { + const wrapper = mount(OSettingRow, { + props: { label: raw("Require digit") }, + global: { provide: { [SETTING_ROW_PAIR_KEY as symbol]: true } }, + }); + + expect(wrapper.classes()).not.toContain("border-b"); + expect(wrapper.classes()).not.toContain("py-3"); + expect(wrapper.classes()).not.toContain("last:border-b-0"); + expect(wrapper.classes()).toContain("flex"); + expect(wrapper.find(".min-w-0").classes()).toContain("w-1/2"); + }); +}); diff --git a/web/src/lib/core/SettingRow/OSettingRow.types.ts b/web/src/lib/core/SettingRow/OSettingRow.types.ts new file mode 100644 index 0000000000..319a18d585 --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRow.types.ts @@ -0,0 +1,23 @@ +// Copyright 2026 OpenObserve Inc. + +import type { InjectionKey } from "vue"; + +import type { I18nText } from "@/types/i18n"; + +export interface SettingRowProps { + /** The setting's name. */ + label: I18nText; + /** One line saying what the setting does or what a special value means. */ + description?: I18nText; + /** Renders the row muted and is forwarded to nothing — the control owns its own disabled state. */ + disabled?: boolean; + dataTest?: string; +} + +export interface SettingRowSlots { + /** The control on the right. */ + default?: () => unknown; +} + +/** Provided by OSettingRowPair; a row inside one leaves the rule and padding to the pair. */ +export const SETTING_ROW_PAIR_KEY: InjectionKey = Symbol("SettingRowPair"); diff --git a/web/src/lib/core/SettingRow/OSettingRow.vue b/web/src/lib/core/SettingRow/OSettingRow.vue new file mode 100644 index 0000000000..f4fc9877a9 --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRow.vue @@ -0,0 +1,48 @@ + + + + + + diff --git a/web/src/lib/core/SettingRow/OSettingRowPair.spec.ts b/web/src/lib/core/SettingRow/OSettingRowPair.spec.ts new file mode 100644 index 0000000000..d389b867c9 --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRowPair.spec.ts @@ -0,0 +1,73 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import { h } from "vue"; + +import { raw } from "@/types/i18n"; + +import OSettingRow from "./OSettingRow.vue"; +import OSettingRowPair from "./OSettingRowPair.vue"; + +// A render-function slot, so the required label prop can be built with raw(). +const twoRows = () => [ + h(OSettingRow, { label: raw("a"), dataTest: "a" }), + h(OSettingRow, { label: raw("b"), dataTest: "b" }), +]; + +describe("OSettingRowPair", () => { + it("renders both rows in one grid", () => { + const wrapper = mount(OSettingRowPair, { slots: { default: twoRows } }); + + expect(wrapper.find('[data-test="a"]').exists()).toBe(true); + expect(wrapper.find('[data-test="b"]').exists()).toBe(true); + const grid = wrapper.find(".grid"); + expect(grid.classes()).toContain("grid-cols-[45%_45%]"); + expect(grid.classes()).toContain("max-md:grid-cols-1"); + }); + + it("owns the rule so the cells do not draw their own", () => { + const wrapper = mount(OSettingRowPair, { slots: { default: twoRows } }); + + expect(wrapper.classes()).toContain("border-b"); + expect(wrapper.classes()).toContain("last:border-b-0"); + for (const row of ["a", "b"]) { + const classes = wrapper.find(`[data-test="${row}"]`).classes(); + expect(classes).not.toContain("border-b"); + expect(classes).not.toContain("py-3"); + } + }); + + it("renders the footer under both cells only when given", () => { + const bare = mount(OSettingRowPair, { slots: { default: twoRows } }); + expect(bare.find(".mt-1").exists()).toBe(false); + + const wrapper = mount(OSettingRowPair, { + slots: { default: twoRows, footer: '

too small

' }, + }); + expect(wrapper.find('[data-test="msg"]').exists()).toBe(true); + expect(wrapper.find(".grid").find('[data-test="msg"]').exists()).toBe(false); + }); + + it("forwards data-test", () => { + const wrapper = mount(OSettingRowPair, { + props: { dataTest: "settings-password-policy-pair-length" }, + slots: { default: twoRows }, + }); + + expect(wrapper.attributes("data-test")).toBe("settings-password-policy-pair-length"); + }); +}); diff --git a/web/src/lib/core/SettingRow/OSettingRowPair.types.ts b/web/src/lib/core/SettingRow/OSettingRowPair.types.ts new file mode 100644 index 0000000000..f0113480f3 --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRowPair.types.ts @@ -0,0 +1,25 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +export interface SettingRowPairProps { + dataTest?: string; +} + +export interface SettingRowPairSlots { + /** Exactly two OSettingRow. */ + default?: () => unknown; + /** Full-width content under both cells — a cross-field validation message. */ + footer?: () => unknown; +} diff --git a/web/src/lib/core/SettingRow/OSettingRowPair.vue b/web/src/lib/core/SettingRow/OSettingRowPair.vue new file mode 100644 index 0000000000..6c2189f5ee --- /dev/null +++ b/web/src/lib/core/SettingRow/OSettingRowPair.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/web/src/lib/feedback/Banner/OBanner.vue b/web/src/lib/feedback/Banner/OBanner.vue index 2686a12575..caa6328d2b 100644 --- a/web/src/lib/feedback/Banner/OBanner.vue +++ b/web/src/lib/feedback/Banner/OBanner.vue @@ -3,7 +3,7 @@ import type { I18nText } from "@/types/i18n"; import { computed, useSlots } from "vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; -interface Props { +export interface Props { variant?: "default" | "info" | "success" | "warning" | "error" | "error-soft" | "promo"; content?: I18nText; icon?: string; diff --git a/web/src/lib/forms/Form/useOForm.ts b/web/src/lib/forms/Form/useOForm.ts index 12341780cd..dc785cbc20 100644 --- a/web/src/lib/forms/Form/useOForm.ts +++ b/web/src/lib/forms/Form/useOForm.ts @@ -22,6 +22,8 @@ import { useForm, revalidateLogic } from "@tanstack/vue-form"; import type { DeepKeys } from "@tanstack/form-core"; +import type { I18nText } from "@/types/i18n"; + /** * The set of valid field-path strings for a form of shape `T` (what * setFieldValue/validateField/getFieldMeta accept as their first argument). @@ -80,3 +82,16 @@ export function useOForm>(options: UseOFormOpt /** The form instance returned by useOForm — accepted by . */ export type OFormInstance = ReturnType>; + +/** Pin server-reported errors onto fields, or clear them with `{}`; TanStack never re-validates `onServer` on change. */ +export function setServerFieldErrors>( + form: ReturnType>, + fields: Partial, I18nText>>, +) { + // useOForm declares no server validator, which narrows the slot's type to `undefined`. + form.setErrorMap({ + onServer: { form: undefined, fields } as unknown as Parameters< + typeof form.setErrorMap + >[0]["onServer"], + }); +} diff --git a/web/src/lib/forms/Input/OFormInput.spec.ts b/web/src/lib/forms/Input/OFormInput.spec.ts index e697c1c872..7e45a3c677 100644 --- a/web/src/lib/forms/Input/OFormInput.spec.ts +++ b/web/src/lib/forms/Input/OFormInput.spec.ts @@ -132,4 +132,25 @@ describe("OFormInput", () => { .form; expect(form.state.values.threshold).toBe(95); }); + + it("forwards revealable so a password field gets its show/hide toggle", async () => { + wrapper = mount(OForm, { + props: { defaultValues: { secret: "hunter2" } }, + slots: { + default: () => + h(OFormInput, { + name: "secret", + type: "password", + revealable: true, + "data-test": "secret", + }), + }, + global: { components: { OFormInput } }, + }); + const toggle = wrapper.find('[data-test="secret-reveal"]'); + expect(toggle.exists()).toBe(true); + + await toggle.trigger("click"); + expect(wrapper.find("input").attributes("type")).toBe("text"); + }); }); diff --git a/web/src/lib/forms/Input/OFormInput.vue b/web/src/lib/forms/Input/OFormInput.vue index 799aa1bfc0..ae511970f3 100644 --- a/web/src/lib/forms/Input/OFormInput.vue +++ b/web/src/lib/forms/Input/OFormInput.vue @@ -39,6 +39,10 @@ if (import.meta.env.DEV && !form) { :maxlength="props.maxlength" :debounce="props.debounce" :mask="props.mask" + :prefix="props.prefix" + :suffix="props.suffix" + :revealable="props.revealable" + :autofocus="props.autofocus" :autocomplete="props.autocomplete" :name="props.name" :id="props.id" diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index 07b3a682d6..11bde59eea 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "ابدأ التجربة المجانية" }, "login": { + "lockedOut": "محاولات تسجيل دخول فاشلة كثيرة جدًا. حاول مرة أخرى خلال {duration}، أو تواصل مع المسؤول لديك.", "redirectingToSsoMessage": "إعادة التوجيه إلى صفحة تسجيل الدخول إلى SSO. إذا لم يتم إعادة توجيهك، يرجى", "clickHere": "اضغط هنا", "loginWithSso": "تسجيل الدخول باستخدام SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "الحقل مطلوب", "passwordRequired": "كلمة المرور مطلوبة.", "passwordPolicyHint": "يجب أن تكون كلمة المرور من 8 إلى 128 حرفًا وتحتوي على حرف صغير واحد على الأقل، وحرف كبير واحد على الأقل، ورقم واحد على الأقل، وحرف خاص واحد على الأقل.", + "lockout": { + "locked": "الحساب مقفل", + "autoUnlock": "يُفتح تلقائيًا خلال {duration}.", + "staged": "سيُفتح عند الحفظ.", + "unlock": "فتح", + "undo": "إبقاء مقفلًا", + "confirmTitle": "فتح {email}؟", + "confirmMessage": "يؤدي هذا إلى مسح عدد محاولاتهم الفاشلة والسماح لهم بتسجيل الدخول مجددًا فورًا. لا يغيّر هذا كلمة مرورهم." + }, "currentPasswordRequired": "كلمة المرور الحالية مطلوبة.", "newPasswordRequired": "كلمة المرور الجديدة مطلوبة.", "otherOrgInvalid": "يجب أن يبدأ اسم المنظمة بحرف وأن يكون أبجدي رقمي، أو _ أو -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "مفاتيح التشفير للحقول الحساسة", "regexPatternsDesc": "أنماط regex القابلة لإعادة الاستخدام للحذف", "domainManagementDesc": "قيد تسجيل الدخول على نطاقات SSO المسموح بها", + "passwordPolicy": "سياسة كلمة المرور", + "passwordPolicyDesc": "سياسات المصادقة لحسابات البريد الإلكتروني/كلمة المرور الأصلية، مطبَّقة على مستوى النسخة بالكامل.", "pipelineDestinationsDesc": "الأهداف الخارجية لمخرجات مسارات المعالجة", "templatesDesc": "قوالب رسائل تنبيه قابلة لإعادة الاستخدام", "storageSettingsDesc": "تكوين التخزين لكل منظمة", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "لم تبدأ بعد عملية استيراد البيانات. لاستكشاف صفحات أخرى، يرجى بدء استيراد البيانات." + }, + "iam": { + "userSaveFailed": "تعذّر حفظ المستخدم" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "الموس" } }, + "passwordPolicy": { + "complexity": "التعقيد", + "minLength": "الحد الأدنى للطول", + "minLengthDesc": "أقصر كلمة مرور مقبولة", + "maxLength": "الحد الأقصى للطول", + "maxLengthDesc": "0 = بلا حد", + "requireUppercase": "اشتراط حرف كبير", + "requireUppercaseDesc": "حرف واحد على الأقل من A–Z", + "requireLowercase": "اشتراط حرف صغير", + "requireLowercaseDesc": "حرف واحد على الأقل من a–z", + "requireDigit": "اشتراط رقم", + "requireDigitDesc": "رقم واحد على الأقل من 0–9", + "requireSpecial": "اشتراط حرف خاص", + "requireSpecialDesc": "حرف واحد على الأقل من المجموعة المسموح بها", + "specialCharSet": "الأحرف الخاصة المسموح بها", + "specialCharSetDesc": "اتركه فارغًا لقبول أي حرف غير أبجدي رقمي", + "maxLengthTooSmall": "يجب أن يكون الحد الأقصى للطول على الأقل مساويًا للحد الأدنى، أو 0 بلا حد.", + "confirmTitle": "حفظ سياسة كلمة المرور؟", + "confirmMessage": "قد يؤدي الحفظ إلى إلزام كل مستخدم أصلي بتعيين كلمة مرور جديدة قبل أن يتمكن من المتابعة. يُستثنى حساب Root والمستخدمون الخارجيون وحسابات الخدمة — وهذا يشملك ما لم تكن حساب Root. لا يمكن التراجع عن هذا الإجراء.", + "saved": "تم حفظ سياسة كلمة المرور. | تم حفظ سياسة كلمة المرور. يجب على {count} مستخدم إعادة تعيين كلمة المرور. | تم حفظ سياسة كلمة المرور. يجب على {count} مستخدم إعادة تعيين كلمة المرور.", + "saveFailed": "تعذّر حفظ سياسة كلمة المرور.", + "notAdminTitle": "مطلوب صلاحية مسؤول", + "notAdmin": "يتطلب تكوين سياسة كلمة المرور صلاحية مسؤول في المؤسسة meta.", + "loadFailedTitle": "تعذّر تحميل سياسة كلمة المرور", + "loadFailed": "حدث خطأ أثناء قراءة السياسة الحالية.", + "rotation": "التدوير", + "rotationDays": "فترة التدوير", + "rotationDaysDesc": "عدد الأيام قبل انتهاء صلاحية كلمة المرور. 0 = لا تنتهي أبدًا", + "rotationWarningDays": "التنبيه قبل الانتهاء", + "rotationWarningDaysDesc": "عدد أيام الإشعار عند تسجيل الدخول. 0 = بلا تنبيه", + "warningDaysTooLong": "لا يمكن أن تكون فترة التنبيه أطول من فترة التدوير.", + "rotationPreview": "تنتهي صلاحية كلمة مرور معيّنة اليوم في {expires}. يبدأ تنبيه تسجيل الدخول في {warns}.", + "rotationPreviewNoWarning": "تنتهي صلاحية كلمة مرور معيّنة اليوم في {expires}. لا يظهر أي تنبيه قبل ذلك.", + "reuse": "منع إعادة الاستخدام", + "historyCount": "كلمات المرور المتذكَّرة", + "historyCountDesc": "لا يجوز أن تطابق كلمة المرور الجديدة هذا العدد من كلمات المرور السابقة. 0 = بلا فحص", + "historyMaxRetained": "السجل المحفوظ", + "historyMaxRetainedDesc": "عدد كلمات المرور السابقة المحفوظة لكل مستخدم", + "historyRetainedTooSmall": "يجب أن يكون السجل المحفوظ على الأقل مساويًا لعدد كلمات المرور المتذكَّرة.", + "reuseExplainer": "تُحتسب كلمة المرور الحالية ضمن المجموعة المتذكَّرة — رفض إعادة استخدامها يتم بواسطة هذه القاعدة، وليس بفحص منفصل.", + "lockout": "قفل الحساب", + "lockoutThreshold": "المحاولات الفاشلة قبل القفل", + "lockoutThresholdDesc": "0 = القفل معطّل", + "lockoutBucketSize": "المحاولات قبل كل قفل لاحق", + "lockoutBucketSizeDesc": "0 = إعادة استخدام القيمة أعلاه", + "lockoutStartSecs": "مدة القفل الأول", + "lockoutStartSecsDesc": "ثوانٍ", + "lockoutMaxSecs": "الحد الأقصى لمدة القفل", + "lockoutMaxSecsDesc": "ثوانٍ — الحد الأقصى الذي يتوقف عنده تصعيد المدة", + "lockoutStartTooLong": "لا يمكن أن تكون مدة القفل الأول أطول من الحد الأقصى للقفل.", + "lockoutBackoff": "التصعيد", + "lockoutBackoffDesc": "كيف تتزايد مدة كل قفل عن القفل السابق", + "backoffExponential": "أُسّي", + "backoffLinear": "خطي", + "lockoutPreview": "{threshold} محاولة فاشلة → {first} · كل {bucket} محاولة إضافية → {rest} · بحد أقصى {max}. تؤدي عملية تسجيل الدخول الناجحة إلى إعادة تعيين كل شيء.", + "sessionEnforcement": "الجلسة والإنفاذ", + "cookieMaxAge": "عمر الجلسة", + "cookieMaxAgeDesc": "عدد الثواني التي تظل فيها الجلسة المسجّلة صالحة. 0 = استخدام القيمة الافتراضية للمثيل من {envVar}", + "applyToRoot": "تطبيق على حساب الجذر", + "applyToRootDesc": "حساب الجذر معفى من كل قاعدة أعلاه ما لم يتم تشغيل هذا الخيار.", + "rootWarning": "قد يؤدي قفل حساب الجذر إلى دخول النظام في حالة يتعذر استردادها.", + "rootDialogTitle": "تطبيق السياسة على حساب الجذر؟", + "rootDialogMessage": "سيخضع حساب الجذر للقفل والتدوير وإعادة التعيين الإجبارية مثل أي حساب محلي آخر. إذا كان حساب الجذر هو حسابك الإداري الوحيد، فإن قفله أو انتهاء صلاحيته دون إمكانية تلبيتها لن يترك أي طريقة للعودة إلى هذا المثيل.", + "rootDialogAck": "أفهم أنه يمكن قفل حساب الجذر من هذا المثيل بشكل دائم.", + "rootDialogConfirm": "تطبيق على الجذر" + }, + "passwordReset": { + "title": "تحديث كلمة المرور", + "signedInAs": "مسجّل الدخول باسم {email}", + "reasonPolicyTightened": "تم تغيير سياسة كلمة المرور. حدّث كلمة المرور لمتابعة استخدام OpenObserve.", + "reasonRotationExpired": "انتهت صلاحية كلمة المرور. عيّن كلمة مرور جديدة لمتابعة استخدام OpenObserve.", + "reasonRotationWarning": "تنتهي صلاحية كلمة المرور قريبًا. عيّن كلمة مرور جديدة الآن لتجنب فقدان الوصول إلى عملك.", + "reused": "تم استخدام كلمة المرور هذه مؤخرًا. اختر كلمة مرور لم تستخدمها في آخر {count} كلمات مرور.", + "expiryBanner": "تنتهي صلاحية كلمة المرور خلال {count} يوم. حدّثها الآن لتجنب فقدان الوصول إلى عملك. | تنتهي صلاحية كلمة المرور خلال {count} أيام. حدّثها الآن لتجنب فقدان الوصول إلى عملك.", + "expiryBannerAction": "حدّث الآن", + "expiryBannerDismiss": "تجاهل", + "currentPassword": "كلمة المرور الحالية", + "newPassword": "كلمة المرور الجديدة", + "confirmPassword": "تأكيد كلمة المرور الجديدة", + "currentPasswordRequired": "كلمة المرور الحالية مطلوبة.", + "newPasswordRequired": "كلمة المرور الجديدة مطلوبة.", + "confirmPasswordRequired": "يرجى إعادة إدخال كلمة المرور الجديدة.", + "mismatch": "كلمتا المرور غير متطابقتين.", + "signOut": "تسجيل الخروج", + "submit": "تحديث كلمة المرور", + "updated": "تم تحديث كلمة المرور. يرجى تسجيل الدخول مرة أخرى.", + "updateFailed": "تعذّر تحديث كلمة المرور.", + "req": { + "minLength": "{count} أحرف على الأقل", + "maxLength": "{count} حرفًا كحد أقصى", + "uppercase": "حرف كبير واحد", + "lowercase": "حرف صغير واحد", + "digit": "رقم واحد", + "special": "حرف خاص واحد", + "specialFromSet": "حرف خاص واحد من {set}", + "unmet": "يجب أن تحتوي كلمة المرور على: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "سياسة كلمة المرور", "loginCallback": "إعادة الاتصال بتسجيل الدخول", "slackOAuthCallback": "جارٍ ربط Slack", "emptyStateDemo": "عرض الحالة الفارغة", diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index 2aa43016e1..3edf2f6b25 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Kostenlose Testversion starten" }, "login": { + "lockedOut": "Zu viele fehlgeschlagene Anmeldeversuche. Versuchen Sie es in {duration} erneut, oder wenden Sie sich an Ihren Administrator.", "redirectingToSsoMessage": "Weiterleitung zur SSO-Anmeldeseite. Wenn Sie nicht weitergeleitet werden, bitte", "clickHere": "klicken Sie hier", "loginWithSso": "Mit SSO anmelden", @@ -1965,6 +1966,15 @@ "fieldRequired": "Feld ist erforderlich", "passwordRequired": "Passwort ist erforderlich.", "passwordPolicyHint": "Das Passwort muss aus 8 bis 128 Zeichen bestehen und mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten.", + "lockout": { + "locked": "Konto gesperrt", + "autoUnlock": "Wird automatisch in {duration} entsperrt.", + "staged": "Wird beim Speichern entsperrt.", + "unlock": "Entsperren", + "undo": "Gesperrt lassen", + "confirmTitle": "{email} entsperren?", + "confirmMessage": "Dies setzt den Zähler der fehlgeschlagenen Versuche zurück und ermöglicht die sofortige erneute Anmeldung. Das Passwort wird dabei nicht geändert." + }, "currentPasswordRequired": "Das aktuelle Passwort ist erforderlich.", "newPasswordRequired": "Ein neues Passwort ist erforderlich.", "otherOrgInvalid": "Der Name der Organisation muss mit einem Buchstaben beginnen und alphanumerisch, _ oder -, sein.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Verschlüsselungsschlüssel für sensible Felder", "regexPatternsDesc": "Wiederverwendbare regex-Muster zur Schwärzung", "domainManagementDesc": "Anmeldung auf zulässige SSO-Domänen beschränken", + "passwordPolicy": "Passwortrichtlinie", + "passwordPolicyDesc": "Authentifizierungsrichtlinien für native E-Mail-/Passwort-Konten, instanzweit angewendet.", "pipelineDestinationsDesc": "Externe Ziele für die Pipeline-Ausgabe", "templatesDesc": "Wiederverwendbare Vorlagen für Alarmmeldungen", "storageSettingsDesc": "Speicherkonfiguration pro Organisation", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Sie haben den Datenaufnahmeprozess noch nicht gestartet. Um andere Seiten zu erkunden, starten Sie bitte die Datenaufnahme." + }, + "iam": { + "userSaveFailed": "Der Benutzer konnte nicht gespeichert werden" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Exponent" } }, + "passwordPolicy": { + "complexity": "Komplexität", + "minLength": "Minimale Länge", + "minLengthDesc": "Kürzestes akzeptiertes Passwort", + "maxLength": "Maximale Länge", + "maxLengthDesc": "0 = unbegrenzt", + "requireUppercase": "Großbuchstabe erforderlich", + "requireUppercaseDesc": "Mindestens ein A–Z", + "requireLowercase": "Kleinbuchstabe erforderlich", + "requireLowercaseDesc": "Mindestens ein a–z", + "requireDigit": "Ziffer erforderlich", + "requireDigitDesc": "Mindestens eine 0–9", + "requireSpecial": "Sonderzeichen erforderlich", + "requireSpecialDesc": "Mindestens eines aus der erlaubten Menge", + "specialCharSet": "Erlaubte Sonderzeichen", + "specialCharSetDesc": "Leer lassen, um beliebige nicht-alphanumerische Zeichen zu akzeptieren", + "maxLengthTooSmall": "Die maximale Länge muss mindestens der minimalen Länge entsprechen, oder 0 für unbegrenzt.", + "confirmTitle": "Passwortrichtlinie speichern?", + "confirmMessage": "Beim Speichern müssen möglicherweise alle nativen Benutzer ein neues Passwort festlegen, bevor sie fortfahren können. Root, externe Benutzer und Dienstkonten sind ausgenommen — das gilt auch für Sie, es sei denn, Sie sind das Root-Konto. Dies kann nicht rückgängig gemacht werden.", + "saved": "Passwortrichtlinie gespeichert. | Passwortrichtlinie gespeichert. {count} Benutzer muss sein Passwort zurücksetzen. | Passwortrichtlinie gespeichert. {count} Benutzer müssen ihr Passwort zurücksetzen.", + "saveFailed": "Die Passwortrichtlinie konnte nicht gespeichert werden.", + "notAdminTitle": "Administratorzugriff erforderlich", + "notAdmin": "Die Konfiguration der Passwortrichtlinie erfordert einen Administrator der Meta-Organisation.", + "loadFailedTitle": "Die Passwortrichtlinie konnte nicht geladen werden", + "loadFailed": "Beim Lesen der aktuellen Richtlinie ist ein Fehler aufgetreten.", + "rotation": "Rotation", + "rotationDays": "Rotationszeitraum", + "rotationDaysDesc": "Tage, bevor ein Passwort abläuft. 0 = läuft nie ab", + "rotationWarningDays": "Vor Ablauf warnen", + "rotationWarningDaysDesc": "Tage Vorlaufzeit bei der Anmeldung. 0 = keine Warnung", + "warningDaysTooLong": "Der Warnzeitraum darf nicht länger als der Rotationszeitraum sein.", + "rotationPreview": "Ein heute festgelegtes Passwort läuft am {expires} ab. Die Anmeldewarnung beginnt am {warns}.", + "rotationPreviewNoWarning": "Ein heute festgelegtes Passwort läuft am {expires} ab. Vorher wird keine Warnung angezeigt.", + "reuse": "Wiederverwendung verhindern", + "historyCount": "Gemerkte Passwörter", + "historyCountDesc": "Ein neues Passwort darf nicht mit so vielen vorherigen übereinstimmen. 0 = keine Prüfung", + "historyMaxRetained": "Verlauf beibehalten", + "historyMaxRetainedDesc": "Wie viele frühere Passwörter pro Benutzer gespeichert werden", + "historyRetainedTooSmall": "Der beibehaltene Verlauf muss mindestens der Anzahl der gemerkten Passwörter entsprechen.", + "reuseExplainer": "Das aktuelle Passwort zählt zur gemerkten Menge — seine Wiederverwendung wird durch diese Regel abgelehnt, nicht durch eine separate Prüfung.", + "lockout": "Kontosperrung", + "lockoutThreshold": "Fehlgeschlagene Versuche vor der Sperrung", + "lockoutThresholdDesc": "0 = Sperrung deaktiviert", + "lockoutBucketSize": "Versuche vor jeder weiteren Sperrung", + "lockoutBucketSizeDesc": "0 = Wert oben wiederverwenden", + "lockoutStartSecs": "Dauer der ersten Sperrung", + "lockoutStartSecsDesc": "Sekunden", + "lockoutMaxSecs": "Maximale Sperrdauer", + "lockoutMaxSecsDesc": "Sekunden — die Obergrenze, auf die die Eskalation begrenzt wird", + "lockoutStartTooLong": "Die erste Sperrung darf nicht länger als die maximale Sperrung sein.", + "lockoutBackoff": "Eskalation", + "lockoutBackoffDesc": "Wie jede Sperrung gegenüber der vorherigen anwächst", + "backoffExponential": "Exponentiell", + "backoffLinear": "Linear", + "lockoutPreview": "{threshold} Fehlversuche → {first} · alle {bucket} weiteren → {rest} · begrenzt auf {max}. Eine erfolgreiche Anmeldung setzt alles zurück.", + "sessionEnforcement": "Sitzung & Durchsetzung", + "cookieMaxAge": "Sitzungsdauer", + "cookieMaxAgeDesc": "Sekunden, die eine angemeldete Sitzung gültig bleibt. 0 = Instanz-Standard aus {envVar} verwenden", + "applyToRoot": "Auf das Root-Konto anwenden", + "applyToRootDesc": "Root ist von allen oben genannten Regeln ausgenommen, sofern dies nicht aktiviert ist", + "rootWarning": "Eine Sperrung von Root kann das System in einen nicht wiederherstellbaren Zustand versetzen.", + "rootDialogTitle": "Richtlinie auf Root anwenden?", + "rootDialogMessage": "Root unterliegt dann wie jedes andere native Konto Sperrungen, Rotation und erzwungenen Zurücksetzungen. Wenn Root Ihr einziges Administratorkonto ist, kann eine Sperrung oder ein Ablauf, den es nicht erfüllen kann, jeden Zugang zu dieser Instanz versperren.", + "rootDialogAck": "Mir ist bewusst, dass Root dauerhaft aus dieser Instanz ausgesperrt werden kann.", + "rootDialogConfirm": "Auf Root anwenden" + }, + "passwordReset": { + "title": "Aktualisieren Sie Ihr Passwort", + "signedInAs": "Angemeldet als {email}", + "reasonPolicyTightened": "Die Passwortrichtlinie wurde geändert. Aktualisieren Sie Ihr Passwort, um OpenObserve weiter zu verwenden.", + "reasonRotationExpired": "Ihr Passwort ist abgelaufen. Legen Sie ein neues fest, um OpenObserve weiter zu verwenden.", + "reasonRotationWarning": "Ihr Passwort läuft bald ab. Legen Sie jetzt ein neues fest, um nicht von Ihrer Arbeit ausgesperrt zu werden.", + "reused": "Dieses Passwort wurde kürzlich verwendet. Wählen Sie eines, das Sie nicht unter Ihren letzten {count} Passwörtern verwendet haben.", + "expiryBanner": "Ihr Passwort läuft in {count} Tag ab. Aktualisieren Sie es jetzt, um nicht von Ihrer Arbeit ausgesperrt zu werden. | Ihr Passwort läuft in {count} Tagen ab. Aktualisieren Sie es jetzt, um nicht von Ihrer Arbeit ausgesperrt zu werden.", + "expiryBannerAction": "Jetzt aktualisieren", + "expiryBannerDismiss": "Ausblenden", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "confirmPassword": "Neues Passwort bestätigen", + "currentPasswordRequired": "Aktuelles Passwort ist erforderlich.", + "newPasswordRequired": "Neues Passwort ist erforderlich.", + "confirmPasswordRequired": "Bitte geben Sie das neue Passwort erneut ein.", + "mismatch": "Passwörter stimmen nicht überein.", + "signOut": "Abmelden", + "submit": "Passwort aktualisieren", + "updated": "Ihr Passwort wurde aktualisiert. Bitte melden Sie sich erneut an.", + "updateFailed": "Ihr Passwort konnte nicht aktualisiert werden.", + "req": { + "minLength": "Mindestens {count} Zeichen", + "maxLength": "Höchstens {count} Zeichen", + "uppercase": "Ein Großbuchstabe", + "lowercase": "Ein Kleinbuchstabe", + "digit": "Eine Ziffer", + "special": "Ein Sonderzeichen", + "specialFromSet": "Ein Sonderzeichen aus {set}", + "unmet": "Das Passwort muss enthalten: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Passwortrichtlinie", "loginCallback": "Anmelde-Rückruf", "slackOAuthCallback": "Slack wird verbunden", "emptyStateDemo": "Demo für leeren Zustand", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index c7676f9044..00a64d92a2 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Start free trial" }, "login": { + "lockedOut": "Too many failed sign-in attempts. Try again in {duration}, or contact your administrator.", "redirectingToSsoMessage": "Redirecting to SSO login page. If you are not redirected, please", "clickHere": "click here", "loginWithSso": "Login with SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Field is required", "passwordRequired": "Password is required.", "passwordPolicyHint": "Password must be 8-128 characters and contain at least one lowercase letter, one uppercase letter, one digit, and one special character.", + "lockout": { + "locked": "Account locked", + "autoUnlock": "Unlocks automatically in {duration}.", + "staged": "Will be unlocked when you save.", + "unlock": "Unlock", + "undo": "Keep locked", + "confirmTitle": "Unlock {email}?", + "confirmMessage": "This clears their failed attempt count and lets them sign in again immediately. It does not change their password." + }, "currentPasswordRequired": "Current password is required.", "newPasswordRequired": "New password is required.", "otherOrgInvalid": "Organization name must start with a letter and be alphanumeric, _ or -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Encryption keys for sensitive fields", "regexPatternsDesc": "Reusable regex patterns for redaction", "domainManagementDesc": "Restrict sign-in to allowed SSO domains", + "passwordPolicy": "Password Policy", + "passwordPolicyDesc": "Authentication policies for native email/password accounts, applied instance-wide.", "pipelineDestinationsDesc": "External targets for pipeline output", "templatesDesc": "Reusable alert message templates", "storageSettingsDesc": "Per-organization storage configuration", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "You haven't initiated the data ingestion process yet. To explore other pages, please start the data ingestion." + }, + "iam": { + "userSaveFailed": "Could not save the user" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Exponent" } }, + "passwordPolicy": { + "complexity": "Complexity", + "minLength": "Minimum length", + "minLengthDesc": "Shortest password accepted", + "maxLength": "Maximum length", + "maxLengthDesc": "0 = unbounded", + "requireUppercase": "Require uppercase letter", + "requireUppercaseDesc": "At least one A–Z", + "requireLowercase": "Require lowercase letter", + "requireLowercaseDesc": "At least one a–z", + "requireDigit": "Require digit", + "requireDigitDesc": "At least one 0–9", + "requireSpecial": "Require special character", + "requireSpecialDesc": "At least one from the allowed set", + "specialCharSet": "Allowed special characters", + "specialCharSetDesc": "Leave empty to accept any non-alphanumeric character", + "maxLengthTooSmall": "Maximum length must be at least the minimum length, or 0 for unbounded.", + "confirmTitle": "Save password policy?", + "confirmMessage": "Saving may force every native user to set a new password before they can continue. Root, external users and service accounts are exempt — this includes you unless you are the root account. It cannot be undone.", + "saved": "Password policy saved. | Password policy saved. {count} user must reset their password. | Password policy saved. {count} users must reset their password.", + "saveFailed": "Could not save the password policy.", + "notAdminTitle": "Administrator access required", + "notAdmin": "Configuring the password policy requires an administrator of the meta organization.", + "loadFailedTitle": "Could not load the password policy", + "loadFailed": "Something went wrong while reading the current policy.", + "rotation": "Rotation", + "rotationDays": "Rotation period", + "rotationDaysDesc": "Days before a password expires. 0 = never expires", + "rotationWarningDays": "Warn before expiry", + "rotationWarningDaysDesc": "Days of notice at sign-in. 0 = no warning", + "warningDaysTooLong": "The warning window cannot be longer than the rotation period.", + "rotationPreview": "A password set today expires {expires}. The sign-in warning starts {warns}.", + "rotationPreviewNoWarning": "A password set today expires {expires}. No warning is shown before then.", + "reuse": "Reuse prevention", + "historyCount": "Passwords remembered", + "historyCountDesc": "A new password may not match this many previous ones. 0 = no check", + "historyMaxRetained": "History retained", + "historyMaxRetainedDesc": "How many past passwords are kept per user", + "historyRetainedTooSmall": "History retained must be at least the number of passwords remembered.", + "reuseExplainer": "The current password counts as one of the remembered set — reusing it is refused by this rule, not by a separate check.", + "lockout": "Account lockout", + "lockoutThreshold": "Failed attempts before lockout", + "lockoutThresholdDesc": "0 = lockout disabled", + "lockoutBucketSize": "Attempts before each further lockout", + "lockoutBucketSizeDesc": "0 = reuse the value above", + "lockoutStartSecs": "First lockout duration", + "lockoutStartSecsDesc": "Seconds", + "lockoutMaxSecs": "Maximum lockout duration", + "lockoutMaxSecsDesc": "Seconds — the ceiling escalation clamps to", + "lockoutStartTooLong": "The first lockout cannot be longer than the maximum lockout.", + "lockoutBackoff": "Escalation", + "lockoutBackoffDesc": "How each lockout grows from the one before", + "backoffExponential": "Exponential", + "backoffLinear": "Linear", + "lockoutPreview": "{threshold} failures → {first} · every {bucket} more → {rest} · capped at {max}. A successful sign-in resets everything.", + "sessionEnforcement": "Session & enforcement", + "cookieMaxAge": "Session lifetime", + "cookieMaxAgeDesc": "Seconds a signed-in session stays valid. 0 = use the instance default from {envVar}", + "applyToRoot": "Apply to the root account", + "applyToRootDesc": "Root is exempt from every rule above unless this is on", + "rootWarning": "Locking root might cause the system to go into an unrecoverable state.", + "rootDialogTitle": "Apply the policy to root?", + "rootDialogMessage": "Root will be subject to lockout, rotation and forced resets like any other native account. If root is your only administrative account, a lockout or an expiry it cannot satisfy will leave no way back into this instance.", + "rootDialogAck": "I understand root can be locked out of this instance permanently.", + "rootDialogConfirm": "Apply to root" + }, + "passwordReset": { + "title": "Update your password", + "signedInAs": "Signed in as {email}", + "reasonPolicyTightened": "The password policy has changed. Update your password to continue using OpenObserve.", + "reasonRotationExpired": "Your password has expired. Set a new one to continue using OpenObserve.", + "reasonRotationWarning": "Your password expires soon. Set a new one now to avoid being locked out of your work.", + "reused": "This password was used recently. Choose one you have not used in your last {count} passwords.", + "expiryBanner": "Your password expires in {count} day. Update it now to avoid being locked out of your work. | Your password expires in {count} days. Update it now to avoid being locked out of your work.", + "expiryBannerAction": "Update now", + "expiryBannerDismiss": "Dismiss", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "currentPasswordRequired": "Current password is required.", + "newPasswordRequired": "New password is required.", + "confirmPasswordRequired": "Please re-enter the new password.", + "mismatch": "Passwords do not match.", + "signOut": "Sign out", + "submit": "Update password", + "updated": "Your password has been updated. Please sign in again.", + "updateFailed": "Could not update your password.", + "req": { + "minLength": "At least {count} characters", + "maxLength": "No more than {count} characters", + "uppercase": "One uppercase letter", + "lowercase": "One lowercase letter", + "digit": "One digit", + "special": "One special character", + "specialFromSet": "One special character from {set}", + "unmet": "Password must have: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Password Policy", "loginCallback": "Login Callback", "slackOAuthCallback": "Connecting Slack", "emptyStateDemo": "Empty State Demo", diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index c9e667af42..9ccba2857c 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Iniciar prueba gratuita" }, "login": { + "lockedOut": "Demasiados intentos fallidos de inicio de sesión. Vuelva a intentarlo en {duration} o póngase en contacto con su administrador.", "redirectingToSsoMessage": "Redirigiendo a la página de inicio de sesión SSO. Si no eres redirigido, por favor", "clickHere": "haz clic aquí", "loginWithSso": "Iniciar sesión con SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "El campo es obligatorio", "passwordRequired": "La contraseña es obligatoria.", "passwordPolicyHint": "La contraseña debe tener entre 8 y 128 caracteres y contener al menos una letra minúscula, una mayúscula, un dígito y un carácter especial.", + "lockout": { + "locked": "Cuenta bloqueada", + "autoUnlock": "Se desbloqueará automáticamente en {duration}.", + "staged": "Se desbloqueará al guardar.", + "unlock": "Desbloquear", + "undo": "Mantener bloqueada", + "confirmTitle": "¿Desbloquear {email}?", + "confirmMessage": "Esto restablece su recuento de intentos fallidos y le permite iniciar sesión de nuevo inmediatamente. No cambia su contraseña." + }, "currentPasswordRequired": "Se requiere la contraseña actual.", "newPasswordRequired": "Se requiere una nueva contraseña.", "otherOrgInvalid": "El nombre de la organización debe empezar por una letra y ser alfanumérico, _ o -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Claves de cifrado para campos sensibles", "regexPatternsDesc": "Patrones regex reutilizables para redacción", "domainManagementDesc": "Restringe el inicio de sesión a los dominios SSO permitidos", + "passwordPolicy": "Política de contraseñas", + "passwordPolicyDesc": "Políticas de autenticación para cuentas nativas de correo electrónico/contraseña, aplicadas a toda la instancia.", "pipelineDestinationsDesc": "Destinos externos para la salida del pipeline", "templatesDesc": "Plantillas de mensajes de alerta reutilizables", "storageSettingsDesc": "Configuración de almacenamiento por organización", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Aún no ha iniciado el proceso de ingesta de datos. Para explorar otras páginas, inicie la ingesta de datos." + }, + "iam": { + "userSaveFailed": "No se pudo guardar el usuario" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Exponente" } }, + "passwordPolicy": { + "complexity": "Complejidad", + "minLength": "Longitud mínima", + "minLengthDesc": "La contraseña más corta aceptada", + "maxLength": "Longitud máxima", + "maxLengthDesc": "0 = sin límite", + "requireUppercase": "Requerir letra mayúscula", + "requireUppercaseDesc": "Al menos una A–Z", + "requireLowercase": "Requerir letra minúscula", + "requireLowercaseDesc": "Al menos una a–z", + "requireDigit": "Requerir dígito", + "requireDigitDesc": "Al menos un 0–9", + "requireSpecial": "Requerir carácter especial", + "requireSpecialDesc": "Al menos uno del conjunto permitido", + "specialCharSet": "Caracteres especiales permitidos", + "specialCharSetDesc": "Déjelo vacío para aceptar cualquier carácter no alfanumérico", + "maxLengthTooSmall": "La longitud máxima debe ser al menos la longitud mínima, o 0 para sin límite.", + "confirmTitle": "¿Guardar la política de contraseñas?", + "confirmMessage": "Guardar puede obligar a todos los usuarios nativos a establecer una nueva contraseña antes de poder continuar. El usuario root, los usuarios externos y las cuentas de servicio están exentos; esto le incluye a usted a menos que sea la cuenta root. No se puede deshacer.", + "saved": "Política de contraseñas guardada. | Política de contraseñas guardada. {count} usuario debe restablecer su contraseña. | Política de contraseñas guardada. {count} usuarios deben restablecer su contraseña.", + "saveFailed": "No se pudo guardar la política de contraseñas.", + "notAdminTitle": "Se requiere acceso de administrador", + "notAdmin": "Configurar la política de contraseñas requiere un administrador de la organización meta.", + "loadFailedTitle": "No se pudo cargar la política de contraseñas", + "loadFailed": "Algo salió mal al leer la política actual.", + "rotation": "Rotación", + "rotationDays": "Periodo de rotación", + "rotationDaysDesc": "Días antes de que caduque una contraseña. 0 = nunca caduca", + "rotationWarningDays": "Avisar antes de la caducidad", + "rotationWarningDaysDesc": "Días de aviso al iniciar sesión. 0 = sin aviso", + "warningDaysTooLong": "El periodo de aviso no puede ser más largo que el periodo de rotación.", + "rotationPreview": "Una contraseña establecida hoy caduca el {expires}. El aviso de inicio de sesión comienza el {warns}.", + "rotationPreviewNoWarning": "Una contraseña establecida hoy caduca el {expires}. No se muestra ningún aviso antes de esa fecha.", + "reuse": "Prevención de reutilización", + "historyCount": "Contraseñas recordadas", + "historyCountDesc": "Una nueva contraseña no puede coincidir con esta cantidad de contraseñas anteriores. 0 = sin comprobación", + "historyMaxRetained": "Historial conservado", + "historyMaxRetainedDesc": "Cuántas contraseñas anteriores se conservan por usuario", + "historyRetainedTooSmall": "El historial conservado debe ser al menos el número de contraseñas recordadas.", + "reuseExplainer": "La contraseña actual cuenta como una de las del conjunto recordado; esta regla rechaza reutilizarla, no una comprobación aparte.", + "lockout": "Bloqueo de cuenta", + "lockoutThreshold": "Intentos fallidos antes del bloqueo", + "lockoutThresholdDesc": "0 = bloqueo deshabilitado", + "lockoutBucketSize": "Intentos antes de cada bloqueo adicional", + "lockoutBucketSizeDesc": "0 = reutilizar el valor anterior", + "lockoutStartSecs": "Duración del primer bloqueo", + "lockoutStartSecsDesc": "Segundos", + "lockoutMaxSecs": "Duración máxima del bloqueo", + "lockoutMaxSecsDesc": "Segundos: el tope al que se limita el escalado", + "lockoutStartTooLong": "El primer bloqueo no puede ser más largo que el bloqueo máximo.", + "lockoutBackoff": "Escalado", + "lockoutBackoffDesc": "Cómo crece cada bloqueo respecto al anterior", + "backoffExponential": "Exponencial", + "backoffLinear": "Lineal", + "lockoutPreview": "{threshold} fallos → {first} · cada {bucket} más → {rest} · con tope de {max}. Un inicio de sesión exitoso restablece todo.", + "sessionEnforcement": "Sesión y aplicación", + "cookieMaxAge": "Duración de la sesión", + "cookieMaxAgeDesc": "Segundos que una sesión iniciada permanece válida. 0 = usar el valor predeterminado de la instancia especificado en {envVar}", + "applyToRoot": "Aplicar a la cuenta root", + "applyToRootDesc": "Root está exento de todas las reglas anteriores a menos que esto esté activado", + "rootWarning": "Bloquear root podría hacer que el sistema entre en un estado irrecuperable.", + "rootDialogTitle": "¿Aplicar la política a root?", + "rootDialogMessage": "Root quedará sujeto a bloqueo, rotación y restablecimientos forzados como cualquier otra cuenta nativa. Si root es tu única cuenta administrativa, un bloqueo o una expiración que no pueda cumplir no dejará forma de volver a entrar en esta instancia.", + "rootDialogAck": "Entiendo que root puede quedar bloqueado permanentemente sin acceso a esta instancia.", + "rootDialogConfirm": "Aplicar a root" + }, + "passwordReset": { + "title": "Actualiza tu contraseña", + "signedInAs": "Sesión iniciada como {email}", + "reasonPolicyTightened": "La política de contraseñas ha cambiado. Actualiza tu contraseña para seguir usando OpenObserve.", + "reasonRotationExpired": "Tu contraseña ha expirado. Establece una nueva para seguir usando OpenObserve.", + "reasonRotationWarning": "Tu contraseña expira pronto. Establece una nueva ahora para evitar que te bloqueen el acceso a tu trabajo.", + "reused": "Esta contraseña se usó recientemente. Elige una que no hayas usado en tus últimas {count} contraseñas.", + "expiryBanner": "Tu contraseña expira en {count} día. Actualízala ahora para evitar que te bloqueen el acceso a tu trabajo. | Tu contraseña expira en {count} días. Actualízala ahora para evitar que te bloqueen el acceso a tu trabajo.", + "expiryBannerAction": "Actualizar ahora", + "expiryBannerDismiss": "Descartar", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "confirmPassword": "Confirmar nueva contraseña", + "currentPasswordRequired": "La contraseña actual es obligatoria.", + "newPasswordRequired": "La nueva contraseña es obligatoria.", + "confirmPasswordRequired": "Vuelve a introducir la nueva contraseña.", + "mismatch": "Las contraseñas no coinciden.", + "signOut": "Cerrar sesión", + "submit": "Actualizar contraseña", + "updated": "Tu contraseña se ha actualizado. Inicia sesión de nuevo.", + "updateFailed": "No se pudo actualizar tu contraseña.", + "req": { + "minLength": "Al menos {count} caracteres", + "maxLength": "Como máximo {count} caracteres", + "uppercase": "Una letra mayúscula", + "lowercase": "Una letra minúscula", + "digit": "Un dígito", + "special": "Un carácter especial", + "specialFromSet": "Un carácter especial de {set}", + "unmet": "La contraseña debe tener: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Política de contraseñas", "loginCallback": "Devolución de llamada de inicio de sesión", "slackOAuthCallback": "Conectando con Slack", "emptyStateDemo": "Demostración de estado vacío", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index 94ca1fbc76..73bdc7297c 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Commencer l'essai gratuit" }, "login": { + "lockedOut": "Trop de tentatives de connexion échouées. Réessayez dans {duration} ou contactez votre administrateur.", "redirectingToSsoMessage": "Redirection vers la page de connexion SSO. Si vous n'êtes pas redirigé, veuillez", "clickHere": "cliquez ici", "loginWithSso": "Se connecter avec SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Ce champ est obligatoire", "passwordRequired": "Le mot de passe est obligatoire.", "passwordPolicyHint": "Le mot de passe doit être composé de 8 à 128 caractères et contenir au moins une lettre minuscule, une lettre majuscule, un chiffre et un caractère spécial.", + "lockout": { + "locked": "Compte verrouillé", + "autoUnlock": "Déverrouillage automatique dans {duration}.", + "staged": "Sera déverrouillé lorsque vous enregistrerez.", + "unlock": "Déverrouiller", + "undo": "Garder verrouillé", + "confirmTitle": "Déverrouiller {email} ?", + "confirmMessage": "Cela réinitialise leur nombre de tentatives échouées et leur permet de se reconnecter immédiatement. Cela ne modifie pas leur mot de passe." + }, "currentPasswordRequired": "Le mot de passe actuel est requis.", "newPasswordRequired": "Un nouveau mot de passe est requis.", "otherOrgInvalid": "Le nom de l'organisation doit commencer par une lettre et être alphanumérique, _ ou -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Clés de chiffrement pour les champs sensibles", "regexPatternsDesc": "Modèles regex réutilisables pour le masquage", "domainManagementDesc": "Restreindre la connexion aux domaines SSO autorisés", + "passwordPolicy": "Politique de mots de passe", + "passwordPolicyDesc": "Politiques d'authentification pour les comptes natifs e-mail/mot de passe, appliquées à l'ensemble de l'instance.", "pipelineDestinationsDesc": "Cibles externes pour la sortie du pipeline", "templatesDesc": "Modèles de messages d'alerte réutilisables", "storageSettingsDesc": "Configuration du stockage par organisation", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Vous n'avez pas encore initié le processus d'ingestion des données. Pour explorer d'autres pages, veuillez démarrer l'ingestion des données." + }, + "iam": { + "userSaveFailed": "Impossible d'enregistrer l'utilisateur" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Exposant" } }, + "passwordPolicy": { + "complexity": "Complexité", + "minLength": "Longueur minimale", + "minLengthDesc": "Mot de passe le plus court accepté", + "maxLength": "Longueur maximale", + "maxLengthDesc": "0 = sans limite", + "requireUppercase": "Exiger une lettre majuscule", + "requireUppercaseDesc": "Au moins un A–Z", + "requireLowercase": "Exiger une lettre minuscule", + "requireLowercaseDesc": "Au moins un a–z", + "requireDigit": "Exiger un chiffre", + "requireDigitDesc": "Au moins un 0–9", + "requireSpecial": "Exiger un caractère spécial", + "requireSpecialDesc": "Au moins un caractère de l'ensemble autorisé", + "specialCharSet": "Caractères spéciaux autorisés", + "specialCharSetDesc": "Laisser vide pour accepter tout caractère non alphanumérique", + "maxLengthTooSmall": "La longueur maximale doit être au moins égale à la longueur minimale, ou 0 pour aucune limite.", + "confirmTitle": "Enregistrer la politique de mots de passe ?", + "confirmMessage": "L'enregistrement peut forcer chaque utilisateur natif à définir un nouveau mot de passe avant de pouvoir continuer. Les comptes root, les utilisateurs externes et les comptes de service en sont exemptés — y compris vous, sauf si vous êtes le compte root. Cette action est irréversible.", + "saved": "Politique de mots de passe enregistrée. | Politique de mots de passe enregistrée. {count} utilisateur doit réinitialiser son mot de passe. | Politique de mots de passe enregistrée. {count} utilisateurs doivent réinitialiser leur mot de passe.", + "saveFailed": "Impossible d'enregistrer la politique de mots de passe.", + "notAdminTitle": "Accès administrateur requis", + "notAdmin": "La configuration de la politique de mots de passe nécessite un administrateur de l'organisation meta.", + "loadFailedTitle": "Impossible de charger la politique de mots de passe", + "loadFailed": "Une erreur s'est produite lors de la lecture de la politique actuelle.", + "rotation": "Rotation", + "rotationDays": "Période de rotation", + "rotationDaysDesc": "Nombre de jours avant l'expiration d'un mot de passe. 0 = n'expire jamais", + "rotationWarningDays": "Avertir avant l'expiration", + "rotationWarningDaysDesc": "Nombre de jours de préavis à la connexion. 0 = aucun avertissement", + "warningDaysTooLong": "La fenêtre d'avertissement ne peut pas être plus longue que la période de rotation.", + "rotationPreview": "Un mot de passe défini aujourd'hui expire {expires}. L'avertissement à la connexion commence {warns}.", + "rotationPreviewNoWarning": "Un mot de passe défini aujourd'hui expire {expires}. Aucun avertissement n'est affiché avant cette date.", + "reuse": "Prévention de la réutilisation", + "historyCount": "Mots de passe mémorisés", + "historyCountDesc": "Un nouveau mot de passe ne peut pas correspondre à ce nombre de mots de passe précédents. 0 = aucune vérification", + "historyMaxRetained": "Historique conservé", + "historyMaxRetainedDesc": "Nombre de mots de passe passés conservés par utilisateur", + "historyRetainedTooSmall": "L'historique conservé doit être au moins égal au nombre de mots de passe mémorisés.", + "reuseExplainer": "Le mot de passe actuel compte parmi l'ensemble mémorisé — sa réutilisation est refusée par cette règle, et non par une vérification distincte.", + "lockout": "Verrouillage du compte", + "lockoutThreshold": "Tentatives échouées avant verrouillage", + "lockoutThresholdDesc": "0 = verrouillage désactivé", + "lockoutBucketSize": "Tentatives avant chaque verrouillage supplémentaire", + "lockoutBucketSizeDesc": "0 = réutiliser la valeur ci-dessus", + "lockoutStartSecs": "Durée du premier verrouillage", + "lockoutStartSecsDesc": "Secondes", + "lockoutMaxSecs": "Durée maximale du verrouillage", + "lockoutMaxSecsDesc": "Secondes — le plafond auquel l'escalade est limitée", + "lockoutStartTooLong": "Le premier verrouillage ne peut pas être plus long que le verrouillage maximal.", + "lockoutBackoff": "Escalade", + "lockoutBackoffDesc": "Comment chaque verrouillage augmente par rapport au précédent", + "backoffExponential": "Exponentielle", + "backoffLinear": "Linéaire", + "lockoutPreview": "{threshold} échecs → {first} · puis tous les {bucket} → {rest} · plafonné à {max}. Une connexion réussie réinitialise tout.", + "sessionEnforcement": "Session et application", + "cookieMaxAge": "Durée de vie de la session", + "cookieMaxAgeDesc": "Durée en secondes pendant laquelle une session connectée reste valide. 0 = utiliser la valeur par défaut de l'instance définie par {envVar}", + "applyToRoot": "Appliquer au compte root", + "applyToRootDesc": "Root est exempté de toutes les règles ci-dessus, sauf si cette option est activée", + "rootWarning": "Verrouiller root pourrait placer le système dans un état irrécupérable.", + "rootDialogTitle": "Appliquer la politique à root ?", + "rootDialogMessage": "Root sera soumis au verrouillage, à la rotation et aux réinitialisations forcées comme tout autre compte natif. Si root est votre seul compte d'administration, un verrouillage ou une expiration qu'il ne peut satisfaire ne laissera aucun moyen de revenir dans cette instance.", + "rootDialogAck": "Je comprends que root peut être définitivement exclu de cette instance.", + "rootDialogConfirm": "Appliquer à root" + }, + "passwordReset": { + "title": "Mettez à jour votre mot de passe", + "signedInAs": "Connecté en tant que {email}", + "reasonPolicyTightened": "La politique de mots de passe a changé. Mettez à jour votre mot de passe pour continuer à utiliser OpenObserve.", + "reasonRotationExpired": "Votre mot de passe a expiré. Définissez-en un nouveau pour continuer à utiliser OpenObserve.", + "reasonRotationWarning": "Votre mot de passe expire bientôt. Définissez-en un nouveau dès maintenant pour éviter d'être bloqué dans votre travail.", + "reused": "Ce mot de passe a été utilisé récemment. Choisissez-en un que vous n'avez pas utilisé parmi vos {count} derniers mots de passe.", + "expiryBanner": "Votre mot de passe expire dans {count} jour. Mettez-le à jour maintenant pour éviter d'être bloqué dans votre travail. | Votre mot de passe expire dans {count} jours. Mettez-le à jour maintenant pour éviter d'être bloqué dans votre travail.", + "expiryBannerAction": "Mettre à jour maintenant", + "expiryBannerDismiss": "Ignorer", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "confirmPassword": "Confirmer le nouveau mot de passe", + "currentPasswordRequired": "Le mot de passe actuel est obligatoire.", + "newPasswordRequired": "Le nouveau mot de passe est obligatoire.", + "confirmPasswordRequired": "Veuillez saisir à nouveau le nouveau mot de passe.", + "mismatch": "Les mots de passe ne correspondent pas.", + "signOut": "Déconnectez-vous", + "submit": "Mettre à jour le mot de passe", + "updated": "Votre mot de passe a été mis à jour. Veuillez vous reconnecter.", + "updateFailed": "Impossible de mettre à jour votre mot de passe.", + "req": { + "minLength": "Au moins {count} caractères", + "maxLength": "Pas plus de {count} caractères", + "uppercase": "Une lettre majuscule", + "lowercase": "Une lettre minuscule", + "digit": "Un chiffre", + "special": "Un caractère spécial", + "specialFromSet": "Un caractère spécial parmi {set}", + "unmet": "Le mot de passe doit contenir : {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Politique de mot de passe", "loginCallback": "Rappel de connexion", "slackOAuthCallback": "Connexion à Slack", "emptyStateDemo": "Démo d'état vide", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 283bd902fd..03615445e3 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Inizia la prova gratuita" }, "login": { + "lockedOut": "Troppi tentativi di accesso non riusciti. Riprova tra {duration} o contatta l'amministratore.", "redirectingToSsoMessage": "Reindirizzamento alla pagina di accesso SSO. Se non vieni reindirizzato, per favore", "clickHere": "clicca qui", "loginWithSso": "Accedi con SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Il campo è obbligatorio", "passwordRequired": "La password è obbligatoria.", "passwordPolicyHint": "La password deve contenere 8-128 caratteri e contenere almeno una lettera minuscola, una lettera maiuscola, una cifra e un carattere speciale.", + "lockout": { + "locked": "Account bloccato", + "autoUnlock": "Si sblocca automaticamente tra {duration}.", + "staged": "Verrà sbloccato quando salvi.", + "unlock": "Sblocca", + "undo": "Mantieni bloccato", + "confirmTitle": "Sbloccare {email}?", + "confirmMessage": "Questa operazione azzera il conteggio dei tentativi non riusciti e consente all'utente di accedere di nuovo immediatamente. Non modifica la password." + }, "currentPasswordRequired": "È richiesta la password attuale.", "newPasswordRequired": "È richiesta una nuova password.", "otherOrgInvalid": "Il nome dell'organizzazione deve iniziare con una lettera ed essere alfanumerico, _ o -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Chiavi di cifratura per i campi sensibili", "regexPatternsDesc": "Pattern regex riutilizzabili per la redazione", "domainManagementDesc": "Limita l'accesso ai domini SSO consentiti", + "passwordPolicy": "Criteri password", + "passwordPolicyDesc": "Criteri di autenticazione per gli account nativi con email/password, applicati all'intera istanza.", "pipelineDestinationsDesc": "Destinazioni esterne per l'output della pipeline", "templatesDesc": "Template di messaggio di avviso riutilizzabili", "storageSettingsDesc": "Configurazione dello storage per organizzazione", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Non hai ancora avviato il processo di acquisizione dati. Per esplorare altre pagine, avvia l'acquisizione dati." + }, + "iam": { + "userSaveFailed": "Impossibile salvare l'utente" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Esponente" } }, + "passwordPolicy": { + "complexity": "Complessità", + "minLength": "Lunghezza minima", + "minLengthDesc": "La password più corta accettata", + "maxLength": "Lunghezza massima", + "maxLengthDesc": "0 = illimitata", + "requireUppercase": "Richiedi lettera maiuscola", + "requireUppercaseDesc": "Almeno una A–Z", + "requireLowercase": "Richiedi lettera minuscola", + "requireLowercaseDesc": "Almeno una a–z", + "requireDigit": "Richiedi cifra", + "requireDigitDesc": "Almeno una cifra 0–9", + "requireSpecial": "Richiedi carattere speciale", + "requireSpecialDesc": "Almeno uno dall'insieme consentito", + "specialCharSet": "Caratteri speciali consentiti", + "specialCharSetDesc": "Lascia vuoto per accettare qualsiasi carattere non alfanumerico", + "maxLengthTooSmall": "La lunghezza massima deve essere almeno pari alla lunghezza minima, oppure 0 per illimitata.", + "confirmTitle": "Salvare i criteri password?", + "confirmMessage": "Il salvataggio potrebbe obbligare tutti gli utenti nativi a impostare una nuova password prima di poter continuare. L'account root, gli utenti esterni e gli account di servizio sono esenti — questo include te, a meno che tu non sia l'account root. L'operazione non può essere annullata.", + "saved": "Criteri password salvati. | Criteri password salvati. {count} utente deve reimpostare la password. | Criteri password salvati. {count} utenti devono reimpostare la password.", + "saveFailed": "Impossibile salvare i criteri password.", + "notAdminTitle": "Accesso amministratore richiesto", + "notAdmin": "La configurazione dei criteri password richiede un amministratore dell'organizzazione meta.", + "loadFailedTitle": "Impossibile caricare i criteri password", + "loadFailed": "Si è verificato un errore durante la lettura dei criteri correnti.", + "rotation": "Rotazione", + "rotationDays": "Periodo di rotazione", + "rotationDaysDesc": "Giorni prima della scadenza di una password. 0 = non scade mai", + "rotationWarningDays": "Avvisa prima della scadenza", + "rotationWarningDaysDesc": "Giorni di preavviso all'accesso. 0 = nessun avviso", + "warningDaysTooLong": "La finestra di avviso non può essere più lunga del periodo di rotazione.", + "rotationPreview": "Una password impostata oggi scade {expires}. L'avviso all'accesso inizia {warns}.", + "rotationPreviewNoWarning": "Una password impostata oggi scade {expires}. Fino ad allora non viene mostrato alcun avviso.", + "reuse": "Prevenzione del riutilizzo", + "historyCount": "Password memorizzate", + "historyCountDesc": "Una nuova password non può coincidere con questo numero di password precedenti. 0 = nessun controllo", + "historyMaxRetained": "Cronologia conservata", + "historyMaxRetainedDesc": "Quante password precedenti vengono conservate per utente", + "historyRetainedTooSmall": "La cronologia conservata deve essere almeno pari al numero di password memorizzate.", + "reuseExplainer": "La password corrente conta come una delle password memorizzate — il suo riutilizzo viene rifiutato da questa regola, non da un controllo separato.", + "lockout": "Blocco dell'account", + "lockoutThreshold": "Tentativi non riusciti prima del blocco", + "lockoutThresholdDesc": "0 = blocco disabilitato", + "lockoutBucketSize": "Tentativi prima di ogni ulteriore blocco", + "lockoutBucketSizeDesc": "0 = riutilizza il valore precedente", + "lockoutStartSecs": "Durata del primo blocco", + "lockoutStartSecsDesc": "Secondi", + "lockoutMaxSecs": "Durata massima del blocco", + "lockoutMaxSecsDesc": "Secondi — il limite massimo a cui l'escalation viene contenuta", + "lockoutStartTooLong": "Il primo blocco non può essere più lungo del blocco massimo.", + "lockoutBackoff": "Escalation", + "lockoutBackoffDesc": "Come ogni blocco cresce rispetto a quello precedente", + "backoffExponential": "Esponenziale", + "backoffLinear": "Lineare", + "lockoutPreview": "{threshold} errori → {first} · ogni {bucket} in più → {rest} · limitato a {max}. Un accesso riuscito azzera tutto.", + "sessionEnforcement": "Sessione e applicazione", + "cookieMaxAge": "Durata della sessione", + "cookieMaxAgeDesc": "Secondi per cui una sessione autenticata resta valida. 0 = usa il valore predefinito dell'istanza da {envVar}", + "applyToRoot": "Applica all'account root", + "applyToRootDesc": "Root è esente da tutte le regole precedenti se questa opzione non è attiva", + "rootWarning": "Bloccare root potrebbe mettere il sistema in uno stato irrecuperabile.", + "rootDialogTitle": "Applicare i criteri a root?", + "rootDialogMessage": "Root sarà soggetto a blocco, rotazione e reimpostazioni forzate come qualsiasi altro account nativo. Se root è il tuo unico account amministrativo, un blocco o una scadenza che non può soddisfare non lascerà alcun modo per rientrare in questa istanza.", + "rootDialogAck": "Comprendo che root può essere bloccato permanentemente da questa istanza.", + "rootDialogConfirm": "Applica a root" + }, + "passwordReset": { + "title": "Aggiorna la tua password", + "signedInAs": "Accesso effettuato come {email}", + "reasonPolicyTightened": "I criteri password sono cambiati. Aggiorna la tua password per continuare a usare OpenObserve.", + "reasonRotationExpired": "La tua password è scaduta. Impostane una nuova per continuare a usare OpenObserve.", + "reasonRotationWarning": "La tua password scadrà presto. Impostane una nuova ora per evitare di essere bloccato dal tuo lavoro.", + "reused": "Questa password è stata usata di recente. Scegline una che non hai usato nelle ultime {count} password.", + "expiryBanner": "La tua password scade tra {count} giorno. Aggiornala ora per evitare di essere bloccato dal tuo lavoro. | La tua password scade tra {count} giorni. Aggiornala ora per evitare di essere bloccato dal tuo lavoro.", + "expiryBannerAction": "Aggiorna ora", + "expiryBannerDismiss": "Ignora", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", + "confirmPassword": "Conferma nuova password", + "currentPasswordRequired": "La password attuale è obbligatoria.", + "newPasswordRequired": "La nuova password è obbligatoria.", + "confirmPasswordRequired": "Reinserisci la nuova password.", + "mismatch": "Le password non corrispondono.", + "signOut": "Esci", + "submit": "Aggiorna password", + "updated": "La tua password è stata aggiornata. Accedi di nuovo.", + "updateFailed": "Impossibile aggiornare la password.", + "req": { + "minLength": "Almeno {count} caratteri", + "maxLength": "Non più di {count} caratteri", + "uppercase": "Una lettera maiuscola", + "lowercase": "Una lettera minuscola", + "digit": "Una cifra", + "special": "Un carattere speciale", + "specialFromSet": "Un carattere speciale da {set}", + "unmet": "La password deve contenere: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Criteri password", "loginCallback": "Callback di accesso", "slackOAuthCallback": "Connessione a Slack", "emptyStateDemo": "Demo stato vuoto", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index e6822acfae..dfdfa3e300 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "無料トライアルを開始" }, "login": { + "lockedOut": "サインインの失敗回数が多すぎます。{duration}後に再試行するか、管理者にお問い合わせください。", "redirectingToSsoMessage": "SSOログインページにリダイレクトしています。リダイレクトされない場合は、", "clickHere": "ここをクリック", "loginWithSso": "SSOでログイン", @@ -1965,6 +1966,15 @@ "fieldRequired": "フィールドは必須です", "passwordRequired": "パスワードが必要です。", "passwordPolicyHint": "パスワードは 8 ~ 128 文字で、少なくとも 1 つの小文字、1 つの大文字、1 つの数字、1 つの特殊文字を含む必要があります。", + "lockout": { + "locked": "アカウントがロックされています", + "autoUnlock": "{duration}後に自動的にロックが解除されます。", + "staged": "保存するとロックが解除されます。", + "unlock": "ロックを解除", + "undo": "ロックを維持", + "confirmTitle": "{email} のロックを解除しますか?", + "confirmMessage": "失敗回数がクリアされ、すぐに再度サインインできるようになります。パスワードは変更されません。" + }, "currentPasswordRequired": "現在のパスワードが必要です。", "newPasswordRequired": "新しいパスワードが必要です。", "otherOrgInvalid": "組織名は文字で始まり、英数字、_ または-でなければなりません。", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "機密フィールドの暗号化キー", "regexPatternsDesc": "墨消し用の再利用可能なregexパターン", "domainManagementDesc": "サインインを許可されたSSOドメインに制限", + "passwordPolicy": "パスワードポリシー", + "passwordPolicyDesc": "ネイティブのメールアドレス/パスワードアカウント向けの認証ポリシーです。インスタンス全体に適用されます。", "pipelineDestinationsDesc": "パイプライン出力の外部ターゲット", "templatesDesc": "再利用可能なアラートメッセージテンプレート", "storageSettingsDesc": "組織ごとのストレージ設定", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "まだデータ取り込みプロセスを開始していません。他のページを探索するには、データ取り込みを開始してください。" + }, + "iam": { + "userSaveFailed": "ユーザーを保存できませんでした" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "指数" } }, + "passwordPolicy": { + "complexity": "複雑さ", + "minLength": "最小文字数", + "minLengthDesc": "受け付けるパスワードの最短文字数", + "maxLength": "最大文字数", + "maxLengthDesc": "0 = 無制限", + "requireUppercase": "大文字を必須にする", + "requireUppercaseDesc": "A–Z を少なくとも1文字", + "requireLowercase": "小文字を必須にする", + "requireLowercaseDesc": "a–z を少なくとも1文字", + "requireDigit": "数字を必須にする", + "requireDigitDesc": "0–9 を少なくとも1文字", + "requireSpecial": "特殊文字を必須にする", + "requireSpecialDesc": "許可された文字セットから少なくとも1文字", + "specialCharSet": "許可する特殊文字", + "specialCharSetDesc": "空欄にすると、英数字以外のすべての文字を許可します", + "maxLengthTooSmall": "最大文字数は最小文字数以上、または無制限の場合は0にする必要があります。", + "confirmTitle": "パスワードポリシーを保存しますか?", + "confirmMessage": "保存すると、すべてのネイティブユーザーが続行前に新しいパスワードを設定する必要がある場合があります。root、外部ユーザー、サービスアカウントは対象外です。root アカウントでない限り、あなたも対象です。この操作は元に戻せません。", + "saved": "パスワードポリシーを保存しました。 | パスワードポリシーを保存しました。{count} 人のユーザーがパスワードをリセットする必要があります。 | パスワードポリシーを保存しました。{count} 人のユーザーがパスワードをリセットする必要があります。", + "saveFailed": "パスワードポリシーを保存できませんでした。", + "notAdminTitle": "管理者権限が必要です", + "notAdmin": "パスワードポリシーの設定には、メタ組織の管理者権限が必要です。", + "loadFailedTitle": "パスワードポリシーを読み込めませんでした", + "loadFailed": "現在のポリシーの読み取り中にエラーが発生しました。", + "rotation": "ローテーション", + "rotationDays": "ローテーション期間", + "rotationDaysDesc": "パスワードが有効期限切れになるまでの日数。0 = 無期限", + "rotationWarningDays": "有効期限前の警告", + "rotationWarningDaysDesc": "サインイン時の通知日数。0 = 警告なし", + "warningDaysTooLong": "警告期間はローテーション期間より長くできません。", + "rotationPreview": "今日設定したパスワードは {expires} に有効期限が切れます。サインイン時の警告は {warns} から始まります。", + "rotationPreviewNoWarning": "今日設定したパスワードは {expires} に有効期限が切れます。それまで警告は表示されません。", + "reuse": "再利用の防止", + "historyCount": "記憶するパスワード数", + "historyCountDesc": "新しいパスワードは、この数の過去のパスワードと一致してはいけません。0 = チェックなし", + "historyMaxRetained": "保持する履歴", + "historyMaxRetainedDesc": "ユーザーごとに保持される過去のパスワード数", + "historyRetainedTooSmall": "保持する履歴は、記憶するパスワード数以上である必要があります。", + "reuseExplainer": "現在のパスワードは記憶されるセットの1つとして数えられます。再利用が拒否されるのはこのルールによるもので、別のチェックによるものではありません。", + "lockout": "アカウントのロックアウト", + "lockoutThreshold": "ロックアウトまでの失敗回数", + "lockoutThresholdDesc": "0 = ロックアウト無効", + "lockoutBucketSize": "以降の各ロックアウトまでの試行回数", + "lockoutBucketSizeDesc": "0 = 上記の値を使用", + "lockoutStartSecs": "最初のロックアウト期間", + "lockoutStartSecsDesc": "秒", + "lockoutMaxSecs": "最大ロックアウト期間", + "lockoutMaxSecsDesc": "秒 — エスカレーションが上限とする値", + "lockoutStartTooLong": "最初のロックアウトは最大ロックアウトより長くできません。", + "lockoutBackoff": "エスカレーション", + "lockoutBackoffDesc": "各ロックアウトが前回からどのように延びるか", + "backoffExponential": "指数関数的", + "backoffLinear": "線形", + "lockoutPreview": "{threshold} 回失敗 → {first} · {bucket} 回ごとに → {rest} · 上限 {max}。サインインに成功するとすべてリセットされます。", + "sessionEnforcement": "セッションと適用", + "cookieMaxAge": "セッションの有効期間", + "cookieMaxAgeDesc": "サインイン済みセッションが有効な秒数。0 = {envVar} のインスタンスデフォルトを使用", + "applyToRoot": "root アカウントに適用", + "applyToRootDesc": "これがオンの場合を除き、root は上記すべてのルールから除外されます", + "rootWarning": "root をロックすると、システムが復旧不能な状態になる可能性があります。", + "rootDialogTitle": "ポリシーを root に適用しますか?", + "rootDialogMessage": "root は他のネイティブアカウントと同様に、ロックアウト、ローテーション、強制リセットの対象になります。root が唯一の管理アカウントである場合、ロックアウトや満たすことのできない有効期限により、このインスタンスに戻る手段がなくなる可能性があります。", + "rootDialogAck": "root がこのインスタンスから恒久的にロックアウトされる可能性があることを理解しました。", + "rootDialogConfirm": "root に適用" + }, + "passwordReset": { + "title": "パスワードを更新", + "signedInAs": "{email} としてサインイン中", + "reasonPolicyTightened": "パスワードポリシーが変更されました。OpenObserve の使用を続けるにはパスワードを更新してください。", + "reasonRotationExpired": "パスワードの有効期限が切れました。OpenObserve の使用を続けるには新しいパスワードを設定してください。", + "reasonRotationWarning": "パスワードの有効期限がまもなく切れます。作業からロックアウトされないよう、今すぐ新しいパスワードを設定してください。", + "reused": "このパスワードは最近使用されています。直近 {count} 個のパスワードで使用していないものを選択してください。", + "expiryBanner": "{count} 日後にパスワードの有効期限が切れます。作業からロックアウトされないよう、今すぐ更新してください。 | {count} 日後にパスワードの有効期限が切れます。作業からロックアウトされないよう、今すぐ更新してください。", + "expiryBannerAction": "今すぐ更新", + "expiryBannerDismiss": "閉じる", + "currentPassword": "現在のパスワード", + "newPassword": "新しいパスワード", + "confirmPassword": "新しいパスワードの確認", + "currentPasswordRequired": "現在のパスワードは必須です。", + "newPasswordRequired": "新しいパスワードは必須です。", + "confirmPasswordRequired": "新しいパスワードを再入力してください。", + "mismatch": "パスワードが一致しません。", + "signOut": "サインアウト", + "submit": "パスワードを更新", + "updated": "パスワードを更新しました。もう一度サインインしてください。", + "updateFailed": "パスワードを更新できませんでした。", + "req": { + "minLength": "{count} 文字以上", + "maxLength": "{count} 文字以内", + "uppercase": "大文字 1 文字", + "lowercase": "小文字 1 文字", + "digit": "数字 1 文字", + "special": "特殊文字を1文字", + "specialFromSet": "{set} の特殊文字を1文字", + "unmet": "パスワードには以下が必要です: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "パスワードポリシー", "loginCallback": "ログインコールバック", "slackOAuthCallback": "Slackに接続中", "emptyStateDemo": "空状態デモ", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index ee89f2f71c..8e56a3cf38 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "무료 체험 시작" }, "login": { + "lockedOut": "로그인 시도 실패 횟수가 너무 많습니다. {duration} 후에 다시 시도하거나 관리자에게 문의하세요.", "redirectingToSsoMessage": "SSO 로그인 페이지로 리디렉션합니다. 리디렉션되지 않으면", "clickHere": "여기를 클릭하세요", "loginWithSso": "SSO로 로그인", @@ -1965,6 +1966,15 @@ "fieldRequired": "필드는 필수입니다", "passwordRequired": "비밀번호가 필요합니다.", "passwordPolicyHint": "암호는 8~128자여야 하며 소문자 1개, 대문자 1개, 숫자 1개, 특수 문자 1개를 포함해야 합니다.", + "lockout": { + "locked": "계정 잠김", + "autoUnlock": "{duration} 후에 자동으로 잠금 해제됩니다.", + "staged": "저장하면 잠금이 해제됩니다.", + "unlock": "잠금 해제", + "undo": "잠금 유지", + "confirmTitle": "{email}의 잠금을 해제하시겠습니까?", + "confirmMessage": "이 작업은 해당 사용자의 실패 시도 횟수를 초기화하고 즉시 다시 로그인할 수 있게 합니다. 비밀번호는 변경되지 않습니다." + }, "currentPasswordRequired": "현재 암호가 필요합니다.", "newPasswordRequired": "새 암호가 필요합니다.", "otherOrgInvalid": "조직 이름은 문자로 시작하고 영숫자, _ 또는 -여야 합니다.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "민감한 필드를 위한 암호화 키", "regexPatternsDesc": "수정에 재사용 가능한 regex 패턴", "domainManagementDesc": "허용된 SSO 도메인으로 로그인 제한", + "passwordPolicy": "비밀번호 정책", + "passwordPolicyDesc": "네이티브 이메일/비밀번호 계정에 대한 인증 정책으로, 인스턴스 전체에 적용됩니다.", "pipelineDestinationsDesc": "파이프라인 출력을 위한 외부 대상", "templatesDesc": "재사용 가능한 알림 메시지 템플릿", "storageSettingsDesc": "조직별 스토리지 구성", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "아직 데이터 수집 프로세스를 시작하지 않았습니다. 다른 페이지를 탐색하려면 데이터 수집을 시작하세요." + }, + "iam": { + "userSaveFailed": "사용자를 저장할 수 없습니다" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "지수" } }, + "passwordPolicy": { + "complexity": "복잡도", + "minLength": "최소 길이", + "minLengthDesc": "허용되는 가장 짧은 비밀번호", + "maxLength": "최대 길이", + "maxLengthDesc": "0 = 제한 없음", + "requireUppercase": "대문자 필수", + "requireUppercaseDesc": "A–Z 중 최소 하나", + "requireLowercase": "소문자 필수", + "requireLowercaseDesc": "a–z 중 최소 하나", + "requireDigit": "숫자 필수", + "requireDigitDesc": "0–9 중 최소 하나", + "requireSpecial": "특수 문자 필수", + "requireSpecialDesc": "허용된 집합 중 최소 하나", + "specialCharSet": "허용되는 특수 문자", + "specialCharSetDesc": "비워 두면 영숫자가 아닌 모든 문자를 허용합니다", + "maxLengthTooSmall": "최대 길이는 최소 길이 이상이어야 하며, 제한 없음은 0입니다.", + "confirmTitle": "비밀번호 정책을 저장하시겠습니까?", + "confirmMessage": "저장하면 모든 네이티브 사용자가 계속하려면 새 비밀번호를 설정해야 할 수 있습니다. Root, 외부 사용자 및 서비스 계정은 예외입니다. 여기에는 root 계정이 아닌 한 본인도 포함됩니다. 이 작업은 되돌릴 수 없습니다.", + "saved": "비밀번호 정책이 저장되었습니다. | 비밀번호 정책이 저장되었습니다. {count}명의 사용자가 비밀번호를 재설정해야 합니다. | 비밀번호 정책이 저장되었습니다. {count}명의 사용자가 비밀번호를 재설정해야 합니다.", + "saveFailed": "비밀번호 정책을 저장할 수 없습니다.", + "notAdminTitle": "관리자 권한이 필요합니다", + "notAdmin": "비밀번호 정책을 구성하려면 메타 조직의 관리자 권한이 필요합니다.", + "loadFailedTitle": "비밀번호 정책을 불러올 수 없습니다", + "loadFailed": "현재 정책을 읽는 중 문제가 발생했습니다.", + "rotation": "교체", + "rotationDays": "교체 주기", + "rotationDaysDesc": "비밀번호가 만료되기까지의 일수입니다. 0 = 만료되지 않음", + "rotationWarningDays": "만료 전 경고", + "rotationWarningDaysDesc": "로그인 시 사전 알림 일수입니다. 0 = 경고 없음", + "warningDaysTooLong": "경고 기간은 교체 주기보다 길 수 없습니다.", + "rotationPreview": "오늘 설정한 비밀번호는 {expires}에 만료됩니다. 로그인 경고는 {warns}부터 시작됩니다.", + "rotationPreviewNoWarning": "오늘 설정한 비밀번호는 {expires}에 만료됩니다. 그 전까지는 경고가 표시되지 않습니다.", + "reuse": "재사용 방지", + "historyCount": "기억되는 비밀번호 수", + "historyCountDesc": "새 비밀번호는 이 개수만큼의 이전 비밀번호와 일치할 수 없습니다. 0 = 검사 안 함", + "historyMaxRetained": "보관되는 이력", + "historyMaxRetainedDesc": "사용자당 보관되는 이전 비밀번호 수", + "historyRetainedTooSmall": "보관되는 이력은 기억되는 비밀번호 수 이상이어야 합니다.", + "reuseExplainer": "현재 비밀번호는 기억되는 집합 중 하나로 간주됩니다. 이를 재사용하는 것은 별도의 검사가 아니라 이 규칙에 의해 거부됩니다.", + "lockout": "계정 잠금", + "lockoutThreshold": "잠금 전 실패 시도 횟수", + "lockoutThresholdDesc": "0 = 잠금 비활성화", + "lockoutBucketSize": "추가 잠금마다 필요한 시도 횟수", + "lockoutBucketSizeDesc": "0 = 위 값 재사용", + "lockoutStartSecs": "첫 잠금 지속 시간", + "lockoutStartSecsDesc": "초", + "lockoutMaxSecs": "최대 잠금 지속 시간", + "lockoutMaxSecsDesc": "초 — 에스컬레이션이 제한되는 상한", + "lockoutStartTooLong": "첫 잠금은 최대 잠금보다 길 수 없습니다.", + "lockoutBackoff": "에스컬레이션", + "lockoutBackoffDesc": "각 잠금이 이전 잠금에서 얼마나 증가하는지", + "backoffExponential": "지수", + "backoffLinear": "선형", + "lockoutPreview": "{threshold}회 실패 → {first} · {bucket}회 추가마다 → {rest} · 최대 {max}. 로그인에 성공하면 모두 초기화됩니다.", + "sessionEnforcement": "세션 및 적용", + "cookieMaxAge": "세션 수명", + "cookieMaxAgeDesc": "로그인된 세션이 유효하게 유지되는 시간(초). 0 = {envVar}의 인스턴스 기본값 사용", + "applyToRoot": "루트 계정에 적용", + "applyToRootDesc": "이 옵션을 켜지 않으면 루트는 위의 모든 규칙에서 제외됩니다", + "rootWarning": "루트를 잠그면 시스템이 복구할 수 없는 상태가 될 수 있습니다.", + "rootDialogTitle": "루트에 정책을 적용할까요?", + "rootDialogMessage": "루트는 다른 네이티브 계정과 마찬가지로 잠금, 순환 및 강제 재설정의 대상이 됩니다. 루트가 유일한 관리 계정인 경우, 잠금이나 충족할 수 없는 만료가 발생하면 이 인스턴스에 다시 접근할 방법이 없어집니다.", + "rootDialogAck": "루트가 이 인스턴스에서 영구적으로 잠길 수 있음을 이해합니다.", + "rootDialogConfirm": "루트에 적용" + }, + "passwordReset": { + "title": "비밀번호 업데이트", + "signedInAs": "{email}(으)로 로그인됨", + "reasonPolicyTightened": "비밀번호 정책이 변경되었습니다. OpenObserve를 계속 사용하려면 비밀번호를 업데이트하세요.", + "reasonRotationExpired": "비밀번호가 만료되었습니다. OpenObserve를 계속 사용하려면 새 비밀번호를 설정하세요.", + "reasonRotationWarning": "비밀번호가 곧 만료됩니다. 작업에서 잠기지 않도록 지금 새 비밀번호를 설정하세요.", + "reused": "최근에 사용한 비밀번호입니다. 최근 {count}개의 비밀번호에 사용하지 않은 비밀번호를 선택하세요.", + "expiryBanner": "{count}일 후에 비밀번호가 만료됩니다. 작업에서 잠기지 않도록 지금 업데이트하세요. | {count}일 후에 비밀번호가 만료됩니다. 작업에서 잠기지 않도록 지금 업데이트하세요.", + "expiryBannerAction": "지금 업데이트", + "expiryBannerDismiss": "닫기", + "currentPassword": "현재 비밀번호", + "newPassword": "새 비밀번호", + "confirmPassword": "새 비밀번호 확인", + "currentPasswordRequired": "현재 비밀번호를 입력하세요.", + "newPasswordRequired": "새 비밀번호를 입력하세요.", + "confirmPasswordRequired": "새 비밀번호를 다시 입력하세요.", + "mismatch": "비밀번호가 일치하지 않습니다.", + "signOut": "로그아웃", + "submit": "비밀번호 업데이트", + "updated": "비밀번호가 업데이트되었습니다. 다시 로그인하세요.", + "updateFailed": "비밀번호를 업데이트할 수 없습니다.", + "req": { + "minLength": "최소 {count}자", + "maxLength": "최대 {count}자", + "uppercase": "대문자 1개", + "lowercase": "소문자 1개", + "digit": "숫자 1개", + "special": "특수 문자 1개", + "specialFromSet": "{set}에서 특수 문자 1개", + "unmet": "비밀번호 요구 사항: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "비밀번호 정책", "loginCallback": "로그인 콜백", "slackOAuthCallback": "Slack 연결 중", "emptyStateDemo": "빈 상태 데모", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index ca56b675c7..f47e373a9b 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Start gratis proef" }, "login": { + "lockedOut": "Te veel mislukte aanmeldpogingen. Probeer het over {duration} opnieuw, of neem contact op met uw beheerder.", "redirectingToSsoMessage": "Doorverwijzen naar SSO-inlogpagina. Als je niet wordt doorgestuurd, ", "clickHere": "klik hier", "loginWithSso": "Inloggen met SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Veld is verplicht", "passwordRequired": "Wachtwoord is vereist.", "passwordPolicyHint": "Het wachtwoord moet 8-128 tekens lang zijn en minstens één kleine letter, één hoofdletter, één cijfer en één speciaal teken bevatten.", + "lockout": { + "locked": "Account vergrendeld", + "autoUnlock": "Wordt automatisch ontgrendeld over {duration}.", + "staged": "Wordt ontgrendeld wanneer u opslaat.", + "unlock": "Ontgrendelen", + "undo": "Vergrendeld houden", + "confirmTitle": "{email} ontgrendelen?", + "confirmMessage": "Hiermee wordt het aantal mislukte pogingen gewist en kan de gebruiker zich direct weer aanmelden. Het wachtwoord wordt niet gewijzigd." + }, "currentPasswordRequired": "Het huidige wachtwoord is vereist.", "newPasswordRequired": "Een nieuw wachtwoord is vereist.", "otherOrgInvalid": "De naam van de organisatie moet beginnen met een letter en alfanumeriek zijn, _ of -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Versleutelingssleutels voor gevoelige velden", "regexPatternsDesc": "Herbruikbare regex-patronen voor redactie", "domainManagementDesc": "Aanmelden beperken tot toegestane SSO-domeinen", + "passwordPolicy": "Wachtwoordbeleid", + "passwordPolicyDesc": "Authenticatiebeleid voor native e-mail-/wachtwoordaccounts, toegepast op de hele instantie.", "pipelineDestinationsDesc": "Externe doelen voor pipeline-uitvoer", "templatesDesc": "Herbruikbare sjablonen voor waarschuwingsberichten", "storageSettingsDesc": "Opslagconfiguratie per organisatie", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "U bent nog niet begonnen met het data-ingestieproces. Om andere pagina's te verkennen, start u de data-ingestie." + }, + "iam": { + "userSaveFailed": "Kon de gebruiker niet opslaan" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Exponent" } }, + "passwordPolicy": { + "complexity": "Complexiteit", + "minLength": "Minimumlengte", + "minLengthDesc": "Kortste geaccepteerde wachtwoord", + "maxLength": "Maximumlengte", + "maxLengthDesc": "0 = onbeperkt", + "requireUppercase": "Hoofdletter vereisen", + "requireUppercaseDesc": "Ten minste één A–Z", + "requireLowercase": "Kleine letter vereisen", + "requireLowercaseDesc": "Ten minste één a–z", + "requireDigit": "Cijfer vereisen", + "requireDigitDesc": "Ten minste één 0–9", + "requireSpecial": "Speciaal teken vereisen", + "requireSpecialDesc": "Ten minste één uit de toegestane set", + "specialCharSet": "Toegestane speciale tekens", + "specialCharSetDesc": "Laat leeg om elk niet-alfanumeriek teken te accepteren", + "maxLengthTooSmall": "Maximumlengte moet ten minste de minimumlengte zijn, of 0 voor onbeperkt.", + "confirmTitle": "Wachtwoordbeleid opslaan?", + "confirmMessage": "Opslaan kan ertoe leiden dat elke native gebruiker een nieuw wachtwoord moet instellen voordat deze verder kan. Root, externe gebruikers en serviceaccounts zijn uitgezonderd — dit geldt ook voor u, tenzij u het rootaccount bent. Dit kan niet ongedaan worden gemaakt.", + "saved": "Wachtwoordbeleid opgeslagen. | Wachtwoordbeleid opgeslagen. {count} gebruiker moet zijn wachtwoord opnieuw instellen. | Wachtwoordbeleid opgeslagen. {count} gebruikers moeten hun wachtwoord opnieuw instellen.", + "saveFailed": "Kon het wachtwoordbeleid niet opslaan.", + "notAdminTitle": "Beheerdersrechten vereist", + "notAdmin": "Voor het configureren van het wachtwoordbeleid is een beheerder van de meta-organisatie vereist.", + "loadFailedTitle": "Kon het wachtwoordbeleid niet laden", + "loadFailed": "Er is iets misgegaan bij het lezen van het huidige beleid.", + "rotation": "Rotatie", + "rotationDays": "Rotatieperiode", + "rotationDaysDesc": "Dagen voordat een wachtwoord verloopt. 0 = verloopt nooit", + "rotationWarningDays": "Waarschuwen voor afloop", + "rotationWarningDaysDesc": "Dagen van kennisgeving bij aanmelding. 0 = geen waarschuwing", + "warningDaysTooLong": "Het waarschuwingsvenster kan niet langer zijn dan de rotatieperiode.", + "rotationPreview": "Een vandaag ingesteld wachtwoord verloopt op {expires}. De aanmeldwaarschuwing begint op {warns}.", + "rotationPreviewNoWarning": "Een vandaag ingesteld wachtwoord verloopt op {expires}. Er wordt voor die tijd geen waarschuwing getoond.", + "reuse": "Hergebruikpreventie", + "historyCount": "Onthouden wachtwoorden", + "historyCountDesc": "Een nieuw wachtwoord mag niet overeenkomen met dit aantal eerdere wachtwoorden. 0 = geen controle", + "historyMaxRetained": "Bewaarde geschiedenis", + "historyMaxRetainedDesc": "Hoeveel eerdere wachtwoorden per gebruiker worden bewaard", + "historyRetainedTooSmall": "Bewaarde geschiedenis moet ten minste gelijk zijn aan het aantal onthouden wachtwoorden.", + "reuseExplainer": "Het huidige wachtwoord telt als een van de onthouden wachtwoorden — hergebruik ervan wordt door deze regel geweigerd, niet door een aparte controle.", + "lockout": "Accountvergrendeling", + "lockoutThreshold": "Mislukte pogingen voor vergrendeling", + "lockoutThresholdDesc": "0 = vergrendeling uitgeschakeld", + "lockoutBucketSize": "Pogingen voor elke volgende vergrendeling", + "lockoutBucketSizeDesc": "0 = hergebruik de waarde hierboven", + "lockoutStartSecs": "Duur van eerste vergrendeling", + "lockoutStartSecsDesc": "Seconden", + "lockoutMaxSecs": "Maximale vergrendelingsduur", + "lockoutMaxSecsDesc": "Seconden — het plafond waarnaar escalatie wordt afgekapt", + "lockoutStartTooLong": "De eerste vergrendeling kan niet langer zijn dan de maximale vergrendeling.", + "lockoutBackoff": "Escalatie", + "lockoutBackoffDesc": "Hoe elke vergrendeling groeit ten opzichte van de vorige", + "backoffExponential": "Exponentieel", + "backoffLinear": "Lineair", + "lockoutPreview": "{threshold} fouten → {first} · elke {bucket} meer → {rest} · gemaximeerd op {max}. Een succesvolle aanmelding reset alles.", + "sessionEnforcement": "Sessie en handhaving", + "cookieMaxAge": "Sessielevensduur", + "cookieMaxAgeDesc": "Seconden dat een aangemelde sessie geldig blijft. 0 = gebruik de standaardwaarde van de instantie uit {envVar}", + "applyToRoot": "Toepassen op het rootaccount", + "applyToRootDesc": "Root is vrijgesteld van alle bovenstaande regels tenzij dit is ingeschakeld", + "rootWarning": "Het vergrendelen van root kan ertoe leiden dat het systeem in een onherstelbare staat terechtkomt.", + "rootDialogTitle": "Het beleid toepassen op root?", + "rootDialogMessage": "Root zal net als elk ander systeemeigen account onderhevig zijn aan vergrendeling, rotatie en gedwongen resets. Als root uw enige beheerdersaccount is, kan een vergrendeling of een vervaldatum waaraan niet kan worden voldaan, ervoor zorgen dat er geen weg terug is naar deze instantie.", + "rootDialogAck": "Ik begrijp dat root permanent buitengesloten kan worden van deze instantie.", + "rootDialogConfirm": "Toepassen op root" + }, + "passwordReset": { + "title": "Uw wachtwoord bijwerken", + "signedInAs": "Aangemeld als {email}", + "reasonPolicyTightened": "Het wachtwoordbeleid is gewijzigd. Werk uw wachtwoord bij om OpenObserve te blijven gebruiken.", + "reasonRotationExpired": "Uw wachtwoord is verlopen. Stel een nieuw wachtwoord in om OpenObserve te blijven gebruiken.", + "reasonRotationWarning": "Uw wachtwoord verloopt binnenkort. Stel nu een nieuw wachtwoord in om te voorkomen dat u buitengesloten raakt van uw werk.", + "reused": "Dit wachtwoord is onlangs gebruikt. Kies een wachtwoord dat u niet hebt gebruikt in uw laatste {count} wachtwoorden.", + "expiryBanner": "Uw wachtwoord verloopt over {count} dag. Werk het nu bij om te voorkomen dat u buitengesloten raakt van uw werk. | Uw wachtwoord verloopt over {count} dagen. Werk het nu bij om te voorkomen dat u buitengesloten raakt van uw werk.", + "expiryBannerAction": "Nu bijwerken", + "expiryBannerDismiss": "Sluiten", + "currentPassword": "Huidig wachtwoord", + "newPassword": "Nieuw wachtwoord", + "confirmPassword": "Bevestig nieuw wachtwoord", + "currentPasswordRequired": "Huidig wachtwoord is vereist.", + "newPasswordRequired": "Nieuw wachtwoord is vereist.", + "confirmPasswordRequired": "Voer het nieuwe wachtwoord opnieuw in.", + "mismatch": "Wachtwoorden komen niet overeen.", + "signOut": "Uitloggen", + "submit": "Wachtwoord bijwerken", + "updated": "Uw wachtwoord is bijgewerkt. Meld u opnieuw aan.", + "updateFailed": "Uw wachtwoord kon niet worden bijgewerkt.", + "req": { + "minLength": "Minimaal {count} tekens", + "maxLength": "Maximaal {count} tekens", + "uppercase": "Eén hoofdletter", + "lowercase": "Eén kleine letter", + "digit": "Eén cijfer", + "special": "Eén speciaal teken", + "specialFromSet": "Eén speciaal teken uit {set}", + "unmet": "Wachtwoord moet bevatten: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Wachtwoordbeleid", "loginCallback": "Login-callback", "slackOAuthCallback": "Slack verbinden", "emptyStateDemo": "Lege-statusdemo", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index 414abdcfc9..79fa9ad290 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Rozpocznij bezpłatny okres próbny" }, "login": { + "lockedOut": "Zbyt wiele nieudanych prób logowania. Spróbuj ponownie za {duration} lub skontaktuj się z administratorem.", "redirectingToSsoMessage": "Przekierowywanie do strony logowania SSO. Jeśli nie nastąpi przekierowanie, proszę", "clickHere": "kliknij tutaj", "loginWithSso": "Zaloguj się przez SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Pole jest wymagane", "passwordRequired": "Hasło jest wymagane.", "passwordPolicyHint": "Hasło musi mieć 8-128 znaków i zawierać co najmniej jedną małą literę, jedną wielką literę, jedną cyfrę i jeden znak specjalny.", + "lockout": { + "locked": "Konto zablokowane", + "autoUnlock": "Odblokuje się automatycznie za {duration}.", + "staged": "Zostanie odblokowane po zapisaniu.", + "unlock": "Odblokuj", + "undo": "Pozostaw zablokowane", + "confirmTitle": "Odblokować {email}?", + "confirmMessage": "Powoduje to wyczyszczenie licznika nieudanych prób i umożliwia natychmiastowe ponowne zalogowanie. Hasło nie zostanie zmienione." + }, "currentPasswordRequired": "Aktualne hasło jest wymagane.", "newPasswordRequired": "Nowe hasło jest wymagane.", "otherOrgInvalid": "Nazwa organizacji musi zaczynać się od litery i zawierać tylko litery, cyfry, _ lub -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Klucze szyfrowania dla poufnych pól", "regexPatternsDesc": "Wielokrotnego użytku wzorce regex do maskowania", "domainManagementDesc": "Ogranicz logowanie do dozwolonych domen SSO", + "passwordPolicy": "Zasady haseł", + "passwordPolicyDesc": "Zasady uwierzytelniania dla natywnych kont e-mail/hasło, stosowane w całej instancji.", "pipelineDestinationsDesc": "Zewnętrzne cele dla wyjścia potoku", "templatesDesc": "Wielokrotnego użytku szablony wiadomości alertów", "storageSettingsDesc": "Konfiguracja przechowywania na organizację", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Nie zainicjowano jeszcze procesu pozyskiwania danych. Aby przeglądać inne strony, rozpocznij pozyskiwanie danych." + }, + "iam": { + "userSaveFailed": "Nie udało się zapisać użytkownika" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Wykładnik" } }, + "passwordPolicy": { + "complexity": "Złożoność", + "minLength": "Minimalna długość", + "minLengthDesc": "Najkrótsze akceptowane hasło", + "maxLength": "Maksymalna długość", + "maxLengthDesc": "0 = bez limitu", + "requireUppercase": "Wymagaj wielkiej litery", + "requireUppercaseDesc": "Co najmniej jedna litera A–Z", + "requireLowercase": "Wymagaj małej litery", + "requireLowercaseDesc": "Co najmniej jedna litera a–z", + "requireDigit": "Wymagaj cyfry", + "requireDigitDesc": "Co najmniej jedna cyfra 0–9", + "requireSpecial": "Wymagaj znaku specjalnego", + "requireSpecialDesc": "Co najmniej jeden z dozwolonego zestawu", + "specialCharSet": "Dozwolone znaki specjalne", + "specialCharSetDesc": "Pozostaw puste, aby akceptować dowolny znak niealfanumeryczny", + "maxLengthTooSmall": "Maksymalna długość musi wynosić co najmniej tyle, ile minimalna, albo 0 dla braku limitu.", + "confirmTitle": "Zapisać zasady haseł?", + "confirmMessage": "Zapisanie może wymusić na każdym natywnym użytkowniku ustawienie nowego hasła przed dalszym korzystaniem. Konto root, użytkownicy zewnętrzni i konta usług są zwolnione — dotyczy to również Ciebie, chyba że jesteś kontem root. Tej operacji nie można cofnąć.", + "saved": "Zasady haseł zostały zapisane. | Zasady haseł zostały zapisane. {count} użytkowników musi zresetować swoje hasła. | Zasady haseł zostały zapisane. {count} użytkowników musi zresetować swoje hasła.", + "saveFailed": "Nie udało się zapisać zasad haseł.", + "notAdminTitle": "Wymagany dostęp administratora", + "notAdmin": "Konfigurowanie zasad haseł wymaga administratora organizacji meta.", + "loadFailedTitle": "Nie udało się wczytać zasad haseł", + "loadFailed": "Wystąpił problem podczas odczytywania bieżących zasad.", + "rotation": "Rotacja", + "rotationDays": "Okres rotacji", + "rotationDaysDesc": "Liczba dni do wygaśnięcia hasła. 0 = nigdy nie wygasa", + "rotationWarningDays": "Ostrzegaj przed wygaśnięciem", + "rotationWarningDaysDesc": "Liczba dni powiadomienia przy logowaniu. 0 = brak ostrzeżenia", + "warningDaysTooLong": "Okno ostrzeżenia nie może być dłuższe niż okres rotacji.", + "rotationPreview": "Hasło ustawione dzisiaj wygasa {expires}. Ostrzeżenie przy logowaniu rozpoczyna się {warns}.", + "rotationPreviewNoWarning": "Hasło ustawione dzisiaj wygasa {expires}. Do tego czasu nie jest wyświetlane żadne ostrzeżenie.", + "reuse": "Zapobieganie ponownemu użyciu", + "historyCount": "Zapamiętane hasła", + "historyCountDesc": "Nowe hasło nie może być takie samo jak ta liczba poprzednich. 0 = brak sprawdzania", + "historyMaxRetained": "Zachowana historia", + "historyMaxRetainedDesc": "Ile poprzednich haseł jest zachowywanych na użytkownika", + "historyRetainedTooSmall": "Zachowana historia musi wynosić co najmniej tyle, ile liczba zapamiętanych haseł.", + "reuseExplainer": "Bieżące hasło liczy się jako jedno z zapamiętanego zestawu — jego ponowne użycie jest odrzucane przez tę regułę, a nie przez osobne sprawdzenie.", + "lockout": "Blokada konta", + "lockoutThreshold": "Nieudane próby przed blokadą", + "lockoutThresholdDesc": "0 = blokada wyłączona", + "lockoutBucketSize": "Próby przed każdą kolejną blokadą", + "lockoutBucketSizeDesc": "0 = użyj wartości powyżej", + "lockoutStartSecs": "Czas trwania pierwszej blokady", + "lockoutStartSecsDesc": "Sekundy", + "lockoutMaxSecs": "Maksymalny czas trwania blokady", + "lockoutMaxSecsDesc": "Sekundy — pułap, do którego ograniczana jest eskalacja", + "lockoutStartTooLong": "Pierwsza blokada nie może trwać dłużej niż maksymalna blokada.", + "lockoutBackoff": "Eskalacja", + "lockoutBackoffDesc": "Jak każda blokada rośnie względem poprzedniej", + "backoffExponential": "Wykładnicza", + "backoffLinear": "Liniowa", + "lockoutPreview": "{threshold} nieudanych prób → {first} · co {bucket} kolejnych prób → {rest} · ograniczone do {max}. Udane logowanie resetuje wszystko.", + "sessionEnforcement": "Sesja i egzekwowanie", + "cookieMaxAge": "Czas życia sesji", + "cookieMaxAgeDesc": "Liczba sekund, przez jaką zalogowana sesja pozostaje ważna. 0 = użyj domyślnej wartości instancji z {envVar}", + "applyToRoot": "Zastosuj do konta root", + "applyToRootDesc": "Konto root jest zwolnione ze wszystkich powyższych reguł, chyba że ta opcja jest włączona.", + "rootWarning": "Zablokowanie konta root może wprowadzić system w stan nie do odzyskania.", + "rootDialogTitle": "Zastosować politykę do konta root?", + "rootDialogMessage": "Konto root będzie podlegać blokadzie, rotacji i wymuszonym resetom tak jak każde inne konto natywne. Jeśli root jest Twoim jedynym kontem administracyjnym, blokada lub wygaśnięcie, którego nie da się spełnić, sprawi, że nie będzie już drogi powrotnej do tej instancji.", + "rootDialogAck": "Rozumiem, że konto root może zostać trwale zablokowane w tej instancji.", + "rootDialogConfirm": "Zastosuj do konta root" + }, + "passwordReset": { + "title": "Zaktualizuj hasło", + "signedInAs": "Zalogowano jako {email}", + "reasonPolicyTightened": "Polityka haseł uległa zmianie. Zaktualizuj hasło, aby dalej korzystać z OpenObserve.", + "reasonRotationExpired": "Twoje hasło wygasło. Ustaw nowe, aby dalej korzystać z OpenObserve.", + "reasonRotationWarning": "Twoje hasło wkrótce wygaśnie. Ustaw nowe teraz, aby uniknąć zablokowania dostępu do pracy.", + "reused": "To hasło było niedawno używane. Wybierz hasło, którego nie było wśród Twoich ostatnich {count} haseł.", + "expiryBanner": "Twoje hasło wygasa za {count} dzień. Zaktualizuj je teraz, aby uniknąć zablokowania dostępu do pracy. | Twoje hasło wygasa za {count} dni. Zaktualizuj je teraz, aby uniknąć zablokowania dostępu do pracy.", + "expiryBannerAction": "Zaktualizuj teraz", + "expiryBannerDismiss": "Odrzuć", + "currentPassword": "Obecne hasło", + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź nowe hasło", + "currentPasswordRequired": "Obecne hasło jest wymagane.", + "newPasswordRequired": "Nowe hasło jest wymagane.", + "confirmPasswordRequired": "Wprowadź ponownie nowe hasło.", + "mismatch": "Hasła nie są zgodne.", + "signOut": "Wyloguj się", + "submit": "Zaktualizuj hasło", + "updated": "Twoje hasło zostało zaktualizowane. Zaloguj się ponownie.", + "updateFailed": "Nie udało się zaktualizować hasła.", + "req": { + "minLength": "Co najmniej {count} znaków", + "maxLength": "Nie więcej niż {count} znaków", + "uppercase": "Jedna wielka litera", + "lowercase": "Jedna mała litera", + "digit": "Jedna cyfra", + "special": "Jeden znak specjalny", + "specialFromSet": "Jeden znak specjalny z {set}", + "unmet": "Hasło musi zawierać: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Polityka haseł", "loginCallback": "Callback logowania", "slackOAuthCallback": "Łączenie ze Slackiem", "emptyStateDemo": "Demo pustego stanu", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index 16293a4433..57d047704b 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Iniciar avaliação gratuita" }, "login": { + "lockedOut": "Muitas tentativas de login falharam. Tente novamente em {duration} ou contate seu administrador.", "redirectingToSsoMessage": "Redirecionando para a página de login SSO. Se você não for redirecionado, por favor", "clickHere": "clique aqui", "loginWithSso": "Entrar com SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "O campo é obrigatório", "passwordRequired": "A senha é obrigatória.", "passwordPolicyHint": "A senha deve ter de 8 a 128 caracteres e conter pelo menos uma letra minúscula, uma letra maiúscula, um dígito e um caractere especial.", + "lockout": { + "locked": "Conta bloqueada", + "autoUnlock": "Desbloqueia automaticamente em {duration}.", + "staged": "Será desbloqueada quando você salvar.", + "unlock": "Desbloquear", + "undo": "Manter bloqueada", + "confirmTitle": "Desbloquear {email}?", + "confirmMessage": "Isso zera a contagem de tentativas falhas e permite que ele faça login novamente imediatamente. Não altera a senha." + }, "currentPasswordRequired": "A senha atual é obrigatória.", "newPasswordRequired": "É necessária uma nova senha.", "otherOrgInvalid": "O nome da organização deve começar com uma letra e ser alfanumérico, _ ou -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Chaves de criptografia para campos sensíveis", "regexPatternsDesc": "Padrões regex reutilizáveis para redação", "domainManagementDesc": "Restringir o login a domínios SSO permitidos", + "passwordPolicy": "Política de senhas", + "passwordPolicyDesc": "Políticas de autenticação para contas nativas de e-mail/senha, aplicadas a toda a instância.", "pipelineDestinationsDesc": "Destinos externos para a saída do pipeline", "templatesDesc": "Modelos reutilizáveis de mensagens de alerta", "storageSettingsDesc": "Configuração de armazenamento por organização", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Você ainda não iniciou o processo de ingestão de dados. Para explorar outras páginas, inicie a ingestão de dados." + }, + "iam": { + "userSaveFailed": "Não foi possível salvar o usuário" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Expoente" } }, + "passwordPolicy": { + "complexity": "Complexidade", + "minLength": "Comprimento mínimo", + "minLengthDesc": "Menor senha aceita", + "maxLength": "Comprimento máximo", + "maxLengthDesc": "0 = sem limite", + "requireUppercase": "Exigir letra maiúscula", + "requireUppercaseDesc": "Pelo menos um A–Z", + "requireLowercase": "Exigir letra minúscula", + "requireLowercaseDesc": "Pelo menos um a–z", + "requireDigit": "Exigir dígito", + "requireDigitDesc": "Pelo menos um 0–9", + "requireSpecial": "Exigir caractere especial", + "requireSpecialDesc": "Pelo menos um do conjunto permitido", + "specialCharSet": "Caracteres especiais permitidos", + "specialCharSetDesc": "Deixe vazio para aceitar qualquer caractere não alfanumérico", + "maxLengthTooSmall": "O comprimento máximo deve ser pelo menos o comprimento mínimo, ou 0 para sem limite.", + "confirmTitle": "Salvar política de senhas?", + "confirmMessage": "Salvar pode forçar todos os usuários nativos a definir uma nova senha antes de continuar. Usuários root, externos e contas de serviço estão isentos — isso inclui você, a menos que você seja a conta root. Não pode ser desfeito.", + "saved": "Política de senhas salva. | Política de senhas salva. {count} usuário deve redefinir a senha. | Política de senhas salva. {count} usuários devem redefinir a senha.", + "saveFailed": "Não foi possível salvar a política de senhas.", + "notAdminTitle": "Acesso de administrador necessário", + "notAdmin": "Configurar a política de senhas exige um administrador da meta organização.", + "loadFailedTitle": "Não foi possível carregar a política de senhas", + "loadFailed": "Algo deu errado ao ler a política atual.", + "rotation": "Rotação", + "rotationDays": "Período de rotação", + "rotationDaysDesc": "Dias antes de a senha expirar. 0 = nunca expira", + "rotationWarningDays": "Avisar antes da expiração", + "rotationWarningDaysDesc": "Dias de antecedência no login. 0 = sem aviso", + "warningDaysTooLong": "O período de aviso não pode ser maior que o período de rotação.", + "rotationPreview": "Uma senha definida hoje expira {expires}. O aviso no login começa {warns}.", + "rotationPreviewNoWarning": "Uma senha definida hoje expira {expires}. Nenhum aviso é exibido antes disso.", + "reuse": "Prevenção de reutilização", + "historyCount": "Senhas memorizadas", + "historyCountDesc": "Uma nova senha não pode ser igual a esta quantidade de senhas anteriores. 0 = sem verificação", + "historyMaxRetained": "Histórico retido", + "historyMaxRetainedDesc": "Quantas senhas anteriores são mantidas por usuário", + "historyRetainedTooSmall": "O histórico retido deve ser pelo menos igual ao número de senhas memorizadas.", + "reuseExplainer": "A senha atual conta como uma das senhas do conjunto memorizado — reutilizá-la é recusado por esta regra, não por uma verificação separada.", + "lockout": "Bloqueio de conta", + "lockoutThreshold": "Tentativas falhas antes do bloqueio", + "lockoutThresholdDesc": "0 = bloqueio desativado", + "lockoutBucketSize": "Tentativas antes de cada bloqueio adicional", + "lockoutBucketSizeDesc": "0 = reutiliza o valor acima", + "lockoutStartSecs": "Duração do primeiro bloqueio", + "lockoutStartSecsDesc": "Segundos", + "lockoutMaxSecs": "Duração máxima do bloqueio", + "lockoutMaxSecsDesc": "Segundos — o teto ao qual a escalada é limitada", + "lockoutStartTooLong": "O primeiro bloqueio não pode ser maior que o bloqueio máximo.", + "lockoutBackoff": "Escalada", + "lockoutBackoffDesc": "Como cada bloqueio cresce em relação ao anterior", + "backoffExponential": "Exponencial", + "backoffLinear": "Linear", + "lockoutPreview": "{threshold} falhas → {first} · a cada {bucket} adicionais → {rest} · limitado a {max}. Um login bem-sucedido redefine tudo.", + "sessionEnforcement": "Sessão e aplicação", + "cookieMaxAge": "Duração da sessão", + "cookieMaxAgeDesc": "Segundos durante os quais uma sessão autenticada permanece válida. 0 = usar o padrão da instância de {envVar}", + "applyToRoot": "Aplicar à conta root", + "applyToRootDesc": "O root está isento de todas as regras acima, a menos que isto esteja ativado", + "rootWarning": "Bloquear o root pode levar o sistema a um estado irrecuperável.", + "rootDialogTitle": "Aplicar a política ao root?", + "rootDialogMessage": "O root ficará sujeito a bloqueio, rotação e redefinições forçadas como qualquer outra conta nativa. Se o root for sua única conta administrativa, um bloqueio ou uma expiração que ele não consiga atender deixará você sem forma de voltar a esta instância.", + "rootDialogAck": "Entendo que o root pode ser bloqueado permanentemente desta instância.", + "rootDialogConfirm": "Aplicar ao root" + }, + "passwordReset": { + "title": "Atualize sua senha", + "signedInAs": "Conectado como {email}", + "reasonPolicyTightened": "A política de senhas mudou. Atualize sua senha para continuar usando o OpenObserve.", + "reasonRotationExpired": "Sua senha expirou. Defina uma nova para continuar usando o OpenObserve.", + "reasonRotationWarning": "Sua senha expira em breve. Defina uma nova agora para não perder o acesso ao seu trabalho.", + "reused": "Esta senha foi usada recentemente. Escolha uma que você não tenha usado nas suas últimas {count} senhas.", + "expiryBanner": "Sua senha expira em {count} dia. Atualize-a agora para não perder o acesso ao seu trabalho. | Sua senha expira em {count} dias. Atualize-a agora para não perder o acesso ao seu trabalho.", + "expiryBannerAction": "Atualizar agora", + "expiryBannerDismiss": "Dispensar", + "currentPassword": "Senha atual", + "newPassword": "Nova senha", + "confirmPassword": "Confirme a nova senha", + "currentPasswordRequired": "A senha atual é obrigatória.", + "newPasswordRequired": "A nova senha é obrigatória.", + "confirmPasswordRequired": "Digite novamente a nova senha.", + "mismatch": "As senhas não coincidem.", + "signOut": "Sair", + "submit": "Atualizar senha", + "updated": "Sua senha foi atualizada. Faça login novamente.", + "updateFailed": "Não foi possível atualizar sua senha.", + "req": { + "minLength": "Pelo menos {count} caracteres", + "maxLength": "No máximo {count} caracteres", + "uppercase": "Uma letra maiúscula", + "lowercase": "Uma letra minúscula", + "digit": "Um dígito", + "special": "Um caractere especial", + "specialFromSet": "Um caractere especial de {set}", + "unmet": "A senha deve ter: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Política de senha", "loginCallback": "Callback de login", "slackOAuthCallback": "Conectando ao Slack", "emptyStateDemo": "Demonstração de estado vazio", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index 65d9f6ebfe..dad3ce99e4 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Начать бесплатный пробный период" }, "login": { + "lockedOut": "Слишком много неудачных попыток входа. Повторите попытку через {duration} или обратитесь к администратору.", "redirectingToSsoMessage": "Перенаправление на страницу входа SSO. Если перенаправление не произошло, пожалуйста", "clickHere": "нажмите здесь", "loginWithSso": "Войти через SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Поле обязательно", "passwordRequired": "Требуется пароль.", "passwordPolicyHint": "Пароль должен содержать от 8 до 128 символов, включая как минимум одну строчную букву, одну заглавную букву, одну цифру и один специальный символ.", + "lockout": { + "locked": "Учетная запись заблокирована", + "autoUnlock": "Автоматически разблокируется через {duration}.", + "staged": "Будет разблокирована при сохранении.", + "unlock": "Разблокировать", + "undo": "Оставить заблокированной", + "confirmTitle": "Разблокировать {email}?", + "confirmMessage": "Это сбрасывает счетчик неудачных попыток и позволяет сразу войти снова. Пароль при этом не меняется." + }, "currentPasswordRequired": "Требуется текущий пароль.", "newPasswordRequired": "Требуется новый пароль.", "otherOrgInvalid": "Имя организации должно начинаться с буквы, содержать буквы, цифры, _ или -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Ключи шифрования для конфиденциальных полей", "regexPatternsDesc": "Многоразовые шаблоны regex для редактирования", "domainManagementDesc": "Ограничить вход разрешенными доменами SSO", + "passwordPolicy": "Политика паролей", + "passwordPolicyDesc": "Политики аутентификации для встроенных учетных записей с email/паролем, применяются на уровне всего экземпляра.", "pipelineDestinationsDesc": "Внешние цели для вывода конвейера", "templatesDesc": "Многоразовые шаблоны сообщений оповещений", "storageSettingsDesc": "Конфигурация хранилища для каждой организации", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Вы еще не начали процесс приема данных. Чтобы просмотреть другие страницы, начните прием данных." + }, + "iam": { + "userSaveFailed": "Не удалось сохранить пользователя" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Степень" } }, + "passwordPolicy": { + "complexity": "Сложность", + "minLength": "Минимальная длина", + "minLengthDesc": "Наименьшая принимаемая длина пароля", + "maxLength": "Максимальная длина", + "maxLengthDesc": "0 = без ограничений", + "requireUppercase": "Требовать заглавную букву", + "requireUppercaseDesc": "Минимум одна буква A–Z", + "requireLowercase": "Требовать строчную букву", + "requireLowercaseDesc": "Минимум одна буква a–z", + "requireDigit": "Требовать цифру", + "requireDigitDesc": "Минимум одна цифра 0–9", + "requireSpecial": "Требовать специальный символ", + "requireSpecialDesc": "Минимум один символ из разрешенного набора", + "specialCharSet": "Разрешенные специальные символы", + "specialCharSetDesc": "Оставьте пустым, чтобы принимать любой небуквенно-цифровой символ", + "maxLengthTooSmall": "Максимальная длина должна быть не меньше минимальной или равна 0 для отсутствия ограничений.", + "confirmTitle": "Сохранить политику паролей?", + "confirmMessage": "После сохранения всем встроенным пользователям может потребоваться задать новый пароль, прежде чем продолжить работу. Root, внешние пользователи и сервисные учетные записи не затрагиваются — включая вас, если вы не учетная запись root. Это действие нельзя отменить.", + "saved": "Политика паролей сохранена. | Политика паролей сохранена. {count} пользователь должен сбросить пароль. | Политика паролей сохранена. {count} пользователей должны сбросить пароль.", + "saveFailed": "Не удалось сохранить политику паролей.", + "notAdminTitle": "Требуется доступ администратора", + "notAdmin": "Для настройки политики паролей требуются права администратора мета-организации.", + "loadFailedTitle": "Не удалось загрузить политику паролей", + "loadFailed": "При чтении текущей политики произошла ошибка.", + "rotation": "Ротация", + "rotationDays": "Период ротации", + "rotationDaysDesc": "Дней до истечения срока действия пароля. 0 = не истекает", + "rotationWarningDays": "Предупреждать до истечения", + "rotationWarningDaysDesc": "За сколько дней предупреждать при входе. 0 = без предупреждения", + "warningDaysTooLong": "Окно предупреждения не может быть больше периода ротации.", + "rotationPreview": "Пароль, заданный сегодня, истекает {expires}. Предупреждение при входе начнется {warns}.", + "rotationPreviewNoWarning": "Пароль, заданный сегодня, истекает {expires}. До этого предупреждение не показывается.", + "reuse": "Запрет повторного использования", + "historyCount": "Запоминаемых паролей", + "historyCountDesc": "Новый пароль не должен совпадать с таким числом предыдущих. 0 = без проверки", + "historyMaxRetained": "Хранимая история", + "historyMaxRetainedDesc": "Сколько прошлых паролей хранится для каждого пользователя", + "historyRetainedTooSmall": "Хранимая история должна быть не меньше числа запоминаемых паролей.", + "reuseExplainer": "Текущий пароль входит в запоминаемый набор — его повторное использование отклоняется этим правилом, а не отдельной проверкой.", + "lockout": "Блокировка аккаунта", + "lockoutThreshold": "Неудачных попыток до блокировки", + "lockoutThresholdDesc": "0 = блокировка отключена", + "lockoutBucketSize": "Попыток до каждой следующей блокировки", + "lockoutBucketSizeDesc": "0 = использовать значение выше", + "lockoutStartSecs": "Длительность первой блокировки", + "lockoutStartSecsDesc": "Секунды", + "lockoutMaxSecs": "Максимальная длительность блокировки", + "lockoutMaxSecsDesc": "Секунды — верхний предел, до которого ограничивается эскалация", + "lockoutStartTooLong": "Первая блокировка не может быть длиннее максимальной блокировки.", + "lockoutBackoff": "Эскалация", + "lockoutBackoffDesc": "Как каждая блокировка растёт относительно предыдущей", + "backoffExponential": "Экспоненциальная", + "backoffLinear": "Линейная", + "lockoutPreview": "{threshold} неудачных попыток → {first} · каждые {bucket} ещё → {rest} · не более {max}. Успешный вход сбрасывает всё.", + "sessionEnforcement": "Сессия и применение политики", + "cookieMaxAge": "Время жизни сессии", + "cookieMaxAgeDesc": "Сколько секунд сессия остаётся действительной. 0 = использовать значение экземпляра по умолчанию из {envVar}", + "applyToRoot": "Применить к root-аккаунту", + "applyToRootDesc": "Root не подчиняется ни одному из правил выше, пока это не включено", + "rootWarning": "Блокировка root может привести систему в невосстановимое состояние.", + "rootDialogTitle": "Применить политику к root?", + "rootDialogMessage": "Root будет подчиняться блокировке, смене пароля и принудительным сбросам, как любой другой нативный аккаунт. Если root — единственный административный аккаунт, блокировка или истечение срока, которое он не сможет продлить, оставят вас без способа вернуться в этот экземпляр.", + "rootDialogAck": "Я понимаю, что root может быть навсегда заблокирован в этом экземпляре.", + "rootDialogConfirm": "Применить к root" + }, + "passwordReset": { + "title": "Обновите свой пароль", + "signedInAs": "Вы вошли как {email}", + "reasonPolicyTightened": "Политика паролей изменилась. Обновите пароль, чтобы продолжить работу с OpenObserve.", + "reasonRotationExpired": "Срок действия вашего пароля истёк. Задайте новый, чтобы продолжить работу с OpenObserve.", + "reasonRotationWarning": "Срок действия вашего пароля скоро истечёт. Задайте новый сейчас, чтобы не потерять доступ к работе.", + "reused": "Этот пароль недавно использовался. Выберите пароль, которого нет среди последних {count} использованных паролей.", + "expiryBanner": "Срок действия вашего пароля истекает через {count} день. Обновите его сейчас, чтобы не потерять доступ к работе. | Срок действия вашего пароля истекает через {count} дней. Обновите его сейчас, чтобы не потерять доступ к работе.", + "expiryBannerAction": "Обновить сейчас", + "expiryBannerDismiss": "Закрыть", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "confirmPassword": "Подтвердите новый пароль", + "currentPasswordRequired": "Введите текущий пароль.", + "newPasswordRequired": "Введите новый пароль.", + "confirmPasswordRequired": "Повторно введите новый пароль.", + "mismatch": "Пароли не совпадают.", + "signOut": "Выйти", + "submit": "Обновить пароль", + "updated": "Пароль обновлён. Войдите снова.", + "updateFailed": "Не удалось обновить пароль.", + "req": { + "minLength": "Не менее {count} символов", + "maxLength": "Не более {count} символов", + "uppercase": "Одна заглавная буква", + "lowercase": "Одна строчная буква", + "digit": "Одна цифра", + "special": "Один специальный символ", + "specialFromSet": "Один специальный символ из {set}", + "unmet": "Пароль должен содержать: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Политика паролей", "loginCallback": "Обратный вызов входа", "slackOAuthCallback": "Подключение Slack", "emptyStateDemo": "Демо пустого состояния", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index 1c9390ed52..48be1c7ce0 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Ücretsiz denemeye başla" }, "login": { + "lockedOut": "Çok fazla başarısız oturum açma denemesi. {duration} sonra tekrar deneyin veya yöneticinizle iletişime geçin.", "redirectingToSsoMessage": "SSO giriş sayfasına yönlendiriliyorsunuz. Yönlendirilmezseniz, lütfen", "clickHere": "buraya tıklayın", "loginWithSso": "SSO ile giriş yap", @@ -1965,6 +1966,15 @@ "fieldRequired": "Alan zorunludur", "passwordRequired": "Şifre gereklidir.", "passwordPolicyHint": "Şifre 8-128 karakter olmalı ve en az bir küçük harf, bir büyük harf, bir basamak ve bir özel karakter içermelidir.", + "lockout": { + "locked": "Hesap kilitlendi", + "autoUnlock": "{duration} sonra otomatik olarak kilidi açılır.", + "staged": "Kaydettiğinizde kilidi açılacak.", + "unlock": "Kilidi aç", + "undo": "Kilitli tut", + "confirmTitle": "{email} kullanıcısının kilidi açılsın mı?", + "confirmMessage": "Bu işlem, başarısız deneme sayısını sıfırlar ve hemen tekrar oturum açmalarını sağlar. Parolalarını değiştirmez." + }, "currentPasswordRequired": "Geçerli şifre gereklidir.", "newPasswordRequired": "Yeni şifre gereklidir.", "otherOrgInvalid": "Kuruluş adı bir harfle başlamalı ve alfasayısal, _ veya - olmalıdır.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Hassas alanlar için şifreleme anahtarları", "regexPatternsDesc": "Maskeleme için yeniden kullanılabilir regex desenleri", "domainManagementDesc": "Oturum açmayı izin verilen SSO alan adlarıyla kısıtlayın", + "passwordPolicy": "Parola İlkesi", + "passwordPolicyDesc": "Yerel e-posta/parola hesapları için kimlik doğrulama ilkeleri, örnek genelinde uygulanır.", "pipelineDestinationsDesc": "Pipeline çıktısı için harici hedefler", "templatesDesc": "Yeniden kullanılabilir uyarı mesajı şablonları", "storageSettingsDesc": "Organizasyon bazında depolama yapılandırması", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Veri alım sürecini henüz başlatmadınız. Diğer sayfaları keşfetmek için lütfen veri alımını başlatın." + }, + "iam": { + "userSaveFailed": "Kullanıcı kaydedilemedi" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Üs" } }, + "passwordPolicy": { + "complexity": "Karmaşıklık", + "minLength": "Minimum uzunluk", + "minLengthDesc": "Kabul edilen en kısa parola", + "maxLength": "Maksimum uzunluk", + "maxLengthDesc": "0 = sınırsız", + "requireUppercase": "Büyük harf zorunlu", + "requireUppercaseDesc": "En az bir A–Z", + "requireLowercase": "Küçük harf zorunlu", + "requireLowercaseDesc": "En az bir a–z", + "requireDigit": "Rakam zorunlu", + "requireDigitDesc": "En az bir 0–9", + "requireSpecial": "Özel karakter zorunlu", + "requireSpecialDesc": "İzin verilen kümeden en az bir karakter", + "specialCharSet": "İzin verilen özel karakterler", + "specialCharSetDesc": "Herhangi bir alfanümerik olmayan karakteri kabul etmek için boş bırakın", + "maxLengthTooSmall": "Maksimum uzunluk en az minimum uzunluk kadar olmalı veya sınırsız için 0 olmalıdır.", + "confirmTitle": "Parola ilkesi kaydedilsin mi?", + "confirmMessage": "Kaydetmek, her yerel kullanıcıyı devam edebilmesi için yeni bir parola belirlemeye zorlayabilir. Root, harici kullanıcılar ve hizmet hesapları muaftır — root hesabı değilseniz bu sizi de kapsar. Bu işlem geri alınamaz.", + "saved": "Parola ilkesi kaydedildi. | Parola ilkesi kaydedildi. {count} kullanıcının parolasını sıfırlaması gerekiyor. | Parola ilkesi kaydedildi. {count} kullanıcının parolasını sıfırlaması gerekiyor.", + "saveFailed": "Parola ilkesi kaydedilemedi.", + "notAdminTitle": "Yönetici erişimi gerekli", + "notAdmin": "Parola ilkesini yapılandırmak için meta organizasyonun bir yöneticisi gerekir.", + "loadFailedTitle": "Parola ilkesi yüklenemedi", + "loadFailed": "Geçerli ilke okunurken bir şeyler ters gitti.", + "rotation": "Rotasyon", + "rotationDays": "Rotasyon süresi", + "rotationDaysDesc": "Parolanın süresi dolmadan önceki gün sayısı. 0 = hiç süresi dolmaz", + "rotationWarningDays": "Süresi dolmadan önce uyar", + "rotationWarningDaysDesc": "Oturum açma sırasında bildirim gün sayısı. 0 = uyarı yok", + "warningDaysTooLong": "Uyarı penceresi rotasyon süresinden uzun olamaz.", + "rotationPreview": "Bugün belirlenen bir parolanın süresi {expires} tarihinde dolar. Oturum açma uyarısı {warns} tarihinde başlar.", + "rotationPreviewNoWarning": "Bugün belirlenen bir parolanın süresi {expires} tarihinde dolar. O zamana kadar uyarı gösterilmez.", + "reuse": "Yeniden kullanımı önleme", + "historyCount": "Hatırlanan parolalar", + "historyCountDesc": "Yeni bir parola bu sayıdaki önceki parolayla eşleşemez. 0 = kontrol yok", + "historyMaxRetained": "Saklanan geçmiş", + "historyMaxRetainedDesc": "Kullanıcı başına kaç geçmiş parolanın saklandığı", + "historyRetainedTooSmall": "Saklanan geçmiş, hatırlanan parola sayısından az olamaz.", + "reuseExplainer": "Geçerli parola, hatırlanan kümenin bir parçası sayılır — onu yeniden kullanmak ayrı bir kontrolle değil, bu kuralla reddedilir.", + "lockout": "Hesap kilitleme", + "lockoutThreshold": "Kilitleme öncesi başarısız deneme sayısı", + "lockoutThresholdDesc": "0 = kilitleme devre dışı", + "lockoutBucketSize": "Her ek kilitleme öncesi deneme sayısı", + "lockoutBucketSizeDesc": "0 = yukarıdaki değeri kullan", + "lockoutStartSecs": "İlk kilitleme süresi", + "lockoutStartSecsDesc": "Saniye", + "lockoutMaxSecs": "Maksimum kilitleme süresi", + "lockoutMaxSecsDesc": "Saniye — artışın sınırlandığı üst değer", + "lockoutStartTooLong": "İlk kilitleme süresi maksimum kilitleme süresinden uzun olamaz.", + "lockoutBackoff": "Artış", + "lockoutBackoffDesc": "Her kilitlemenin bir öncekine göre nasıl arttığı", + "backoffExponential": "Üstel", + "backoffLinear": "Doğrusal", + "lockoutPreview": "{threshold} başarısız deneme → {first} · her {bucket} denemede bir → {rest} · üst sınır {max}. Başarılı bir oturum açma her şeyi sıfırlar.", + "sessionEnforcement": "Oturum ve zorlama", + "cookieMaxAge": "Oturum ömrü", + "cookieMaxAgeDesc": "Oturum açmış bir oturumun geçerli kaldığı saniye sayısı. 0 = {envVar} içindeki örnek varsayılanını kullan", + "applyToRoot": "Root hesabına uygula", + "applyToRootDesc": "Bu açık olmadıkça Root, yukarıdaki tüm kurallardan muaftır", + "rootWarning": "Root'un kilitlenmesi sistemin kurtarılamaz bir duruma girmesine neden olabilir.", + "rootDialogTitle": "Politika Root'a uygulansın mı?", + "rootDialogMessage": "Root, diğer tüm yerel hesaplar gibi kilitleme, rotasyon ve zorunlu sıfırlamalara tabi olacaktır. Root tek yönetici hesabınızsa, karşılayamayacağı bir kilitleme veya süre sonu bu örneğe geri dönüş yolunu tamamen kapatabilir.", + "rootDialogAck": "Root'un bu örnekten kalıcı olarak kilitlenebileceğini anlıyorum.", + "rootDialogConfirm": "Root'a uygula" + }, + "passwordReset": { + "title": "Parolanızı güncelleyin", + "signedInAs": "{email} olarak oturum açıldı", + "reasonPolicyTightened": "Parola politikası değişti. OpenObserve'u kullanmaya devam etmek için parolanızı güncelleyin.", + "reasonRotationExpired": "Parolanızın süresi doldu. OpenObserve'u kullanmaya devam etmek için yeni bir parola belirleyin.", + "reasonRotationWarning": "Parolanızın süresi yakında doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi yeni bir parola belirleyin.", + "reused": "Bu parola yakın zamanda kullanıldı. Son {count} parolanızda kullanmadığınız birini seçin.", + "expiryBanner": "Parolanızın süresi {count} gün içinde doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi güncelleyin. | Parolanızın süresi {count} gün içinde doluyor. Çalışmalarınıza erişimi kaybetmemek için şimdi güncelleyin.", + "expiryBannerAction": "Şimdi güncelle", + "expiryBannerDismiss": "Yoksay", + "currentPassword": "Mevcut parola", + "newPassword": "Yeni parola", + "confirmPassword": "Yeni parolayı doğrula", + "currentPasswordRequired": "Mevcut parola gereklidir.", + "newPasswordRequired": "Yeni parola gereklidir.", + "confirmPasswordRequired": "Lütfen yeni parolayı yeniden girin.", + "mismatch": "Parolalar eşleşmiyor.", + "signOut": "Oturumu kapat", + "submit": "Parolayı güncelle", + "updated": "Parolanız güncellendi. Lütfen tekrar oturum açın.", + "updateFailed": "Parolanız güncellenemedi.", + "req": { + "minLength": "En az {count} karakter", + "maxLength": "En fazla {count} karakter", + "uppercase": "Bir büyük harf", + "lowercase": "Bir küçük harf", + "digit": "Bir rakam", + "special": "Bir özel karakter", + "specialFromSet": "{set} kümesinden bir özel karakter", + "unmet": "Parola şunu içermelidir: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Parola Politikası", "loginCallback": "Giriş Geri Çağrısı", "slackOAuthCallback": "Slack'e bağlanılıyor", "emptyStateDemo": "Boş Durum Demosu", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index aa70a64971..d106c0d383 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "Bắt đầu dùng thử miễn phí" }, "login": { + "lockedOut": "Quá nhiều lần đăng nhập thất bại. Thử lại sau {duration}, hoặc liên hệ quản trị viên.", "redirectingToSsoMessage": "Đang chuyển hướng đến trang đăng nhập SSO. Nếu bạn không được chuyển hướng, vui lòng", "clickHere": "bấm vào đây", "loginWithSso": "Đăng nhập bằng SSO", @@ -1965,6 +1966,15 @@ "fieldRequired": "Trường này là bắt buộc", "passwordRequired": "Mật khẩu là bắt buộc.", "passwordPolicyHint": "Mật khẩu phải có 8-128 ký tự và chứa ít nhất một chữ cái thường, một chữ cái in hoa, một chữ số và một ký tự đặc biệt.", + "lockout": { + "locked": "Tài khoản đã bị khóa", + "autoUnlock": "Tự động mở khóa sau {duration}.", + "staged": "Sẽ được mở khóa khi bạn lưu.", + "unlock": "Mở khóa", + "undo": "Giữ khóa", + "confirmTitle": "Mở khóa {email}?", + "confirmMessage": "Thao tác này xóa số lần đăng nhập thất bại và cho phép họ đăng nhập lại ngay lập tức. Mật khẩu của họ không thay đổi." + }, "currentPasswordRequired": "Mật khẩu hiện tại là bắt buộc.", "newPasswordRequired": "Mật khẩu mới là bắt buộc.", "otherOrgInvalid": "Tên tổ chức phải bắt đầu bằng chữ cái và chỉ gồm chữ cái, số, _ hoặc -.", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "Khóa mã hóa cho trường nhạy cảm", "regexPatternsDesc": "Mẫu regex tái sử dụng cho việc làm rõ", "domainManagementDesc": "Giới hạn đăng nhập vào tên miền SSO được phép", + "passwordPolicy": "Chính sách mật khẩu", + "passwordPolicyDesc": "Chính sách xác thực cho các tài khoản email/mật khẩu nội bộ, áp dụng trên toàn instance.", "pipelineDestinationsDesc": "Mục tiêu bên ngoài cho đầu ra pipeline", "templatesDesc": "Mẫu tin nhắn cảnh báo tái sử dụng", "storageSettingsDesc": "Cấu hình lưu trữ theo tổ chức", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "Bạn chưa bắt đầu quá trình thu thập dữ liệu. Để khám phá các trang khác, vui lòng bắt đầu thu thập dữ liệu." + }, + "iam": { + "userSaveFailed": "Không thể lưu người dùng" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "Lũy thừa" } }, + "passwordPolicy": { + "complexity": "Độ phức tạp", + "minLength": "Độ dài tối thiểu", + "minLengthDesc": "Mật khẩu ngắn nhất được chấp nhận", + "maxLength": "Độ dài tối đa", + "maxLengthDesc": "0 = không giới hạn", + "requireUppercase": "Yêu cầu chữ hoa", + "requireUppercaseDesc": "Ít nhất một ký tự A–Z", + "requireLowercase": "Yêu cầu chữ thường", + "requireLowercaseDesc": "Ít nhất một ký tự a–z", + "requireDigit": "Yêu cầu chữ số", + "requireDigitDesc": "Ít nhất một chữ số 0–9", + "requireSpecial": "Yêu cầu ký tự đặc biệt", + "requireSpecialDesc": "Ít nhất một ký tự trong tập được phép", + "specialCharSet": "Ký tự đặc biệt được phép", + "specialCharSetDesc": "Để trống để chấp nhận mọi ký tự không phải chữ và số", + "maxLengthTooSmall": "Độ dài tối đa phải ít nhất bằng độ dài tối thiểu, hoặc 0 để không giới hạn.", + "confirmTitle": "Lưu chính sách mật khẩu?", + "confirmMessage": "Việc lưu có thể buộc mọi người dùng nội bộ đặt mật khẩu mới trước khi có thể tiếp tục. Tài khoản root, người dùng bên ngoài và tài khoản dịch vụ được miễn trừ — bao gồm cả bạn, trừ khi bạn là tài khoản root. Thao tác này không thể hoàn tác.", + "saved": "Đã lưu chính sách mật khẩu. | Đã lưu chính sách mật khẩu. {count} người dùng phải đặt lại mật khẩu. | Đã lưu chính sách mật khẩu. {count} người dùng phải đặt lại mật khẩu.", + "saveFailed": "Không thể lưu chính sách mật khẩu.", + "notAdminTitle": "Cần quyền quản trị viên", + "notAdmin": "Việc cấu hình chính sách mật khẩu yêu cầu quyền quản trị viên của tổ chức meta.", + "loadFailedTitle": "Không thể tải chính sách mật khẩu", + "loadFailed": "Đã xảy ra lỗi khi đọc chính sách hiện tại.", + "rotation": "Luân chuyển", + "rotationDays": "Chu kỳ luân chuyển", + "rotationDaysDesc": "Số ngày trước khi mật khẩu hết hạn. 0 = không bao giờ hết hạn", + "rotationWarningDays": "Cảnh báo trước khi hết hạn", + "rotationWarningDaysDesc": "Số ngày cảnh báo khi đăng nhập. 0 = không cảnh báo", + "warningDaysTooLong": "Khoảng thời gian cảnh báo không thể dài hơn chu kỳ luân chuyển.", + "rotationPreview": "Mật khẩu đặt hôm nay sẽ hết hạn vào {expires}. Cảnh báo đăng nhập bắt đầu từ {warns}.", + "rotationPreviewNoWarning": "Mật khẩu đặt hôm nay sẽ hết hạn vào {expires}. Không có cảnh báo nào trước thời điểm đó.", + "reuse": "Ngăn chặn tái sử dụng", + "historyCount": "Số mật khẩu ghi nhớ", + "historyCountDesc": "Mật khẩu mới không được trùng với số mật khẩu trước đó này. 0 = không kiểm tra", + "historyMaxRetained": "Số mật khẩu lưu giữ", + "historyMaxRetainedDesc": "Số mật khẩu cũ được lưu cho mỗi người dùng", + "historyRetainedTooSmall": "Số mật khẩu lưu giữ phải ít nhất bằng số mật khẩu ghi nhớ.", + "reuseExplainer": "Mật khẩu hiện tại được tính là một trong tập mật khẩu ghi nhớ — việc tái sử dụng nó bị từ chối bởi quy tắc này, chứ không phải bởi một kiểm tra riêng.", + "lockout": "Khóa tài khoản", + "lockoutThreshold": "Số lần thử thất bại trước khi khóa", + "lockoutThresholdDesc": "0 = tắt khóa tài khoản", + "lockoutBucketSize": "Số lần thử trước mỗi lần khóa tiếp theo", + "lockoutBucketSizeDesc": "0 = dùng lại giá trị ở trên", + "lockoutStartSecs": "Thời gian khóa lần đầu", + "lockoutStartSecsDesc": "Giây", + "lockoutMaxSecs": "Thời gian khóa tối đa", + "lockoutMaxSecsDesc": "Giây — giới hạn tối đa mà mức tăng dừng lại", + "lockoutStartTooLong": "Thời gian khóa lần đầu không được dài hơn thời gian khóa tối đa.", + "lockoutBackoff": "Mức tăng dần", + "lockoutBackoffDesc": "Cách mỗi lần khóa tăng lên so với lần trước", + "backoffExponential": "Lũy thừa", + "backoffLinear": "Tuyến tính", + "lockoutPreview": "{threshold} lần thất bại → {first} · cứ thêm {bucket} lần → {rest} · tối đa {max}. Đăng nhập thành công sẽ đặt lại mọi thứ.", + "sessionEnforcement": "Phiên làm việc & thực thi", + "cookieMaxAge": "Thời hạn phiên", + "cookieMaxAgeDesc": "Số giây một phiên đăng nhập còn hiệu lực. 0 = dùng giá trị mặc định của instance từ {envVar}", + "applyToRoot": "Áp dụng cho tài khoản root", + "applyToRootDesc": "Tài khoản root được miễn mọi quy tắc ở trên trừ khi bật tùy chọn này", + "rootWarning": "Khóa tài khoản root có thể khiến hệ thống rơi vào trạng thái không thể khôi phục.", + "rootDialogTitle": "Áp dụng chính sách cho tài khoản root?", + "rootDialogMessage": "Tài khoản root sẽ bị khóa, xoay mật khẩu và buộc đặt lại như mọi tài khoản native khác. Nếu root là tài khoản quản trị duy nhất của bạn, việc bị khóa hoặc hết hạn mà tài khoản không thể đáp ứng sẽ khiến bạn không còn cách nào truy cập lại instance này.", + "rootDialogAck": "Tôi hiểu rằng tài khoản root có thể bị khóa vĩnh viễn khỏi instance này.", + "rootDialogConfirm": "Áp dụng cho root" + }, + "passwordReset": { + "title": "Cập nhật mật khẩu của bạn", + "signedInAs": "Đã đăng nhập với {email}", + "reasonPolicyTightened": "Chính sách mật khẩu đã thay đổi. Hãy cập nhật mật khẩu để tiếp tục sử dụng OpenObserve.", + "reasonRotationExpired": "Mật khẩu của bạn đã hết hạn. Hãy đặt mật khẩu mới để tiếp tục sử dụng OpenObserve.", + "reasonRotationWarning": "Mật khẩu của bạn sắp hết hạn. Hãy đặt mật khẩu mới ngay để tránh bị khóa khỏi công việc của bạn.", + "reused": "Mật khẩu này đã được dùng gần đây. Hãy chọn mật khẩu bạn chưa dùng trong {count} mật khẩu gần nhất.", + "expiryBanner": "Mật khẩu của bạn sẽ hết hạn sau {count} ngày. Hãy cập nhật ngay để tránh bị khóa khỏi công việc của bạn. | Mật khẩu của bạn sẽ hết hạn sau {count} ngày. Hãy cập nhật ngay để tránh bị khóa khỏi công việc của bạn.", + "expiryBannerAction": "Cập nhật ngay", + "expiryBannerDismiss": "Bỏ qua", + "currentPassword": "Mật khẩu hiện tại", + "newPassword": "Mật khẩu mới", + "confirmPassword": "Xác nhận mật khẩu mới", + "currentPasswordRequired": "Cần nhập mật khẩu hiện tại.", + "newPasswordRequired": "Cần nhập mật khẩu mới.", + "confirmPasswordRequired": "Vui lòng nhập lại mật khẩu mới.", + "mismatch": "Mật khẩu không khớp.", + "signOut": "Đăng xuất", + "submit": "Cập nhật mật khẩu", + "updated": "Mật khẩu của bạn đã được cập nhật. Vui lòng đăng nhập lại.", + "updateFailed": "Không thể cập nhật mật khẩu của bạn.", + "req": { + "minLength": "Ít nhất {count} ký tự", + "maxLength": "Không quá {count} ký tự", + "uppercase": "Một chữ cái in hoa", + "lowercase": "Một chữ cái in thường", + "digit": "Một chữ số", + "special": "Một ký tự đặc biệt", + "specialFromSet": "Một ký tự đặc biệt từ {set}", + "unmet": "Mật khẩu phải có: {requirement}" + } + }, "routeTitles": { + "passwordPolicy": "Chính sách mật khẩu", "loginCallback": "Callback đăng nhập", "slackOAuthCallback": "Đang kết nối Slack", "emptyStateDemo": "Demo trạng thái trống", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index 6b87b83ef0..c340cafbc6 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "开始免费试用" }, "login": { + "lockedOut": "登录失败次数过多。请在 {duration} 后重试,或联系管理员。", "redirectingToSsoMessage": "正在重定向到 SSO 登录页面。如果未自动重定向,请", "clickHere": "点击此处", "loginWithSso": "使用 SSO 登录", @@ -1965,6 +1966,15 @@ "fieldRequired": "字段为必填字段", "passwordRequired": "密码为必填项。", "passwordPolicyHint": "密码必须为 8-128 个字符,并且至少包含一个小写字母、一个大写字母、一个数字和一个特殊字符。", + "lockout": { + "locked": "账户已锁定", + "autoUnlock": "将在 {duration} 后自动解锁。", + "staged": "保存后将自动解锁。", + "unlock": "解锁", + "undo": "保持锁定", + "confirmTitle": "解锁 {email}?", + "confirmMessage": "这会清除其失败尝试次数,使其可立即重新登录。不会更改其密码。" + }, "currentPasswordRequired": "当前密码为必填项。", "newPasswordRequired": "需要新密码。", "otherOrgInvalid": "组织名称必须以字母开头,并且必须是字母数字、_ 或-。", @@ -8810,6 +8820,8 @@ "cipherKeysDesc": "用于敏感字段的加密密钥", "regexPatternsDesc": "用于脱敏的可重用 regex 模式", "domainManagementDesc": "限制仅允许的 SSO 域登录", + "passwordPolicy": "密码策略", + "passwordPolicyDesc": "适用于原生邮箱/密码账户的认证策略,在整个实例范围内生效。", "pipelineDestinationsDesc": "流水线输出的外部目标", "templatesDesc": "可重用的告警消息模板", "storageSettingsDesc": "按组织划分的存储配置", @@ -16043,6 +16055,9 @@ }, "layouts": { "ingestionNotStarted": "您尚未开始数据接入流程。要浏览其他页面,请先开始数据接入。" + }, + "iam": { + "userSaveFailed": "无法保存用户" } }, "cipherKeys": { @@ -16460,7 +16475,106 @@ "exponentName": "指数" } }, + "passwordPolicy": { + "complexity": "复杂度", + "minLength": "最小长度", + "minLengthDesc": "可接受的最短密码长度", + "maxLength": "最大长度", + "maxLengthDesc": "0 = 不限制", + "requireUppercase": "要求包含大写字母", + "requireUppercaseDesc": "至少包含一个大写字母 A–Z", + "requireLowercase": "要求包含小写字母", + "requireLowercaseDesc": "至少包含一个小写字母 a–z", + "requireDigit": "要求包含数字", + "requireDigitDesc": "至少包含一个数字 0–9", + "requireSpecial": "要求包含特殊字符", + "requireSpecialDesc": "至少包含一个允许的特殊字符", + "specialCharSet": "允许的特殊字符", + "specialCharSetDesc": "留空则接受任意非字母数字字符", + "maxLengthTooSmall": "最大长度必须不小于最小长度,或设为 0 表示不限制。", + "confirmTitle": "保存密码策略?", + "confirmMessage": "保存后可能强制所有原生用户在继续使用前设置新密码。Root、外部用户和服务账户除外——这包括你本人,除非你是 root 账户。此操作无法撤销。", + "saved": "密码策略已保存。 | 密码策略已保存。{count} 个用户必须重置密码。 | 密码策略已保存。{count} 个用户必须重置密码。", + "saveFailed": "无法保存密码策略。", + "notAdminTitle": "需要管理员权限", + "notAdmin": "配置密码策略需要 meta 组织的管理员权限。", + "loadFailedTitle": "无法加载密码策略", + "loadFailed": "读取当前策略时出错。", + "rotation": "轮换", + "rotationDays": "轮换周期", + "rotationDaysDesc": "密码过期前的天数。0 = 永不过期", + "rotationWarningDays": "过期前提醒", + "rotationWarningDaysDesc": "登录时提前提醒的天数。0 = 不提醒", + "warningDaysTooLong": "提醒窗口不能长于轮换周期。", + "rotationPreview": "今天设置的密码将于 {expires} 过期。登录提醒将于 {warns} 开始。", + "rotationPreviewNoWarning": "今天设置的密码将于 {expires} 过期。在此之前不会显示任何提醒。", + "reuse": "防止重复使用", + "historyCount": "记住的密码数", + "historyCountDesc": "新密码不得与此数量的历史密码相同。0 = 不检查", + "historyMaxRetained": "保留的历史密码数", + "historyMaxRetainedDesc": "每个用户保留的历史密码数量", + "historyRetainedTooSmall": "保留的历史密码数必须不小于记住的密码数量。", + "reuseExplainer": "当前密码属于已记住的密码之一——重复使用它会被此规则拒绝,而不是通过单独的检查。", + "lockout": "账户锁定", + "lockoutThreshold": "锁定前的失败尝试次数", + "lockoutThresholdDesc": "0 = 禁用锁定", + "lockoutBucketSize": "每次后续锁定前的尝试次数", + "lockoutBucketSizeDesc": "0 = 重用上面的值", + "lockoutStartSecs": "首次锁定时长", + "lockoutStartSecsDesc": "秒", + "lockoutMaxSecs": "最大锁定时长", + "lockoutMaxSecsDesc": "秒 — 递增锁定时长封顶于此值", + "lockoutStartTooLong": "首次锁定时长不能超过最大锁定时长。", + "lockoutBackoff": "递增", + "lockoutBackoffDesc": "每次锁定时长相对于上一次的增长方式", + "backoffExponential": "指数", + "backoffLinear": "线性", + "lockoutPreview": "{threshold} 次失败 → {first} · 每再失败 {bucket} 次 → {rest} · 上限为 {max}。成功登录会重置一切。", + "sessionEnforcement": "会话与强制执行", + "cookieMaxAge": "会话有效期", + "cookieMaxAgeDesc": "已登录会话保持有效的秒数。0 = 使用来自 {envVar} 的实例默认值", + "applyToRoot": "应用到 root 账户", + "applyToRootDesc": "除非启用此项,否则 root 不受上述所有规则的约束", + "rootWarning": "锁定 root 可能导致系统进入不可恢复状态。", + "rootDialogTitle": "将策略应用到 root?", + "rootDialogMessage": "root 将像任何其他原生账户一样受到锁定、轮换和强制重置的约束。如果 root 是您唯一的管理账户,一次锁定或无法满足的到期要求将使您无法再回到此实例。", + "rootDialogAck": "我明白 root 可能会被永久锁定在此实例之外。", + "rootDialogConfirm": "应用到 root" + }, + "passwordReset": { + "title": "更新您的密码", + "signedInAs": "已登录为 {email}", + "reasonPolicyTightened": "密码策略已更改。请更新您的密码以继续使用 OpenObserve。", + "reasonRotationExpired": "您的密码已过期。请设置新密码以继续使用 OpenObserve。", + "reasonRotationWarning": "您的密码即将过期。请立即设置新密码,以免无法继续您的工作。", + "reused": "此密码最近使用过。请选择一个未在您最近 {count} 个密码中使用过的密码。", + "expiryBanner": "您的密码将在 {count} 天后过期。请立即更新,以免无法继续您的工作。 | 您的密码将在 {count} 天后过期。请立即更新,以免无法继续您的工作。", + "expiryBannerAction": "立即更新", + "expiryBannerDismiss": "忽略", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmPassword": "确认新密码", + "currentPasswordRequired": "当前密码为必填项。", + "newPasswordRequired": "新密码为必填项。", + "confirmPasswordRequired": "请重新输入新密码。", + "mismatch": "密码不匹配。", + "signOut": "退出", + "submit": "更新密码", + "updated": "您的密码已更新。请重新登录。", + "updateFailed": "无法更新您的密码。", + "req": { + "minLength": "至少 {count} 个字符", + "maxLength": "不超过 {count} 个字符", + "uppercase": "一个大写字母", + "lowercase": "一个小写字母", + "digit": "一个数字", + "special": "一个特殊字符", + "specialFromSet": "来自 {set} 的一个特殊字符", + "unmet": "密码必须包含:{requirement}" + } + }, "routeTitles": { + "passwordPolicy": "密码策略", "loginCallback": "登录回调", "slackOAuthCallback": "正在连接 Slack", "emptyStateDemo": "空状态演示", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index 0da1e0c841..43ed5c0a39 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -1906,6 +1906,7 @@ "startFreeTrial": "開始免費試用" }, "login": { + "lockedOut": "登入嘗試失敗次數過多。請在 {duration} 後再試,或聯絡您的管理員。", "redirectingToSsoMessage": "正在重新導向至 SSO 登入頁面。如果您未被重新導向,請", "clickHere": "按一下這裡", "loginWithSso": "使用 SSO 登入", @@ -1965,6 +1966,15 @@ "fieldRequired": "此欄位為必填", "passwordRequired": "必須輸入密碼。", "passwordPolicyHint": "密碼長度必須為 8 至 128 個字元,且至少包含一個小寫字母、一個大寫字母、一個數字與一個特殊字元。", + "lockout": { + "locked": "帳號已鎖定", + "autoUnlock": "將在 {duration} 後自動解鎖。", + "staged": "儲存後將解鎖。", + "unlock": "解鎖", + "undo": "保持鎖定", + "confirmTitle": "要解鎖 {email} 嗎?", + "confirmMessage": "這會清除該使用者的失敗嘗試次數,並讓其可以立即重新登入。這不會變更其密碼。" + }, "currentPasswordRequired": "必須輸入目前的密碼。", "newPasswordRequired": "必須輸入新密碼。", "otherOrgInvalid": "組織名稱必須以字母開頭,且僅能包含英數字元、_ 或 -。", @@ -8805,6 +8815,8 @@ "cipherKeysDesc": "用於敏感欄位的加密金鑰", "regexPatternsDesc": "用於遮蔽的可重複使用 regex 模式", "domainManagementDesc": "限制僅允許的 SSO 網域登入", + "passwordPolicy": "密碼原則", + "passwordPolicyDesc": "適用於原生電子郵件/密碼帳號並套用至整個執行個體的驗證原則。", "pipelineDestinationsDesc": "管線輸出的外部目標", "templatesDesc": "可重複使用的警示訊息範本", "storageSettingsDesc": "各組織的儲存設定", @@ -16038,6 +16050,9 @@ }, "layouts": { "ingestionNotStarted": "您尚未開始資料擷取流程。若要探索其他頁面,請先開始資料擷取。" + }, + "iam": { + "userSaveFailed": "無法儲存使用者" } }, "cipherKeys": { @@ -16455,7 +16470,106 @@ "exponentName": "冪運算" } }, + "passwordPolicy": { + "complexity": "複雜度", + "minLength": "最小長度", + "minLengthDesc": "可接受的最短密碼", + "maxLength": "最大長度", + "maxLengthDesc": "0 = 無限制", + "requireUppercase": "要求大寫字母", + "requireUppercaseDesc": "至少一個 A–Z", + "requireLowercase": "要求小寫字母", + "requireLowercaseDesc": "至少一個 a–z", + "requireDigit": "要求數字", + "requireDigitDesc": "至少一個 0–9", + "requireSpecial": "要求特殊字元", + "requireSpecialDesc": "至少一個來自允許集合的字元", + "specialCharSet": "允許的特殊字元", + "specialCharSetDesc": "留空以接受任何非英數字元", + "maxLengthTooSmall": "最大長度必須至少等於最小長度,或設為 0 表示無限制。", + "confirmTitle": "儲存密碼原則?", + "confirmMessage": "儲存後可能會強制所有原生使用者在繼續之前設定新密碼。Root、外部使用者與服務帳號不受此限——除非您是 root 帳號,否則這也包含您。此操作無法復原。", + "saved": "密碼原則已儲存。 | 密碼原則已儲存。{count} 位使用者必須重設密碼。 | 密碼原則已儲存。{count} 位使用者必須重設密碼。", + "saveFailed": "無法儲存密碼原則。", + "notAdminTitle": "需要管理員存取權", + "notAdmin": "設定密碼原則需要中繼組織的管理員。", + "loadFailedTitle": "無法載入密碼原則", + "loadFailed": "讀取目前原則時發生錯誤。", + "rotation": "輪換", + "rotationDays": "輪換期間", + "rotationDaysDesc": "密碼到期前的天數。0 = 永不到期", + "rotationWarningDays": "到期前警告", + "rotationWarningDaysDesc": "登入時提前通知的天數。0 = 不顯示警告", + "warningDaysTooLong": "警告期間不能長於輪換期間。", + "rotationPreview": "今天設定的密碼將於 {expires} 到期。登入警告會從 {warns} 開始。", + "rotationPreviewNoWarning": "今天設定的密碼將於 {expires} 到期。在此之前不會顯示警告。", + "reuse": "防止重複使用", + "historyCount": "記住的密碼數", + "historyCountDesc": "新密碼不得與先前這麼多個密碼相同。0 = 不檢查", + "historyMaxRetained": "保留歷史筆數", + "historyMaxRetainedDesc": "每位使用者保留多少個過去密碼", + "historyRetainedTooSmall": "保留歷史筆數必須至少等於記住的密碼數。", + "reuseExplainer": "目前密碼會計入已記住的密碼集合之一——重複使用目前密碼會遭到此規則拒絕,而非由另一項檢查拒絕。", + "lockout": "帳戶鎖定", + "lockoutThreshold": "鎖定前的失敗嘗試次數", + "lockoutThresholdDesc": "0 = 停用鎖定", + "lockoutBucketSize": "每次進一步鎖定前的嘗試次數", + "lockoutBucketSizeDesc": "0 = 沿用上方的值", + "lockoutStartSecs": "首次鎖定持續時間", + "lockoutStartSecsDesc": "秒", + "lockoutMaxSecs": "最長鎖定持續時間", + "lockoutMaxSecsDesc": "秒 — 遞增鎖定所封頂的上限", + "lockoutStartTooLong": "首次鎖定不得長於最長鎖定時間。", + "lockoutBackoff": "遞增", + "lockoutBackoffDesc": "每次鎖定相較前一次如何遞增", + "backoffExponential": "指數", + "backoffLinear": "線性", + "lockoutPreview": "{threshold} 次失敗 → {first} · 每再 {bucket} 次 → {rest} · 上限為 {max}。成功登入會重設所有設定。", + "sessionEnforcement": "工作階段與強制執行", + "cookieMaxAge": "工作階段存續時間", + "cookieMaxAgeDesc": "已登入工作階段保持有效的秒數。0 = 使用 {envVar} 中的執行個體預設值", + "applyToRoot": "套用至 root 帳戶", + "applyToRootDesc": "除非開啟此選項,否則 root 不受上述所有規則限制", + "rootWarning": "鎖定 root 可能導致系統進入無法復原的狀態。", + "rootDialogTitle": "要將原則套用至 root 嗎?", + "rootDialogMessage": "root 將與其他原生帳戶一樣受到鎖定、輪替和強制重設的約束。如果 root 是您唯一的管理帳戶,一旦遭到鎖定或發生其無法符合的到期情形,將再也無法進入此執行個體。", + "rootDialogAck": "我了解 root 可能會永久被鎖定而無法進入此執行個體。", + "rootDialogConfirm": "套用至 root" + }, + "passwordReset": { + "title": "更新您的密碼", + "signedInAs": "已登入為 {email}", + "reasonPolicyTightened": "密碼原則已變更。請更新您的密碼以繼續使用 OpenObserve。", + "reasonRotationExpired": "您的密碼已過期。請設定新密碼以繼續使用 OpenObserve。", + "reasonRotationWarning": "您的密碼即將過期。請立即設定新密碼,以免無法繼續您的工作。", + "reused": "此密碼最近曾使用過。請選擇一個未出現在您最近 {count} 個密碼中的密碼。", + "expiryBanner": "您的密碼將於 {count} 天後過期。請立即更新,以免無法繼續您的工作。 | 您的密碼將於 {count} 天後過期。請立即更新,以免無法繼續您的工作。", + "expiryBannerAction": "立即更新", + "expiryBannerDismiss": "關閉", + "currentPassword": "目前密碼", + "newPassword": "新密碼", + "confirmPassword": "確認新密碼", + "currentPasswordRequired": "必須輸入目前密碼。", + "newPasswordRequired": "必須輸入新密碼。", + "confirmPasswordRequired": "請再次輸入新密碼。", + "mismatch": "密碼不相符。", + "signOut": "登出", + "submit": "更新密碼", + "updated": "您的密碼已更新。請重新登入。", + "updateFailed": "無法更新您的密碼。", + "req": { + "minLength": "至少 {count} 個字元", + "maxLength": "不超過 {count} 個字元", + "uppercase": "一個大寫字母", + "lowercase": "一個小寫字母", + "digit": "一個數字", + "special": "一個特殊字元", + "specialFromSet": "一個來自 {set} 的特殊字元", + "unmet": "密碼必須包含:{requirement}" + } + }, "routeTitles": { + "passwordPolicy": "密碼原則", "loginCallback": "登入回呼", "slackOAuthCallback": "正在連線 Slack", "emptyStateDemo": "空狀態示範", diff --git a/web/src/services/http.ts b/web/src/services/http.ts index 41e59e37b6..2174393dc8 100644 --- a/web/src/services/http.ts +++ b/web/src/services/http.ts @@ -19,6 +19,8 @@ import axios from "axios"; import config from "../aws-exports"; import { useLocalUserInfo, useLocalCurrentUser } from "@/utils/zincutils"; import { addUnauthorizedError } from "@/composables/useUnauthorizedErrorGrouper"; +import { usePasswordReset } from "@/composables/usePasswordReset"; +import { isPasswordResetError } from "@/utils/passwordResetErrors"; // Shared refresh state — ensures only one dex_refresh request is in-flight // at a time across all axios instances and streaming fetch requests. All @@ -107,6 +109,12 @@ const http = ({ headers } = {} as any) => { return response; }, function (error) { + // Ahead of the status switch: a reset-required 403 is a policy state, not an authorization failure. + if (isPasswordResetError(error)) { + usePasswordReset().open(error.response.data.reason); + // A blocked page fires many requests behind an undismissable dialog; one toast per rejection would stack. + return new Promise(() => {}); + } if (error && error.response && error.response.status) { switch (error.response.status) { case 400: diff --git a/web/src/services/passwordPolicy.ts b/web/src/services/passwordPolicy.ts new file mode 100644 index 0000000000..146b62feca --- /dev/null +++ b/web/src/services/passwordPolicy.ts @@ -0,0 +1,72 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import http from "./http"; + +/** What a password must look like. Mirrors `PasswordComplexity` in the Rust API. */ +export interface PasswordComplexity { + min_length: number; + /** `0` means unbounded. */ + max_length: number; + require_uppercase: boolean; + require_lowercase: boolean; + require_digit: boolean; + require_special: boolean; + /** Empty means any non-alphanumeric character counts as special. */ + special_char_set: string; +} + +export interface LockoutPolicy { + threshold: number; + bucket_size: number; + start_secs: number; + max_secs: number; + backoff: "linear" | "exponential"; +} + +/** The whole instance policy. Mirrors `PasswordPolicy` in the Rust API. */ +export interface PasswordPolicy extends PasswordComplexity { + rotation_days: number; + rotation_warning_days: number; + history_count: number; + history_max_retained: number; + lockout: LockoutPolicy; + /** `0` defers to the instance's `ZO_COOKIE_MAX_AGE`. */ + cookie_max_age_secs: number; + apply_to_root: boolean; +} + +export interface SetPasswordPolicyResponse { + policy: PasswordPolicy; + users_flagged: number; +} + +const passwordPolicy = { + /** Readable by any authenticated user — the only policy route a flagged user can reach. */ + getComplexity: (orgIdentifier: string) => { + return http().get(`/api/${orgIdentifier}/password_complexity`); + }, + getPolicy: (metaOrg: string) => { + return http().get(`/api/${metaOrg}/settings/password_policy`); + }, + updatePolicy: (metaOrg: string, policy: PasswordPolicy) => { + return http().put( + `/api/${metaOrg}/settings/password_policy`, + policy, + ); + }, +}; + +export default passwordPolicy; diff --git a/web/src/services/users.ts b/web/src/services/users.ts index 8dbbeb4ced..fb70d6e726 100644 --- a/web/src/services/users.ts +++ b/web/src/services/users.ts @@ -27,6 +27,10 @@ const users = { update: (data: any, org_identifier: string, user_email: string) => { return http().put(`/api/${org_identifier}/users/${user_email}`, data); }, + /** One member with their failed-login lockout state. Enterprise only; Root/Admin on the org. */ + get: (org_identifier: string, user_email: string) => { + return http().get(`/api/${org_identifier}/users/${user_email}`); + }, updateexistinguser: (data: any, org_identifier: string, user_email: string) => { return http().post(`/api/${org_identifier}/users/${user_email}`, data); }, diff --git a/web/src/utils/passwordComplexity.spec.ts b/web/src/utils/passwordComplexity.spec.ts new file mode 100644 index 0000000000..bea77e1f10 --- /dev/null +++ b/web/src/utils/passwordComplexity.spec.ts @@ -0,0 +1,178 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; + +import type { PasswordComplexity } from "@/services/passwordPolicy"; +import type { TranslateFn } from "@/types/i18n"; +import { + buildPasswordRequirements, + countMetRequirements, + DEFAULT_COMPLEXITY, + firstUnmetRequirement, + reuseRejection, + validateAgainstComplexity, +} from "./passwordComplexity"; + +// Echoes the key so assertions read against stable ids rather than English copy. +const t = ((key: string) => key) as unknown as TranslateFn; + +const complexity = (overrides: Partial = {}): PasswordComplexity => ({ + ...DEFAULT_COMPLEXITY, + ...overrides, +}); + +describe("buildPasswordRequirements", () => { + it("emits only the requirements the policy actually enforces", () => { + const rows = buildPasswordRequirements(complexity(), t); + + expect(rows.map((r) => r.key)).toEqual(["minLength"]); + }); + + it("emits no rows at all when the policy enforces nothing", () => { + const rows = buildPasswordRequirements(complexity({ min_length: 0 }), t); + + expect(rows).toEqual([]); + }); + + it("emits every row for a fully configured policy", () => { + const rows = buildPasswordRequirements( + complexity({ + min_length: 12, + max_length: 64, + require_uppercase: true, + require_lowercase: true, + require_digit: true, + require_special: true, + }), + t, + ); + + expect(rows.map((r) => r.key)).toEqual([ + "minLength", + "maxLength", + "uppercase", + "lowercase", + "digit", + "special", + ]); + }); + + it("omits maxLength when unbounded", () => { + const rows = buildPasswordRequirements(complexity({ max_length: 0 }), t); + + expect(rows.some((r) => r.key === "maxLength")).toBe(false); + }); +}); + +describe("requirement checks", () => { + const rowFor = (key: string, policy: PasswordComplexity) => + buildPasswordRequirements(policy, t).find((r) => r.key === key)!; + + it("min and max length are inclusive bounds", () => { + const policy = complexity({ min_length: 4, max_length: 6 }); + + expect(rowFor("minLength", policy).isMet("abcd")).toBe(true); + expect(rowFor("minLength", policy).isMet("abc")).toBe(false); + expect(rowFor("maxLength", policy).isMet("abcdef")).toBe(true); + expect(rowFor("maxLength", policy).isMet("abcdefg")).toBe(false); + }); + + it("treats any non-alphanumeric as special when the set is empty", () => { + const row = rowFor("special", complexity({ require_special: true, special_char_set: "" })); + + expect(row.isMet("abc-def")).toBe(true); + expect(row.isMet("abcdef1")).toBe(false); + }); + + it("honours a configured special-character set", () => { + const row = rowFor("special", complexity({ require_special: true, special_char_set: "!@#" })); + + expect(row.isMet("abc!")).toBe(true); + // A special character outside the configured set does not count. + expect(row.isMet("abc-")).toBe(false); + }); + + it("checks character classes independently", () => { + const policy = complexity({ + require_uppercase: true, + require_lowercase: true, + require_digit: true, + }); + + expect(rowFor("uppercase", policy).isMet("aB1")).toBe(true); + expect(rowFor("uppercase", policy).isMet("ab1")).toBe(false); + expect(rowFor("lowercase", policy).isMet("AB1")).toBe(false); + expect(rowFor("digit", policy).isMet("aBc")).toBe(false); + }); +}); + +describe("countMetRequirements", () => { + it("counts only satisfied rows", () => { + const rows = buildPasswordRequirements( + complexity({ min_length: 4, require_uppercase: true, require_digit: true }), + t, + ); + + expect(countMetRequirements(rows, "")).toBe(0); + expect(countMetRequirements(rows, "abcd")).toBe(1); + expect(countMetRequirements(rows, "Abcd")).toBe(2); + expect(countMetRequirements(rows, "Abcd1")).toBe(3); + }); +}); + +describe("firstUnmetRequirement / validateAgainstComplexity", () => { + it("returns null once every requirement is satisfied", () => { + const policy = complexity({ min_length: 4, require_digit: true }); + + expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "abc1")).toBeNull(); + expect(validateAgainstComplexity("abc1", policy, t)).toBeNull(); + }); + + it("names the first failure in policy order", () => { + const policy = complexity({ min_length: 8, require_digit: true }); + + expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "ab")?.key).toBe( + "minLength", + ); + expect(firstUnmetRequirement(buildPasswordRequirements(policy, t), "abcdefgh")?.key).toBe( + "digit", + ); + }); + + it("accepts anything when the policy enforces nothing", () => { + expect(validateAgainstComplexity("a", complexity({ min_length: 0 }), t)).toBeNull(); + }); +}); + +describe("reuseRejection", () => { + const tWithParams = ((key: string, params: Record) => + `${key}:${params.count}`) as unknown as TranslateFn; + const error = (message: string) => ({ response: { status: 400, data: { message } } }); + + it("reads the depth out of the server's message", () => { + expect( + reuseRejection( + error("New password matches one of your last 5 passwords, please choose a different one"), + tWithParams, + ), + ).toBe("passwordReset.reused:5"); + }); + + it("leaves every other failure to the toast", () => { + expect(reuseRejection(error("Password too short"), tWithParams)).toBeNull(); + expect(reuseRejection(new Error("network"), tWithParams)).toBeNull(); + }); +}); diff --git a/web/src/utils/passwordComplexity.ts b/web/src/utils/passwordComplexity.ts new file mode 100644 index 0000000000..82097320ba --- /dev/null +++ b/web/src/utils/passwordComplexity.ts @@ -0,0 +1,140 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Pure (no Vue, no fetching) so the reset dialog and the user forms share one rule set. + +import type { PasswordComplexity } from "@/services/passwordPolicy"; +import { raw, type I18nText, type TranslateFn } from "@/types/i18n"; + +export type PasswordRequirementKey = + "minLength" | "maxLength" | "uppercase" | "lowercase" | "digit" | "special"; + +export interface PasswordRequirement { + key: PasswordRequirementKey; + label: I18nText; + isMet: (_password: string) => boolean; +} + +/** What an instance that has never configured a policy enforces. */ +export const DEFAULT_COMPLEXITY: PasswordComplexity = { + min_length: 8, + max_length: 0, + require_uppercase: false, + require_lowercase: false, + require_digit: false, + require_special: false, + special_char_set: "", +}; + +const isSpecialChar = (char: string, specialCharSet: string): boolean => { + if (specialCharSet) return specialCharSet.includes(char); + return !/[a-zA-Z0-9]/.test(char); +}; + +const hasSpecialChar = (password: string, specialCharSet: string): boolean => + password.split("").some((char) => isSpecialChar(char, specialCharSet)); + +/** The requirement rows for a policy, generated so the console never states a rule the server does not check. */ +export const buildPasswordRequirements = ( + complexity: PasswordComplexity, + t: TranslateFn, +): PasswordRequirement[] => { + const requirements: PasswordRequirement[] = []; + + if (complexity.min_length > 0) { + requirements.push({ + key: "minLength", + label: t("passwordReset.req.minLength", { count: complexity.min_length }), + isMet: (password) => password.length >= complexity.min_length, + }); + } + + if (complexity.max_length > 0) { + requirements.push({ + key: "maxLength", + label: t("passwordReset.req.maxLength", { count: complexity.max_length }), + isMet: (password) => password.length <= complexity.max_length, + }); + } + + if (complexity.require_uppercase) { + requirements.push({ + key: "uppercase", + label: t("passwordReset.req.uppercase"), + isMet: (password) => /[A-Z]/.test(password), + }); + } + + if (complexity.require_lowercase) { + requirements.push({ + key: "lowercase", + label: t("passwordReset.req.lowercase"), + isMet: (password) => /[a-z]/.test(password), + }); + } + + if (complexity.require_digit) { + requirements.push({ + key: "digit", + label: t("passwordReset.req.digit"), + isMet: (password) => /[0-9]/.test(password), + }); + } + + if (complexity.require_special) { + requirements.push({ + key: "special", + label: complexity.special_char_set + ? t("passwordReset.req.specialFromSet", { set: raw(complexity.special_char_set) }) + : t("passwordReset.req.special"), + isMet: (password) => hasSpecialChar(password, complexity.special_char_set), + }); + } + + return requirements; +}; + +/** How many requirements a password currently satisfies. Drives the strength indicator. */ +export const countMetRequirements = ( + requirements: PasswordRequirement[], + password: string, +): number => requirements.filter((requirement) => requirement.isMet(password)).length; + +/** The first requirement a password fails, or `null`; the rows on screen already list the rest. */ +export const firstUnmetRequirement = ( + requirements: PasswordRequirement[], + password: string, +): PasswordRequirement | null => + requirements.find((requirement) => !requirement.isMet(password)) ?? null; + +/** The message for the first failure, or `null`; the server is still authoritative. */ +export const validateAgainstComplexity = ( + password: string, + complexity: PasswordComplexity, + t: TranslateFn, +): I18nText | null => { + const unmet = firstUnmetRequirement(buildPasswordRequirements(complexity, t), password); + return unmet ? t("passwordReset.req.unmet", { requirement: unmet.label }) : null; +}; + +// The 400 carries no code, so the server's wording is the only signal and the count comes from it too. +const REUSE_MESSAGE = /matches one of your last (\d+) passwords/; + +/** The inline error for a reuse rejection, or `null`; every requirement row can be green while this fails. */ +export const reuseRejection = (error: unknown, t: TranslateFn): I18nText | null => { + const message = (error as any)?.response?.data?.message; + const match = typeof message === "string" ? REUSE_MESSAGE.exec(message) : null; + return match ? t("passwordReset.reused", { count: Number(match[1]) }) : null; +}; diff --git a/web/src/utils/passwordResetErrors.spec.ts b/web/src/utils/passwordResetErrors.spec.ts new file mode 100644 index 0000000000..b63c0568aa --- /dev/null +++ b/web/src/utils/passwordResetErrors.spec.ts @@ -0,0 +1,38 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; + +import { isPasswordResetError } from "./passwordResetErrors"; + +const forbidden = (data: Record) => ({ response: { status: 403, data } }); + +describe("isPasswordResetError", () => { + it("recognises only the middleware's reset-required 403", () => { + expect(isPasswordResetError(forbidden({ code: "password_reset_required" }))).toBe(true); + // A plain 403 is an authorization failure and belongs to the existing handler. + expect(isPasswordResetError(forbidden({}))).toBe(false); + expect(isPasswordResetError({ response: { status: 401 } })).toBe(false); + expect(isPasswordResetError(undefined)).toBe(false); + }); + + it("ignores the code on any status other than 403", () => { + expect( + isPasswordResetError({ + response: { status: 400, data: { code: "password_reset_required" } }, + }), + ).toBe(false); + }); +}); diff --git a/web/src/utils/passwordResetErrors.ts b/web/src/utils/passwordResetErrors.ts new file mode 100644 index 0000000000..7892265af3 --- /dev/null +++ b/web/src/utils/passwordResetErrors.ts @@ -0,0 +1,29 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const RESET_REQUIRED_CODE = "password_reset_required"; + +interface RejectedResponse { + response?: { status?: number; data?: { code?: unknown } }; +} + +const forbiddenCode = (error: unknown): unknown => { + const response = (error as RejectedResponse | undefined)?.response; + return response?.status === 403 ? response.data?.code : undefined; +}; + +/** Whether a rejected response is the middleware refusing everything until the password changes. */ +export const isPasswordResetError = (error: unknown): boolean => + forbiddenCode(error) === RESET_REQUIRED_CODE;