Add system status displays for Minio and Redis, and enhance backup configuration
This commit is contained in:
@@ -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.
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
+112
-48
@@ -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 {
|
||||
|
||||
+12
-62
@@ -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, "&")
|
||||
@@ -12,6 +22,112 @@ const escapeHtml = (value) => String(value ?? "")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = {}) =>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
+361
-3
@@ -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=" ">
|
||||
<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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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." });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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*}/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user