Add system status displays for Minio and Redis, and enhance backup configuration

This commit is contained in:
Jeppe Bundgaard
2026-07-13 10:07:56 +02:00
parent 8a0ea6cae5
commit 54e59b4b3e
81 changed files with 5814 additions and 3979 deletions
+90
View File
@@ -0,0 +1,90 @@
# Apple App Store Release Runbook
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
## Account And App Record
- Use the Truck Wash ApS Apple Developer account. The Account Holder must accept
the latest Apple agreements before builds can be uploaded.
- Create or verify the App Store Connect app record:
- Platform: iOS
- Name: Truck Wash Kundeportal
- Bundle ID: `io.truckwash.app`
- SKU: `truckwash-ios`
- Primary language: Danish
- Category: Business
- Price: Free
- Initial availability: Denmark
- Keep the GitHub environment `app-store-production` protected and store iOS
signing plus App Store Connect API secrets there.
## Build And Upload
1. Merge the release commit to `master`.
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
3. Create a release tag such as `mobile-v1.0.0`.
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts. For
iOS, it archives, exports, validates the IPA with App Store Connect, and
uploads it when the run is tag-triggered.
5. For a manual upload, dispatch `Mobile Store Artifacts` with
`upload_to_app_store=true`, `version_name`, and `version_code`.
The iOS workflow expects these environment secrets:
- `IOS_CERTIFICATE_BASE64`
- `IOS_CERTIFICATE_PASSWORD`
- `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
## Product Page Defaults
- Support URL: `https://truckwash.io/support`
- Privacy URL: `https://truckwash.io/privacy-policy`
- Subtitle: `Book og start truckvask`
- Promotional text: `Administrer vask, koeretoejer, ordrer og fakturaer fra mobilen.`
- Keywords: `truck wash,lastbilvask,vask,booking,kundeportal`
- Expected age rating: 4+, subject to the App Store Connect questionnaire.
Use real iOS simulator or device screenshots. Provide at least:
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
Recommended screenshot scenes: dashboard, booking flow, self-service wash start,
vehicles/orders, and invoices/payment history. Do not include real customer
data, private tokens, or placeholder copy.
## Privacy And Review Notes
App Store Connect privacy labels must match the actual app and backend behavior.
Expected minimum disclosures include account/contact data, identifiers such as
customer number, vehicle/license plate data, order and invoice history, payment
state, approximate/precise location when used, and photos or attachments when
users upload them. Tracking should remain false unless analytics/ad tracking is
introduced.
Review notes must include:
- A demo account and password.
- OTP/2FA/passkey fallback instructions when enabled for the account.
- A clear statement that Stripe/card payments are for physical truck-wash
services consumed outside the app, so Apple in-app purchase is not used.
- Any hardware-dependent functionality that reviewers cannot reproduce, with a
short demo video if needed.
- Confirmation that the backend environment is online for the whole review
window.
## TestFlight And Release
1. Wait for App Store Connect processing to finish.
2. Distribute the processed build to internal TestFlight testers.
3. Run clean-device QA on iPhone and iPad.
4. Fix issues using the same marketing version and an incremented build number.
5. Submit for App Review with manual release after approval.
6. After approval, release to Denmark first and monitor crashes, support mail,
and App Store Connect feedback before expanding availability.
+12 -1
View File
@@ -175,10 +175,21 @@ export const sourceMappings = [
},
{
name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
patterns: [/system[-/]?status/iu, /system(?:Database|Redis|Minio)/iu, /SystemDependencyDisplay/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-security",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/system\/SystemSecurity\.vue$/u,
/^src\/services\/superuserSecurity\.js$/u,
/superuser[-/]?security/iu,
/system\/security/iu,
],
specs: ["tests/e2e/superuser-security.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
+1 -1
View File
@@ -80,7 +80,6 @@ export const ownedFilesByRole = {
"pos.visual.spec.js",
],
superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
@@ -114,6 +113,7 @@ export const ownedFilesByRole = {
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-security.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
@@ -4,6 +4,7 @@ import {
customer_id,
customer_data,
customer_attributes,
department_id,
loadCustomerAttributes,
hideDiscountsCatalog,
hidePricesCatalog,
@@ -20,17 +21,11 @@ import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
import {
popperBox,
popper,
removePopperIfOpen,
showPopperWithContent,
showPopper,
} from "@/components/displays/PopperDefault.vue";
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
const { t } = useI18n();
const emit = defineEmits(["update:activeTab"]);
@@ -276,12 +271,15 @@ const customer_data_has_empty_details = () => {
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
<CustomerRuleTooltip
:attribute="attribute.attribute"
:active="hasAttribute(attribute.prop)"
:customer-number="customer_id"
:department-id="department_id"
:test-id="`pos-customer-rule-tooltip-${attribute.attribute}`"
>
<span class="pos-selected-customer__label">{{ attribute.name }}</span>
</CustomerRuleTooltip>
<span class="pos-selected-customer__value">
<span
v-if="hasAttribute(attribute.prop)"
@@ -63,10 +63,10 @@ onBeforeUnmount(() => {
:class="{ 'is-active': isOpen }"
:data-testid="testId"
>
<div class="dropdown-trigger">
<div class="dropdown-trigger pagination-other-filters__trigger">
<button
type="button"
class="button is-light is-small pagination-other-filters__button"
class="button is-light pagination-other-filters__button"
:aria-expanded="isOpen ? 'true' : 'false'"
aria-haspopup="true"
:aria-label="label"
@@ -77,14 +77,14 @@ onBeforeUnmount(() => {
<i class="fas fa-filter" aria-hidden="true"></i>
</span>
<span>{{ label }}</span>
<span
v-if="normalizedActiveCount > 0"
class="tag is-info is-rounded is-small pagination-other-filters__count"
:data-testid="`${testId}-count`"
>
{{ normalizedActiveCount }}
</span>
</button>
<span
v-if="normalizedActiveCount > 0"
class="tag is-info is-rounded is-small pagination-other-filters__count"
:data-testid="`${testId}-count`"
>
{{ normalizedActiveCount }}
</span>
</div>
<div
@@ -93,20 +93,37 @@ onBeforeUnmount(() => {
:data-testid="`${testId}-menu`"
>
<div class="dropdown-content pagination-other-filters__content">
<slot />
<div class="pagination-other-filters__body">
<slot />
</div>
<div
v-if="$slots.footer"
class="pagination-other-filters__footer"
>
<slot name="footer" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pagination-other-filters__button {
.pagination-other-filters {
overflow: visible;
}
.pagination-other-filters__button {
height: 2.25em;
padding-right: 1.15rem;
position: relative;
white-space: nowrap;
}
.pagination-other-filters__trigger {
display: inline-flex;
overflow: visible;
position: relative;
}
.pagination-other-filters__count {
align-items: center;
border: 2px solid #fff;
@@ -115,9 +132,11 @@ onBeforeUnmount(() => {
justify-content: center;
min-width: 1.15rem;
padding: 0 0.3rem;
pointer-events: none;
position: absolute;
right: -0.35rem;
top: -0.45rem;
top: -0.25rem;
z-index: 1;
}
.pagination-other-filters__menu {
@@ -127,8 +146,22 @@ onBeforeUnmount(() => {
.pagination-other-filters__content {
border: 1px solid #dbdbdb;
border-radius: 6px;
overflow: hidden;
padding: 0;
}
.pagination-other-filters__body {
max-height: min(70vh, 36rem);
overflow-y: auto;
padding: 0.75rem;
}
.pagination-other-filters__footer {
align-items: center;
background: #fff;
border-top: 1px solid #dbdbdb;
display: flex;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
}
</style>
@@ -323,6 +323,25 @@ const activeHiddenOrderFiltersCount = computed(() => hiddenOrderFilterDefinition
return isActiveHiddenOrderFilterValue(value);
}).length);
const hasActiveHiddenOrderFilters = computed(() => activeHiddenOrderFiltersCount.value > 0);
const clearHiddenOrderFilters = () => {
if (!hasActiveHiddenOrderFilters.value) {
return;
}
hiddenOrderFilterDefinitions.value.forEach((filterDefinition) => {
if (filterDefinition.type === "order") {
setOrder("created_at", filterDefinition.defaultValue);
return;
}
setFilter(filterDefinition.filterKey, "*", false);
});
loadList();
};
const doesEndpointMatch = (matcher) => {
// Check if the endpoint matches the current endpoint
return endpoint.value === matcher;
@@ -454,56 +473,67 @@ watch(orderDateEventSignature, () => {
</div>
</div>
</template>
<template #rightFilterActions>
<div class="column is-narrow invoice-orders-other-filters-column">
<PaginationOtherFiltersDropdown
:label="t('pagination.other_filters')"
:active-count="activeHiddenOrderFiltersCount"
test-id="invoice-orders-other-filters"
>
<div class="invoice-orders-other-filters__fields">
<div
v-for="filterDefinition in hiddenOrderFilterDefinitions"
:key="filterDefinition.key"
class="field invoice-orders-other-filters__field"
<template #rightPaginationColumns>
<div class="column is-12 invoice-orders-shortcuts-column">
<div class="invoice-orders-shortcut-actions">
<div class="invoice-orders-other-filters-trigger">
<PaginationOtherFiltersDropdown
:label="t('pagination.other_filters')"
:active-count="activeHiddenOrderFiltersCount"
test-id="invoice-orders-other-filters"
>
<label
class="label is-small"
:for="`invoice-orders-other-filter-${filterDefinition.key}`"
>
{{ filterDefinition.label }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
:id="`invoice-orders-other-filter-${filterDefinition.key}`"
:value="getHiddenOrderFilterValue(filterDefinition)"
:data-testid="`invoice-orders-other-filter-${filterDefinition.key}`"
@change="applyHiddenOrderFilter(filterDefinition, $event.target.value)"
<div class="invoice-orders-other-filters__fields">
<div
v-for="filterDefinition in hiddenOrderFilterDefinitions"
:key="filterDefinition.key"
class="field invoice-orders-other-filters__field"
>
<label
class="label is-small"
:for="`invoice-orders-other-filter-${filterDefinition.key}`"
>
<option
v-for="option in filterDefinition.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
{{ filterDefinition.label }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
:id="`invoice-orders-other-filter-${filterDefinition.key}`"
:value="getHiddenOrderFilterValue(filterDefinition)"
:data-testid="`invoice-orders-other-filter-${filterDefinition.key}`"
@change="applyHiddenOrderFilter(filterDefinition, $event.target.value)"
>
<option
v-for="option in filterDefinition.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<button
type="button"
class="button is-text is-small invoice-orders-other-filters__clear"
data-testid="invoice-orders-other-filters-clear"
:disabled="!hasActiveHiddenOrderFilters"
@click="clearHiddenOrderFilters"
>
{{ t('pagination.clear_all') }}
</button>
</template>
</PaginationOtherFiltersDropdown>
</div>
</PaginationOtherFiltersDropdown>
</div>
</template>
<template #rightPaginationColumns>
<div class="column is-12">
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
</div>
</div>
</template>
<template #default>
@@ -530,9 +560,43 @@ watch(orderDateEventSignature, () => {
margin-bottom: 0.25rem;
}
.invoice-orders-other-filters-column {
align-self: flex-end;
margin-left: auto;
.invoice-orders-shortcuts-column {
display: flex;
justify-content: flex-end;
}
.invoice-orders-shortcut-actions {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
column-gap: 0;
justify-content: flex-end;
margin-top: -0.5rem;
padding-top: 0.5rem;
row-gap: 0.5rem;
overflow: visible;
width: 100%;
}
.invoice-orders-shortcut-actions :deep(.date-period-selector .navbar-item) {
padding-left: 0.5rem;
}
.invoice-orders-other-filters-trigger {
display: flex;
justify-content: flex-end;
}
@media screen and (min-width: 769px) {
.invoice-orders-other-filters-trigger {
margin-top: 0.625rem;
}
}
.invoice-orders-other-filters__clear {
height: auto;
min-height: 1.75rem;
padding: 0;
}
.invoice-orders-other-filters__field {
@@ -1,11 +1,9 @@
<script setup>
import { provide } from "vue";
import { useI18n } from "vue-i18n";
import SubusersTable from "@/components/displays/superuser/tables/SubusersTable.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
hideSearch: {
@@ -29,22 +27,14 @@ const props = defineProps({
default: null,
},
});
const { t } = useI18n();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
search,
setOrder,
hideSearchField,
setHideSearchField,
@@ -65,57 +55,17 @@ if (props.autoLoad) {
</script>
<template>
<div class="columns is-vcentered is-multiline">
<div class="column">
<input
v-if="!hideSearchField"
class="input"
type="text"
:placeholder="$t('global.search_driver')"
@input="search($event.target.value)"
/>
</div>
<div class="column is-narrow pl-0">
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">
{{ t('pagination.reload') }}
</LoadButtonWhileAwait>
</div>
</div>
<PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
<TableLabeledPagination
:label="SessionUser.objects.subusers.meta.title"
:hide-search="hideSearchField"
:search-placeholder="$t('global.search_driver')"
>
<template #paginationColumns>
<div class="column is-narrow my-3">
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">{{ t("pagination.ascending") }}</option>
<option value="desc" selected>{{ t("pagination.descending") }}</option>
</select>
</div>
</div>
</div>
</template>
</PaginationDisplay>
<SubusersTable
:objects="list"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
/>
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<SubusersTable
:objects="list"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
/>
</TableLabeledPagination>
</template>
<style scoped>
@@ -0,0 +1,7 @@
<script setup>
import SystemDependencyDisplay from "@/components/displays/superuser/system/SystemDependencyDisplay.vue";
</script>
<template>
<SystemDependencyDisplay dependency-key="minio" title-key="system_status.cards.minio" />
</template>
@@ -0,0 +1,7 @@
<script setup>
import SystemDependencyDisplay from "@/components/displays/superuser/system/SystemDependencyDisplay.vue";
</script>
<template>
<SystemDependencyDisplay dependency-key="redis" title-key="system_status.cards.redis" />
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import {
SuperUserSystemStatusObject,
getSystemDependencyStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
const props = defineProps({
dependencyKey: {
type: String,
required: true,
validator: (value) => ["database", "redis", "minio"].includes(value),
},
titleKey: {
type: String,
required: true,
},
});
const { t } = useI18n();
const dependencyStatus = computed(() => SuperUserSystemStatusObject.dependencies.value?.[props.dependencyKey] ?? null);
const loading = computed(() => SuperUserSystemStatusObject.loading.value);
const error = computed(() => SuperUserSystemStatusObject.error.value);
const testId = computed(() => `${props.dependencyKey}-status-card`);
const bucketRows = computed(() => {
if (props.dependencyKey !== "minio" || !Array.isArray(dependencyStatus.value?.buckets)) {
return [];
}
return dependencyStatus.value.buckets;
});
const rows = computed(() => {
const status = dependencyStatus.value;
if (!status) {
return [];
}
if (props.dependencyKey === "database") {
return [
statusRow(status),
row("database", t("system_status.cards.database"), status.database || "MySQL"),
row("server_version", t("system_status.labels.server_version"), status.server_version || "--"),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
}
if (props.dependencyKey === "redis") {
return [
statusRow(status),
row("database_index", t("system_status.labels.database_index"), status.database ?? "--"),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
}
return [
statusRow(status),
row("endpoint", t("system_status.labels.endpoint"), status.endpoint || "--"),
row("buckets-summary", t("system_status.labels.buckets"), formatMinioBuckets(status.buckets)),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
});
const refresh = async ({ force = false } = {}) => {
await getSystemDependencyStatus(props.dependencyKey, { force });
};
onMounted(async () => {
await refresh();
});
function row(key, label, value) {
return { key, label, value };
}
function statusRow(status) {
return row("overall", t("system_status.summary.overall"), t(`system_status.status.${status.status || "unknown"}`));
}
function formatLatency(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value).toFixed(1)} ms`;
}
function formatMinioBuckets(buckets) {
if (!Array.isArray(buckets) || buckets.length === 0) {
return "--";
}
const available = buckets.filter((bucket) => bucket.status === "ok").length;
return t("system_status.labels.buckets_available", { available, total: buckets.length });
}
</script>
<template>
<section class="system-dependency-card" :data-testid="testId">
<div class="system-dependency-card__top">
<div>
<p class="system-dependency-card__eyebrow">{{ $t("system_status.sections.infrastructure") }}</p>
<h2>{{ $t(titleKey) }}</h2>
</div>
<button class="button is-dark is-small" type="button" @click="refresh({ force: true })">
{{ $t("system_status.actions.refresh") }}
</button>
</div>
<div v-if="loading && !dependencyStatus" class="notification is-light">
{{ $t("system_status.states.loading") }}
</div>
<div v-if="error" class="notification is-danger is-light">
<strong>{{ $t("system_status.states.error") }}</strong>
<span>{{ error?.message || $t("system_status.states.error_generic") }}</span>
</div>
<div v-if="dependencyStatus" class="system-dependency-card__grid">
<article v-for="entry in rows" :key="entry.key" :data-testid="`${dependencyKey}-status-${entry.key}`">
<span>{{ entry.label }}</span>
<strong>{{ entry.value }}</strong>
</article>
</div>
<div v-if="bucketRows.length" class="system-dependency-card__buckets" data-testid="minio-status-buckets">
<article v-for="bucket in bucketRows" :key="bucket.name || bucket.status">
<span>{{ bucket.name || "--" }}</span>
<strong>{{ $t(`system_status.status.${bucket.status || "unknown"}`) }}</strong>
</article>
</div>
</section>
</template>
<style scoped>
.system-dependency-card {
border: 1px solid #d7dde7;
border-radius: 8px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
display: grid;
gap: 1rem;
}
.system-dependency-card__top {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
}
.system-dependency-card__eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.system-dependency-card__top h2 {
margin: 0;
color: #102a43;
}
.system-dependency-card__grid,
.system-dependency-card__buckets {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.system-dependency-card__grid article,
.system-dependency-card__buckets article {
display: grid;
gap: 0.25rem;
}
.system-dependency-card__grid span,
.system-dependency-card__buckets span {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
color: #64748b;
}
.system-dependency-card__grid strong,
.system-dependency-card__buckets strong {
color: #0f172a;
overflow-wrap: anywhere;
}
.system-dependency-card__buckets {
border-top: 1px solid #e2e8f0;
padding-top: 1rem;
}
@media (max-width: 768px) {
.system-dependency-card__top {
flex-direction: column;
align-items: start;
}
}
</style>
@@ -6,6 +6,8 @@ import { sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.j
// Define the departments
export const departments = ref([]);
export const isLoading = ref(true);
export const timeBookingDepartments = ref([]);
export const timeBookingDepartmentsIsLoading = ref(false);
// Get the departments from the API
export const getDepartments = async () => {
@@ -57,4 +59,19 @@ export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = false;
}
};
export const getTimeBookingDepartments = async (queryParams = {}) => {
timeBookingDepartmentsIsLoading.value = true;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest(
"/department/timebookings/departments/public" + (queryString ? `?${queryString}` : ""),
"get"
);
timeBookingDepartments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return timeBookingDepartments.value;
} finally {
timeBookingDepartmentsIsLoading.value = false;
}
};
</script>
@@ -2,8 +2,18 @@
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import i18n from "@/i18n";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
const t = (key, values = undefined) => i18n.global.t(key, values);
let qrCodeModulePromise;
const loadQRCodeModule = () => {
if (!qrCodeModulePromise) {
qrCodeModulePromise = import("qrcode");
}
return qrCodeModulePromise;
};
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
@@ -12,6 +22,112 @@ const escapeHtml = (value) => String(value ?? "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const apiErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
const absoluteLoginLink = (path) => {
try {
return new URL(path, window.location.origin).toString();
} catch (_error) {
return String(path || "");
}
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_error) {
// Fall back below when clipboard permission is denied.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
const buildAdminContactFormHtml = (subuser = {}) => `
<div class="subuser-form">
<div class="field">
<label class="label" for="subuser-admin-email">E-mail</label>
<div class="control">
<input id="subuser-admin-email" class="input" type="email" value="${escapeHtml(subuser.email || "")}" placeholder="chauffor@example.com" />
</div>
</div>
<div class="columns">
<div class="column is-4">
<div class="field">
<label class="label" for="subuser-admin-phone-country-code">Landekode</label>
<div class="control">
<input id="subuser-admin-phone-country-code" class="input" type="number" inputmode="numeric" value="${escapeHtml(subuser.phone_country_code || 45)}" />
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="subuser-admin-phone">Telefon</label>
<div class="control">
<input id="subuser-admin-phone" class="input" type="number" inputmode="numeric" value="${escapeHtml(subuser.phone || "")}" />
</div>
</div>
</div>
</div>
</div>
`;
const readAdminContactFormValues = () => {
const email = document.getElementById("subuser-admin-email")?.value?.trim() || "";
const phoneCountryCode = document.getElementById("subuser-admin-phone-country-code")?.value?.trim() || "";
const phone = document.getElementById("subuser-admin-phone")?.value?.trim() || "";
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
Swal.showValidationMessage("E-mail er ugyldig.");
return null;
}
if (!/^\d{1,3}$/.test(phoneCountryCode)) {
Swal.showValidationMessage("Landekode skal være 1-3 cifre.");
return null;
}
if (!/^\d{4,15}$/.test(phone)) {
Swal.showValidationMessage("Telefon skal være 4-15 cifre.");
return null;
}
return {
email: email || null,
phone_country_code: Number.parseInt(phoneCountryCode, 10),
phone: Number.parseInt(phone, 10),
};
};
const buildPasswordFormHtml = () => `
<div class="subuser-form">
<div class="field">
<label class="label" for="subuser-admin-password">Ny adgangskode</label>
<div class="control">
<input id="subuser-admin-password" class="input" type="password" autocomplete="new-password" />
</div>
<p class="help">Minimum 8 tegn med store bogstaver, små bogstaver og tal.</p>
</div>
<div class="field">
<label class="label" for="subuser-admin-password-confirm">Gentag adgangskode</label>
<div class="control">
<input id="subuser-admin-password-confirm" class="input" type="password" autocomplete="new-password" />
</div>
</div>
</div>
`;
const buildAccessTemplateOptions = (templates = [], selectedKey = "driver") => templates
.filter((template) => template?.key && template.key !== "custom")
.map((template) => `
@@ -192,6 +308,161 @@ export const Subusers = {
const normalized = permissions.filter(Boolean);
return customSummary(t("superuser.driver_access.summary.permission_count", { count: normalized.length }));
},
async updateAdminProfile(subuserId, payload) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}`, "PATCH", payload);
},
async setAdminPassword(subuserId, password) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}/password`, "POST", { password });
},
async generateAdminLoginLink(subuserId, context = {}) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link`, "POST", {
...(context.customer_number ? { customer_number: context.customer_number } : {}),
...(context.grant_id ? { grant_id: context.grant_id } : {}),
});
},
async showEditNameForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Redigér chaufførnavn",
input: "text",
inputLabel: "Navn",
inputValue: subuser?.name || "",
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
inputValidator: (value) => {
const name = String(value || "").trim();
if (name.length < 3) {
return "Navn skal være mindst 3 tegn.";
}
if (name.length > 255) {
return "Navn må højst være 255 tegn.";
}
return null;
},
preConfirm: async (value) => {
try {
return await this.updateAdminProfile(subuser.id, { name: String(value || "").trim() });
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme navnet."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showEditContactForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Redigér kontaktoplysninger",
html: buildAdminContactFormHtml(subuser),
width: 640,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
preConfirm: async () => {
const values = readAdminContactFormValues();
if (!values) {
return false;
}
try {
return await this.updateAdminProfile(subuser.id, values);
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme kontaktoplysningerne."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showSetPasswordForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Sæt adgangskode",
html: buildPasswordFormHtml(),
width: 560,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
preConfirm: async () => {
const password = document.getElementById("subuser-admin-password")?.value || "";
const passwordConfirm = document.getElementById("subuser-admin-password-confirm")?.value || "";
const policyError = getSubuserPasswordPolicyError(password);
if (policyError) {
Swal.showValidationMessage(policyError);
return false;
}
if (password !== passwordConfirm) {
Swal.showValidationMessage("Adgangskoderne matcher ikke.");
return false;
}
try {
return await this.setAdminPassword(subuser.id, password);
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme adgangskoden."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showDirectLoginQrCode(subuser, context = {}) {
try {
const response = await this.generateAdminLoginLink(subuser.id, context);
const payload = response?.data?.data || response?.data || {};
const loginPath = String(payload.login_path || "");
if (!loginPath) {
throw new Error("Loginlink mangler i API-svaret.");
}
const loginUrl = absoluteLoginLink(loginPath);
const { toDataURL } = await loadQRCodeModule();
const qrSrc = await toDataURL(loginUrl, { margin: 2, width: 240 });
return Swal.fire({
title: "Direkte login",
html: `
<div class="has-text-centered">
<img src="${escapeHtml(qrSrc)}" alt="Direkte login QR-kode" width="240" height="240" />
<input id="subuser-direct-login-link" class="input mt-4" type="text" readonly value="${escapeHtml(loginUrl)}" />
</div>
`,
showCancelButton: true,
confirmButtonText: "Kopiér link",
cancelButtonText: "Luk",
preConfirm: async () => {
await copyText(loginUrl);
return loginUrl;
},
});
} catch (error) {
await Swal.fire({
title: "Kunne ikke oprette loginlink",
text: apiErrorMessage(error, "Der opstod en fejl."),
icon: "error",
confirmButtonText: "Luk",
});
return null;
}
},
async showInviteForm(refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const userId = options.userId || null;
@@ -30,12 +30,47 @@ export const Backups = {
}
)
},
getJob: async (jobId) => {
return authenticatedRequest(
`/modules/backup/jobs/${jobId}`,
"GET"
)
},
listBackups: async () => {
return authenticatedRequest(
Backups.meta.endpoint,
"GET"
)
},
verifyBackup: async (backupUuid) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/verify`,
"POST"
)
},
previewRestore: async (backupUuid) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/restore/preview`,
"POST"
)
},
restoreBackup: async (backupUuid, previewId, confirmationPhrase, reason) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/restore`,
"POST",
{
preview_id: previewId,
confirmation_phrase: confirmationPhrase,
reason: reason
}
)
},
restoreAudit: async () => {
return authenticatedRequest(
"/modules/backup/restore-audit",
"GET"
)
},
};
</script>
</script>
@@ -42,7 +42,28 @@ export const Config = {
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
},
retentionRecentHours: {
set: async (value) => Config.set("retention_recent_hours", value),
},
retentionDailyDays: {
set: async (value) => Config.set("retention_daily_days", value),
},
retentionWeeklyWeeks: {
set: async (value) => Config.set("retention_weekly_weeks", value),
},
retentionMonthlyMonths: {
set: async (value) => Config.set("retention_monthly_months", value),
},
appDataEnabled: {
set: async (enabled) => Config.set("app_data_enabled", enabled),
},
verificationRequired: {
set: async (enabled) => Config.set("verification_required", enabled),
},
restoreEnabled: {
set: async (enabled) => Config.set("restore_enabled", enabled),
},
};
</script>
</script>
@@ -2,7 +2,12 @@
export {
SuperUserSystemStatusObject,
DatabaseSystemObject,
RedisSystemObject,
MinioSystemObject,
getSuperuserSystemStatus,
getSystemDependencyStatus,
getSystemDatabaseStatus,
getSystemRedisStatus,
getSystemMinioStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -0,0 +1,8 @@
<script>
export {
SuperUserSystemStatusObject,
MinioSystemObject,
getSuperuserSystemStatus,
getSystemMinioStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -0,0 +1,8 @@
<script>
export {
SuperUserSystemStatusObject,
RedisSystemObject,
getSuperuserSystemStatus,
getSystemRedisStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -13,12 +13,15 @@ export const SuperUserSystemStatusObject = {
runtime: computed(() => SuperUserSystemStatusObject.snapshot.value?.runtime ?? {}),
dependencies: computed(() => SuperUserSystemStatusObject.snapshot.value?.dependencies ?? {}),
modules: computed(() => SuperUserSystemStatusObject.snapshot.value?.modules ?? []),
sessions: computed(() => SuperUserSystemStatusObject.snapshot.value?.sessions ?? {
active_window_minutes: 15,
active_users: 0,
active_sessions: 0,
recent_sessions: [],
}),
sessions: computed(
() =>
SuperUserSystemStatusObject.snapshot.value?.sessions ?? {
active_window_minutes: 15,
active_users: 0,
active_sessions: 0,
recent_sessions: [],
}
),
warnings: computed(() => SuperUserSystemStatusObject.snapshot.value?.warnings ?? []),
refreshAfterSeconds: computed(() => Number(SuperUserSystemStatusObject.snapshot.value?.refresh_after_seconds ?? 30)),
};
@@ -49,15 +52,23 @@ export const getSuperuserSystemStatus = async ({ force = false } = {}) => {
return pendingRequest.value;
};
export const DatabaseSystemObject = {
status: computed(() => SuperUserSystemStatusObject.dependencies.value?.database ?? null),
const createDependencySystemObject = (key) => ({
status: computed(() => SuperUserSystemStatusObject.dependencies.value?.[key] ?? null),
loading: computed(() => SuperUserSystemStatusObject.loading.value),
error: computed(() => SuperUserSystemStatusObject.error.value),
lastLoadedAt: computed(() => SuperUserSystemStatusObject.lastLoadedAt.value),
});
export const DatabaseSystemObject = createDependencySystemObject("database");
export const RedisSystemObject = createDependencySystemObject("redis");
export const MinioSystemObject = createDependencySystemObject("minio");
export const getSystemDependencyStatus = async (key, { force = false } = {}) => {
const snapshot = await getSuperuserSystemStatus({ force });
return snapshot?.dependencies?.[key] ?? null;
};
export const getSystemDatabaseStatus = async ({ force = false } = {}) => {
const snapshot = await getSuperuserSystemStatus({ force });
return snapshot?.dependencies?.database ?? null;
};
export const getSystemDatabaseStatus = (options = {}) => getSystemDependencyStatus("database", options);
export const getSystemRedisStatus = (options = {}) => getSystemDependencyStatus("redis", options);
export const getSystemMinioStatus = (options = {}) => getSystemDependencyStatus("minio", options);
</script>
+116 -61
View File
@@ -2,7 +2,11 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import {
DatabaseSystemObject,
RedisSystemObject,
MinioSystemObject,
} from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
import { reCAPTCHA } from "@/components/session/token/superUser/modules/reCAPTCHA/reCAPTCHA.vue";
@@ -20,7 +24,7 @@ import { Limble } from "@/components/session/token/superUser/modules/limble/Limb
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.vue";
import { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
import { VirkData} from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
import { SelfServe } from "@/components/session/token/superUser/modules/selfserve/SelfServe.vue";
import { Bird } from "@/components/session/token/superUser/modules/bird/Bird.vue";
@@ -40,40 +44,97 @@ const loadQRCodeModule = async () => {
return qrCodeModulePromise;
};
/**
* The superuser object
*/
export const SuperUserObject = {
system: {
get database() { return DatabaseSystemObject; },
get database() {
return DatabaseSystemObject;
},
get redis() {
return RedisSystemObject;
},
get minio() {
return MinioSystemObject;
},
},
modules: {
get economic() { return Economic; },
get reCAPTCHA() { return reCAPTCHA; },
get email() { return Email; },
get backups() { return Backups; },
get motorapi() { return MotorAPI; },
get stripe() { return Stripe; },
get fxratesapi() { return FXRatesAPI; },
get weatherapi() { return WeatherAPI; },
get workfeed() { return Workfeed; },
get gatewayapi() { return GatewayAPI; },
get xlvask() { return XLVask; },
get entra() { return Entra; },
get limble() { return Limble; },
get ocrspace() { return OcrSpace; },
get openai() { return OpenAI; },
get licenseplaterecognizer() { return LicensePlateRecognizer; },
get virkdata() { return VirkData; },
get shelly() { return Shelly; },
get selfserve() { return SelfServe; },
get bird() { return Bird; },
get edgegateway() { return EdgeGateway; },
get failover() { return Failover; },
get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
get economic() {
return Economic;
},
get reCAPTCHA() {
return reCAPTCHA;
},
get email() {
return Email;
},
get backups() {
return Backups;
},
get motorapi() {
return MotorAPI;
},
get stripe() {
return Stripe;
},
get fxratesapi() {
return FXRatesAPI;
},
get weatherapi() {
return WeatherAPI;
},
get workfeed() {
return Workfeed;
},
get gatewayapi() {
return GatewayAPI;
},
get xlvask() {
return XLVask;
},
get entra() {
return Entra;
},
get limble() {
return Limble;
},
get ocrspace() {
return OcrSpace;
},
get openai() {
return OpenAI;
},
get licenseplaterecognizer() {
return LicensePlateRecognizer;
},
get virkdata() {
return VirkData;
},
get shelly() {
return Shelly;
},
get selfserve() {
return SelfServe;
},
get bird() {
return Bird;
},
get edgegateway() {
return EdgeGateway;
},
get failover() {
return Failover;
},
get coolify() {
return Coolify;
},
get releasemanager() {
return ReleaseManager;
},
get slack() {
return Slack;
},
},
/** Intimidate a user */
intimidate: {
@@ -83,18 +144,14 @@ export const SuperUserObject = {
* @returns {Promise<string>} The impersonation token
*/
getImpersonationToken: async (userId) => {
return await authenticatedRequest("/su/intimidate", "POST", {user_id: userId})
.then(
(response) => {
// Return the token
return response.data.data.token;
}
)
.catch(
(error) => {
parseError(error, 'auth');
}
);
return await authenticatedRequest("/su/intimidate", "POST", { user_id: userId })
.then((response) => {
// Return the token
return response.data.data.token;
})
.catch((error) => {
parseError(error, "auth");
});
},
/**
* Intimidate a user
@@ -102,24 +159,20 @@ export const SuperUserObject = {
* @returns {Promise<void>}
*/
intimidateUser: async (userId) => {
return await authenticatedRequest("/su/intimidate", "POST", {user_id: userId})
.then(
(response) => {
// Save the current token, so we can restore it after the user has been intimidated
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
}
)
.catch(
(error) => {
parseError(error, 'auth');
}
);
return await authenticatedRequest("/su/intimidate", "POST", { user_id: userId })
.then((response) => {
// Save the current token, so we can restore it after the user has been intimidated
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
})
.catch((error) => {
parseError(error, "auth");
});
},
/**
* Can the current superuser intimidate another superuser?
@@ -160,8 +213,10 @@ export const SuperUserObject = {
*/
isIntimidated: () => {
return localStorage.getItem("superuser_token") !== null;
}
},
},
get cron() {
return Cron;
},
get cron() { return Cron; }
};
</script>
@@ -0,0 +1,400 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { BTooltip } from "buefy";
import { useI18n } from "vue-i18n";
import { getCustomerRuleTooltipModel, groupHasProducts } from "@/features/customer/customerRuleProductImpact.js";
import { loadCustomerRuleProductCatalog } from "@/features/customer/customerRuleProductCatalog.js";
const props = defineProps({
active: {
type: Boolean,
default: false,
},
attribute: {
type: String,
required: true,
},
customerNumber: {
type: [Number, String],
default: null,
},
departmentId: {
type: [Number, String],
default: null,
},
position: {
type: String,
default: "is-bottom",
},
products: {
type: Array,
default: null,
},
testId: {
type: String,
default: "",
},
});
const { t } = useI18n({ useScope: "global" });
const catalogProducts = ref([]);
const isLoading = ref(false);
const loadFailed = ref(false);
const isTooltipOpen = ref(false);
const triggerHovered = ref(false);
const contentHovered = ref(false);
const focusWithinTooltip = ref(false);
const tooltipRef = ref(null);
let loadRequestId = 0;
let closeTimer = null;
const hasProvidedProducts = computed(() => Array.isArray(props.products));
const effectiveProducts = computed(() => (hasProvidedProducts.value ? props.products : catalogProducts.value));
const tooltipModel = computed(() => getCustomerRuleTooltipModel(props.attribute, {
active: props.active,
products: effectiveProducts.value,
}));
const hasTooltip = computed(() => tooltipModel.value !== null);
const shouldLoadProducts = computed(() => (
hasTooltip.value &&
tooltipModel.value.hasProductImpact &&
!hasProvidedProducts.value &&
!isLoading.value &&
catalogProducts.value.length === 0 &&
!loadFailed.value
));
const blockedTitleKey = computed(() => (
props.active
? "customer_rules.tooltip.blocked_now"
: "customer_rules.tooltip.blocked_if_enabled"
));
const availableTitleKey = computed(() => (
props.active
? "customer_rules.tooltip.available_while_enabled"
: "customer_rules.tooltip.available_if_enabled"
));
const listTestId = (sectionKey, groupKey) => (
props.testId
? `${props.testId}-${sectionKey}-${groupKey}`
: `customer-rule-tooltip-${props.attribute}-${sectionKey}-${groupKey}`
);
const ensureProductsLoaded = async () => {
if (!shouldLoadProducts.value) {
return;
}
const requestId = ++loadRequestId;
isLoading.value = true;
loadFailed.value = false;
try {
const products = await loadCustomerRuleProductCatalog({
departmentId: props.departmentId,
customerNumber: props.customerNumber,
});
if (requestId === loadRequestId) {
catalogProducts.value = products;
}
} catch (error) {
console.warn("Unable to load products for customer rule tooltip", error);
if (requestId === loadRequestId) {
loadFailed.value = true;
}
} finally {
if (requestId === loadRequestId) {
isLoading.value = false;
}
}
};
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const updateTooltipPosition = async () => {
await nextTick();
tooltipRef.value?.updateAppendToBody?.();
};
const openTooltip = () => {
clearCloseTimer();
isTooltipOpen.value = true;
ensureProductsLoaded();
updateTooltipPosition();
};
const closeTooltip = () => {
clearCloseTimer();
triggerHovered.value = false;
contentHovered.value = false;
focusWithinTooltip.value = false;
isTooltipOpen.value = false;
};
const scheduleTooltipClose = () => {
clearCloseTimer();
closeTimer = setTimeout(() => {
closeTimer = null;
if (!triggerHovered.value && !contentHovered.value && !focusWithinTooltip.value) {
isTooltipOpen.value = false;
}
}, 180);
};
const onTriggerMouseEnter = () => {
triggerHovered.value = true;
openTooltip();
};
const onTriggerMouseLeave = () => {
triggerHovered.value = false;
scheduleTooltipClose();
};
const onContentMouseEnter = () => {
contentHovered.value = true;
openTooltip();
};
const onContentMouseLeave = () => {
contentHovered.value = false;
scheduleTooltipClose();
};
const onTooltipFocusIn = () => {
focusWithinTooltip.value = true;
openTooltip();
};
const onTooltipFocusOut = () => {
focusWithinTooltip.value = false;
scheduleTooltipClose();
};
watch(
() => [props.attribute, props.customerNumber, props.departmentId],
() => {
closeTooltip();
if (!hasProvidedProducts.value) {
catalogProducts.value = [];
loadFailed.value = false;
}
}
);
onBeforeUnmount(() => {
clearCloseTimer();
});
</script>
<template>
<BTooltip
v-if="hasTooltip"
ref="tooltipRef"
class="customer-rule-tooltip"
:active="isTooltipOpen"
:triggers="[]"
:position="position"
content-class="customer-rule-tooltip__panel"
type="is-dark"
multilined
append-to-body
always
:auto-close="false"
>
<template #content>
<div
class="customer-rule-tooltip__content"
tabindex="-1"
:data-testid="testId ? `${testId}-content` : `customer-rule-tooltip-${attribute}`"
@mouseenter="onContentMouseEnter"
@mouseleave="onContentMouseLeave"
@focusin="onTooltipFocusIn"
@focusout="onTooltipFocusOut"
@keydown.escape.stop.prevent="closeTooltip"
>
<div class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t("customer_rules.tooltip.changes") }}</strong>
<p>{{ t(tooltipModel.descriptionKey) }}</p>
</div>
<template v-if="tooltipModel.hasProductImpact">
<div v-if="isLoading" class="customer-rule-tooltip__state">
{{ t("customer_rules.tooltip.loading_products") }}
</div>
<div v-else-if="loadFailed" class="customer-rule-tooltip__state customer-rule-tooltip__state--error">
{{ t("customer_rules.tooltip.load_failed") }}
</div>
<template v-else>
<div class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t(blockedTitleKey) }}</strong>
<template v-if="groupHasProducts(tooltipModel.blocked)">
<div
v-for="group in tooltipModel.groups"
:key="`blocked-${group.key}`"
class="customer-rule-tooltip__group"
>
<template v-if="(tooltipModel.blocked[group.key] || []).length > 0">
<span class="customer-rule-tooltip__group-title">{{ t(group.labelKey) }}</span>
<ul
class="customer-rule-tooltip__blocked-list"
:data-testid="listTestId('blocked', group.key)"
>
<li
v-for="product in tooltipModel.blocked[group.key]"
:key="`${group.key}-${product.id}`"
class="customer-rule-tooltip__blocked-product"
>
<span class="customer-rule-tooltip__blocked-prefix" aria-hidden="true">%</span>
<span>{{ product.name || product.product_name }}</span>
</li>
</ul>
</template>
</div>
</template>
<p v-else>{{ t("customer_rules.tooltip.no_affected_products") }}</p>
</div>
<div v-if="groupHasProducts(tooltipModel.available)" class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t(availableTitleKey) }}</strong>
<div
v-for="group in tooltipModel.groups"
:key="`available-${group.key}`"
class="customer-rule-tooltip__group"
>
<template v-if="(tooltipModel.available[group.key] || []).length > 0">
<span class="customer-rule-tooltip__group-title">{{ t(group.labelKey) }}</span>
<ul :data-testid="listTestId('available', group.key)">
<li v-for="product in tooltipModel.available[group.key]" :key="`${group.key}-${product.id}`">
{{ product.name || product.product_name }}
</li>
</ul>
</template>
</div>
</div>
</template>
</template>
</div>
</template>
<span
class="customer-rule-tooltip__trigger"
tabindex="0"
:data-testid="testId || undefined"
@mouseenter="onTriggerMouseEnter"
@mouseleave="onTriggerMouseLeave"
@focusin="onTooltipFocusIn"
@focusout="onTooltipFocusOut"
@keydown.escape.stop.prevent="closeTooltip"
>
<slot />
</span>
</BTooltip>
<slot v-else />
</template>
<style scoped>
.customer-rule-tooltip {
display: inline-flex;
max-width: 100%;
}
.customer-rule-tooltip :deep(.tooltip-trigger) {
display: inline-flex;
max-width: 100%;
}
.customer-rule-tooltip__trigger {
display: inline-flex;
max-width: 100%;
outline-offset: 2px;
}
:global(.b-tooltip .tooltip-content.customer-rule-tooltip__panel) {
padding: 0;
pointer-events: auto;
}
.customer-rule-tooltip__content {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 0.7rem;
max-height: min(26rem, 70vh);
min-width: 16rem;
max-width: min(28rem, 90vw);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.35rem 0.75rem;
scrollbar-width: thin;
text-align: left;
}
.customer-rule-tooltip__section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.customer-rule-tooltip__heading {
color: #ffffff;
font-size: 0.82rem;
letter-spacing: 0;
}
.customer-rule-tooltip__group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.customer-rule-tooltip__group-title {
color: #d7e3f3;
font-size: 0.76rem;
font-weight: 700;
}
.customer-rule-tooltip__content p,
.customer-rule-tooltip__content ul {
margin: 0;
}
.customer-rule-tooltip__content ul {
padding-left: 1.1rem;
}
.customer-rule-tooltip__blocked-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
list-style: none;
padding-left: 0;
}
.customer-rule-tooltip__blocked-product {
color: #ff8a8a;
display: flex;
gap: 0.35rem;
}
.customer-rule-tooltip__blocked-prefix {
flex: 0 0 auto;
font-weight: 700;
}
.customer-rule-tooltip__state {
color: #d7e3f3;
}
.customer-rule-tooltip__state--error {
color: #ffd6d6;
}
</style>
+46 -1
View File
@@ -1,6 +1,9 @@
const ADDON_CATEGORY_ID = 4;
const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens", "tank rens"];
const ADDITIONAL_SERVICE_TERMS = ["add-on", "add on", "addon", "tilvalg"];
const SPOT_FREE_PRODUCT_IDS = [23, 24];
const SPOT_FREE_TERMS = ["spot free", "spotfree", "skylning med ro"];
const CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS = {
restrictAdditionalServices: "pos.restrictions.addons_not_allowed",
@@ -65,6 +68,27 @@ export const isAddonCategory = (category, categoryName = null, options = {}) =>
return textContainsAny([category, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
};
export const isStandaloneAdditionalServiceCatalogProduct = (product) => {
if (!product) {
return false;
}
const category = product.category ?? product.product_category;
if (Number(category) === STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
return true;
}
return isAddonCategory(category, product.category_name ?? product.categoryName) || textContainsAny(
[
product.name,
product.product_name,
product.category_name,
product.categoryName,
].join(" "),
ADDITIONAL_SERVICE_TERMS
);
};
export const isTankCleaningProduct = (product) => {
if (!product) {
return false;
@@ -85,6 +109,27 @@ export const isTankCleaningProduct = (product) => {
);
};
export const isSpotFreeProduct = (product) => {
if (!product) {
return false;
}
const productId = Number(product.id ?? product.product_id ?? 0);
if (SPOT_FREE_PRODUCT_IDS.includes(productId)) {
return true;
}
return textContainsAny(
[
product.name,
product.product_name,
product.category_name,
product.categoryName,
].join(" "),
SPOT_FREE_TERMS
);
};
export const getProductCategoryRestrictionForCustomer = (category, attributes = [], options = {}) => {
if (
hasCustomerAttribute(attributes, "restrictAdditionalServices") &&
@@ -150,7 +195,7 @@ export const getCustomerProductRestriction = (product, attributes = [], options
) {
return restrictedByRule("restrictAdditionalServices");
}
if (hasCustomerAttribute(attributes, "restrictSpotFree") && productName.includes("spot free")) {
if (hasCustomerAttribute(attributes, "restrictSpotFree") && isSpotFreeProduct(product)) {
return restrictedByRule("restrictSpotFree");
}
if (
@@ -0,0 +1,74 @@
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const productCatalogCache = new Map();
const productCatalogRequests = new Map();
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeProductsResponse = (response) => {
if (Array.isArray(response?.data?.data)) {
return response.data.data;
}
if (Array.isArray(response?.data)) {
return response.data;
}
if (Array.isArray(response)) {
return response;
}
return [];
};
export const getCustomerRuleProductCatalogCacheKey = ({ departmentId = null, customerNumber = null } = {}) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
return [
normalizedDepartmentId ? `department:${normalizedDepartmentId}` : "department:global",
normalizedCustomerNumber ? `customer:${normalizedCustomerNumber}` : "customer:global",
].join("|");
};
export const loadCustomerRuleProductCatalog = async ({ departmentId = null, customerNumber = null } = {}) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
const cacheKey = getCustomerRuleProductCatalogCacheKey({
departmentId: normalizedDepartmentId,
customerNumber: normalizedCustomerNumber,
});
if (productCatalogCache.has(cacheKey)) {
return productCatalogCache.get(cacheKey);
}
if (productCatalogRequests.has(cacheKey)) {
return productCatalogRequests.get(cacheKey);
}
const request = SessionUser.objects.products.get.all({
...(normalizedDepartmentId ? { department_id: normalizedDepartmentId } : {}),
...(normalizedCustomerNumber ? { customer_id: normalizedCustomerNumber } : {}),
final_price: false,
})
.then((response) => {
const products = normalizeProductsResponse(response);
productCatalogCache.set(cacheKey, products);
return products;
})
.finally(() => {
productCatalogRequests.delete(cacheKey);
});
productCatalogRequests.set(cacheKey, request);
return request;
};
export const clearCustomerRuleProductCatalogCache = () => {
productCatalogCache.clear();
productCatalogRequests.clear();
};
@@ -0,0 +1,167 @@
import {
getCustomerProductRestriction,
isStandaloneAdditionalServiceCatalogProduct,
isTankCleaningProduct,
} from "@/features/customer/customerProductRules.js";
import { getCustomerRuleDefinition } from "@/features/customer/customerRuleRegistry.js";
const PRODUCT_GROUPS = Object.freeze([
{
key: "primaryProducts",
labelKey: "customer_rules.tooltip.groups.primary_products",
},
{
key: "relatedAddons",
labelKey: "customer_rules.tooltip.groups.related_addons",
},
{
key: "standaloneAdditionalServices",
labelKey: "customer_rules.tooltip.groups.standalone_additional_services",
},
]);
const normalizeProductId = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeProductName = (product) => String(product?.name ?? product?.product_name ?? "").trim();
const sortProductsByDisplayOrder = (products) =>
[...products].sort((left, right) => {
const leftPriority = Number(left?.order_priority ?? 0);
const rightPriority = Number(right?.order_priority ?? 0);
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return normalizeProductName(left).localeCompare(normalizeProductName(right));
});
export const normalizeRuleProductList = (products = []) => {
if (!Array.isArray(products)) {
return [];
}
const seen = new Set();
return sortProductsByDisplayOrder(products)
.filter((product) => {
const productId = normalizeProductId(product?.id ?? product?.product_id);
if (productId === null || seen.has(productId)) {
return false;
}
seen.add(productId);
return normalizeProductName(product) !== "";
});
};
const addonToProduct = (addon, parentProduct) => {
const addonProduct = addon?.product || {};
const addonId = normalizeProductId(addon?.option_id ?? addonProduct.id ?? addon?.id);
return {
...addonProduct,
id: addonId ?? addonProduct.id ?? addon?.id,
name: addon?.name || addonProduct.name,
category: addonProduct.category ?? addon?.category,
category_name: addonProduct.category_name ?? addon?.category_name,
order_priority: addonProduct.order_priority ?? addon?.order_priority ?? parentProduct?.order_priority,
parentProductName: normalizeProductName(parentProduct),
};
};
const flattenRelatedAddons = (products) =>
products.flatMap((product) => (
Array.isArray(product?.addons)
? product.addons.map((addon) => addonToProduct(addon, product))
: []
));
const buildAttributeSet = (attribute) => [{ attribute }];
const productIsRestrictedByAttribute = (product, attribute, options = {}) => (
getCustomerProductRestriction(product, buildAttributeSet(attribute), options).rule === attribute
);
const emptyGroups = () => ({
primaryProducts: [],
relatedAddons: [],
standaloneAdditionalServices: [],
});
const groupedRestrictedProducts = (attribute, products) => {
const normalizedProducts = normalizeRuleProductList(products);
const relatedAddons = normalizeRuleProductList(flattenRelatedAddons(normalizedProducts));
const standaloneCandidates = normalizeRuleProductList(
normalizedProducts.filter((product) => isStandaloneAdditionalServiceCatalogProduct(product))
);
return {
primaryProducts: normalizedProducts.filter((product) => productIsRestrictedByAttribute(product, attribute)),
relatedAddons: relatedAddons.filter((product) => productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
})),
standaloneAdditionalServices: standaloneCandidates.filter((product) =>
productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
})
),
};
};
const groupedAllowedOnlyTankCleaningProducts = (products) => ({
primaryProducts: normalizeRuleProductList(products).filter((product) => isTankCleaningProduct(product)),
relatedAddons: normalizeRuleProductList(flattenRelatedAddons(products)).filter((product) => isTankCleaningProduct(product)),
standaloneAdditionalServices: [],
});
export const getCustomerRuleProductImpact = (attribute, products = []) => {
const definition = getCustomerRuleDefinition(attribute);
if (!definition?.productImpact) {
return {
hasProductImpact: false,
blocked: emptyGroups(),
available: emptyGroups(),
};
}
const normalizedProducts = normalizeRuleProductList(products);
const blocked = groupedRestrictedProducts(attribute, normalizedProducts);
const available = attribute === "onlyTankCleaning"
? groupedAllowedOnlyTankCleaningProducts(normalizedProducts)
: emptyGroups();
return {
hasProductImpact: true,
groups: PRODUCT_GROUPS,
blocked,
available,
};
};
export const groupHasProducts = (groupedProducts = {}) => (
PRODUCT_GROUPS.some((group) => (groupedProducts[group.key] || []).length > 0)
);
export const getCustomerRuleTooltipModel = (attribute, options = {}) => {
const definition = getCustomerRuleDefinition(attribute);
if (!definition) {
return null;
}
const productImpact = getCustomerRuleProductImpact(attribute, options.products || []);
return {
attribute,
active: options.active === true,
definition,
descriptionKey: definition.descriptionKey,
hasProductImpact: productImpact.hasProductImpact,
groups: productImpact.groups || PRODUCT_GROUPS,
blocked: productImpact.blocked,
available: productImpact.available,
};
};
@@ -8,6 +8,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictAdditionalServices.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictTankCleaning",
@@ -18,6 +19,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictSpotFree",
@@ -28,6 +30,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictSpotFree.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictInteriorCleaning",
@@ -38,6 +41,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictInteriorCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "onlyTankCleaning",
@@ -48,6 +52,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.onlyTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "requiresReferenceNumber",
+18
View File
@@ -1144,6 +1144,24 @@
}
},
"templates": {
"support": {
"title": "Support",
"meta_description": "Support og kontaktoplysninger for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakt os, hvis du har brug for hjælp til booking, selvvask, betalinger eller adgang til kundeportalen.",
"contact_title": "Kontakt",
"email_label": "Email",
"phone_label": "Telefon",
"company_label": "Virksomhed",
"help_title": "Vi kan hjælpe med",
"help_booking": "Booking og ændring af vasketider.",
"help_self_wash": "Start og afslutning af selvvask.",
"help_payments": "Ordrer, fakturaer og kortbetalinger.",
"help_account": "Login, adgang og køretøjer på din konto.",
"privacy_prefix": "Læs også vores",
"privacy_link": "privatlivspolitik",
"privacy_suffix": "for oplysninger om data og rettigheder."
},
"common": {
"step_of": "@.capitalize:{'words.generated.trin'} {current} @:{'words.generated.af'} {total}",
"page_of": "@.capitalize:{'words.generated.side'} {current} @:{'words.generated.af'} {total}",
+86 -1
View File
@@ -1255,6 +1255,24 @@
}
},
"templates": {
"support": {
"title": "Support",
"meta_description": "Support- und Kontaktinformationen fuer Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontaktieren Sie uns, wenn Sie Hilfe bei Buchungen, Selbstbedienungswaesche, Zahlungen oder dem Zugang zum Kundenportal benoetigen.",
"contact_title": "Kontakt",
"email_label": "E-Mail",
"phone_label": "Telefon",
"company_label": "Unternehmen",
"help_title": "Wir helfen bei",
"help_booking": "Buchung und Aenderung von Waschzeiten.",
"help_self_wash": "Start und Abschluss von Selbstbedienungswaeschen.",
"help_payments": "Bestellungen, Rechnungen und Kartenzahlungen.",
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
"privacy_prefix": "Lesen Sie auch unsere",
"privacy_link": "Datenschutzerklaerung",
"privacy_suffix": "fuer Informationen zu Daten und Rechten."
},
"common": {
"step_of": "@:{'words.generated.schritt'} {current} @:{'words.generated.von'} {total}",
"page_of": "@:{'words.generated.seite'} {current} @:{'words.generated.von'} {total}",
@@ -2611,21 +2629,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@:{'words.generated.backup'} @:{'words.generated.erstellt'}",
"backup_created_success": "@:{'words.generated.backup'} @:{'words.generated.wurde'} @:{'words.generated.erfolgreich'} @:{'words.generated.erstellt'}.",
"backup_error": "@.capitalize:{'words.generated.beim'} @.capitalize:{'words.generated.erstellen'} @:{'words.generated.des'} @:{'words.generated.backups'} @:{'words.generated.ist'} @:{'words.generated.ein'} @:{'words.generated.fehler'} @:{'words.generated.aufgetreten'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@:{'words.generated.backup'}-Zeitplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.backup'} @:{'words.generated.erstellen'}",
"create_backup_confirm": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}, @:{'words.generated.dass'} @.capitalize:{'words.generated.sie'} @:{'words.generated.ein'} @:{'words.generated.backup'} @:{'words.generated.erstellen'} @:{'words.generated.m'}?@:{'words.generated.chten'}?",
"delete_backup": "@:{'words.generated.backup'} @:{'words.generated.l'}?@:{'words.generated.schen'}",
"disabled": "Disabled",
"download_backup": "@:{'words.generated.backup'} @:{'words.generated.herunterladen'}",
"enable_system": "@:{'words.generated.backup'}-@.capitalize:{'words.generated.system'} @:{'words.generated.aktivieren'}",
"enable_system_desc": "@:{'words.generated.backup'}-@.capitalize:{'words.generated.system'} @:{'words.generated.aktivieren'} @:{'words.generated.oder'} @:{'words.generated.deaktivieren'}.",
"enabled": "Enabled",
"general_settings_desc": "@:{'words.generated.allgemeine'} @:{'words.generated.einstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.das'} @:{'words.generated.backup'}-@.capitalize:{'words.generated.system'}.",
"last_backup": "Letztes @:{'words.generated.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.backup'} wiederherstellen",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@:{'words.generated.konfiguration'} @:{'words.generated.des'} @:{'words.generated.backup'}-@:{'words.generated.systems'}.",
"title": "@:{'words.generated.backup'}-@:{'words.generated.konfiguration'}"
"title": "@:{'words.generated.backup'}-@:{'words.generated.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@:{'words.generated.einstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.verwaltungs'}- @:{'words.generated.und'} @:{'words.generated.umweltgeb'}?@:{'words.generated.hr'} @:{'words.generated.der'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}-@.capitalize:{'words.generated.integration'}.",
@@ -3538,6 +3604,21 @@
"toggle_unavailable": "Sie haben keine Berechtigung, diese Kundenregel zu ändern.",
"unavailable": "Sie haben keine Berechtigung, Kundenregeln anzuzeigen.",
"unknown_active": "Unbekannte aktive Regeln"
},
"tooltip": {
"available_if_enabled": "Bleibt verfügbar, wenn aktiviert",
"available_while_enabled": "Verfügbar, solange aktiv",
"blocked_if_enabled": "Wird blockiert, wenn aktiviert",
"blocked_now": "Blockiert, solange aktiv",
"changes": "Änderungen",
"groups": {
"primary_products": "Primäre Produkte",
"related_addons": "Verknüpfte Add-ons",
"standalone_additional_services": "Eigenständige Zusatzleistungen"
},
"load_failed": "Betroffene Produkte konnten nicht geladen werden.",
"loading_products": "Betroffene Produkte werden geladen...",
"no_affected_products": "Im aktuellen Produktkatalog wurden keine betroffenen Produkte gefunden."
}
},
"customers": {
@@ -4757,6 +4838,7 @@
},
"pagination": {
"archive": "Archiv",
"clear_all": "Alle leeren",
"ascending": "Aufsteigend (?lteste @:{'words.generated.zuerst'})",
"booking_status": "Buchungsstatus",
"bookings_overview": "@:{'words.generated.buchungs'}?@:{'words.generated.bersicht'}",
@@ -6020,6 +6102,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} default @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} implemented.",
+86 -1
View File
@@ -975,6 +975,24 @@
}
},
"templates": {
"support": {
"title": "Support",
"meta_description": "Support and contact information for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Contact us if you need help with bookings, self-service wash, payments, or access to the customer portal.",
"contact_title": "Contact",
"email_label": "Email",
"phone_label": "Phone",
"company_label": "Company",
"help_title": "We can help with",
"help_booking": "Booking and changing wash appointments.",
"help_self_wash": "Starting and ending self-service wash sessions.",
"help_payments": "Orders, invoices, and card payments.",
"help_account": "Login, access, and vehicles on your account.",
"privacy_prefix": "Also read our",
"privacy_link": "privacy policy",
"privacy_suffix": "for details about data and rights."
},
"common": {
"step_of": "@.capitalize:{'words.generated.step'} {current} @:{'words.generated.of'} {total}",
"page_of": "@.capitalize:{'words.generated.page'} {current} @:{'words.generated.of'} {total}",
@@ -2331,21 +2349,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'words.generated.backup'} @:{'words.generated.created'}",
"backup_created_success": "@.capitalize:{'words.generated.backup'} @:{'words.generated.created'} @:{'words.generated.successfully'}.",
"backup_error": "@.capitalize:{'words.generated.an'} @:{'words.generated.error'} @:{'words.generated.occurred'} @:{'words.generated.while'} @:{'words.generated.creating'} @:{'words.generated.a'} @:{'words.generated.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@.capitalize:{'words.generated.backup'} schedule",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@.capitalize:{'words.generated.create'} @:{'words.generated.backup'}",
"create_backup_confirm": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'} @:{'words.generated.you'} @:{'words.generated.want'} @:{'words.generated.to'} @:{'words.generated.create'} @:{'words.generated.a'} @:{'words.generated.backup'}?",
"delete_backup": "@.capitalize:{'words.generated.delete'} @:{'words.generated.backup'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'words.generated.download'} @:{'words.generated.backup'}",
"enable_system": "@.capitalize:{'words.generated.enable'} @:{'words.generated.backup'} @:{'words.generated.system'}",
"enable_system_desc": "@.capitalize:{'words.generated.enable'} @:{'words.generated.or'} @:{'words.generated.disable'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.general'} @:{'words.generated.settings'} @:{'words.generated.for'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"last_backup": "Latest @:{'words.generated.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "Restore @:{'words.generated.backup'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.configuration'} @:{'words.generated.of'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.configuration'}"
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.configuration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.administration'} @:{'words.generated.and'} @:{'words.generated.environmental'} @:{'words.generated.fee'} @:{'words.generated.settings'} @:{'words.generated.for'} @:{'words.replication.article.host_mention'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.integration'}.",
@@ -3258,6 +3324,21 @@
"toggle_unavailable": "You do not have permission to change this customer rule.",
"unavailable": "You do not have permission to view customer rules.",
"unknown_active": "Unknown active rules"
},
"tooltip": {
"available_if_enabled": "Would remain available if enabled",
"available_while_enabled": "Available while active",
"blocked_if_enabled": "Would be blocked if enabled",
"blocked_now": "Blocked while active",
"changes": "Changes",
"groups": {
"primary_products": "Primary products",
"related_addons": "Related add-ons",
"standalone_additional_services": "Standalone additional services"
},
"load_failed": "Could not load affected products.",
"loading_products": "Loading affected products...",
"no_affected_products": "No affected products found in the current product catalog."
}
},
"customers": {
@@ -4477,6 +4558,7 @@
},
"pagination": {
"archive": "Archive",
"clear_all": "Clear all",
"ascending": "Ascending (Oldest @:{'words.generated.first'})",
"booking_status": "@.capitalize:{'words.generated.booking'} @:{'words.generated.status'}",
"bookings_overview": "@.capitalize:{'words.generated.bookings'} @:{'words.generated.overview'}",
@@ -5740,6 +5822,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} @:{'words.generated.default'} @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.implemented'}.",
+223 -1
View File
@@ -1188,22 +1188,70 @@
},
"configuration": {
"backups": {
"active_jobs": "@:{'templates.generated.compat.configuration.backups.active_jobs'}",
"actions": "@:{'templates.generated.compat.configuration.backups.actions'}",
"app_data_enabled": "@:{'templates.generated.compat.configuration.backups.app_data_enabled'}",
"app_data_enabled_desc": "@:{'templates.generated.compat.configuration.backups.app_data_enabled_desc'}",
"backup": "@:{'templates.generated.compat.configuration.backups.backup'}",
"backup_created": "@:{'templates.generated.compat.configuration.backups.backup_created'}",
"backup_created_success": "@:{'templates.generated.compat.configuration.backups.backup_created_success'}",
"backup_error": "@:{'templates.generated.compat.configuration.backups.backup_error'}",
"backup_queued": "@:{'templates.generated.compat.configuration.backups.backup_queued'}",
"backup_queued_success": "@:{'templates.generated.compat.configuration.backups.backup_queued_success'}",
"backup_schedule": "@:{'templates.generated.compat.configuration.backups.backup_schedule'}",
"backups": "@:{'templates.generated.compat.configuration.backups.backups'}",
"completed_at": "@:{'templates.generated.compat.configuration.backups.completed_at'}",
"create_backup": "@:{'templates.generated.compat.configuration.backups.create_backup'}",
"create_backup_confirm": "@:{'templates.generated.compat.configuration.backups.create_backup_confirm'}",
"delete_backup": "@:{'templates.generated.compat.configuration.backups.delete_backup'}",
"disabled": "@:{'templates.generated.compat.configuration.backups.disabled'}",
"download_backup": "@:{'templates.generated.compat.configuration.backups.download_backup'}",
"enable_system": "@:{'templates.generated.compat.configuration.backups.enable_system'}",
"enable_system_desc": "@:{'templates.generated.compat.configuration.backups.enable_system_desc'}",
"enabled": "@:{'templates.generated.compat.configuration.backups.enabled'}",
"general_settings": "@:{'templates.generated.compat.configuration.general.title'}",
"general_settings_desc": "@:{'templates.generated.compat.configuration.general.title_desc'}",
"last_backup": "@:{'templates.generated.compat.configuration.backups.last_backup'}",
"latest_backup": "@:{'templates.generated.compat.configuration.backups.latest_backup'}",
"latest_verified": "@:{'templates.generated.compat.configuration.backups.latest_verified'}",
"legacy_backups": "@:{'templates.generated.compat.configuration.backups.legacy_backups'}",
"name": "@:{'templates.generated.compat.configuration.backups.name'}",
"no_backups": "@:{'templates.generated.compat.configuration.backups.no_backups'}",
"no_restore_audit": "@:{'templates.generated.compat.configuration.backups.no_restore_audit'}",
"not_available": "@:{'templates.generated.compat.configuration.backups.not_available'}",
"objects": "@:{'templates.generated.compat.configuration.backups.objects'}",
"refresh": "@:{'templates.generated.compat.configuration.backups.refresh'}",
"restore_audit": "@:{'templates.generated.compat.configuration.backups.restore_audit'}",
"restore_backup": "@:{'templates.generated.compat.configuration.backups.restore_backup'}",
"restore_confirm_intro": "@:{'templates.generated.compat.configuration.backups.restore_confirm_intro'}",
"restore_confirmation": "@:{'templates.generated.compat.configuration.backups.restore_confirmation'}",
"restore_enabled": "@:{'templates.generated.compat.configuration.backups.restore_enabled'}",
"restore_enabled_desc": "@:{'templates.generated.compat.configuration.backups.restore_enabled_desc'}",
"restore_error": "@:{'templates.generated.compat.configuration.backups.restore_error'}",
"restore_queued": "@:{'templates.generated.compat.configuration.backups.restore_queued'}",
"restore_queued_success": "@:{'templates.generated.compat.configuration.backups.restore_queued_success'}",
"restore_reason": "@:{'templates.generated.compat.configuration.backups.restore_reason'}",
"restore_state": "@:{'templates.generated.compat.configuration.backups.restore_state'}",
"restore_state_desc": "@:{'templates.generated.compat.configuration.backups.restore_state_desc'}",
"restore_validation_error": "@:{'templates.generated.compat.configuration.backups.restore_validation_error'}",
"retention": "@:{'templates.generated.compat.configuration.backups.retention'}",
"retention_daily_days": "@:{'templates.generated.compat.configuration.backups.retention_daily_days'}",
"retention_daily_days_desc": "@:{'templates.generated.compat.configuration.backups.retention_daily_days_desc'}",
"retention_monthly_months": "@:{'templates.generated.compat.configuration.backups.retention_monthly_months'}",
"retention_monthly_months_desc": "@:{'templates.generated.compat.configuration.backups.retention_monthly_months_desc'}",
"retention_recent_hours": "@:{'templates.generated.compat.configuration.backups.retention_recent_hours'}",
"retention_recent_hours_desc": "@:{'templates.generated.compat.configuration.backups.retention_recent_hours_desc'}",
"retention_weekly_weeks": "@:{'templates.generated.compat.configuration.backups.retention_weekly_weeks'}",
"retention_weekly_weeks_desc": "@:{'templates.generated.compat.configuration.backups.retention_weekly_weeks_desc'}",
"size": "@:{'templates.generated.compat.configuration.backups.size'}",
"started_at": "@:{'templates.generated.compat.configuration.backups.started_at'}",
"status": "@:{'templates.generated.compat.configuration.backups.status'}",
"subtitle": "@:{'templates.generated.compat.configuration.backups.subtitle'}",
"title": "@:{'templates.generated.compat.configuration.backups.title'}"
"title": "@:{'templates.generated.compat.configuration.backups.title'}",
"verification_required": "@:{'templates.generated.compat.configuration.backups.verification_required'}",
"verification_required_desc": "@:{'templates.generated.compat.configuration.backups.verification_required_desc'}",
"verified_at": "@:{'templates.generated.compat.configuration.backups.verified_at'}",
"verify": "@:{'templates.generated.compat.configuration.backups.verify'}"
},
"departments": {
"subtitle": "@:departments.subtitle",
@@ -2226,6 +2274,21 @@
"toggle_unavailable": "@:{'templates.generated.compat.customer_rules.manager.toggle_unavailable'}",
"unavailable": "@:{'templates.generated.compat.customer_rules.manager.unavailable'}",
"unknown_active": "@:{'templates.generated.compat.customer_rules.manager.unknown_active'}"
},
"tooltip": {
"available_if_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.available_if_enabled'}",
"available_while_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.available_while_enabled'}",
"blocked_if_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.blocked_if_enabled'}",
"blocked_now": "@:{'templates.generated.compat.customer_rules.tooltip.blocked_now'}",
"changes": "@:{'templates.generated.compat.customer_rules.tooltip.changes'}",
"groups": {
"primary_products": "@:{'templates.generated.compat.customer_rules.tooltip.groups.primary_products'}",
"related_addons": "@:{'templates.generated.compat.customer_rules.tooltip.groups.related_addons'}",
"standalone_additional_services": "@:{'templates.generated.compat.customer_rules.tooltip.groups.standalone_additional_services'}"
},
"load_failed": "@:{'templates.generated.compat.customer_rules.tooltip.load_failed'}",
"loading_products": "@:{'templates.generated.compat.customer_rules.tooltip.loading_products'}",
"no_affected_products": "@:{'templates.generated.compat.customer_rules.tooltip.no_affected_products'}"
}
},
"customers": {
@@ -3928,6 +3991,7 @@
"pagination": {
"all": "@:common.all",
"archive": "@:{'templates.generated.compat.pagination.archive'}",
"clear_all": "@:{'templates.generated.compat.pagination.clear_all'}",
"ascending": "@:{'templates.generated.compat.pagination.ascending'}",
"booked": "@:global.booked",
"booking_status": "@:{'templates.generated.compat.pagination.booking_status'}",
@@ -4589,6 +4653,141 @@
"subtitle": "@:{'templates.generated.compat.roles.subtitle'}",
"title": "@:{'templates.generated.compat.common.roles'}"
},
"security": {
"actions": {
"acknowledge": "Acknowledge",
"add_note": "Add note",
"clear": "Clear",
"create": "Create",
"delete": "Delete",
"disable": "Disable",
"edit": "Edit",
"enable": "Enable",
"false_positive": "False positive",
"open_incidents": "Open incidents",
"refresh": "Refresh",
"reopen": "Reopen",
"resolve": "Resolve",
"save": "Save",
"search": "Search",
"update": "Update"
},
"empty_value": "--",
"errors": {
"generic": "Unable to load security controls."
},
"firewall": {
"action": "Action",
"actions": {
"allow": "Allow",
"block": "Block",
"watch": "Watch"
},
"confirm_delete": "Delete this firewall rule?",
"disabled": "Disabled",
"empty": "No firewall rules found.",
"enabled": "Enabled",
"expires_at": "Expires at",
"management": "Firewall management",
"priority": "Priority",
"reason": "Reason",
"route_pattern": "Route pattern",
"target": "Target",
"target_type": "Target type",
"target_types": {
"cidr": "CIDR",
"customer": "Customer",
"ip": "IP address",
"route": "Route",
"user": "User"
},
"target_value": "Target value",
"title": "Security firewall"
},
"incidents": {
"add_note": "Add note",
"customer_number": "Customer number",
"empty": "No security incidents found.",
"management": "Incident management",
"no_notes": "No notes have been added.",
"note_placeholder": "Add investigation notes or resolution context",
"notes": "Notes",
"route": "Route",
"search": "Search incidents",
"select_prompt": "Select an incident to inspect details.",
"source_ip": "Source IP",
"status_filter": "Status",
"title": "Security incidents",
"type": "Type"
},
"loading": "Loading security controls...",
"messages": {
"firewall_created": "Firewall rule created.",
"firewall_deleted": "Firewall rule deleted.",
"firewall_updated": "Firewall rule updated.",
"incident_updated": "Incident updated.",
"note_added": "Incident note added.",
"settings_saved": "Security settings saved."
},
"metrics": {
"acknowledged_incidents": "Acknowledged incidents",
"active_firewall_rules": "Active firewall rules",
"block_rules": "Block rules",
"open_incidents": "Open incidents"
},
"nav": "Security",
"overview": {
"recent_incidents": "Recent incidents",
"title": "Security overview"
},
"rule_descriptions": {
"bookings_created": "Observe high booking creation volume for a customer.",
"failed_login_attempts": "Observe repeated failed sign-in attempts for the same principal.",
"requests_per_customer": "Observe high API request volume for one customer context.",
"requests_per_ip": "Observe high API request volume from one source IP.",
"vehicles_created": "Observe high vehicle creation volume for a customer."
},
"rules": {
"bookings_created": "Max bookings",
"failed_login_attempts": "Max failed login attempts",
"requests_per_customer": "Max requests per customer",
"requests_per_ip": "Max requests per IP",
"vehicles_created": "Max vehicle creations"
},
"settings": {
"enabled": "Enabled",
"exemptions": "Permission node exemptions",
"exemptions_placeholder": "permission_a, permission_b",
"management": "Security settings",
"observe_mode": "Observe mode",
"threshold": "Maximum",
"title": "Security settings",
"window_seconds": "Window seconds"
},
"status": {
"acknowledged": "Acknowledged",
"all": "All",
"false_positive": "False positive",
"open": "Open",
"resolved": "Resolved"
},
"subtitle": "Application firewall rules, observe-mode thresholds, permission exemptions, and incident handling.",
"table": {
"actions": "Actions",
"count": "Count",
"last_seen": "Last seen",
"severity": "Severity",
"status": "Status",
"title": "Title"
},
"tabs": {
"firewall": "Firewall",
"incidents": "Incidents",
"overview": "Overview",
"settings": "Settings"
},
"title": "System security"
},
"self_wash": {
"add_as_new_vehicle": "@:{'templates.generated.compat.self_wash.add_as_new_vehicle'}",
"answer_questions": "@:{'templates.generated.compat.self_wash.answer_questions'}",
@@ -5595,6 +5794,24 @@
"objects": "@:{'templates.generated.compat.superuser.xlvask.objects'}"
}
},
"support": {
"title": "@:{'templates.generated.compat.support.title'}",
"meta_description": "@:{'templates.generated.compat.support.meta_description'}",
"eyebrow": "@:{'templates.generated.compat.support.eyebrow'}",
"intro": "@:{'templates.generated.compat.support.intro'}",
"contact_title": "@:{'templates.generated.compat.support.contact_title'}",
"email_label": "@:{'templates.generated.compat.support.email_label'}",
"phone_label": "@:{'templates.generated.compat.support.phone_label'}",
"company_label": "@:{'templates.generated.compat.support.company_label'}",
"help_title": "@:{'templates.generated.compat.support.help_title'}",
"help_booking": "@:{'templates.generated.compat.support.help_booking'}",
"help_self_wash": "@:{'templates.generated.compat.support.help_self_wash'}",
"help_payments": "@:{'templates.generated.compat.support.help_payments'}",
"help_account": "@:{'templates.generated.compat.support.help_account'}",
"privacy_prefix": "@:{'templates.generated.compat.support.privacy_prefix'}",
"privacy_link": "@:{'templates.generated.compat.support.privacy_link'}",
"privacy_suffix": "@:{'templates.generated.compat.support.privacy_suffix'}"
},
"system_status": {
"actions": {
"open_config": "@:{'templates.generated.compat.system_status.actions.open_config'}",
@@ -5656,8 +5873,10 @@
"buckets_available": "@:{'templates.generated.compat.system_status.labels.buckets_available'}",
"checked_at": "@:{'templates.generated.compat.system_status.labels.checked_at'}",
"configured": "@:{'templates.generated.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'words.generated.buckets'}",
"database_index": "@:replication.fields.database_index",
"enabled": "@:{'templates.generated.compat.system_status.labels.enabled'}",
"endpoint": "@.capitalize:{'words.generated.endpoint'}",
"latency": "@:{'templates.generated.compat.system_status.labels.latency'}",
"no_reason": "@:{'templates.generated.compat.system_status.labels.no_reason'}",
"replication_percent": "@:{'templates.generated.compat.system_status.labels.replication_percent'}",
@@ -5689,6 +5908,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@:{'templates.generated.compat.system_status.reasons.backup_connectivity_confirmed'}",
"backup_encryption_key_missing": "@:{'templates.generated.compat.system_status.reasons.backup_encryption_key_missing'}",
"backup_latest_verified_stale": "@:{'templates.generated.compat.system_status.reasons.backup_latest_verified_stale'}",
"backup_no_verified_backup": "@:{'templates.generated.compat.system_status.reasons.backup_no_verified_backup'}",
"backup_probe_failed": "@:{'templates.generated.compat.system_status.reasons.backup_probe_failed'}",
"economic_credentials_missing": "@:{'templates.generated.compat.system_status.reasons.economic_credentials_missing'}",
"email_delivery_not_implemented": "@:{'templates.generated.compat.system_status.reasons.email_delivery_not_implemented'}",
+18
View File
@@ -1258,6 +1258,24 @@
}
},
"templates": {
"support": {
"title": "Support",
"meta_description": "Support og kontaktinformasjon for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakt oss hvis du trenger hjelp med booking, selvvask, betalinger eller tilgang til kundeportalen.",
"contact_title": "Kontakt",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Selskap",
"help_title": "Vi kan hjelpe med",
"help_booking": "Booking og endring av vasketider.",
"help_self_wash": "Start og avslutning av selvvask.",
"help_payments": "Ordrer, fakturaer og kortbetalinger.",
"help_account": "Innlogging, tilgang og kjoretoy paa kontoen din.",
"privacy_prefix": "Les ogsaa vaar",
"privacy_link": "personvernerklaering",
"privacy_suffix": "for informasjon om data og rettigheter."
},
"common": {
"step_of": "@.capitalize:{'words.generated.trinn'} {current} @:{'words.generated.av'} {total}",
"page_of": "@.capitalize:{'words.generated.side'} {current} @:{'words.generated.av'} {total}",
+86 -1
View File
@@ -1308,6 +1308,24 @@
}
},
"templates": {
"support": {
"title": "Support",
"meta_description": "Support och kontaktinformation for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakta oss om du behover hjalp med bokningar, sjalvtvatt, betalningar eller atkomst till kundportalen.",
"contact_title": "Kontakt",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Foretag",
"help_title": "Vi kan hjalpa till med",
"help_booking": "Bokning och andring av tvattider.",
"help_self_wash": "Start och avslut av sjalvtvatt.",
"help_payments": "Ordrar, fakturor och kortbetalningar.",
"help_account": "Inloggning, atkomst och fordon pa ditt konto.",
"privacy_prefix": "Las ocksa var",
"privacy_link": "integritetspolicy",
"privacy_suffix": "for information om data och rattigheter."
},
"common": {
"step_of": "@.capitalize:{'words.generated.steg'} {current} @:{'words.generated.av'} {total}",
"page_of": "@.capitalize:{'words.generated.sida'} {current} @:{'words.generated.av'} {total}",
@@ -2664,21 +2682,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'words.generated.backup'} oprettet",
"backup_created_success": "Säkerhetskopian @:{'words.generated.skapades'}.",
"backup_error": "@.capitalize:{'words.generated.der'} opstod @:{'words.replication.host_definite_suffix'} @:{'words.generated.fejl'} @:{'words.generated.under'} oprettelse @:{'words.generated.af'} @:{'words.generated.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Säkerhetskopieringsschema",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.opret'} @:{'words.generated.backup'}",
"create_backup_confirm": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'} @:{'words.generated.pa'} @:{'words.generated.att'} @:{'words.generated.du'} @:{'words.generated.vill'} @:{'words.generated.skapa'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sakerhetskopia'}?",
"delete_backup": "@.capitalize:{'words.generated.radera'} @:{'words.generated.sakerhetskopia'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'words.generated.ladda'} @:{'words.generated.ner'} @:{'words.generated.sakerhetskopia'}",
"enable_system": "@:{'words.generated.aktiver'} @:{'words.generated.backup'} @:{'words.generated.system'}",
"enable_system_desc": "@:{'words.generated.aktiver'} @:{'words.generated.eller'} @:{'words.generated.deaktiver'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.generated.de'} @:{'words.generated.generelle'} @:{'words.generated.indstillinger'} @:{'words.generated.for_2'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"last_backup": "@.capitalize:{'words.generated.senaste'} @:{'words.generated.sakerhetskopia'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.aterstall'} @:{'words.generated.sakerhetskopia'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.konfiguration'} @:{'words.generated.af'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.konfiguration'}"
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.installningar'} @:{'words.generated.for'} @:{'words.generated.administrations'}- @:{'words.generated.och'} @:{'words.generated.miljoavgift'} @:{'words.generated.for'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}-@:{'words.generated.integrationen'}.",
@@ -3591,6 +3657,21 @@
"toggle_unavailable": "Du har inte behörighet att ändra denna kundregel.",
"unavailable": "Du har inte behörighet att visa kundregler.",
"unknown_active": "Okända aktiva regler"
},
"tooltip": {
"available_if_enabled": "Förblir tillgängliga om aktiverad",
"available_while_enabled": "Tillgängliga medan aktiv",
"blocked_if_enabled": "Blockeras om aktiverad",
"blocked_now": "Blockerad medan aktiv",
"changes": "Ändringar",
"groups": {
"primary_products": "Primära produkter",
"related_addons": "Kopplade add-ons",
"standalone_additional_services": "Fristående tilläggstjänster"
},
"load_failed": "Berörda produkter kunde inte läsas in.",
"loading_products": "Läser in berörda produkter...",
"no_affected_products": "Inga berörda produkter hittades i den aktuella produktkatalogen."
}
},
"customers": {
@@ -4810,6 +4891,7 @@
},
"pagination": {
"archive": "Archive",
"clear_all": "Rensa alla",
"ascending": "Ascending (Oldest @:{'words.generated.first'})",
"booking_status": "Bokningsstatus",
"bookings_overview": "Bokningsäversikt",
@@ -6073,6 +6155,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} @:{'words.generated.default'} @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} implemented.",
@@ -0,0 +1,20 @@
{
"support": {
"title": "Support",
"meta_description": "Support og kontaktoplysninger for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakt os, hvis du har brug for hjælp til booking, selvvask, betalinger eller adgang til kundeportalen.",
"contact_title": "Kontakt",
"email_label": "Email",
"phone_label": "Telefon",
"company_label": "Virksomhed",
"help_title": "Vi kan hjælpe med",
"help_booking": "Booking og ændring af vasketider.",
"help_self_wash": "Start og afslutning af selvvask.",
"help_payments": "Ordrer, fakturaer og kortbetalinger.",
"help_account": "Login, adgang og køretøjer på din konto.",
"privacy_prefix": "Læs også vores",
"privacy_link": "privatlivspolitik",
"privacy_suffix": "for oplysninger om data og rettigheder."
}
}
@@ -2,21 +2,69 @@
"compat": {
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@:{'terms.glossary.backup'} @:{'terms.glossary.erstellt'}",
"backup_created_success": "@:{'terms.glossary.backup'} @:{'terms.glossary.wurde'} @:{'terms.glossary.erfolgreich'} @:{'terms.glossary.erstellt'}.",
"backup_error": "@.capitalize:{'terms.glossary.beim'} @.capitalize:{'terms.glossary.erstellen'} @:{'terms.glossary.des'} @:{'terms.glossary.backups'} @:{'terms.glossary.ist'} @:{'terms.glossary.ein'} @:{'terms.glossary.fehler'} @:{'terms.glossary.aufgetreten'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@:{'terms.glossary.backup'}-Zeitplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'terms.glossary.backup'} @:{'terms.glossary.erstellen'}",
"create_backup_confirm": "@.capitalize:{'terms.glossary.sind'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.sicher'}, @:{'terms.glossary.dass'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.ein'} @:{'terms.glossary.backup'} @:{'terms.glossary.erstellen'} @:{'terms.glossary.m'}?@:{'terms.glossary.chten'}?",
"delete_backup": "@:{'terms.glossary.backup'} @:{'terms.glossary.l'}?@:{'terms.glossary.schen'}",
"disabled": "Disabled",
"download_backup": "@:{'terms.glossary.backup'} @:{'terms.glossary.herunterladen'}",
"enable_system": "@:{'terms.glossary.backup'}-@.capitalize:{'terms.glossary.system'} @:{'terms.glossary.aktivieren'}",
"enable_system_desc": "@:{'terms.glossary.backup'}-@.capitalize:{'terms.glossary.system'} @:{'terms.glossary.aktivieren'} @:{'terms.glossary.oder'} @:{'terms.glossary.deaktivieren'}.",
"enabled": "Enabled",
"general_settings_desc": "@:{'terms.glossary.allgemeine'} @:{'terms.glossary.einstellungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.das'} @:{'terms.glossary.backup'}-@.capitalize:{'terms.glossary.system'}.",
"last_backup": "Letztes @:{'terms.glossary.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'terms.glossary.backup'} wiederherstellen",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@:{'terms.glossary.konfiguration'} @:{'terms.glossary.des'} @:{'terms.glossary.backup'}-@:{'terms.glossary.systems'}.",
"title": "@:{'terms.glossary.backup'}-@:{'terms.glossary.konfiguration'}"
"title": "@:{'terms.glossary.backup'}-@:{'terms.glossary.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@:{'terms.glossary.einstellungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.verwaltungs'}- @:{'terms.glossary.und'} @:{'terms.glossary.umweltgeb'}?@:{'terms.glossary.hr'} @:{'terms.glossary.der'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}-@.capitalize:{'terms.glossary.integration'}.",
@@ -60,6 +60,21 @@
"toggle_unavailable": "Sie haben keine Berechtigung, diese Kundenregel zu ändern.",
"unavailable": "Sie haben keine Berechtigung, Kundenregeln anzuzeigen.",
"unknown_active": "Unbekannte aktive Regeln"
},
"tooltip": {
"available_if_enabled": "Bleibt verfügbar, wenn aktiviert",
"available_while_enabled": "Verfügbar, solange aktiv",
"blocked_if_enabled": "Wird blockiert, wenn aktiviert",
"blocked_now": "Blockiert, solange aktiv",
"changes": "Änderungen",
"groups": {
"primary_products": "Primäre Produkte",
"related_addons": "Verknüpfte Add-ons",
"standalone_additional_services": "Eigenständige Zusatzleistungen"
},
"load_failed": "Betroffene Produkte konnten nicht geladen werden.",
"loading_products": "Betroffene Produkte werden geladen...",
"no_affected_products": "Im aktuellen Produktkatalog wurden keine betroffenen Produkte gefunden."
}
}
}
@@ -2,6 +2,7 @@
"compat": {
"pagination": {
"archive": "Archiv",
"clear_all": "Alle leeren",
"ascending": "Aufsteigend (?lteste @:{'terms.glossary.zuerst'})",
"booking_status": "Buchungsstatus",
"bookings_overview": "@:{'terms.glossary.buchungs'}?@:{'terms.glossary.bersicht'}",
@@ -0,0 +1,20 @@
{
"support": {
"title": "Support",
"meta_description": "Support- und Kontaktinformationen fuer Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontaktieren Sie uns, wenn Sie Hilfe bei Buchungen, Selbstbedienungswaesche, Zahlungen oder dem Zugang zum Kundenportal benoetigen.",
"contact_title": "Kontakt",
"email_label": "E-Mail",
"phone_label": "Telefon",
"company_label": "Unternehmen",
"help_title": "Wir helfen bei",
"help_booking": "Buchung und Aenderung von Waschzeiten.",
"help_self_wash": "Start und Abschluss von Selbstbedienungswaeschen.",
"help_payments": "Bestellungen, Rechnungen und Kartenzahlungen.",
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
"privacy_prefix": "Lesen Sie auch unsere",
"privacy_link": "Datenschutzerklaerung",
"privacy_suffix": "fuer Informationen zu Daten und Rechten."
}
}
@@ -71,6 +71,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@:{'terms.glossary.backup'} @:{'terms.glossary.store'} @:{'terms.glossary.connectivity'} @:{'terms.glossary.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@:{'terms.glossary.backup'} @:{'terms.glossary.probe'} @:{'terms.glossary.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.credentials'} @:{'terms.glossary.are'} @:{'terms.glossary.missing'} @:{'terms.glossary.from'} @:{'terms.glossary.runtime'} environment @:{'terms.glossary.configuration'}.",
"email_delivery_not_implemented": "@:{'terms.glossary.mailersend_mailersend'} @:{'terms.glossary.must'} @:{'terms.glossary.be'} @:{'terms.glossary.enabled'} @:{'terms.glossary.because'} default @:{'terms.glossary.smtp'} delivery @:{'terms.glossary.is'} @:{'terms.glossary.not'} implemented.",
@@ -2,21 +2,69 @@
"compat": {
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.created'}",
"backup_created_success": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.created'} @:{'terms.glossary.successfully'}.",
"backup_error": "@.capitalize:{'terms.glossary.an'} @:{'terms.glossary.error'} @:{'terms.glossary.occurred'} @:{'terms.glossary.while'} @:{'terms.glossary.creating'} @:{'terms.glossary.a'} @:{'terms.glossary.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@.capitalize:{'terms.glossary.backup'} schedule",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@.capitalize:{'terms.glossary.create'} @:{'terms.glossary.backup'}",
"create_backup_confirm": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.sure'} @:{'terms.glossary.you'} @:{'terms.glossary.want'} @:{'terms.glossary.to'} @:{'terms.glossary.create'} @:{'terms.glossary.a'} @:{'terms.glossary.backup'}?",
"delete_backup": "@.capitalize:{'terms.glossary.delete'} @:{'terms.glossary.backup'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'terms.glossary.download'} @:{'terms.glossary.backup'}",
"enable_system": "@.capitalize:{'terms.glossary.enable'} @:{'terms.glossary.backup'} @:{'terms.glossary.system'}",
"enable_system_desc": "@.capitalize:{'terms.glossary.enable'} @:{'terms.glossary.or'} @:{'terms.glossary.disable'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.backup'} @:{'terms.glossary.system'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.general'} @:{'terms.glossary.settings'} @:{'terms.glossary.for'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.backup'} @:{'terms.glossary.system'}.",
"last_backup": "Latest @:{'terms.glossary.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "Restore @:{'terms.glossary.backup'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'terms.glossary.configuration'} @:{'terms.glossary.of'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.backup'} @:{'terms.glossary.system'}.",
"title": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.configuration'}"
"title": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.configuration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'terms.glossary.administration'} @:{'terms.glossary.and'} @:{'terms.glossary.environmental'} @:{'terms.glossary.fee'} @:{'terms.glossary.settings'} @:{'terms.glossary.for'} @:{'terms.replication.article.host_mention'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.integration'}.",
@@ -60,6 +60,21 @@
"toggle_unavailable": "You do not have permission to change this customer rule.",
"unavailable": "You do not have permission to view customer rules.",
"unknown_active": "Unknown active rules"
},
"tooltip": {
"available_if_enabled": "Would remain available if enabled",
"available_while_enabled": "Available while active",
"blocked_if_enabled": "Would be blocked if enabled",
"blocked_now": "Blocked while active",
"changes": "Changes",
"groups": {
"primary_products": "Primary products",
"related_addons": "Related add-ons",
"standalone_additional_services": "Standalone additional services"
},
"load_failed": "Could not load affected products.",
"loading_products": "Loading affected products...",
"no_affected_products": "No affected products found in the current product catalog."
}
}
}
@@ -2,6 +2,7 @@
"compat": {
"pagination": {
"archive": "Archive",
"clear_all": "Clear all",
"ascending": "Ascending (Oldest @:{'terms.glossary.first'})",
"booking_status": "@.capitalize:{'terms.glossary.booking'} @:{'terms.glossary.status'}",
"bookings_overview": "@.capitalize:{'terms.glossary.bookings'} @:{'terms.glossary.overview'}",
@@ -0,0 +1,20 @@
{
"support": {
"title": "Support",
"meta_description": "Support and contact information for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Contact us if you need help with bookings, self-service wash, payments, or access to the customer portal.",
"contact_title": "Contact",
"email_label": "Email",
"phone_label": "Phone",
"company_label": "Company",
"help_title": "We can help with",
"help_booking": "Booking and changing wash appointments.",
"help_self_wash": "Starting and ending self-service wash sessions.",
"help_payments": "Orders, invoices, and card payments.",
"help_account": "Login, access, and vehicles on your account.",
"privacy_prefix": "Also read our",
"privacy_link": "privacy policy",
"privacy_suffix": "for details about data and rights."
}
}
@@ -71,6 +71,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.store'} @:{'terms.glossary.connectivity'} @:{'terms.glossary.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.probe'} @:{'terms.glossary.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.credentials'} @:{'terms.glossary.are'} @:{'terms.glossary.missing'} @:{'terms.glossary.from'} @:{'terms.glossary.runtime'} environment @:{'terms.glossary.configuration'}.",
"email_delivery_not_implemented": "@:{'terms.glossary.mailersend_mailersend'} @:{'terms.glossary.must'} @:{'terms.glossary.be'} @:{'terms.glossary.enabled'} @:{'terms.glossary.because'} @:{'terms.glossary.default'} @:{'terms.glossary.smtp'} delivery @:{'terms.glossary.is'} @:{'terms.glossary.not'} @:{'terms.glossary.implemented'}.",
@@ -1,22 +1,70 @@
{
"configuration": {
"backups": {
"active_jobs": "@:{'phrases.compat.configuration.backups.active_jobs'}",
"actions": "@:{'phrases.compat.configuration.backups.actions'}",
"app_data_enabled": "@:{'phrases.compat.configuration.backups.app_data_enabled'}",
"app_data_enabled_desc": "@:{'phrases.compat.configuration.backups.app_data_enabled_desc'}",
"backup": "@:{'phrases.compat.configuration.backups.backup'}",
"backup_created": "@:{'phrases.compat.configuration.backups.backup_created'}",
"backup_created_success": "@:{'phrases.compat.configuration.backups.backup_created_success'}",
"backup_error": "@:{'phrases.compat.configuration.backups.backup_error'}",
"backup_queued": "@:{'phrases.compat.configuration.backups.backup_queued'}",
"backup_queued_success": "@:{'phrases.compat.configuration.backups.backup_queued_success'}",
"backup_schedule": "@:{'phrases.compat.configuration.backups.backup_schedule'}",
"backups": "@:{'phrases.compat.configuration.backups.backups'}",
"completed_at": "@:{'phrases.compat.configuration.backups.completed_at'}",
"create_backup": "@:{'phrases.compat.configuration.backups.create_backup'}",
"create_backup_confirm": "@:{'phrases.compat.configuration.backups.create_backup_confirm'}",
"delete_backup": "@:{'phrases.compat.configuration.backups.delete_backup'}",
"disabled": "@:{'phrases.compat.configuration.backups.disabled'}",
"download_backup": "@:{'phrases.compat.configuration.backups.download_backup'}",
"enable_system": "@:{'phrases.compat.configuration.backups.enable_system'}",
"enable_system_desc": "@:{'phrases.compat.configuration.backups.enable_system_desc'}",
"enabled": "@:{'phrases.compat.configuration.backups.enabled'}",
"general_settings": "@:{'phrases.compat.configuration.general.title'}",
"general_settings_desc": "@:{'phrases.compat.configuration.general.title_desc'}",
"last_backup": "@:{'phrases.compat.configuration.backups.last_backup'}",
"latest_backup": "@:{'phrases.compat.configuration.backups.latest_backup'}",
"latest_verified": "@:{'phrases.compat.configuration.backups.latest_verified'}",
"legacy_backups": "@:{'phrases.compat.configuration.backups.legacy_backups'}",
"name": "@:{'phrases.compat.configuration.backups.name'}",
"no_backups": "@:{'phrases.compat.configuration.backups.no_backups'}",
"no_restore_audit": "@:{'phrases.compat.configuration.backups.no_restore_audit'}",
"not_available": "@:{'phrases.compat.configuration.backups.not_available'}",
"objects": "@:{'phrases.compat.configuration.backups.objects'}",
"refresh": "@:{'phrases.compat.configuration.backups.refresh'}",
"restore_audit": "@:{'phrases.compat.configuration.backups.restore_audit'}",
"restore_backup": "@:{'phrases.compat.configuration.backups.restore_backup'}",
"restore_confirm_intro": "@:{'phrases.compat.configuration.backups.restore_confirm_intro'}",
"restore_confirmation": "@:{'phrases.compat.configuration.backups.restore_confirmation'}",
"restore_enabled": "@:{'phrases.compat.configuration.backups.restore_enabled'}",
"restore_enabled_desc": "@:{'phrases.compat.configuration.backups.restore_enabled_desc'}",
"restore_error": "@:{'phrases.compat.configuration.backups.restore_error'}",
"restore_queued": "@:{'phrases.compat.configuration.backups.restore_queued'}",
"restore_queued_success": "@:{'phrases.compat.configuration.backups.restore_queued_success'}",
"restore_reason": "@:{'phrases.compat.configuration.backups.restore_reason'}",
"restore_state": "@:{'phrases.compat.configuration.backups.restore_state'}",
"restore_state_desc": "@:{'phrases.compat.configuration.backups.restore_state_desc'}",
"restore_validation_error": "@:{'phrases.compat.configuration.backups.restore_validation_error'}",
"retention": "@:{'phrases.compat.configuration.backups.retention'}",
"retention_daily_days": "@:{'phrases.compat.configuration.backups.retention_daily_days'}",
"retention_daily_days_desc": "@:{'phrases.compat.configuration.backups.retention_daily_days_desc'}",
"retention_monthly_months": "@:{'phrases.compat.configuration.backups.retention_monthly_months'}",
"retention_monthly_months_desc": "@:{'phrases.compat.configuration.backups.retention_monthly_months_desc'}",
"retention_recent_hours": "@:{'phrases.compat.configuration.backups.retention_recent_hours'}",
"retention_recent_hours_desc": "@:{'phrases.compat.configuration.backups.retention_recent_hours_desc'}",
"retention_weekly_weeks": "@:{'phrases.compat.configuration.backups.retention_weekly_weeks'}",
"retention_weekly_weeks_desc": "@:{'phrases.compat.configuration.backups.retention_weekly_weeks_desc'}",
"size": "@:{'phrases.compat.configuration.backups.size'}",
"started_at": "@:{'phrases.compat.configuration.backups.started_at'}",
"status": "@:{'phrases.compat.configuration.backups.status'}",
"subtitle": "@:{'phrases.compat.configuration.backups.subtitle'}",
"title": "@:{'phrases.compat.configuration.backups.title'}"
"title": "@:{'phrases.compat.configuration.backups.title'}",
"verification_required": "@:{'phrases.compat.configuration.backups.verification_required'}",
"verification_required_desc": "@:{'phrases.compat.configuration.backups.verification_required_desc'}",
"verified_at": "@:{'phrases.compat.configuration.backups.verified_at'}",
"verify": "@:{'phrases.compat.configuration.backups.verify'}"
},
"departments": {
"subtitle": "@:departments.subtitle",
@@ -60,6 +60,21 @@
"toggle_unavailable": "@:{'phrases.compat.customer_rules.manager.toggle_unavailable'}",
"unavailable": "@:{'phrases.compat.customer_rules.manager.unavailable'}",
"unknown_active": "@:{'phrases.compat.customer_rules.manager.unknown_active'}"
},
"tooltip": {
"available_if_enabled": "@:{'phrases.compat.customer_rules.tooltip.available_if_enabled'}",
"available_while_enabled": "@:{'phrases.compat.customer_rules.tooltip.available_while_enabled'}",
"blocked_if_enabled": "@:{'phrases.compat.customer_rules.tooltip.blocked_if_enabled'}",
"blocked_now": "@:{'phrases.compat.customer_rules.tooltip.blocked_now'}",
"changes": "@:{'phrases.compat.customer_rules.tooltip.changes'}",
"groups": {
"primary_products": "@:{'phrases.compat.customer_rules.tooltip.groups.primary_products'}",
"related_addons": "@:{'phrases.compat.customer_rules.tooltip.groups.related_addons'}",
"standalone_additional_services": "@:{'phrases.compat.customer_rules.tooltip.groups.standalone_additional_services'}"
},
"load_failed": "@:{'phrases.compat.customer_rules.tooltip.load_failed'}",
"loading_products": "@:{'phrases.compat.customer_rules.tooltip.loading_products'}",
"no_affected_products": "@:{'phrases.compat.customer_rules.tooltip.no_affected_products'}"
}
}
}
@@ -2,6 +2,7 @@
"pagination": {
"all": "@:common.all",
"archive": "@:{'phrases.compat.pagination.archive'}",
"clear_all": "@:{'phrases.compat.pagination.clear_all'}",
"ascending": "@:{'phrases.compat.pagination.ascending'}",
"booked": "@:global.booked",
"booking_status": "@:{'phrases.compat.pagination.booking_status'}",
@@ -0,0 +1,137 @@
{
"security": {
"actions": {
"acknowledge": "Acknowledge",
"add_note": "Add note",
"clear": "Clear",
"create": "Create",
"delete": "Delete",
"disable": "Disable",
"edit": "Edit",
"enable": "Enable",
"false_positive": "False positive",
"open_incidents": "Open incidents",
"refresh": "Refresh",
"reopen": "Reopen",
"resolve": "Resolve",
"save": "Save",
"search": "Search",
"update": "Update"
},
"empty_value": "--",
"errors": {
"generic": "Unable to load security controls."
},
"firewall": {
"action": "Action",
"actions": {
"allow": "Allow",
"block": "Block",
"watch": "Watch"
},
"confirm_delete": "Delete this firewall rule?",
"disabled": "Disabled",
"empty": "No firewall rules found.",
"enabled": "Enabled",
"expires_at": "Expires at",
"management": "Firewall management",
"priority": "Priority",
"reason": "Reason",
"route_pattern": "Route pattern",
"target": "Target",
"target_type": "Target type",
"target_types": {
"cidr": "CIDR",
"customer": "Customer",
"ip": "IP address",
"route": "Route",
"user": "User"
},
"target_value": "Target value",
"title": "Security firewall"
},
"incidents": {
"add_note": "Add note",
"customer_number": "Customer number",
"empty": "No security incidents found.",
"management": "Incident management",
"no_notes": "No notes have been added.",
"note_placeholder": "Add investigation notes or resolution context",
"notes": "Notes",
"route": "Route",
"search": "Search incidents",
"select_prompt": "Select an incident to inspect details.",
"source_ip": "Source IP",
"status_filter": "Status",
"title": "Security incidents",
"type": "Type"
},
"loading": "Loading security controls...",
"messages": {
"firewall_created": "Firewall rule created.",
"firewall_deleted": "Firewall rule deleted.",
"firewall_updated": "Firewall rule updated.",
"incident_updated": "Incident updated.",
"note_added": "Incident note added.",
"settings_saved": "Security settings saved."
},
"metrics": {
"acknowledged_incidents": "Acknowledged incidents",
"active_firewall_rules": "Active firewall rules",
"block_rules": "Block rules",
"open_incidents": "Open incidents"
},
"nav": "Security",
"overview": {
"recent_incidents": "Recent incidents",
"title": "Security overview"
},
"rule_descriptions": {
"bookings_created": "Observe high booking creation volume for a customer.",
"failed_login_attempts": "Observe repeated failed sign-in attempts for the same principal.",
"requests_per_customer": "Observe high API request volume for one customer context.",
"requests_per_ip": "Observe high API request volume from one source IP.",
"vehicles_created": "Observe high vehicle creation volume for a customer."
},
"rules": {
"bookings_created": "Max bookings",
"failed_login_attempts": "Max failed login attempts",
"requests_per_customer": "Max requests per customer",
"requests_per_ip": "Max requests per IP",
"vehicles_created": "Max vehicle creations"
},
"settings": {
"enabled": "Enabled",
"exemptions": "Permission node exemptions",
"exemptions_placeholder": "permission_a, permission_b",
"management": "Security settings",
"observe_mode": "Observe mode",
"threshold": "Maximum",
"title": "Security settings",
"window_seconds": "Window seconds"
},
"status": {
"acknowledged": "Acknowledged",
"all": "All",
"false_positive": "False positive",
"open": "Open",
"resolved": "Resolved"
},
"subtitle": "Application firewall rules, observe-mode thresholds, permission exemptions, and incident handling.",
"table": {
"actions": "Actions",
"count": "Count",
"last_seen": "Last seen",
"severity": "Severity",
"status": "Status",
"title": "Title"
},
"tabs": {
"firewall": "Firewall",
"incidents": "Incidents",
"overview": "Overview",
"settings": "Settings"
},
"title": "System security"
}
}
@@ -0,0 +1,20 @@
{
"support": {
"title": "@:{'phrases.compat.support.title'}",
"meta_description": "@:{'phrases.compat.support.meta_description'}",
"eyebrow": "@:{'phrases.compat.support.eyebrow'}",
"intro": "@:{'phrases.compat.support.intro'}",
"contact_title": "@:{'phrases.compat.support.contact_title'}",
"email_label": "@:{'phrases.compat.support.email_label'}",
"phone_label": "@:{'phrases.compat.support.phone_label'}",
"company_label": "@:{'phrases.compat.support.company_label'}",
"help_title": "@:{'phrases.compat.support.help_title'}",
"help_booking": "@:{'phrases.compat.support.help_booking'}",
"help_self_wash": "@:{'phrases.compat.support.help_self_wash'}",
"help_payments": "@:{'phrases.compat.support.help_payments'}",
"help_account": "@:{'phrases.compat.support.help_account'}",
"privacy_prefix": "@:{'phrases.compat.support.privacy_prefix'}",
"privacy_link": "@:{'phrases.compat.support.privacy_link'}",
"privacy_suffix": "@:{'phrases.compat.support.privacy_suffix'}"
}
}
@@ -60,8 +60,10 @@
"buckets_available": "@:{'phrases.compat.system_status.labels.buckets_available'}",
"checked_at": "@:{'phrases.compat.system_status.labels.checked_at'}",
"configured": "@:{'phrases.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'terms.glossary.buckets'}",
"database_index": "@:replication.fields.database_index",
"enabled": "@:{'phrases.compat.system_status.labels.enabled'}",
"endpoint": "@.capitalize:{'terms.glossary.endpoint'}",
"latency": "@:{'phrases.compat.system_status.labels.latency'}",
"no_reason": "@:{'phrases.compat.system_status.labels.no_reason'}",
"replication_percent": "@:{'phrases.compat.system_status.labels.replication_percent'}",
@@ -93,6 +95,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@:{'phrases.compat.system_status.reasons.backup_connectivity_confirmed'}",
"backup_encryption_key_missing": "@:{'phrases.compat.system_status.reasons.backup_encryption_key_missing'}",
"backup_latest_verified_stale": "@:{'phrases.compat.system_status.reasons.backup_latest_verified_stale'}",
"backup_no_verified_backup": "@:{'phrases.compat.system_status.reasons.backup_no_verified_backup'}",
"backup_probe_failed": "@:{'phrases.compat.system_status.reasons.backup_probe_failed'}",
"economic_credentials_missing": "@:{'phrases.compat.system_status.reasons.economic_credentials_missing'}",
"email_delivery_not_implemented": "@:{'phrases.compat.system_status.reasons.email_delivery_not_implemented'}",
@@ -0,0 +1,20 @@
{
"support": {
"title": "Support",
"meta_description": "Support og kontaktinformasjon for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakt oss hvis du trenger hjelp med booking, selvvask, betalinger eller tilgang til kundeportalen.",
"contact_title": "Kontakt",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Selskap",
"help_title": "Vi kan hjelpe med",
"help_booking": "Booking og endring av vasketider.",
"help_self_wash": "Start og avslutning av selvvask.",
"help_payments": "Ordrer, fakturaer og kortbetalinger.",
"help_account": "Innlogging, tilgang og kjoretoy paa kontoen din.",
"privacy_prefix": "Les ogsaa vaar",
"privacy_link": "personvernerklaering",
"privacy_suffix": "for informasjon om data og rettigheter."
}
}
@@ -2,21 +2,69 @@
"compat": {
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'terms.glossary.backup'} oprettet",
"backup_created_success": "Säkerhetskopian @:{'terms.glossary.skapades'}.",
"backup_error": "@.capitalize:{'terms.glossary.der'} opstod @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.fejl'} @:{'terms.glossary.under'} oprettelse @:{'terms.glossary.af'} @:{'terms.glossary.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Säkerhetskopieringsschema",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'terms.glossary.opret'} @:{'terms.glossary.backup'}",
"create_backup_confirm": "@.capitalize:{'terms.glossary.ar'} @:{'terms.glossary.du'} @:{'terms.glossary.saker'} @:{'terms.glossary.pa'} @:{'terms.glossary.att'} @:{'terms.glossary.du'} @:{'terms.glossary.vill'} @:{'terms.glossary.skapa'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.sakerhetskopia'}?",
"delete_backup": "@.capitalize:{'terms.glossary.radera'} @:{'terms.glossary.sakerhetskopia'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'terms.glossary.ladda'} @:{'terms.glossary.ner'} @:{'terms.glossary.sakerhetskopia'}",
"enable_system": "@:{'terms.glossary.aktiver'} @:{'terms.glossary.backup'} @:{'terms.glossary.system'}",
"enable_system_desc": "@:{'terms.glossary.aktiver'} @:{'terms.glossary.eller'} @:{'terms.glossary.deaktiver'} @:{'terms.glossary.backup'} @:{'terms.glossary.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'terms.glossary.de'} @:{'terms.glossary.generelle'} @:{'terms.glossary.indstillinger'} @:{'terms.glossary.for_2'} @:{'terms.glossary.backup'} @:{'terms.glossary.systemet'}.",
"last_backup": "@.capitalize:{'terms.glossary.senaste'} @:{'terms.glossary.sakerhetskopia'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'terms.glossary.aterstall'} @:{'terms.glossary.sakerhetskopia'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'terms.glossary.konfiguration'} @:{'terms.glossary.af'} @:{'terms.glossary.backup'} @:{'terms.glossary.systemet'}.",
"title": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.konfiguration'}"
"title": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'terms.glossary.installningar'} @:{'terms.glossary.for'} @:{'terms.glossary.administrations'}- @:{'terms.glossary.och'} @:{'terms.glossary.miljoavgift'} @:{'terms.glossary.for'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}-@:{'terms.glossary.integrationen'}.",
@@ -60,6 +60,21 @@
"toggle_unavailable": "Du har inte behörighet att ändra denna kundregel.",
"unavailable": "Du har inte behörighet att visa kundregler.",
"unknown_active": "Okända aktiva regler"
},
"tooltip": {
"available_if_enabled": "Förblir tillgängliga om aktiverad",
"available_while_enabled": "Tillgängliga medan aktiv",
"blocked_if_enabled": "Blockeras om aktiverad",
"blocked_now": "Blockerad medan aktiv",
"changes": "Ändringar",
"groups": {
"primary_products": "Primära produkter",
"related_addons": "Kopplade add-ons",
"standalone_additional_services": "Fristående tilläggstjänster"
},
"load_failed": "Berörda produkter kunde inte läsas in.",
"loading_products": "Läser in berörda produkter...",
"no_affected_products": "Inga berörda produkter hittades i den aktuella produktkatalogen."
}
}
}
@@ -2,6 +2,7 @@
"compat": {
"pagination": {
"archive": "Archive",
"clear_all": "Rensa alla",
"ascending": "Ascending (Oldest @:{'terms.glossary.first'})",
"booking_status": "Bokningsstatus",
"bookings_overview": "Bokningsäversikt",
@@ -0,0 +1,20 @@
{
"support": {
"title": "Support",
"meta_description": "Support och kontaktinformation for Truck Wash.",
"eyebrow": "Truck Wash",
"intro": "Kontakta oss om du behover hjalp med bokningar, sjalvtvatt, betalningar eller atkomst till kundportalen.",
"contact_title": "Kontakt",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Foretag",
"help_title": "Vi kan hjalpa till med",
"help_booking": "Bokning och andring av tvattider.",
"help_self_wash": "Start och avslut av sjalvtvatt.",
"help_payments": "Ordrar, fakturor och kortbetalningar.",
"help_account": "Inloggning, atkomst och fordon pa ditt konto.",
"privacy_prefix": "Las ocksa var",
"privacy_link": "integritetspolicy",
"privacy_suffix": "for information om data och rattigheter."
}
}
@@ -71,6 +71,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.store'} @:{'terms.glossary.connectivity'} @:{'terms.glossary.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.probe'} @:{'terms.glossary.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.credentials'} @:{'terms.glossary.are'} @:{'terms.glossary.missing'} @:{'terms.glossary.from'} @:{'terms.glossary.runtime'} environment @:{'terms.glossary.configuration'}.",
"email_delivery_not_implemented": "@:{'terms.glossary.mailersend_mailersend'} @:{'terms.glossary.must'} @:{'terms.glossary.be'} @:{'terms.glossary.enabled'} @:{'terms.glossary.because'} @:{'terms.glossary.default'} @:{'terms.glossary.smtp'} delivery @:{'terms.glossary.is'} @:{'terms.glossary.not'} implemented.",
+56 -3
View File
@@ -83,8 +83,10 @@ const MyOrders = lazyView('@/views/dashboards/userDashboard/orders/MyOrders.vue'
const NewVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/NewVehicle.vue');
const MyBookings = lazyView('@/views/dashboards/userDashboard/bookings/MyBookings.vue');
const DatabaseOverview = lazyView('@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue');
const RedisOverview = lazyView('@/views/dashboards/superUserDashboard/system/RedisOverview.vue');
const MinioOverview = lazyView('@/views/dashboards/superUserDashboard/system/MinioOverview.vue');
const CronOperations = lazyView('@/views/dashboards/superUserDashboard/system/CronOperations.vue');
const ReplicationManagement = lazyView('@/views/dashboards/superUserDashboard/system/ReplicationManagement.vue');
const SystemSecurity = lazyView('@/views/dashboards/superUserDashboard/system/SystemSecurity.vue');
const DepartmentBookings = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue');
const Employee = lazyView('@/views/auth/Employee.vue');
const User = lazyView('@/views/dashboards/superUserDashboard/user/User.vue');
@@ -177,6 +179,7 @@ const LandingPage = lazyView('@/views/pages/LandingPage.vue');
const BookDemoPage = lazyView('@/views/pages/BookDemoPage.vue');
const AboutUsPage = lazyView('@/views/pages/AboutUsPage.vue');
const PrivacyPolicyPage = lazyView('@/views/pages/PrivacyPolicyPage.vue');
const SupportPage = lazyView('@/views/pages/SupportPage.vue');
const PasswordResetPage = lazyView('@/views/pages/auth/PasswordResetPage.vue');
const PasswordResetConfirmationPage = lazyView('@/views/pages/auth/PasswordResetConfirmationPage.vue');
const SubuserCompleteRegistrationPage = lazyView('@/views/pages/auth/SubuserCompleteRegistrationPage.vue');
@@ -270,6 +273,12 @@ export const router = createRouter({
component: PrivacyPolicyPage,
meta: { template: 'with-header' }
},
{
name: 'support',
path: '/support',
component: SupportPage,
meta: { template: 'with-header' }
},
{
name: 'default',
path: '/redirect',
@@ -1127,9 +1136,53 @@ export const router = createRouter({
meta: { middleware: superUserMiddleware }
},
{
name: 'systemreplication',
name: 'systemminio',
path: '/superuser/system/minio',
component: MinioOverview,
meta: { middleware: superUserMiddleware }
},
{
name: 'systemredis',
path: '/superuser/system/redis',
component: RedisOverview,
meta: { middleware: superUserMiddleware }
},
{
path: '/superuser/system/security',
redirect: '/superuser/system/security/overview',
meta: { middleware: superUserMiddleware }
},
{
name: 'systemsecurityoverview',
path: '/superuser/system/security/overview',
component: SystemSecurity,
props: { page: 'overview' },
meta: { middleware: superUserMiddleware }
},
{
name: 'systemsecurityfirewall',
path: '/superuser/system/security/firewall',
component: SystemSecurity,
props: { page: 'firewall' },
meta: { middleware: superUserMiddleware }
},
{
name: 'systemsecuritysettings',
path: '/superuser/system/security/settings',
component: SystemSecurity,
props: { page: 'settings' },
meta: { middleware: superUserMiddleware }
},
{
name: 'systemsecurityincidents',
path: '/superuser/system/security/incidents',
component: SystemSecurity,
props: { page: 'incidents' },
meta: { middleware: superUserMiddleware }
},
{
path: '/superuser/system/replication',
component: ReplicationManagement,
redirect: '/superuser',
meta: { middleware: superUserMiddleware }
},
{
+6
View File
@@ -223,6 +223,12 @@ export const getReleaseOperation = (id) =>
export const runReleaseTest = (payload = {}) =>
requestReleaseManager("/superuser/releases/test-runs", "POST", payload);
export const previewReleaseCoolifyCleanup = (payload = {}) =>
requestReleaseManager("/superuser/releases/coolify-cleanup/preview", "POST", payload);
export const applyReleaseCoolifyCleanup = (payload = {}) =>
requestReleaseManager("/superuser/releases/coolify-cleanup/apply", "POST", payload);
export const listReleaseAssignments = () => requestReleaseManager("/superuser/releases/assignments", "GET", {});
export const searchReleaseAssignmentSubjects = (params = {}) =>
-38
View File
@@ -1,38 +0,0 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
export const getReplicationSummary = ({ refresh = false } = {}) =>
authenticatedRequest("/superuser/replication", "GET", refresh ? { refresh: 1 } : {});
export const addReplicationHost = (kind, payload) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}`, "POST", payload);
export const generateReplicationComposeTemplate = (payload) =>
authenticatedRequest("/superuser/replication/compose-template", "POST", payload);
export const testReplicationCredentials = (payload) =>
authenticatedRequest("/superuser/replication/test-credentials", "POST", payload);
export const testReplicationHost = (kind, id) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}/${id}/test`, "POST", {});
export const provisionReplicationHost = (kind, id) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}/${id}/provision`, "POST", {});
export const promoteReplicationHost = (kind, id) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}/${id}/promote`, "POST", {});
export const renameReplicationHost = (kind, id, label) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}/${id}`, "PATCH", { label });
export const removeReplicationHost = (kind, id) =>
authenticatedRequest(`/superuser/replication/${kindPath(kind)}/${id}`, "DELETE", {});
function kindPath(kind) {
if (kind === "database") {
return "databases";
}
if (kind === "minio") {
return "minio";
}
return "redis";
}
+33
View File
@@ -0,0 +1,33 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const basePath = "/superuser/system/security";
export const getSecuritySummary = () => authenticatedRequest(`${basePath}/summary`, "GET", {});
export const getSecuritySettings = () => authenticatedRequest(`${basePath}/settings`, "GET", {});
export const updateSecuritySettings = (payload) => authenticatedRequest(`${basePath}/settings`, "PATCH", payload);
export const listFirewallRules = (payload = {}) =>
authenticatedRequest(`${basePath}/firewall-rules`, "GET", payload);
export const createFirewallRule = (payload) =>
authenticatedRequest(`${basePath}/firewall-rules`, "POST", payload);
export const updateFirewallRule = (id, payload) =>
authenticatedRequest(`${basePath}/firewall-rules/${id}`, "PATCH", payload);
export const deleteFirewallRule = (id) =>
authenticatedRequest(`${basePath}/firewall-rules/${id}`, "DELETE", {});
export const listSecurityIncidents = (payload = {}) =>
authenticatedRequest(`${basePath}/incidents`, "GET", payload);
export const getSecurityIncident = (id) =>
authenticatedRequest(`${basePath}/incidents/${id}`, "GET", {});
export const updateSecurityIncident = (id, payload) =>
authenticatedRequest(`${basePath}/incidents/${id}`, "PATCH", payload);
export const addSecurityIncidentNote = (id, payload) =>
authenticatedRequest(`${basePath}/incidents/${id}/notes`, "POST", payload);
@@ -1,108 +1,238 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import Swal from "sweetalert2";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { ConfigurationSelectOption } from "@/components/displays/superuser/configuration/ConfigurationSelectObject.vue";
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ConfigurationError from "@/components/displays/superuser/configuration/ConfigurationError.vue";
import { useI18n } from 'vue-i18n';
// Get the department from the route
const router = useRouter()
const { t } = useI18n();
const enabled = ref(false);
const module_config = ref([]);
const moduleConfig = ref([]);
const backups = ref([]);
const legacyBackups = ref([]);
const activeJobs = ref([]);
const restoreAudit = ref([]);
const errors = ref([]);
const loading = ref(false);
const pollingTimers = new Map();
const addError = (error) => {
errors.value.push(error);
};
const getEnabled = async () => {
await SessionUser.superUser.modules.backups.config.enabled.get().then((response) => {
console.log('Backups enabled: ', response.data.data);
enabled.value = response.data.data;
}).catch((error) => {
console.log(error);
addError(error);
const configMap = computed(() => {
const map = {};
moduleConfig.value.forEach((item) => {
map[item.variable] = item.value;
});
return map;
});
const latestBackup = computed(() => backups.value[0] || null);
const latestVerifiedBackup = computed(() => backups.value.find((backup) => backup.status === 'available') || null);
const configValue = (key, fallback = null) => {
return Object.prototype.hasOwnProperty.call(configMap.value, key) ? configMap.value[key] : fallback;
};
const getModuleConfig = async () => {
await SessionUser.superUser.modules.backups.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
let tmp_module_config_array = [];
for (const [key, value] of Object.entries(tmp_module_config)) {
console.log(`${value.variable}: ${value.value}`);
tmp_module_config_array.push({
variable: value.variable,
value: value.value,
});
}
module_config.value = tmp_module_config_array;
console.log(module_config.value);
}).catch((error) => {
console.log(error);
addError(error);
});
const configBool = (key, fallback = false) => {
const value = configValue(key, fallback);
return value === true || value === 'true' || value === 1 || value === '1';
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((config) => config.variable === variable);
// Log to the console, what the value was found
if (config) {
console.log(`Found value for ${variable}: ${config.value}`);
} else {
console.log(`No value found for ${variable}`);
}
return config ? config.value : '';
const loadConfig = async () => {
const response = await SessionUser.superUser.modules.backups.config.get_all();
moduleConfig.value = response.data.data || [];
};
const loadBackups = async () => {
const response = await SessionUser.superUser.modules.backups.listBackups();
const data = response.data.data || {};
backups.value = data.items || [];
legacyBackups.value = data.legacy_items || [];
};
const loadAudit = async () => {
const response = await SessionUser.superUser.modules.backups.restoreAudit();
restoreAudit.value = response.data.data?.items || [];
};
const load = async () => {
await getModuleConfig();
loading.value = true;
errors.value = [];
try {
await Promise.all([loadConfig(), loadBackups(), loadAudit()]);
} catch (error) {
errors.value.push(error);
} finally {
loading.value = false;
}
};
const setConfig = async (variable, value) => {
await SessionUser.superUser.modules.backups.config.set(variable, value);
await loadConfig();
};
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const formatDate = (value) => {
if (!value) {
return t('configuration.backups.not_available');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
};
const showCreateBackup = async () => {
await Swal.fire({
const formatBytes = (value) => {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
};
const statusClass = (status) => {
if (status === 'available' || status === 'succeeded') return 'is-success';
if (status === 'running' || status === 'queued' || status === 'creating') return 'is-info';
if (status === 'created_unverified' || status === 'legacy_unverified' || status === 'skipped') return 'is-warning';
if (status === 'failed' || status === 'pruned') return 'is-danger';
return 'is-light';
};
const escapeHtml = (value) => {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
};
const rememberJob = (job) => {
const id = Number(job.job_id || job.id);
if (!id) return;
const existingIndex = activeJobs.value.findIndex((item) => Number(item.id || item.job_id) === id);
const normalized = { ...job, id };
if (existingIndex >= 0) {
activeJobs.value.splice(existingIndex, 1, normalized);
} else {
activeJobs.value.unshift(normalized);
}
};
const pollJob = (jobId) => {
if (pollingTimers.has(jobId)) return;
const timer = window.setInterval(async () => {
try {
const response = await SessionUser.superUser.modules.backups.getJob(jobId);
const job = response.data.data;
rememberJob(job);
if (!['queued', 'running'].includes(job.status)) {
window.clearInterval(timer);
pollingTimers.delete(jobId);
await Promise.all([loadBackups(), loadAudit()]);
}
} catch (error) {
window.clearInterval(timer);
pollingTimers.delete(jobId);
errors.value.push(error);
}
}, 3000);
pollingTimers.set(jobId, timer);
};
const queueBackup = async () => {
const result = await Swal.fire({
title: t('configuration.backups.create_backup'),
text: t('configuration.backups.create_backup_confirm'),
showCancelButton: true,
confirmButtonText: t('configuration.backups.create_backup'),
showLoaderOnConfirm: true,
preConfirm: () => {
SessionUser.superUser.modules.backups.createBackup(null, null)
.then((response) => {
Swal.fire({
title: t('configuration.backups.backup_created'),
text: t('configuration.backups.backup_created_success'),
icon: 'success',
});
})
.catch((error) => {
Swal.fire({
title: t('common.error'),
text: t('configuration.backups.backup_error'),
icon: 'error',
});
});
preConfirm: async () => {
const response = await SessionUser.superUser.modules.backups.createBackup(null, null);
return response.data.data;
},
allowOutsideClick: () => !Swal.isLoading()
allowOutsideClick: () => !Swal.isLoading(),
});
if (result.isConfirmed && result.value) {
rememberJob(result.value);
pollJob(Number(result.value.job_id));
await Swal.fire(t('configuration.backups.backup_queued'), t('configuration.backups.backup_queued_success'), 'success');
}
};
load();
const queueVerification = async (backup) => {
const response = await SessionUser.superUser.modules.backups.verifyBackup(backup.backup_uuid);
const job = response.data.data;
rememberJob(job);
pollJob(Number(job.job_id));
};
const previewRestore = async (backup) => {
try {
const previewResponse = await SessionUser.superUser.modules.backups.previewRestore(backup.backup_uuid);
const preview = previewResponse.data.data;
const checksHtml = (preview.checks || [])
.map((check) => `<li><strong>${escapeHtml(check.status)}</strong> ${escapeHtml(check.message)}</li>`)
.join('');
const result = await Swal.fire({
title: t('configuration.backups.restore_backup'),
html: `
<div class="has-text-left">
<p class="mb-3">${t('configuration.backups.restore_confirm_intro')}</p>
<ul class="mb-3">${checksHtml}</ul>
<label class="label">${t('configuration.backups.restore_reason')}</label>
<textarea id="backup-restore-reason" class="textarea" rows="3"></textarea>
<label class="label mt-3">${t('configuration.backups.restore_confirmation')}</label>
<input id="backup-restore-confirmation" class="input" autocomplete="off" />
<p class="help">${preview.confirmation_phrase}</p>
</div>
`,
showCancelButton: true,
confirmButtonText: t('configuration.backups.restore_backup'),
focusConfirm: false,
preConfirm: () => {
const reason = document.getElementById('backup-restore-reason')?.value || '';
const confirmation = document.getElementById('backup-restore-confirmation')?.value || '';
if (!reason.trim() || confirmation.trim() !== preview.confirmation_phrase) {
Swal.showValidationMessage(t('configuration.backups.restore_validation_error'));
return false;
}
return { reason, confirmation };
},
});
if (!result.isConfirmed || !result.value) {
return;
}
const restoreResponse = await SessionUser.superUser.modules.backups.restoreBackup(
backup.backup_uuid,
preview.preview_id,
result.value.confirmation,
result.value.reason
);
const job = restoreResponse.data.data;
rememberJob(job);
pollJob(Number(job.job_id));
await Swal.fire(t('configuration.backups.restore_queued'), t('configuration.backups.restore_queued_success'), 'success');
} catch (error) {
errors.value.push(error);
await Swal.fire(t('common.error'), t('configuration.backups.restore_error'), 'error');
}
};
onMounted(load);
onBeforeUnmount(() => {
pollingTimers.forEach((timer) => window.clearInterval(timer));
pollingTimers.clear();
});
</script>
<template>
@@ -111,32 +241,272 @@ load();
<template #title>
<PageTitle :title="$t('configuration.backups.title')" :subtitle="$t('configuration.backups.subtitle')"/>
</template>
<template v-if="module_config.length > 0" #content>
<ConfigurationError v-if="errors" :errors="errors"/>
<ConfigurationCategory
class="mt-2"
module="Backups"
:title="$t('configuration.backups.general_settings')"
:description="$t('configuration.backups.general_settings_desc')"
icon="fas fa-cogs"
>
<ConfigurationSwitch
class="mt-2"
module="Backups"
:title="$t('configuration.backups.enable_system')"
:description="$t('configuration.backups.enable_system_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.backups.config.enabled.set"
/>
</ConfigurationCategory>
<template #content>
<ConfigurationError v-if="errors.length" :errors="errors"/>
<button class="button is-primary mt-2" @click="showCreateBackup">{{ $t('configuration.backups.create_backup') }}</button>
<div class="backup-toolbar">
<button class="button is-primary" :class="{ 'is-loading': loading }" @click="queueBackup">
<span class="icon"><i class="fas fa-database"></i></span>
<span>{{ $t('configuration.backups.create_backup') }}</span>
</button>
<button class="button" :class="{ 'is-loading': loading }" @click="load">
<span class="icon"><i class="fas fa-sync"></i></span>
<span>{{ $t('configuration.backups.refresh') }}</span>
</button>
</div>
<div class="backup-summary">
<div class="summary-item">
<span class="summary-label">{{ $t('configuration.backups.latest_backup') }}</span>
<strong>{{ latestBackup ? latestBackup.name : $t('configuration.backups.not_available') }}</strong>
<small>{{ latestBackup ? formatDate(latestBackup.completed_at || latestBackup.created_at) : '' }}</small>
</div>
<div class="summary-item">
<span class="summary-label">{{ $t('configuration.backups.latest_verified') }}</span>
<strong>{{ latestVerifiedBackup ? latestVerifiedBackup.name : $t('configuration.backups.not_available') }}</strong>
<small>{{ latestVerifiedBackup ? formatDate(latestVerifiedBackup.verified_at) : '' }}</small>
</div>
<div class="summary-item">
<span class="summary-label">{{ $t('configuration.backups.restore_state') }}</span>
<strong>{{ configBool('restore_enabled') ? $t('configuration.backups.enabled') : $t('configuration.backups.disabled') }}</strong>
<small>{{ $t('configuration.backups.restore_state_desc') }}</small>
</div>
</div>
<section class="backup-section">
<h2>{{ $t('configuration.backups.general_settings') }}</h2>
<div class="settings-grid">
<ConfigurationSwitch
:title="$t('configuration.backups.enable_system')"
:description="$t('configuration.backups.enable_system_desc')"
:value="configBool('enabled')"
:on-switch="(value) => setConfig('enabled', value)"
/>
<ConfigurationSwitch
:title="$t('configuration.backups.app_data_enabled')"
:description="$t('configuration.backups.app_data_enabled_desc')"
:value="configBool('app_data_enabled', true)"
:on-switch="(value) => setConfig('app_data_enabled', value)"
/>
<ConfigurationSwitch
:title="$t('configuration.backups.verification_required')"
:description="$t('configuration.backups.verification_required_desc')"
:value="configBool('verification_required', true)"
:on-switch="(value) => setConfig('verification_required', value)"
/>
<ConfigurationSwitch
:title="$t('configuration.backups.restore_enabled')"
:description="$t('configuration.backups.restore_enabled_desc')"
:value="configBool('restore_enabled')"
:on-switch="(value) => setConfig('restore_enabled', value)"
/>
</div>
</section>
<section class="backup-section">
<h2>{{ $t('configuration.backups.retention') }}</h2>
<div class="settings-grid compact">
<ConfigurationInputNumber
:title="$t('configuration.backups.retention_recent_hours')"
:description="$t('configuration.backups.retention_recent_hours_desc')"
:value="Number(configValue('retention_recent_hours', 48))"
:on-save="(value) => setConfig('retention_recent_hours', Number(value))"
/>
<ConfigurationInputNumber
:title="$t('configuration.backups.retention_daily_days')"
:description="$t('configuration.backups.retention_daily_days_desc')"
:value="Number(configValue('retention_daily_days', 30))"
:on-save="(value) => setConfig('retention_daily_days', Number(value))"
/>
<ConfigurationInputNumber
:title="$t('configuration.backups.retention_weekly_weeks')"
:description="$t('configuration.backups.retention_weekly_weeks_desc')"
:value="Number(configValue('retention_weekly_weeks', 8))"
:on-save="(value) => setConfig('retention_weekly_weeks', Number(value))"
/>
<ConfigurationInputNumber
:title="$t('configuration.backups.retention_monthly_months')"
:description="$t('configuration.backups.retention_monthly_months_desc')"
:value="Number(configValue('retention_monthly_months', 3))"
:on-save="(value) => setConfig('retention_monthly_months', Number(value))"
/>
</div>
</section>
<section v-if="activeJobs.length" class="backup-section">
<h2>{{ $t('configuration.backups.active_jobs') }}</h2>
<div class="job-list">
<div v-for="job in activeJobs" :key="job.id" class="job-row">
<span class="tag" :class="statusClass(job.status)">{{ job.status }}</span>
<strong>{{ job.job_type || job.type }}</strong>
<progress class="progress is-small is-info" :value="job.progress_percent || 0" max="100"></progress>
<small>{{ job.progress_message }}</small>
</div>
</div>
</section>
<section class="backup-section">
<h2>{{ $t('configuration.backups.backups') }}</h2>
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>{{ $t('configuration.backups.name') }}</th>
<th>{{ $t('configuration.backups.status') }}</th>
<th>{{ $t('configuration.backups.completed_at') }}</th>
<th>{{ $t('configuration.backups.verified_at') }}</th>
<th>{{ $t('configuration.backups.size') }}</th>
<th>{{ $t('configuration.backups.objects') }}</th>
<th>{{ $t('configuration.backups.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="backup in backups" :key="backup.backup_uuid">
<td>
<strong>{{ backup.name }}</strong>
<small class="backup-id">{{ backup.backup_uuid }}</small>
</td>
<td><span class="tag" :class="statusClass(backup.status)">{{ backup.status }}</span></td>
<td>{{ formatDate(backup.completed_at || backup.created_at) }}</td>
<td>{{ formatDate(backup.verified_at) }}</td>
<td>{{ formatBytes(backup.total_bytes) }}</td>
<td>{{ backup.object_count || 0 }}</td>
<td class="actions-cell">
<button class="button is-small" @click="queueVerification(backup)">
<span class="icon"><i class="fas fa-check-circle"></i></span>
<span>{{ $t('configuration.backups.verify') }}</span>
</button>
<button class="button is-small is-warning" :disabled="backup.status !== 'available'" @click="previewRestore(backup)">
<span class="icon"><i class="fas fa-history"></i></span>
<span>{{ $t('configuration.backups.restore_backup') }}</span>
</button>
</td>
</tr>
<tr v-if="!backups.length">
<td colspan="7">{{ $t('configuration.backups.no_backups') }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-if="legacyBackups.length" class="backup-section">
<h2>{{ $t('configuration.backups.legacy_backups') }}</h2>
<div class="legacy-list">
<span v-for="backup in legacyBackups" :key="backup.backup_uuid" class="tag is-warning">
{{ backup.backup_name || backup.backup_uuid }}
</span>
</div>
</section>
<section class="backup-section">
<h2>{{ $t('configuration.backups.restore_audit') }}</h2>
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>{{ $t('configuration.backups.backup') }}</th>
<th>{{ $t('configuration.backups.status') }}</th>
<th>{{ $t('configuration.backups.started_at') }}</th>
<th>{{ $t('configuration.backups.completed_at') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in restoreAudit" :key="entry.id">
<td>{{ entry.backup_uuid }}</td>
<td><span class="tag" :class="statusClass(entry.status)">{{ entry.status }}</span></td>
<td>{{ formatDate(entry.started_at) }}</td>
<td>{{ formatDate(entry.completed_at) }}</td>
</tr>
<tr v-if="!restoreAudit.length">
<td colspan="4">{{ $t('configuration.backups.no_restore_audit') }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</ConfigurationSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.backup-toolbar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
</style>
.backup-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.summary-item,
.backup-section {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 1rem;
background: #fff;
}
.summary-label,
.backup-id {
display: block;
color: #64748b;
font-size: 0.8rem;
}
.backup-section {
margin-top: 1rem;
}
.backup-section h2 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.settings-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.job-list {
display: grid;
gap: 0.75rem;
}
.job-row {
display: grid;
grid-template-columns: auto 180px minmax(140px, 1fr) minmax(180px, 2fr);
gap: 0.75rem;
align-items: center;
}
.actions-cell {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.legacy-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 768px) {
.job-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -27,6 +27,7 @@ import {
import { useReleaseEntityDrawer } from "@/components/release/useReleaseEntityDrawer.js";
import { useReleaseKeyboardShortcuts } from "@/components/release/useReleaseKeyboardShortcuts.js";
import {
applyReleaseCoolifyCleanup,
clearReleaseManagerControlApiUrl,
completeReleaseServiceSetIsolatedDataServices,
createReleaseAssignment,
@@ -47,6 +48,7 @@ import {
listReleaseGithubRepositories,
promoteReleaseBundle,
promoteReleaseDeployment,
previewReleaseCoolifyCleanup,
requestReleaseManager,
rollbackReleaseChannel,
runReleaseTest,
@@ -274,6 +276,16 @@ const bundleForm = reactive({
});
const bundleVersionLabelAutoManaged = ref(true);
const bundleGeneratedVersionLabel = ref("");
const coolifyCleanupForm = reactive({
instance_id: "",
channel_id: "",
app: "",
resource_type: "",
action: "delete",
confirm: "",
});
const coolifyCleanupPreview = ref(null);
const coolifyCleanupResult = ref(null);
const canView = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_release_manager_view")
@@ -287,6 +299,9 @@ const canDeploy = computed(
const canReplay = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_release_manager_replay")
);
const canManageCoolify = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_coolify_manage")
);
const channels = computed(() => (Array.isArray(summary.value?.channels) ? summary.value.channels : []));
const assignments = computed(() => (Array.isArray(summary.value?.assignments) ? summary.value.assignments : []));
@@ -374,6 +389,30 @@ const moduleKeySuggestions = computed(() =>
const coolifyInstances = computed(() =>
Array.isArray(suggestions.value.coolify_instances) ? suggestions.value.coolify_instances : []
);
const coolifyCleanupCandidates = computed(() =>
Array.isArray(coolifyCleanupPreview.value?.candidates) ? coolifyCleanupPreview.value.candidates : []
);
const coolifyCleanupProtected = computed(() =>
Array.isArray(coolifyCleanupPreview.value?.protected) ? coolifyCleanupPreview.value.protected : []
);
const coolifyCleanupBlocked = computed(() =>
Array.isArray(coolifyCleanupPreview.value?.blocked) ? coolifyCleanupPreview.value.blocked : []
);
const coolifyCleanupBusy = computed(() =>
["coolify-cleanup:preview", "coolify-cleanup:apply"].includes(String(busy.value || ""))
);
const coolifyCleanupConfirmMatches = computed(
() =>
Boolean(coolifyCleanupPreview.value?.confirmation_phrase) &&
String(coolifyCleanupForm.confirm || "").trim() === String(coolifyCleanupPreview.value.confirmation_phrase)
);
const canApplyCoolifyCleanup = computed(
() =>
canDeploy.value &&
canManageCoolify.value &&
coolifyCleanupCandidates.value.length > 0 &&
coolifyCleanupConfirmMatches.value
);
const coolifyProjects = computed(() =>
Array.isArray(suggestions.value.coolify_projects) ? suggestions.value.coolify_projects : []
);
@@ -3564,6 +3603,72 @@ async function retryOperationStep(step) {
await runDashboardReleaseTest();
}
function coolifyCleanupPayload() {
return {
instance_id: Number(coolifyCleanupForm.instance_id || 0) || null,
channel_id: Number(coolifyCleanupForm.channel_id || 0) || null,
app: coolifyCleanupForm.app || null,
resource_type: coolifyCleanupForm.resource_type || null,
action: coolifyCleanupForm.action || "delete",
};
}
async function previewCoolifyCleanupAction() {
await run("coolify-cleanup:preview", async () => {
coolifyCleanupPreview.value = responseData(await previewReleaseCoolifyCleanup(coolifyCleanupPayload()), null);
coolifyCleanupResult.value = null;
coolifyCleanupForm.confirm = "";
});
}
async function applyCoolifyCleanupAction() {
if (!coolifyCleanupPreview.value?.selection_hash) {
return;
}
await run("coolify-cleanup:apply", async () => {
coolifyCleanupResult.value = responseData(
await applyReleaseCoolifyCleanup({
...coolifyCleanupPayload(),
selection_hash: coolifyCleanupPreview.value.selection_hash,
confirm: coolifyCleanupForm.confirm,
}),
null
);
await refreshOperations();
if (coolifyCleanupResult.value?.operation_id) {
selectedReleaseOperation.value = responseData(
await getReleaseOperation(coolifyCleanupResult.value.operation_id),
selectedReleaseOperation.value
);
operationModalOpen.value = true;
}
});
}
function cleanupResourceApps(resource) {
const apps = Array.isArray(resource?.apps) ? resource.apps : [];
return apps.length > 0 ? apps.join(", ") : "--";
}
function cleanupResourceChannels(resource) {
const slugs = Array.isArray(resource?.channel_slugs) ? resource.channel_slugs : [];
if (slugs.length > 0) {
return slugs.join(", ");
}
const channelsList = Array.isArray(resource?.channels) ? resource.channels : [];
return channelsList.length > 0 ? channelsList.join(", ") : "--";
}
function cleanupProtectionReasons(resource) {
const protection = Array.isArray(resource?.protection) ? resource.protection : [];
const reasons = [...new Set(protection.map((entry) => entry?.reason).filter(Boolean))];
return reasons.length > 0 ? reasons.join(", ") : "--";
}
function cleanupBlockedResource(blocked) {
return blocked?.resource || {};
}
async function refreshOperations() {
loadingState.operations = true;
errors.value = [];
@@ -3638,6 +3743,7 @@ function statusClass(status) {
"ok",
"active",
"deployed",
"completed",
"promoted",
"ready",
"stable",
@@ -6440,6 +6546,221 @@ onMounted(async () => {
</ReleaseDataGrid>
</ReleaseWorkspacePanel>
<ReleaseWorkspacePanel
v-show="activeReleasePanel === 'operations' && !initialSummaryLoading"
title="Coolify cleanup"
subtitle="Release-owned frontend and API resources outside the active and rollback set."
icon="fas fa-broom"
:loading="coolifyCleanupBusy"
:loading-text="trFallback('loading.coolify_cleanup', 'Checking Coolify cleanup candidates...')"
data-testid="release-coolify-cleanup-panel"
>
<template #actions>
<b-tag v-if="coolifyCleanupPreview" :class="statusClass(coolifyCleanupCandidates.length > 0 ? 'warning' : 'ready')">
{{ coolifyCleanupCandidates.length }} candidates
</b-tag>
<b-tag v-if="!canManageCoolify" class="is-warning">
Coolify manage required
</b-tag>
</template>
<div class="release-cleanup-toolbar" data-testid="release-coolify-cleanup-controls">
<b-field :label="trFallback('coolify_cleanup.instance', 'Instance')">
<b-select v-model="coolifyCleanupForm.instance_id" expanded>
<option value="">{{ trFallback("coolify_cleanup.all_instances", "All instances") }}</option>
<option v-for="instance in coolifyInstances" :key="instance.id" :value="instance.id">
{{ instance.label || instance.base_url || instance.id }}
</option>
</b-select>
</b-field>
<b-field :label="trFallback('coolify_cleanup.channel', 'Channel')">
<b-select v-model="coolifyCleanupForm.channel_id" expanded>
<option value="">{{ trFallback("coolify_cleanup.all_channels", "All channels") }}</option>
<option v-for="channel in channels" :key="channel.id" :value="channel.id">
{{ channel.slug }}
</option>
</b-select>
</b-field>
<b-field :label="trFallback('coolify_cleanup.app', 'App')">
<b-select v-model="coolifyCleanupForm.app" expanded>
<option value="">{{ trFallback("coolify_cleanup.all_apps", "Frontend and API") }}</option>
<option value="frontend">{{ valueLabel("app", "frontend") }}</option>
<option value="api">{{ valueLabel("app", "api") }}</option>
</b-select>
</b-field>
<b-field :label="trFallback('coolify_cleanup.resource_type', 'Type')">
<b-select v-model="coolifyCleanupForm.resource_type" expanded>
<option value="">{{ trFallback("coolify_cleanup.all_types", "Applications and services") }}</option>
<option value="application">{{ trFallback("coolify_cleanup.application", "Application") }}</option>
<option value="service">{{ trFallback("coolify_cleanup.service", "Service") }}</option>
</b-select>
</b-field>
<b-field :label="trFallback('coolify_cleanup.action', 'Action')">
<b-select v-model="coolifyCleanupForm.action" expanded>
<option value="delete">{{ trFallback("coolify_cleanup.delete", "Delete") }}</option>
<option value="stop">{{ trFallback("coolify_cleanup.stop", "Stop") }}</option>
</b-select>
</b-field>
<b-field label="&nbsp;">
<b-button
type="is-primary"
icon-left="search"
icon-pack="fas"
:loading="busy === 'coolify-cleanup:preview'"
:disabled="!canDeploy"
data-testid="release-coolify-cleanup-preview"
@click="previewCoolifyCleanupAction"
>
{{ trFallback("coolify_cleanup.preview", "Preview cleanup") }}
</b-button>
</b-field>
</div>
<div v-if="coolifyCleanupPreview" class="release-cleanup-summary" data-testid="release-coolify-cleanup-summary">
<article>
<span>{{ trFallback("coolify_cleanup.live_resources", "Live resources") }}</span>
<strong>{{ coolifyCleanupPreview.summary?.live_resources_scanned || 0 }}</strong>
</article>
<article>
<span>{{ trFallback("coolify_cleanup.protected", "Protected") }}</span>
<strong>{{ coolifyCleanupProtected.length }}</strong>
</article>
<article>
<span>{{ trFallback("coolify_cleanup.blocked", "Blocked") }}</span>
<strong>{{ coolifyCleanupBlocked.length }}</strong>
</article>
<article>
<span>{{ trFallback("coolify_cleanup.hash", "Selection") }}</span>
<strong>{{ String(coolifyCleanupPreview.selection_hash || "").slice(0, 12) || "--" }}</strong>
</article>
</div>
<ReleaseDataGrid
v-if="coolifyCleanupPreview"
class="mt-3"
:loading="busy === 'coolify-cleanup:preview'"
:loading-text="trFallback('loading.coolify_cleanup', 'Checking Coolify cleanup candidates...')"
:loading-colspan="7"
>
<template #head>
<thead>
<tr>
<th>{{ trFallback("coolify_cleanup.resource", "Resource") }}</th>
<th>{{ trFallback("coolify_cleanup.instance", "Instance") }}</th>
<th>{{ trFallback("coolify_cleanup.type", "Type") }}</th>
<th>{{ trFallback("coolify_cleanup.app", "App") }}</th>
<th>{{ trFallback("coolify_cleanup.channel", "Channel") }}</th>
<th>{{ trFallback("coolify_cleanup.status", "Status") }}</th>
<th>{{ trFallback("coolify_cleanup.action", "Action") }}</th>
</tr>
</thead>
</template>
<tbody>
<tr
v-for="candidate in coolifyCleanupCandidates"
:key="candidate.resource_key"
data-testid="release-coolify-cleanup-candidate"
>
<td>
<strong>{{ candidate.name }}</strong>
<div class="release-muted">{{ candidate.uuid }}</div>
</td>
<td>{{ candidate.instance_label || candidate.instance_id }}</td>
<td>{{ candidate.type }}</td>
<td>{{ cleanupResourceApps(candidate) }}</td>
<td>{{ cleanupResourceChannels(candidate) }}</td>
<td><b-tag :class="statusClass(candidate.status)">{{ candidate.status || "unknown" }}</b-tag></td>
<td>{{ candidate.action }}</td>
</tr>
<tr v-if="coolifyCleanupCandidates.length === 0">
<td colspan="7">{{ trFallback("coolify_cleanup.no_candidates", "No cleanup candidates matched the current filters.") }}</td>
</tr>
</tbody>
</ReleaseDataGrid>
<div v-if="coolifyCleanupPreview" class="release-cleanup-apply" data-testid="release-coolify-cleanup-apply">
<b-field
:label="trFallback('coolify_cleanup.confirmation', 'Confirmation')"
:message="coolifyCleanupPreview.confirmation_phrase"
>
<b-input
v-model="coolifyCleanupForm.confirm"
:placeholder="coolifyCleanupPreview.confirmation_phrase"
:disabled="coolifyCleanupCandidates.length === 0"
data-testid="release-coolify-cleanup-confirm"
/>
</b-field>
<b-button
type="is-danger"
icon-left="trash"
icon-pack="fas"
:loading="busy === 'coolify-cleanup:apply'"
:disabled="!canApplyCoolifyCleanup"
data-testid="release-coolify-cleanup-apply-button"
@click="applyCoolifyCleanupAction"
>
{{ trFallback("coolify_cleanup.apply", "Apply cleanup") }}
</b-button>
</div>
<div v-if="coolifyCleanupProtected.length > 0" class="table-container mt-3">
<table class="table is-fullwidth is-hoverable" data-testid="release-coolify-cleanup-protected">
<thead>
<tr>
<th>{{ trFallback("coolify_cleanup.protected_resource", "Protected resource") }}</th>
<th>{{ trFallback("coolify_cleanup.reason", "Reason") }}</th>
<th>{{ trFallback("coolify_cleanup.channel", "Channel") }}</th>
<th>{{ trFallback("coolify_cleanup.app", "App") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="resource in coolifyCleanupProtected" :key="`protected-${resource.resource_key}`">
<td>
<strong>{{ resource.name }}</strong>
<div class="release-muted">{{ resource.uuid }}</div>
</td>
<td>{{ cleanupProtectionReasons(resource) }}</td>
<td>{{ cleanupResourceChannels(resource) }}</td>
<td>{{ cleanupResourceApps(resource) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="coolifyCleanupBlocked.length > 0" class="table-container mt-3">
<table class="table is-fullwidth is-hoverable" data-testid="release-coolify-cleanup-blocked">
<thead>
<tr>
<th>{{ trFallback("coolify_cleanup.blocked_reason", "Blocked reason") }}</th>
<th>{{ trFallback("coolify_cleanup.resource", "Resource") }}</th>
<th>{{ trFallback("coolify_cleanup.message", "Message") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(blocked, index) in coolifyCleanupBlocked" :key="`blocked-${index}`">
<td>{{ blocked.reason || "--" }}</td>
<td>
<strong>{{ cleanupBlockedResource(blocked).name || blocked.instance_label || "--" }}</strong>
<div class="release-muted">{{ cleanupBlockedResource(blocked).uuid || blocked.instance_id || "" }}</div>
</td>
<td>{{ blocked.message || "--" }}</td>
</tr>
</tbody>
</table>
</div>
<b-notification
v-if="coolifyCleanupResult"
type="is-info"
has-icon
:closable="false"
class="mt-3"
data-testid="release-coolify-cleanup-result"
>
{{ coolifyCleanupResult.summary || coolifyCleanupResult.status }}
</b-notification>
</ReleaseWorkspacePanel>
<ConfigurationCategory
v-show="activeReleasePanel === 'operations' && !initialSummaryLoading"
class="mt-2"
@@ -9052,6 +9373,21 @@ onMounted(async () => {
margin-bottom: 1rem;
}
.release-cleanup-toolbar {
align-items: end;
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
margin-bottom: 1rem;
}
.release-cleanup-summary {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(9rem, 1fr));
margin-bottom: 1rem;
}
.release-data-services-summary article {
background: #f8fafc;
border: 1px solid #e1e7ef;
@@ -9062,16 +9398,36 @@ onMounted(async () => {
padding: 0.75rem;
}
.release-cleanup-summary article {
background: #f8fafc;
border: 1px solid #e1e7ef;
border-radius: 6px;
display: grid;
gap: 0.25rem;
min-width: 0;
padding: 0.75rem;
}
.release-data-services-summary span,
.release-data-services-summary small {
.release-data-services-summary small,
.release-cleanup-summary span {
color: #667085;
font-size: 0.82rem;
}
.release-data-services-summary strong {
.release-data-services-summary strong,
.release-cleanup-summary strong {
color: #172033;
}
.release-cleanup-apply {
align-items: end;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(16rem, 1fr) max-content;
margin-top: 1rem;
}
.release-operation-row {
align-items: center;
background: #f8fafc;
@@ -9166,7 +9522,9 @@ onMounted(async () => {
.release-channel-lanes,
.release-integrated-panels,
.release-data-services-summary {
.release-data-services-summary,
.release-cleanup-summary,
.release-cleanup-apply {
grid-template-columns: 1fr;
}
}
@@ -53,7 +53,7 @@ export const permissionGroupDefinitions = [
{
key: "system_config",
icon: "cogs",
patterns: [/system/iu, /config/iu, /configuration/iu, /replication/iu, /release/iu, /coolify/iu, /module/iu, /backup/iu, /workfeed/iu, /error[-_]?report/iu],
patterns: [/system/iu, /security/iu, /firewall/iu, /incident/iu, /config/iu, /configuration/iu, /replication/iu, /release/iu, /coolify/iu, /module/iu, /backup/iu, /workfeed/iu, /error[-_]?report/iu],
},
{
key: "other",
@@ -0,0 +1,15 @@
<script setup>
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import MinioStatusDisplay from "@/components/displays/superuser/system/MinioDisplay.vue";
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<PageTitle :title="$t('system_status.cards.minio')" />
<MinioStatusDisplay />
</RestrictedPageWrapper>
</template>
@@ -0,0 +1,15 @@
<script setup>
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import RedisStatusDisplay from "@/components/displays/superuser/system/RedisDisplay.vue";
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<PageTitle :title="$t('system_status.cards.redis')" />
<RedisStatusDisplay />
</RestrictedPageWrapper>
</template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import {
getCustomerRuleAttributeKey,
getCustomerRuleDefinitions,
} from "@/features/customer/customerRuleRegistry.js";
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
const props = defineProps({
userId: {
@@ -233,7 +234,11 @@ watch(
:data-testid="`superuser-user-security-rule-${rule.attribute}`"
>
<div class="user-customer-rule-manager__rule-main">
<b-tooltip :label="rule.description" multilined position="is-bottom" type="is-dark">
<CustomerRuleTooltip
:attribute="rule.attribute"
:active="rule.isActive"
:test-id="`superuser-user-security-rule-tooltip-${rule.attribute}`"
>
<span
class="tag"
:class="rule.isActive ? 'is-info is-light' : 'is-light'"
@@ -241,7 +246,7 @@ watch(
>
{{ rule.label }}
</span>
</b-tooltip>
</CustomerRuleTooltip>
<p>{{ rule.description }}</p>
</div>
<b-tooltip
@@ -17,7 +17,11 @@ import NewBookingElementVehicles
from "@/views/dashboards/userDashboard/bookings/displays/elements/NewBookingElementVehicles.vue";
import NewBookingStep4 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep4.vue";
import NewBookingStep5 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {
getTimeBookingDepartments,
timeBookingDepartments,
timeBookingDepartmentsIsLoading,
} from "@/components/pagination/departmentTabs.vue";
import type {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import type {PosAddon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
@@ -62,6 +66,7 @@ const customerNumber = ref<number | null>(SessionUser?.user?.customer_number.val
// Guest mode: enabled when the user is not authenticated (no token/session)
const guestMode = computed(() => !SessionUser.authenticated.value);
const department = ref<any>(null);
const bookingDepartmentOptions = computed(() => timeBookingDepartments.value || []);
const isDepartmentInput = ref(false);
const wash_type = ref("general"); // general or tankcleaning
const reg1 = ref("");
@@ -102,6 +107,18 @@ const setInputFocus = (id: string) => {
const forcedPickupProductId = 40;
const forcedPickupProduct = ref<PosProduct | null>(null);
const isSelectedDepartmentAvailable = (dept: any) => {
return bookingDepartmentOptions.value.some((candidate: any) => Number(candidate.id) === Number(dept?.id));
};
const loadTimeBookingDepartments = async () => {
try {
await getTimeBookingDepartments();
} catch (e) {
console.warn('Could not load public booking departments', e);
}
};
const loadInclPickupProduct = async () => {
if (guestMode.value) {
@@ -118,6 +135,7 @@ const loadInclPickupProduct = async () => {
}
onMounted(() => {
void loadTimeBookingDepartments();
// Load the semi-universal products
loadInclPickupProduct();
loadWashCertificateProduct();
@@ -135,6 +153,9 @@ const onClickSelectDepartment = () => {
// When a department is selected, automatically open and focus the first vehicle input
const onSelectDepartment = (dept: any) => {
if (!isSelectedDepartmentAvailable(dept)) {
return;
}
// Update state and close the department picker
department.value = dept;
isDepartmentInput.value = false;
@@ -156,6 +177,16 @@ const onSelectDepartment = (dept: any) => {
}, 0);
}
};
watch(bookingDepartmentOptions, (options) => {
if (!department.value) {
return;
}
if (!options.some((candidate: any) => Number(candidate.id) === Number(department.value?.id))) {
department.value = null;
}
});
// Watch for changes in the customer number
watch(SessionUser.user.customer_number, (newCustomerNumber) => {
const parsedCustomerNumber = parseInt(newCustomerNumber);
@@ -1100,9 +1131,11 @@ watch(customerNumber, (newVal) => {
</span>
<p class="is-size-5 mt-2">{{ $t('user_dashboard.bookings.book.select_wash_hall') }}</p>
</div>
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-3" v-for="dept in departments" :key="dept.id">
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :has-selected-style="department && department.id === dept.id" @click="department = dept" :toggleable="false" class="is-clickable department-select" :has-hover-effect="true" :hasSelectionStyle="true" :id="`department-option-${dept.id}`">
<p v-if="timeBookingDepartmentsIsLoading" class="has-text-grey">{{ $t('common.loading') }}</p>
<p v-else-if="bookingDepartmentOptions.length === 0" class="has-text-grey">{{ $t('common.not_available') }}</p>
<div v-else class="columns is-mobile is-vcentered is-multiline">
<div class="column is-3" v-for="dept in bookingDepartmentOptions" :key="dept.id">
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :has-selected-style="department && department.id === dept.id" @click="onSelectDepartment(dept)" :toggleable="false" class="is-clickable department-select" :has-hover-effect="true" :hasSelectionStyle="true" :id="`department-option-${dept.id}`">
<template v-slot:header>
<div class="card-header-icon">
<span class="icon">
@@ -1338,9 +1371,11 @@ watch(customerNumber, (newVal) => {
<p class="is-size-5 mt-2">{{ $t('user_dashboard.bookings.book.select_wash_hall') }}</p>
</div>
<!-- Department selection -->
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-3" v-for="dept in departments" :key="dept.id">
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :has-selected-style="department && department.id === dept.id" @click="department = dept" :toggleable="false" class="is-clickable" :has-hover-effect="true" :hasSelectionStyle="true">
<p v-if="timeBookingDepartmentsIsLoading" class="has-text-grey">{{ $t('common.loading') }}</p>
<p v-else-if="bookingDepartmentOptions.length === 0" class="has-text-grey">{{ $t('common.not_available') }}</p>
<div v-else class="columns is-mobile is-vcentered is-multiline">
<div class="column is-3" v-for="dept in bookingDepartmentOptions" :key="dept.id">
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :has-selected-style="department && department.id === dept.id" @click="onSelectDepartment(dept)" :toggleable="false" class="is-clickable" :has-hover-effect="true" :hasSelectionStyle="true">
<template v-slot:header>
<div class="card-header-icon">
<span class="icon">
@@ -1553,8 +1588,10 @@ watch(customerNumber, (newVal) => {
<hr />
<!-- Department selection -->
<div style="max-width: inherit; overflow-x: scroll; white-space: nowrap; overflow-y: auto; padding: 2px 16px; max-height: 40vh;" class="has-invisible-scrollbar">
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-12-mobile is-3-desktop" v-for="dept in departments" :key="dept.id">
<p v-if="timeBookingDepartmentsIsLoading" class="has-text-grey">{{ $t('common.loading') }}</p>
<p v-else-if="bookingDepartmentOptions.length === 0" class="has-text-grey">{{ $t('common.not_available') }}</p>
<div v-else class="columns is-mobile is-vcentered is-multiline">
<div class="column is-12-mobile is-3-desktop" v-for="dept in bookingDepartmentOptions" :key="dept.id">
<div role="button" tabindex="0" @keydown.enter="onSelectDepartment(dept)" :aria-pressed="department && department.id === dept.id" :aria-label="`${$t('common.select')} ${dept.name}`">
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :has-selected-style="department && department.id === dept.id" @click="onSelectDepartment(dept)" :toggleable="false" :hasHoverEffect="true" :hasSelectionStyle="true" class="is-clickable">
<template v-slot:header>
@@ -1869,8 +1906,10 @@ watch(customerNumber, (newVal) => {
</div>
<hr />
<div style="max-width: inherit; overflow-x: scroll; white-space: nowrap; overflow-y: auto; padding: 2px 16px; max-height: 40vh;" class="has-invisible-scrollbar">
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-12-mobile is-3-desktop" v-for="dept in departments" :key="dept.id">
<p v-if="timeBookingDepartmentsIsLoading" class="has-text-grey">{{ $t('common.loading') }}</p>
<p v-else-if="bookingDepartmentOptions.length === 0" class="has-text-grey">{{ $t('common.not_available') }}</p>
<div v-else class="columns is-mobile is-vcentered is-multiline">
<div class="column is-12-mobile is-3-desktop" v-for="dept in bookingDepartmentOptions" :key="dept.id">
<div
role="button"
tabindex="0"
+179
View File
@@ -0,0 +1,179 @@
<script setup>
import { computed } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const supportEmail = "cph@truckwash.dk";
const supportPhone = "+45 42 78 28 28";
const supportPhoneHref = "tel:+4542782828";
const supportEmailHref = `mailto:${supportEmail}`;
useHead({
title: computed(() => `${t("support.title")} - Truck Wash`),
meta: [
{
name: "description",
content: computed(() => t("support.meta_description")),
},
],
});
</script>
<template>
<section class="support-page" data-testid="support-page">
<div class="container">
<div class="support-page__header">
<p class="support-page__eyebrow">{{ t("support.eyebrow") }}</p>
<h1>{{ t("support.title") }}</h1>
<p>{{ t("support.intro") }}</p>
</div>
<div class="support-page__grid">
<article class="support-page__panel">
<h2>{{ t("support.contact_title") }}</h2>
<dl class="support-page__details">
<div>
<dt>{{ t("support.email_label") }}</dt>
<dd><a :href="supportEmailHref">{{ supportEmail }}</a></dd>
</div>
<div>
<dt>{{ t("support.phone_label") }}</dt>
<dd><a :href="supportPhoneHref">{{ supportPhone }}</a></dd>
</div>
<div>
<dt>{{ t("support.company_label") }}</dt>
<dd>
Truck Wash ApS<br />
Letland Alle 2<br />
2630 Taastrup<br />
CVR 41004355
</dd>
</div>
</dl>
</article>
<article class="support-page__panel">
<h2>{{ t("support.help_title") }}</h2>
<ul class="support-page__list">
<li>{{ t("support.help_booking") }}</li>
<li>{{ t("support.help_self_wash") }}</li>
<li>{{ t("support.help_payments") }}</li>
<li>{{ t("support.help_account") }}</li>
</ul>
</article>
</div>
<p class="support-page__footer">
{{ t("support.privacy_prefix") }}
<router-link :to="{ name: 'privacy-policy' }">{{ t("support.privacy_link") }}</router-link>
{{ t("support.privacy_suffix") }}
</p>
</div>
</section>
</template>
<style scoped>
.support-page {
padding: 4rem 1rem;
color: #13324c;
}
.support-page__header {
max-width: 760px;
margin: 0 auto 2rem;
text-align: center;
}
.support-page__eyebrow {
margin: 0 0 0.5rem;
color: #0787bb;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.support-page h1 {
margin: 0 0 1rem;
font-size: 2.5rem;
line-height: 1.1;
}
.support-page__header p:last-child {
margin: 0;
font-size: 1.05rem;
line-height: 1.6;
}
.support-page__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
max-width: 960px;
margin: 0 auto;
}
.support-page__panel {
border: 1px solid #dce5f1;
border-radius: 8px;
padding: 1.5rem;
background: #fff;
}
.support-page__panel h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}
.support-page__details {
display: grid;
gap: 1rem;
margin: 0;
}
.support-page__details div {
display: grid;
gap: 0.25rem;
}
.support-page__details dt {
font-weight: 700;
}
.support-page__details dd {
margin: 0;
}
.support-page__list {
margin: 0;
padding-left: 1.2rem;
line-height: 1.7;
}
.support-page__footer {
max-width: 760px;
margin: 1.5rem auto 0;
text-align: center;
}
.support-page a {
color: #0787bb;
font-weight: 700;
}
@media (max-width: 768px) {
.support-page {
padding: 2.5rem 1rem;
}
.support-page h1 {
font-size: 2rem;
}
.support-page__grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
File diff suppressed because it is too large Load Diff
+92 -1
View File
@@ -53,7 +53,13 @@ test.describe("Superuser cron operations", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "superuser_cron_view", "SUPERUSER_RUN_CRON", "superuser_cron_manage"],
permissions: [
"superuser",
"superuser_cron_view",
"SUPERUSER_RUN_CRON",
"superuser_cron_manage",
"superuser_coolify_manage",
],
});
await primeMockSession(page, { token: "superuser-cron-token", bootPath: null });
});
@@ -61,6 +67,7 @@ test.describe("Superuser cron operations", () => {
test("superusers can inspect, run, toggle, and reschedule cron tasks", async ({ page }) => {
const patchPayloads: Array<Record<string, unknown>> = [];
const runPayloads: Array<Record<string, unknown>> = [];
const deployPayloads: Array<Record<string, unknown>> = [];
let currentTasks = cronTasks.map((task) => ({ ...task, schedule: { ...task.schedule } }));
await page.route(apiPathPattern("/superuser/cron"), async (route) => {
@@ -99,6 +106,78 @@ test.describe("Superuser cron operations", () => {
});
});
await page.route(apiPathPattern("/superuser/cron/workers"), async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
workers: [
{
worker_id: "release-stable-cron-worker",
name: "release-stable-cron-worker",
hostname: "coolify-cron-1",
source: "coolify_worker",
status: "running",
stale: false,
last_heartbeat_at: "2026-07-09 12:01:30",
last_run_count: 1,
commit_sha: "abcdef1234567890",
},
],
summary: {
total: 1,
running: 1,
stale: 0,
},
deployment: {
ok: true,
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
target: {
id: 71,
app: "cron",
coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false,
},
},
},
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/workers/deploy"), async (route) => {
deployPayloads.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
ok: true,
target: {
id: 71,
app: "cron",
coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false,
},
},
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/run"), async (route) => {
runPayloads.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
@@ -147,6 +226,18 @@ test.describe("Superuser cron operations", () => {
"Process e-conomic transfer queue"
);
await expect(page.getByTestId("cron-total-tasks")).toContainText("2");
await expect(page.getByTestId("cron-worker-panel")).toContainText("release-stable-cron-worker");
await expect(page.getByTestId("cron-worker-running")).toContainText("1");
await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid");
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes("/superuser/cron/workers/deploy") && response.request().method() === "POST"
),
page.getByTestId("cron-worker-deploy").click(),
]);
expect(deployPayloads).toEqual([{}]);
await Promise.all([
page.waitForResponse(
@@ -25,6 +25,7 @@ test.describe("superuser order date filters", () => {
test("shows date filters, moves specialized filters into other filters, and counts active hidden filters", async ({
page,
}) => {
await page.setViewportSize({ width: 2560, height: 720 });
await page.clock.setFixedTime(new Date("2026-07-07T10:00:00.000Z"));
await seedAuthenticatedState(page);
@@ -73,6 +74,9 @@ test.describe("superuser order date filters", () => {
const otherFiltersButton = page.getByTestId("invoice-orders-other-filters-button");
const otherFiltersMenu = page.getByTestId("invoice-orders-other-filters-menu");
const anytimeDateShortcut = page.getByTestId("date-period-shortcut-anytime");
const todayDateShortcut = page.getByTestId("date-period-shortcut-today");
const shortcutActions = page.locator(".invoice-orders-shortcut-actions");
const otherFiltersLabel = (label: string) =>
otherFiltersMenu.locator("label").filter({ hasText: new RegExp(`^${label}$`) });
@@ -82,21 +86,52 @@ test.describe("superuser order date filters", () => {
await expect(page.getByText("Fejlstatus", { exact: true })).toHaveCount(0);
await expect(otherFiltersButton).toBeVisible();
await expect(otherFiltersButton).not.toHaveClass(/is-small/);
const otherFiltersCount = page.getByTestId("invoice-orders-other-filters-count");
await expect(otherFiltersCount).toHaveText("3");
await expect(anytimeDateShortcut).toBeVisible();
await expect(todayDateShortcut).toBeVisible();
const layoutProbeButtonBox = await otherFiltersButton.boundingBox();
const layoutProbeCountBox = await otherFiltersCount.boundingBox();
const layoutProbeShortcutBox = await anytimeDateShortcut.boundingBox();
const layoutProbeTodayShortcutBox = await todayDateShortcut.boundingBox();
const layoutProbeShortcutActionsBox = await shortcutActions.boundingBox();
console.log(
JSON.stringify({
button: layoutProbeButtonBox,
count: layoutProbeCountBox,
shortcut: layoutProbeShortcutBox,
todayShortcut: layoutProbeTodayShortcutBox,
shortcutActions: layoutProbeShortcutActionsBox,
})
);
await expect
.poll(async () => {
const buttonBox = await otherFiltersButton.boundingBox();
const countBox = await otherFiltersCount.boundingBox();
const shortcutBox = await anytimeDateShortcut.boundingBox();
const todayShortcutBox = await todayDateShortcut.boundingBox();
const shortcutActionsBox = await shortcutActions.boundingBox();
if (!buttonBox || !countBox) {
if (!buttonBox || !countBox || !shortcutBox || !todayShortcutBox || !shortcutActionsBox) {
return false;
}
const countCenterX = countBox.x + countBox.width / 2;
const countCenterY = countBox.y + countBox.height / 2;
const buttonCenterY = buttonBox.y + buttonBox.height / 2;
const shortcutCenterY = shortcutBox.y + shortcutBox.height / 2;
const otherToAnytimeGap = shortcutBox.x - (buttonBox.x + buttonBox.width);
const dateShortcutGap = todayShortcutBox.x - (shortcutBox.x + shortcutBox.width);
const doesCountProtrudeFromTopRight =
countBox.y < buttonBox.y && countBox.x + countBox.width > buttonBox.x + buttonBox.width;
const hasCountTopClearance = countBox.y >= shortcutActionsBox.y + 2;
return countCenterX > buttonBox.x + buttonBox.width - 12 && countCenterY < buttonBox.y + 8;
return (
Math.abs(buttonCenterY - shortcutCenterY) <= 4 &&
Math.abs(buttonBox.height - shortcutBox.height) <= 4 &&
Math.abs(otherToAnytimeGap - dateShortcutGap) <= 2 &&
doesCountProtrudeFromTopRight &&
hasCountTopClearance
);
})
.toBe(true);
await expect(otherFiltersMenu).toBeHidden();
@@ -25,9 +25,9 @@ const permissionCatalog = {
create_bookings: "Create bookings",
},
},
"/superuser/system/replication": {
"/superuser/system/status": {
GET: {
superuser_replication_view: "View replication status",
superuser_system_status_view: "View system status",
},
},
};
+264
View File
@@ -0,0 +1,264 @@
import { expect, test } from "@playwright/test";
import { apiPathPattern, mockApi, primeMockSession } from "./support/network.js";
function json(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
const policyRules = [
{
id: 1,
rule_key: "failed_login_attempts",
enabled: true,
threshold_count: 5,
window_seconds: 900,
mode: "observe",
exempt_permission_nodes: ["superuser_security_limits_exempt"],
},
{
id: 2,
rule_key: "bookings_created",
enabled: true,
threshold_count: 25,
window_seconds: 86400,
mode: "observe",
exempt_permission_nodes: [],
},
{
id: 3,
rule_key: "vehicles_created",
enabled: true,
threshold_count: 20,
window_seconds: 86400,
mode: "observe",
exempt_permission_nodes: [],
},
{
id: 4,
rule_key: "requests_per_ip",
enabled: true,
threshold_count: 300,
window_seconds: 60,
mode: "observe",
exempt_permission_nodes: [],
},
{
id: 5,
rule_key: "requests_per_customer",
enabled: true,
threshold_count: 600,
window_seconds: 60,
mode: "observe",
exempt_permission_nodes: [],
},
];
const baseIncident = {
id: 7,
incident_key: "firewall:12:test",
type: "firewall_block",
severity: "high",
status: "open",
title: "Block firewall rule matched: ip 203.0.113.9",
source_ip: "203.0.113.9",
customer_number: 12345,
route_path: "/auth/login",
route_template: "/auth/login",
occurrence_count: 3,
first_seen_at: "2026-07-13 10:00:00",
last_seen_at: "2026-07-13 10:05:00",
notes: [],
};
test.describe("Superuser system security", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockApi(page, {
authenticated: true,
permissions: [
"superuser",
"superuser_security_view",
"superuser_security_settings_manage",
"superuser_security_firewall_manage",
"superuser_security_incidents_manage",
],
});
await primeMockSession(page, { token: "superuser-security-token", bootPath: null });
});
test("supports overview, firewall, settings, and incident management", async ({ page }) => {
const settingsPayloads: Array<Record<string, unknown>> = [];
const firewallCreatePayloads: Array<Record<string, unknown>> = [];
const incidentPatchPayloads: Array<Record<string, unknown>> = [];
const incidentNotePayloads: Array<Record<string, unknown>> = [];
let firewallRules = [
{
id: 12,
action: "watch",
target_type: "ip",
target_value: "203.0.113.9",
route_pattern: "/auth/*",
priority: 50,
reason: "Repeated login failures",
enabled: true,
expires_at: null,
},
];
let selectedIncident = { ...baseIncident };
await page.route(apiPathPattern("/superuser/system/security/summary"), async (route) => {
await route.fulfill(
json({
success: true,
data: {
settings: { mode: "observe", rules: policyRules },
firewall: { active_rules: firewallRules.length, block_rules: 0, watch_rules: firewallRules.length },
incidents: { open: 1, acknowledged: 0, resolved: 0, recent: [selectedIncident] },
},
meta: {},
includes: {},
})
);
});
await page.route(apiPathPattern("/superuser/system/security/settings"), async (route) => {
if (route.request().method() === "PATCH") {
settingsPayloads.push(route.request().postDataJSON() as Record<string, unknown>);
}
await route.fulfill(
json({
success: true,
data: { mode: "observe", rules: policyRules },
meta: {},
includes: {},
})
);
});
await page.route(apiPathPattern("/superuser/system/security/firewall-rules"), async (route) => {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>;
firewallCreatePayloads.push(payload);
firewallRules = [...firewallRules, { id: 13, ...payload }];
}
await route.fulfill(
json({
success: true,
data: { rules: firewallRules },
meta: {},
includes: {},
})
);
});
await page.route(apiPathPattern("/superuser/system/security/incidents"), async (route) => {
await route.fulfill(
json({
success: true,
data: { incidents: [selectedIncident] },
meta: {},
includes: {},
})
);
});
await page.route(apiPathPattern("/superuser/system/security/incidents/7"), async (route) => {
if (route.request().method() === "PATCH") {
const payload = route.request().postDataJSON() as Record<string, unknown>;
incidentPatchPayloads.push(payload);
selectedIncident = { ...selectedIncident, status: String(payload.status || selectedIncident.status) };
}
await route.fulfill(
json({
success: true,
data: selectedIncident,
meta: {},
includes: {},
})
);
});
await page.route(apiPathPattern("/superuser/system/security/incidents/7/notes"), async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
incidentNotePayloads.push(payload);
selectedIncident = {
...selectedIncident,
notes: [
{
id: 1,
incident_id: 7,
note: String(payload.note),
created_at: "2026-07-13 10:10:00",
},
],
};
await route.fulfill(
json({
success: true,
data: selectedIncident,
meta: {},
includes: {},
})
);
});
await page.goto("/superuser/system/security/overview");
await expect(page.getByTestId("superuser-security-page")).toBeVisible();
await expect(page.getByText("Security overview")).toBeVisible();
await expect(page.getByRole("link", { name: "Open incidents" })).toBeVisible();
await expect(page.getByText("Block firewall rule matched: ip 203.0.113.9")).toBeVisible();
await page.getByTestId("security-tab-settings").click();
await expect(page).toHaveURL(/\/superuser\/system\/security\/settings$/);
await expect(page.getByText("Max failed login attempts")).toBeVisible();
await page.getByTestId("security-setting-threshold-failed_login_attempts").fill("6");
await page
.getByTestId("security-setting-exemptions-failed_login_attempts")
.fill("superuser_security_limits_exempt, support_login_bypass");
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => settingsPayloads.length).toBe(1);
expect(settingsPayloads[0].rules).toEqual(
expect.arrayContaining([
expect.objectContaining({
rule_key: "failed_login_attempts",
threshold_count: 6,
exempt_permission_nodes: ["superuser_security_limits_exempt", "support_login_bypass"],
}),
])
);
await page.getByTestId("security-tab-firewall").click();
await expect(page.getByText("Firewall management")).toBeVisible();
await page.getByTestId("security-firewall-action").selectOption("block");
await page.getByTestId("security-firewall-target-type").selectOption("ip");
await page.getByTestId("security-firewall-target-value").fill("198.51.100.44");
await page.getByTestId("security-firewall-route-pattern").fill("/auth/*");
await page.getByTestId("security-firewall-reason").fill("Temporary login abuse block");
await page.getByRole("button", { name: "Create" }).click();
await expect.poll(() => firewallCreatePayloads.length).toBe(1);
expect(firewallCreatePayloads[0]).toMatchObject({
action: "block",
target_type: "ip",
target_value: "198.51.100.44",
route_pattern: "/auth/*",
});
await page.getByTestId("security-tab-incidents").click();
await expect(page.getByText("Incident management")).toBeVisible();
await page.getByText("Block firewall rule matched: ip 203.0.113.9").click();
await expect(page.getByTestId("security-incident-detail")).toContainText("203.0.113.9");
await page.getByRole("button", { name: "Resolve" }).click();
await expect.poll(() => incidentPatchPayloads.length).toBe(1);
expect(incidentPatchPayloads[0]).toMatchObject({ status: "resolved" });
await page.getByTestId("security-incident-note").fill("Confirmed temporary abusive source.");
await page.getByRole("button", { name: "Add note" }).click();
await expect.poll(() => incidentNotePayloads.length).toBe(1);
expect(incidentNotePayloads[0]).toMatchObject({ note: "Confirmed temporary abusive source." });
});
});
+56 -2
View File
@@ -650,6 +650,22 @@ function resolveDepartmentIncludeInInvoice(posFixture, departmentId) {
return true;
}
function toPublicTimeBookingDepartments(departments = []) {
return (departments || [])
.filter(
(department) =>
department.bookingsystem_time_based_enabled === true ||
department.bookingsystem_time_based_enabled === "true" ||
department.time_booking_enabled === true
)
.map((department) => ({
...department,
description: department.description ?? department.address ?? "",
address: department.address ?? department.description ?? "",
time_booking_enabled: true,
}));
}
function withEffectiveOrderState(posFixture, order) {
if (!order) {
return order;
@@ -811,6 +827,7 @@ function createSelfServeFixture(overrides = {}) {
latitude: 55.6415,
longitude: 12.0803,
self_serve_enabled: true,
bookingsystem_time_based_enabled: true,
lanes: [
{
id: 7,
@@ -853,6 +870,7 @@ function createSelfServeFixture(overrides = {}) {
latitude: 55.4038,
longitude: 10.4024,
self_serve_enabled: true,
bookingsystem_time_based_enabled: true,
lanes: [
{
id: 9,
@@ -2676,8 +2694,20 @@ export function createPosFixture(overrides = {}) {
const baseFixture = {
departments: [
{ id: 1, name: "Taastrup", exclude_from_invoicing: true },
{ id: 12, name: "Demo", exclude_from_invoicing: false },
{
id: 1,
name: "Taastrup",
description: "Taastrup",
exclude_from_invoicing: true,
bookingsystem_time_based_enabled: true,
},
{
id: 12,
name: "Demo",
description: "Demo",
exclude_from_invoicing: false,
bookingsystem_time_based_enabled: true,
},
],
customersByNumber: {
[defaultCustomer.customerNumber]: defaultCustomer,
@@ -3069,6 +3099,11 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return false;
}
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
await route.fulfill(json({ success: true, data: toPublicTimeBookingDepartments(posFixture.departments) }));
return true;
}
if (pathname.endsWith("/departments") && method === "GET") {
await route.fulfill(json({ success: true, data: posFixture.departments }));
return true;
@@ -4139,6 +4174,7 @@ function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
description: "Primary launch department",
order_priority: 1,
self_serve_enabled: true,
bookingsystem_time_based_enabled: true,
lanes: [
{
id: 7,
@@ -4178,6 +4214,7 @@ function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
description: "Fallback transport department",
order_priority: 2,
self_serve_enabled: true,
bookingsystem_time_based_enabled: true,
lanes: [
{
id: 9,
@@ -6339,6 +6376,11 @@ export async function mockApi(page, options = {}) {
}
if (selfServe) {
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
await route.fulfill(json({ data: toPublicTimeBookingDepartments(selfServe.departments) }));
return;
}
if (pathname.endsWith("/guest/departments") && method === "GET") {
await route.fulfill(json({ data: selfServe.departments }));
return;
@@ -6750,6 +6792,18 @@ export async function mockApi(page, options = {}) {
}
}
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
await route.fulfill(
json({
data: toPublicTimeBookingDepartments([
{ id: 1, name: "Copenhagen", bookingsystem_time_based_enabled: true },
{ id: 2, name: "Odense", bookingsystem_time_based_enabled: true },
]),
})
);
return;
}
if (pathname.endsWith("/departments") && method === "GET") {
await route.fulfill(
json({
+39
View File
@@ -8,6 +8,7 @@ import {
selectBookingDateTime,
} from "./support/bookingFlow";
import { mockApi, primeMockSession } from "./support/network.js";
import { isCompactProject } from "./support/projects";
async function prepareBookingPage(
page: Page,
@@ -33,6 +34,44 @@ test("[BOOKINGS][User][Creation] should create a new booking", async ({ page })
await completeBookingCreationFlow(page, bookingTestData);
});
test("[BOOKINGS][User][Departments] should hide departments without time booking enabled", async ({
page,
}, testInfo) => {
await prepareBookingPage(page, {
departments: [
{
id: bookingTestData.departmentId,
name: "Bookable Demo",
description: "Bookable address",
exclude_from_invoicing: false,
bookingsystem_time_based_enabled: true,
},
{
id: 77,
name: "Disabled Booking Hall",
description: "Disabled address",
exclude_from_invoicing: false,
bookingsystem_time_based_enabled: false,
},
],
});
await page.goto("/user/bookings/book");
if (isCompactProject(testInfo)) {
const mobileTrigger = page.getByTestId("booking-mobile-department-trigger");
await expect(mobileTrigger).toBeVisible();
await mobileTrigger.click();
await expect(page.getByTestId(`booking-mobile-department-option-${bookingTestData.departmentId}`)).toBeVisible();
await expect(page.getByTestId("booking-mobile-department-option-77")).toHaveCount(0);
} else {
await expect(page.locator(`#department-option-${bookingTestData.departmentId}`)).toBeVisible();
await expect(page.locator("#department-option-77")).toHaveCount(0);
}
await expect(page.getByText("Disabled Booking Hall")).toHaveCount(0);
});
test("[BOOKINGS][User][Creation] should show interior wash products on the interior category", async ({ page }) => {
await prepareBookingPage(page);
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const productsGetAllMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
products: {
get: {
all: productsGetAllMock,
},
},
},
},
}));
import {
clearCustomerRuleProductCatalogCache,
getCustomerRuleProductCatalogCacheKey,
loadCustomerRuleProductCatalog,
} from "@/features/customer/customerRuleProductCatalog.js";
afterEach(() => {
clearCustomerRuleProductCatalogCache();
productsGetAllMock.mockReset();
});
describe("customer rule product catalog", () => {
it("builds stable cache keys from normalized department and customer values", () => {
expect(getCustomerRuleProductCatalogCacheKey({ departmentId: "12", customerNumber: "12345679" })).toBe(
"department:12|customer:12345679"
);
expect(getCustomerRuleProductCatalogCacheKey({ departmentId: "0", customerNumber: null })).toBe(
"department:global|customer:global"
);
});
it("loads the unpriced customer product catalog and caches repeat requests", async () => {
productsGetAllMock.mockResolvedValueOnce({
data: {
data: [{ id: 23, name: "Spot free rinse" }],
},
});
const firstProducts = await loadCustomerRuleProductCatalog({
departmentId: "12",
customerNumber: "12345679",
});
const secondProducts = await loadCustomerRuleProductCatalog({
departmentId: 12,
customerNumber: 12345679,
});
expect(productsGetAllMock).toHaveBeenCalledTimes(1);
expect(productsGetAllMock).toHaveBeenCalledWith({
customer_id: 12345679,
department_id: 12,
final_price: false,
});
expect(firstProducts).toEqual([{ id: 23, name: "Spot free rinse" }]);
expect(secondProducts).toEqual(firstProducts);
});
});
+16
View File
@@ -4,6 +4,7 @@ import {
getCustomerRuleAttributeKey,
getCustomerRuleAttributeKeys,
getCustomerRuleDefinition,
getCustomerRuleDefinitions,
} from "@/features/customer/customerRuleRegistry.js";
describe("customer rule registry attribute normalization", () => {
@@ -31,4 +32,19 @@ describe("customer rule registry attribute normalization", () => {
expect(getCustomerRuleDefinition(keys[0])?.attribute).toBe("invoiceAllOrdersIndividually");
expect(getCustomerRuleDefinition(keys[1])?.attribute).toBe("onlyTankCleaning");
});
it("marks only product-affecting rules for product impact explanations", () => {
const productImpactAttributes = getCustomerRuleDefinitions()
.filter((definition) => definition.productImpact)
.map((definition) => definition.attribute);
expect(productImpactAttributes).toEqual([
"restrictAdditionalServices",
"restrictTankCleaning",
"restrictSpotFree",
"restrictInteriorCleaning",
"onlyTankCleaning",
]);
expect(getCustomerRuleDefinition("invoiceAllOrdersIndividually")?.productImpact).toBeUndefined();
});
});
+211
View File
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
const loadCatalogMock = vi.hoisted(() => vi.fn());
vi.mock("@/features/customer/customerRuleProductCatalog.js", () => ({
loadCustomerRuleProductCatalog: loadCatalogMock,
}));
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
const BTooltipStub = {
name: "BTooltip",
props: {
active: {
type: Boolean,
default: false,
},
contentClass: {
type: String,
default: "",
},
},
template: `
<span class="b-tooltip-stub" :data-active="active ? 'true' : 'false'">
<slot />
<span class="b-tooltip-stub__content" :class="contentClass" v-show="active"><slot name="content" /></span>
</span>
`,
};
const messages = {
en: {
customer_rules: {
attributes: {
onlyTankCleaning: {
description: "Allows only tank cleaning services.",
},
restrictAdditionalServices: {
description: "Blocks additional services.",
},
restrictSpotFree: {
description: "Blocks spot-free rinse services.",
},
},
tooltip: {
available_if_enabled: "Would remain available if enabled",
available_while_enabled: "Available while active",
blocked_if_enabled: "Would be blocked if enabled",
blocked_now: "Blocked while active",
changes: "Changes",
groups: {
primary_products: "Primary products",
related_addons: "Related add-ons",
standalone_additional_services: "Standalone additional services",
},
load_failed: "Could not load affected products.",
loading_products: "Loading affected products...",
no_affected_products: "No affected products found in the current product catalog.",
},
},
},
};
function mountTooltip(props = {}) {
return mount(CustomerRuleTooltip, {
props: {
testId: "rule-tooltip",
...props,
},
slots: {
default: "<span>Rule label</span>",
},
global: {
plugins: [createTestI18n(messages)],
stubs: {
BTooltip: BTooltipStub,
},
},
});
}
afterEach(() => {
loadCatalogMock.mockReset();
vi.useRealTimers();
});
describe("CustomerRuleTooltip", () => {
it("renders the rule change and exact additional-service product groups", () => {
const wrapper = mountTooltip({
active: true,
attribute: "restrictAdditionalServices",
products: [
{
id: 10,
category: 4,
name: "Truck wash",
addons: [
{
option_id: 62,
name: "Interior add-on",
product: { id: 62, category: 4, name: "Interior add-on" },
},
],
},
{ id: 91, category: 8, name: "Extra detergent" },
],
});
const content = wrapper.get('[data-testid="rule-tooltip-content"]');
expect(content.text()).toContain("Changes");
expect(content.text()).toContain("Blocks additional services.");
expect(content.text()).toContain("Blocked while active");
expect(content.text()).not.toContain("Truck wash");
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"]').text()).toContain("Interior add-on");
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"]').text()).toContain("%");
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"] li').classes()).toContain(
"customer-rule-tooltip__blocked-product"
);
expect(wrapper.get('[data-testid="rule-tooltip-blocked-standaloneAdditionalServices"]').text()).toContain(
"Extra detergent"
);
});
it("renders both blocked and available products for only-tank-cleaning rules", () => {
const wrapper = mountTooltip({
active: false,
attribute: "onlyTankCleaning",
products: [
{ id: 10, category: 4, name: "Truck wash" },
{ id: 20, category: 5, name: "Tank cleaning 4 spulehoveder" },
],
});
expect(wrapper.get('[data-testid="rule-tooltip-blocked-primaryProducts"]').text()).toContain("Truck wash");
expect(wrapper.get('[data-testid="rule-tooltip-blocked-primaryProducts"]').text()).toContain("%");
expect(wrapper.get('[data-testid="rule-tooltip-available-primaryProducts"]').text()).toContain(
"Tank cleaning 4 spulehoveder"
);
expect(wrapper.get('[data-testid="rule-tooltip-available-primaryProducts"]').text()).not.toContain("%");
});
it("lazy-loads catalog products on hover when no products are provided", async () => {
loadCatalogMock.mockResolvedValueOnce([
{ id: 23, category: 4, name: "Spot free rinse" },
{ id: 88, category: 4, name: "Standard wash" },
]);
const wrapper = mountTooltip({
active: false,
attribute: "restrictSpotFree",
customerNumber: 12345679,
departmentId: 12,
testId: "spot-free-tooltip",
});
await wrapper.get('[data-testid="spot-free-tooltip"]').trigger("mouseenter");
await flushPromises();
expect(loadCatalogMock).toHaveBeenCalledWith({
customerNumber: 12345679,
departmentId: 12,
});
expect(wrapper.get('[data-testid="spot-free-tooltip-content"]').text()).toContain("Spot free rinse");
expect(wrapper.get('[data-testid="spot-free-tooltip-content"]').text()).not.toContain("Standard wash");
});
it("keeps the tooltip open while moving the pointer into the scrollable product panel", async () => {
vi.useFakeTimers();
const wrapper = mountTooltip({
active: true,
attribute: "restrictAdditionalServices",
products: [
{
id: 10,
category: 4,
name: "Truck wash",
addons: [
{
option_id: 62,
name: "Interior add-on",
product: { id: 62, category: 4, name: "Interior add-on" },
},
],
},
],
});
const tooltip = wrapper.get(".b-tooltip-stub");
const trigger = wrapper.get('[data-testid="rule-tooltip"]');
const content = wrapper.get('[data-testid="rule-tooltip-content"]');
expect(tooltip.attributes("data-active")).toBe("false");
await trigger.trigger("mouseenter");
expect(tooltip.attributes("data-active")).toBe("true");
await trigger.trigger("mouseleave");
await content.trigger("mouseenter");
vi.advanceTimersByTime(180);
await flushPromises();
expect(tooltip.attributes("data-active")).toBe("true");
await content.trigger("mouseleave");
vi.advanceTimersByTime(180);
await flushPromises();
expect(tooltip.attributes("data-active")).toBe("false");
});
});
@@ -77,7 +77,6 @@ describe("Playwright full-slice ownership", () => {
expect(ownedFilesByRole.admin).toEqual(expect.arrayContaining(["default-mobile-redirect.spec.ts"]));
expect(ownedFilesByRole.superuser).toEqual(
expect.arrayContaining([
"coolify-infrastructure.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"release-manager.spec.js",
+7
View File
@@ -144,6 +144,13 @@ describe("Playwright PR mapping", () => {
);
});
it("maps superuser security changes to security E2E coverage", () => {
expect(specsFor("src/views/dashboards/superUserDashboard/system/SystemSecurity.vue")).toContain(
"tests/e2e/superuser-security.spec.ts"
);
expect(specsFor("src/services/superuserSecurity.js")).toContain("tests/e2e/superuser-security.spec.ts");
});
it("keeps PR runner and full-slice metadata edits out of broad PR smoke fallback", () => {
expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(false);
expect(triggersFallback("scripts/run-playwright-ci-parallel.mjs")).toBe(true);
+17 -8
View File
@@ -23,9 +23,14 @@ const catalog = {
create_bookings: "Create bookings",
},
},
"/superuser/system/replication": {
"/superuser/system/status": {
GET: {
superuser_replication_view: "View replication status",
superuser_system_status_view: "View system status",
},
},
"/superuser/system/security/firewall-rules": {
POST: {
superuser_security_firewall_manage: "Create firewall rules",
},
},
};
@@ -77,7 +82,8 @@ describe("role permission catalog helpers", () => {
"list_orders",
"add_order",
"create_bookings",
"superuser_replication_view",
"superuser_system_status_view",
"superuser_security_firewall_manage",
])
);
expect(records.find((record) => record.permission === "department_access_7")).toMatchObject({
@@ -97,7 +103,10 @@ describe("role permission catalog helpers", () => {
expect(records.find((record) => record.permission === "create_bookings")).toMatchObject({
groupKey: "bookings",
});
expect(records.find((record) => record.permission === "superuser_replication_view")).toMatchObject({
expect(records.find((record) => record.permission === "superuser_system_status_view")).toMatchObject({
groupKey: "system_config",
});
expect(records.find((record) => record.permission === "superuser_security_firewall_manage")).toMatchObject({
groupKey: "system_config",
});
});
@@ -114,8 +123,8 @@ describe("role permission catalog helpers", () => {
expect(filterPermissionRecords(records, "POST").map((record) => record.permission)).toEqual(
expect.arrayContaining(["add_order", "create_bookings"])
);
expect(filterPermissionRecords(records, "replication status").map((record) => record.permission)).toEqual([
"superuser_replication_view",
expect(filterPermissionRecords(records, "system status").map((record) => record.permission)).toEqual([
"superuser_system_status_view",
]);
});
@@ -153,8 +162,8 @@ describe("role permission catalog helpers", () => {
});
it("humanizes and sanitizes permission display values", () => {
expect(humanizePermissionKey("superuser_replication_view")).toBe("Superuser replication view");
expect(humanizeEndpoint("/superuser/system/replication")).toBe("Superuser system replication");
expect(humanizePermissionKey("superuser_system_status_view")).toBe("Superuser system status view");
expect(humanizeEndpoint("/superuser/system/status")).toBe("Superuser system status");
expect(sanitizePermissionDomId("department/access 7")).toBe("department-access-7");
});
});
@@ -147,6 +147,8 @@ describe("superuser module initialization regression contract", () => {
it("keeps superuser system binding lazy to avoid circular-import TDZ errors", () => {
const systemBindings = parseGetterBindings(superUserSystemBlock);
expect(systemBindings.get("database")).toBe("DatabaseSystemObject");
expect(systemBindings.get("redis")).toBe("RedisSystemObject");
expect(systemBindings.get("minio")).toBe("MinioSystemObject");
});
it("keeps all superuser module bindings lazy at object construction time", () => {
@@ -159,6 +161,6 @@ describe("superuser module initialization regression contract", () => {
expect(moduleGetterBindings.get(key)).toBe(expectedBinding);
}
expect(superUserObjectSource).toContain("get cron() { return Cron; }");
expect(superUserObjectSource).toMatch(/get\s+cron\s*\(\)\s*{\s*return\s+Cron;\s*}/);
});
});