Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels.
This commit is contained in:
@@ -32,6 +32,24 @@ const successMessage = ref('');
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const resolveLoginRedirectPath = () => {
|
||||
const redirectQuery = router.currentRoute.value.query.redirect;
|
||||
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
|
||||
if (!redirectValue) {
|
||||
return "/redirect";
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = new URL(redirectValue, window.location.origin);
|
||||
if (redirectUrl.origin !== window.location.origin) {
|
||||
return "/redirect";
|
||||
}
|
||||
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
||||
} catch {
|
||||
return "/redirect";
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const response = await axios.post(API_URL + '/auth/login', {
|
||||
@@ -55,8 +73,7 @@ const login = async () => {
|
||||
successMessage.value = "Du er nu logget ind!"
|
||||
// Wait 500ms before reloading the page to show the success message
|
||||
setTimeout(() => {
|
||||
// Reload the page to update the UI
|
||||
router.go(0);
|
||||
window.location.assign(resolveLoginRedirectPath());
|
||||
}, 500);
|
||||
// window.location.reload();
|
||||
} catch (e) {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/Su
|
||||
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
|
||||
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
|
||||
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
|
||||
import {
|
||||
isReleaseChannelApiAvailabilityError,
|
||||
markReleaseChannelApiUnavailable,
|
||||
@@ -96,6 +97,19 @@ const hydrateSessionFromStorage = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearStoredSession = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem("token");
|
||||
window.localStorage.removeItem("is_subuser");
|
||||
} catch {
|
||||
// Storage can be unavailable in test or private browsing contexts.
|
||||
}
|
||||
};
|
||||
|
||||
const applyReleaseRuntimeConfig = (runtime) => {
|
||||
const normalizedRuntime = runtime || {};
|
||||
configureReleaseRuntime(normalizedRuntime);
|
||||
@@ -142,6 +156,96 @@ export const refreshReleaseRuntime = async ({ throwOnError = false } = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetSessionState = () => {
|
||||
SessionUser.user.id.value = null;
|
||||
SessionUser.user.customer_number.value = null;
|
||||
SessionUser.user.display_name.value = null;
|
||||
SessionUser.user.group_id.value = null;
|
||||
SessionUser.user.email.value = null;
|
||||
SessionUser.user.phone.number.value = null;
|
||||
SessionUser.user.phone.country_code.value = null;
|
||||
SessionUser.user.notifications.wash_certificate_email.value = null;
|
||||
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = null;
|
||||
SessionUser.user.created_at.value = null;
|
||||
SessionUser.user.updated_at.value = null;
|
||||
SessionUser.user.cached_at.value = null;
|
||||
SessionUser.permissions.value = [];
|
||||
SessionUser.token.value = null;
|
||||
SessionUser.authenticated.value = false;
|
||||
SessionUser.isSubuser.value = false;
|
||||
SessionUser.initiated.value = false;
|
||||
|
||||
SessionUser.subuser.id.value = null;
|
||||
SessionUser.subuser.username.value = null;
|
||||
SessionUser.subuser.name.value = null;
|
||||
SessionUser.subuser.email.value = null;
|
||||
SessionUser.subuser.phone.country_code.value = null;
|
||||
SessionUser.subuser.phone.number.value = null;
|
||||
SessionUser.subuser.grants.value = [];
|
||||
SessionUser.subuser.created_at.value = null;
|
||||
SessionUser.subuser.updated_at.value = null;
|
||||
SessionUser.subuser.suspended_at.value = null;
|
||||
SessionUser.subuser.cached_at.value = null;
|
||||
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.removeItem("selected_customer_number");
|
||||
} catch {
|
||||
// Storage is optional for the in-memory reset.
|
||||
}
|
||||
}
|
||||
|
||||
SessionUser.economicData.customerNumber.value = null;
|
||||
SessionUser.economicData.name.value = null;
|
||||
SessionUser.economicData.address.value = null;
|
||||
SessionUser.economicData.zip.value = null;
|
||||
SessionUser.economicData.city.value = null;
|
||||
SessionUser.economicData.mobilePhone.value = null;
|
||||
SessionUser.economicData.email.value = null;
|
||||
SessionUser.economicData.cvr.value = null;
|
||||
SessionUser.economicData.currency.value = null;
|
||||
SessionUser.economicData.country.value = null;
|
||||
SessionUser.economicData.cached_at.value = null;
|
||||
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
|
||||
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
|
||||
SessionUser.runtimeConfig.release.traceId.value = null;
|
||||
SessionUser.runtimeConfig.release.channel.value = null;
|
||||
SessionUser.runtimeConfig.release.availableChannels.value = [];
|
||||
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
|
||||
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
|
||||
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
|
||||
SessionUser.runtimeConfig.release.availability.value = {
|
||||
configured: true,
|
||||
missing: [],
|
||||
status: "ready",
|
||||
explicit: false,
|
||||
};
|
||||
SessionUser.runtimeConfig.release.capturePolicy.value = {
|
||||
enabled: false,
|
||||
capture_level: "metadata",
|
||||
all_failure_metadata: true,
|
||||
retention_days: 14,
|
||||
};
|
||||
setReleaseChannelSwitchNoticePrincipal("");
|
||||
configureReleaseRuntime({});
|
||||
};
|
||||
|
||||
export const refreshSessionData = async () => {
|
||||
if (!hydrateSessionFromStorage()) {
|
||||
resetSessionState();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SessionUser.isSubuser.value) {
|
||||
return await getSubuserSessionData();
|
||||
}
|
||||
|
||||
return await getSessionData();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate the user session on app start
|
||||
* @returns {Promise<void>}
|
||||
@@ -296,50 +400,50 @@ export const getSubuserSessionData = async () => {
|
||||
export const getSessionData = async () => {
|
||||
return await authenticatedRequest("/auth/session", "GET", releaseChannelRuntimeRequestParams())
|
||||
.then((response) => {
|
||||
SessionUser.user.id.value = response.data.data.id;
|
||||
SessionUser.user.customer_number.value = response.data.data.customer_number;
|
||||
SessionUser.user.group_id.value = response.data.data.group_id;
|
||||
SessionUser.user.email.value = response.data.data.email;
|
||||
SessionUser.user.phone.number.value = response.data.data.phone.number;
|
||||
SessionUser.user.phone.country_code.value = response.data.data.phone.country_code;
|
||||
SessionUser.user.notifications.wash_certificate_email.value =
|
||||
response.data.data.notifications.wash_certificate_email;
|
||||
const session = normalizeSessionPayload(response?.data?.data);
|
||||
|
||||
SessionUser.user.id.value = session.id;
|
||||
SessionUser.user.customer_number.value = session.customer_number;
|
||||
SessionUser.user.group_id.value = session.group_id;
|
||||
SessionUser.user.email.value = session.email;
|
||||
SessionUser.user.phone.number.value = session.phone.number;
|
||||
SessionUser.user.phone.country_code.value = session.phone.country_code;
|
||||
SessionUser.user.notifications.wash_certificate_email.value = session.notifications.wash_certificate_email;
|
||||
SessionUser.user.notifications.email_notifications_enabled.value =
|
||||
response.data.data.notifications.email_notifications_enabled;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value =
|
||||
response.data.data.notifications.sms_notifications_enabled;
|
||||
SessionUser.user.created_at.value = response.data.data.created_at;
|
||||
SessionUser.user.updated_at.value = response.data.data.updated_at;
|
||||
SessionUser.user.display_name.value = response.data.data.display_name;
|
||||
session.notifications.email_notifications_enabled;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
|
||||
SessionUser.user.created_at.value = session.created_at;
|
||||
SessionUser.user.updated_at.value = session.updated_at;
|
||||
SessionUser.user.display_name.value = session.display_name;
|
||||
setReleaseChannelSwitchNoticePrincipal(
|
||||
`user:${response.data.data.id || response.data.data.customer_number || response.data.data.email || "unknown"}`
|
||||
`user:${session.id || session.customer_number || session.email || "unknown"}`
|
||||
);
|
||||
// Set the last cached time to now, this is used to determine if the user's data is outdated
|
||||
SessionUser.user.cached_at.value = new Date();
|
||||
SessionUser.permissions.value = response.data.data.permissions;
|
||||
SessionUser.permissions.value = session.permissions;
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
|
||||
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number
|
||||
session.runtime_config.economic.transaction_draft_customer_number
|
||||
);
|
||||
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = normalizePositiveInteger(
|
||||
response?.data?.data?.runtime_config?.economic?.default_distribution_department_id
|
||||
session.runtime_config.economic.default_distribution_department_id
|
||||
);
|
||||
applyReleaseRuntimeConfig(response?.data?.data?.runtime_config?.release || {});
|
||||
applyReleaseRuntimeConfig(session.runtime_config.release);
|
||||
// E-conomic data is only fetched if the array isn't empty
|
||||
if (response.data.data.economic_customer.length > 0) {
|
||||
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
|
||||
SessionUser.economicData.name.value = response.data.data.economic_customer.name;
|
||||
if (session.economic_customer) {
|
||||
SessionUser.economicData.customerNumber.value = session.economic_customer.customerNumber;
|
||||
SessionUser.economicData.name.value = session.economic_customer.name;
|
||||
// Set the display name to the e-conomic name if it's empty
|
||||
if (SessionUser.user.display_name.value === null || SessionUser.user.display_name.value === "Unnamed") {
|
||||
SessionUser.user.display_name.value = response.data.data.economic_customer.name;
|
||||
SessionUser.user.display_name.value = session.economic_customer.name;
|
||||
}
|
||||
SessionUser.economicData.address.value = response.data.data.economic_customer.address;
|
||||
SessionUser.economicData.zip.value = response.data.data.economic_customer.zip;
|
||||
SessionUser.economicData.city.value = response.data.data.economic_customer.city;
|
||||
SessionUser.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone;
|
||||
SessionUser.economicData.email.value = response.data.data.economic_customer.email;
|
||||
SessionUser.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber;
|
||||
SessionUser.economicData.currency.value = response.data.data.economic_customer.currency;
|
||||
SessionUser.economicData.country.value = response.data.data.economic_customer.country;
|
||||
SessionUser.economicData.address.value = session.economic_customer.address;
|
||||
SessionUser.economicData.zip.value = session.economic_customer.zip;
|
||||
SessionUser.economicData.city.value = session.economic_customer.city;
|
||||
SessionUser.economicData.mobilePhone.value = session.economic_customer.mobilePhone;
|
||||
SessionUser.economicData.email.value = session.economic_customer.email;
|
||||
SessionUser.economicData.cvr.value = session.economic_customer.corporateIdentificationNumber;
|
||||
SessionUser.economicData.currency.value = session.economic_customer.currency;
|
||||
SessionUser.economicData.country.value = session.economic_customer.country;
|
||||
SessionUser.economicData.cached_at.value = new Date();
|
||||
}
|
||||
SessionUser.initiated.value = true;
|
||||
@@ -371,22 +475,13 @@ export const getSessionData = async () => {
|
||||
*/
|
||||
export const destroySession = async () => {
|
||||
return await authenticatedRequest("/auth/logout", "GET")
|
||||
.then(() => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("is_subuser");
|
||||
SessionUser.forceRefresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
parseError(error, "auth");
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("is_subuser");
|
||||
SessionUser.forceRefresh();
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("is_subuser");
|
||||
SessionUser.forceRefresh();
|
||||
clearStoredSession();
|
||||
resetSessionState();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -394,9 +489,8 @@ export const destroySession = async () => {
|
||||
* Force sign out the user
|
||||
*/
|
||||
export const forceClearSession = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("is_subuser");
|
||||
SessionUser.forceRefresh();
|
||||
clearStoredSession();
|
||||
resetSessionState();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -726,70 +820,7 @@ export const SessionUser = {
|
||||
},
|
||||
/** Reset the user's session data (Cache) */
|
||||
forceRefresh: () => {
|
||||
SessionUser.user.id.value = null;
|
||||
SessionUser.user.customer_number.value = null;
|
||||
SessionUser.user.display_name.value = null;
|
||||
SessionUser.user.group_id.value = null;
|
||||
SessionUser.user.email.value = null;
|
||||
SessionUser.user.phone.number.value = null;
|
||||
SessionUser.user.phone.country_code.value = null;
|
||||
SessionUser.user.notifications.wash_certificate_email.value = null;
|
||||
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = null;
|
||||
SessionUser.user.created_at.value = null;
|
||||
SessionUser.user.updated_at.value = null;
|
||||
SessionUser.user.cached_at.value = null;
|
||||
SessionUser.permissions.value = [];
|
||||
SessionUser.authenticated.value = false;
|
||||
SessionUser.isSubuser.value = false;
|
||||
// Clear subuser data
|
||||
SessionUser.subuser.id.value = null;
|
||||
SessionUser.subuser.username.value = null;
|
||||
SessionUser.subuser.name.value = null;
|
||||
SessionUser.subuser.email.value = null;
|
||||
SessionUser.subuser.phone.country_code.value = null;
|
||||
SessionUser.subuser.phone.number.value = null;
|
||||
SessionUser.subuser.grants.value = [];
|
||||
SessionUser.subuser.created_at.value = null;
|
||||
SessionUser.subuser.updated_at.value = null;
|
||||
SessionUser.subuser.suspended_at.value = null;
|
||||
SessionUser.subuser.cached_at.value = null;
|
||||
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
||||
localStorage.removeItem("selected_customer_number");
|
||||
// Clear economic data
|
||||
SessionUser.economicData.customerNumber.value = null;
|
||||
SessionUser.economicData.name.value = null;
|
||||
SessionUser.economicData.address.value = null;
|
||||
SessionUser.economicData.zip.value = null;
|
||||
SessionUser.economicData.city.value = null;
|
||||
SessionUser.economicData.mobilePhone.value = null;
|
||||
SessionUser.economicData.email.value = null;
|
||||
SessionUser.economicData.cvr.value = null;
|
||||
SessionUser.economicData.currency.value = null;
|
||||
SessionUser.economicData.country.value = null;
|
||||
SessionUser.economicData.cached_at.value = null;
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
|
||||
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
|
||||
SessionUser.runtimeConfig.release.traceId.value = null;
|
||||
SessionUser.runtimeConfig.release.channel.value = null;
|
||||
SessionUser.runtimeConfig.release.availableChannels.value = [];
|
||||
SessionUser.runtimeConfig.release.versions.value = { frontend: null, api: null, bundle_id: null };
|
||||
SessionUser.runtimeConfig.release.frontendBaseUrl.value = null;
|
||||
SessionUser.runtimeConfig.release.apiBaseUrl.value = null;
|
||||
SessionUser.runtimeConfig.release.availability.value = {
|
||||
configured: true,
|
||||
missing: [],
|
||||
status: "ready",
|
||||
explicit: false,
|
||||
};
|
||||
SessionUser.runtimeConfig.release.capturePolicy.value = {
|
||||
enabled: false,
|
||||
capture_level: "metadata",
|
||||
all_failure_metadata: true,
|
||||
retention_days: 14,
|
||||
};
|
||||
setReleaseChannelSwitchNoticePrincipal("");
|
||||
getSessionData();
|
||||
resetSessionState();
|
||||
},
|
||||
auth: {
|
||||
/** Authenticate the user */
|
||||
@@ -798,7 +829,7 @@ export const SessionUser = {
|
||||
authenticateSubuser: authenticateSubuser,
|
||||
/** Logout the user */
|
||||
logout: destroySession,
|
||||
/** Force clear the user's session WARNING: This will not destroy the user's token */
|
||||
/** Force clear the user's local session state and stored token */
|
||||
forceClearSession: forceClearSession,
|
||||
/** reCAPTCHA */
|
||||
reCAPTCHA: reCAPTCHA,
|
||||
@@ -1166,6 +1197,7 @@ export const SessionUser = {
|
||||
editField: EditFieldForm,
|
||||
getSessionData: getSessionData,
|
||||
getSubuserSessionData: getSubuserSessionData,
|
||||
refreshSessionData: refreshSessionData,
|
||||
refreshReleaseRuntime: refreshReleaseRuntime,
|
||||
initiateOnAppStart: initiateOnAppStart,
|
||||
checkForUpdates() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_URL, IS_DEV, RELEASE_PUBLIC_GATEWAY_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
|
||||
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
|
||||
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
|
||||
|
||||
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||
@@ -80,26 +80,18 @@ export const resolveReleaseSourceMode = (
|
||||
return readReleaseSourceOverride() || RELEASE_SOURCE_MODES.AUTO;
|
||||
};
|
||||
|
||||
const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
|
||||
const slug = normalizeReleaseChannelSlug(channelSlug);
|
||||
if (!slug) {
|
||||
return "";
|
||||
const selectedReleaseChannelSlug = (selectedChannel) => {
|
||||
if (selectedChannel !== undefined && selectedChannel !== null) {
|
||||
return normalizeReleaseChannelSlug(selectedChannel);
|
||||
}
|
||||
const routeSlug = slug === "stable" ? "master" : slug;
|
||||
|
||||
const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
|
||||
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new URL(`${routeSlug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
|
||||
return readSelectedReleaseChannel();
|
||||
};
|
||||
|
||||
const buildRuntimeHeaders = () => {
|
||||
const buildRuntimeHeaders = (selectedChannel) => {
|
||||
const storage = browserStorage();
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
...buildReleaseHeaders({ channelSlug: readSelectedReleaseChannel() }),
|
||||
...buildReleaseHeaders({ channelSlug: selectedReleaseChannelSlug(selectedChannel) }),
|
||||
};
|
||||
const token = storage?.getItem("token");
|
||||
if (token) {
|
||||
@@ -112,10 +104,9 @@ const buildRuntimeHeaders = () => {
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
|
||||
const selectedChannel = readSelectedReleaseChannel();
|
||||
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl);
|
||||
const runtimeBaseUrl = selectedChannelApiBaseUrl || resolveRuntimeBaseUrl(apiBaseUrl);
|
||||
export const runtimeApiUrl = (apiBaseUrl = RELEASE_MANAGER_CONTROL_API_URL || API_URL, options = {}) => {
|
||||
const selectedChannel = selectedReleaseChannelSlug(options?.selectedChannel);
|
||||
const runtimeBaseUrl = resolveRuntimeBaseUrl(apiBaseUrl || API_URL);
|
||||
const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
|
||||
if (selectedChannel) {
|
||||
url.searchParams.set("release_channel", selectedChannel);
|
||||
@@ -140,16 +131,16 @@ const parseJsonResponse = async (response, label) => {
|
||||
|
||||
export const fetchReleaseRuntime = async ({
|
||||
fetchFn = globalThis.fetch,
|
||||
apiBaseUrl = API_URL,
|
||||
gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL,
|
||||
apiBaseUrl = RELEASE_MANAGER_CONTROL_API_URL || API_URL,
|
||||
selectedChannel,
|
||||
} = {}) => {
|
||||
if (typeof fetchFn !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, gatewayBaseUrl), {
|
||||
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, { selectedChannel }), {
|
||||
method: "GET",
|
||||
headers: buildRuntimeHeaders(),
|
||||
headers: buildRuntimeHeaders(selectedChannel),
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const objectOrEmpty = (value) => (isPlainObject(value) ? value : {});
|
||||
|
||||
const arrayOrEmpty = (value) => (Array.isArray(value) ? value : []);
|
||||
|
||||
const normalizeEconomicCustomer = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.find(isPlainObject) || null;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const normalizeSessionPayload = (payload = {}) => {
|
||||
const data = objectOrEmpty(payload);
|
||||
const phone = objectOrEmpty(data.phone);
|
||||
const notifications = objectOrEmpty(data.notifications);
|
||||
const runtimeConfig = objectOrEmpty(data.runtime_config);
|
||||
const economicConfig = objectOrEmpty(runtimeConfig.economic);
|
||||
|
||||
return {
|
||||
id: data.id ?? null,
|
||||
customer_number: data.customer_number ?? null,
|
||||
group_id: data.group_id ?? null,
|
||||
email: data.email ?? null,
|
||||
phone: {
|
||||
number: phone.number ?? null,
|
||||
country_code: phone.country_code ?? null,
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: notifications.wash_certificate_email ?? null,
|
||||
email_notifications_enabled: notifications.email_notifications_enabled ?? null,
|
||||
sms_notifications_enabled: notifications.sms_notifications_enabled ?? null,
|
||||
},
|
||||
created_at: data.created_at ?? null,
|
||||
updated_at: data.updated_at ?? null,
|
||||
display_name: data.display_name ?? null,
|
||||
permissions: arrayOrEmpty(data.permissions),
|
||||
economic_customer: normalizeEconomicCustomer(data.economic_customer),
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: economicConfig.transaction_draft_customer_number ?? null,
|
||||
default_distribution_department_id: economicConfig.default_distribution_department_id ?? null,
|
||||
},
|
||||
release: objectOrEmpty(runtimeConfig.release),
|
||||
},
|
||||
};
|
||||
};
|
||||
+212
-33
@@ -442,16 +442,28 @@ const selectedServiceSet = computed(
|
||||
() => serviceSets.value.find((set) => Number(set.id) === Number(bundleForm.source_service_set_id)) || null
|
||||
);
|
||||
const isIsolatedStackMode = computed(() => bundleForm.dataset_mode === "isolated_stack");
|
||||
const frontendBundleSourceTarget = computed(
|
||||
() => selectedServiceSet.value?.targets?.frontend || targets.value.find((target) => target.app === "frontend") || null
|
||||
const selectedBundleChannelSlug = computed(() => releaseChannelSlug(selectedBundleChannel()));
|
||||
const isBetaBundleChannel = computed(() => selectedBundleChannelSlug.value === "beta");
|
||||
const betaProductionDataSourceServiceSet = computed(() => productionDataSourceServiceSet());
|
||||
const frontendBundleSourceTarget = computed(() =>
|
||||
isBetaBundleChannel.value
|
||||
? targetForBundleChannelApp("frontend") || targets.value.find((target) => target.app === "frontend") || null
|
||||
: selectedServiceSet.value?.targets?.frontend || targets.value.find((target) => target.app === "frontend") || null
|
||||
);
|
||||
const apiBundleSourceTarget = computed(
|
||||
() => selectedServiceSet.value?.targets?.api || targets.value.find((target) => target.app === "api") || null
|
||||
const apiBundleSourceTarget = computed(() =>
|
||||
isBetaBundleChannel.value
|
||||
? targetForBundleChannelApp("api") || targets.value.find((target) => target.app === "api") || null
|
||||
: selectedServiceSet.value?.targets?.api || targets.value.find((target) => target.app === "api") || null
|
||||
);
|
||||
const frontendBundleTarget = computed(() => (isIsolatedStackMode.value ? null : frontendBundleSourceTarget.value));
|
||||
const apiBundleTarget = computed(() => (isIsolatedStackMode.value ? null : apiBundleSourceTarget.value));
|
||||
const datasetModeMessage = computed(() =>
|
||||
isIsolatedStackMode.value
|
||||
isBetaBundleChannel.value
|
||||
? trFallback(
|
||||
"bundles.beta_production_data_message",
|
||||
"Beta is locked to production-shared data. Frontend and API targets stay beta-specific."
|
||||
)
|
||||
: isIsolatedStackMode.value
|
||||
? trFallback(
|
||||
"bundles.isolated_stack_message",
|
||||
"Creates new Coolify frontend, API, database, Redis, and MinIO services without attaching production data."
|
||||
@@ -461,17 +473,27 @@ const datasetModeMessage = computed(() =>
|
||||
"Reuse services, clone them through replicas, or start isolated empty data services."
|
||||
)
|
||||
);
|
||||
const datasetModeOptions = computed(() => [
|
||||
const datasetModeOptions = computed(() => {
|
||||
const betaLocked = isBetaBundleChannel.value;
|
||||
return [
|
||||
{
|
||||
value: "attach_existing",
|
||||
icon: "recycle",
|
||||
title: trFallback("bundles.modes.attach_existing", "Reuse existing services"),
|
||||
description: trFallback(
|
||||
title: betaLocked
|
||||
? trFallback("bundles.modes.beta_production_shared", "Use production data")
|
||||
: trFallback("bundles.modes.attach_existing", "Reuse existing services"),
|
||||
description: betaLocked
|
||||
? trFallback(
|
||||
"bundles.modes.beta_production_shared_description",
|
||||
"Create a beta service set with beta frontend/API targets and production_shared MariaDB, Redis, and MinIO."
|
||||
)
|
||||
: trFallback(
|
||||
"bundles.modes.attach_existing_description",
|
||||
"Attach the release to the selected existing frontend, API, MariaDB, Redis, and MinIO services."
|
||||
),
|
||||
tag: trFallback("bundles.preview.production_data_attached", "production data attached"),
|
||||
tag: "production_shared",
|
||||
type: "is-warning is-light",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
value: "clone_existing",
|
||||
@@ -481,8 +503,9 @@ const datasetModeOptions = computed(() => [
|
||||
"bundles.modes.clone_existing_description",
|
||||
"Create a new service set that plans replica clones from the selected source data services."
|
||||
),
|
||||
tag: trFallback("bundles.preview.clone_plan", "clone plan"),
|
||||
type: "is-info is-light",
|
||||
tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.clone_plan", "clone plan"),
|
||||
type: betaLocked ? "is-light" : "is-info is-light",
|
||||
disabled: betaLocked,
|
||||
},
|
||||
{
|
||||
value: "fresh_empty",
|
||||
@@ -492,8 +515,9 @@ const datasetModeOptions = computed(() => [
|
||||
"bundles.modes.fresh_empty_description",
|
||||
"Create a new service set with empty or unattached MariaDB, Redis, and MinIO services."
|
||||
),
|
||||
tag: trFallback("bundles.preview.no_production_data", "no production data"),
|
||||
type: "is-success is-light",
|
||||
tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.no_production_data", "no production data"),
|
||||
type: betaLocked ? "is-light" : "is-success is-light",
|
||||
disabled: betaLocked,
|
||||
},
|
||||
{
|
||||
value: "isolated_stack",
|
||||
@@ -503,23 +527,36 @@ const datasetModeOptions = computed(() => [
|
||||
"bundles.modes.isolated_stack_description",
|
||||
"Create new Coolify frontend/API services plus isolated MariaDB, Redis, and MinIO services."
|
||||
),
|
||||
tag: trFallback("bundles.preview.new_coolify_services", "new Coolify services"),
|
||||
type: "is-success is-light",
|
||||
tag: betaLocked ? trFallback("bundles.modes.beta_locked", "locked for beta") : trFallback("bundles.preview.new_coolify_services", "new Coolify services"),
|
||||
type: betaLocked ? "is-light" : "is-success is-light",
|
||||
disabled: betaLocked,
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
const selectedDatasetModeOption = computed(
|
||||
() => datasetModeOptions.value.find((option) => option.value === bundleForm.dataset_mode) || datasetModeOptions.value[0]
|
||||
);
|
||||
const bundleApplySteps = computed(() => {
|
||||
const mode = bundleForm.dataset_mode;
|
||||
const sourceName = selectedServiceSet.value?.name || trFallback("bundles.use_current_targets", "current integration targets");
|
||||
const betaDataOnly = isBetaBundleChannel.value;
|
||||
const sourceName =
|
||||
(betaDataOnly ? betaProductionDataSourceServiceSet.value?.name : selectedServiceSet.value?.name) ||
|
||||
trFallback("bundles.use_current_targets", "current integration targets");
|
||||
const serviceSetName =
|
||||
mode === "attach_existing" && selectedServiceSet.value
|
||||
betaDataOnly
|
||||
? bundleForm.service_set_name || "beta-production-data"
|
||||
: mode === "attach_existing" && selectedServiceSet.value
|
||||
? sourceName
|
||||
: bundleForm.service_set_name || `${selectedBundleChannel()?.slug || "release"} service set`;
|
||||
|
||||
const serviceSetStep =
|
||||
mode === "attach_existing"
|
||||
betaDataOnly
|
||||
? trFallback(
|
||||
"bundles.preview.step_beta_production_data_service_set",
|
||||
`Create service set ${serviceSetName} with beta frontend/API targets and production_shared data from ${sourceName}.`,
|
||||
{ name: serviceSetName, source: sourceName }
|
||||
)
|
||||
: mode === "attach_existing"
|
||||
? trFallback(
|
||||
"bundles.preview.step_attach_service_set",
|
||||
`Attach the release bundle to the selected service set: ${sourceName}.`,
|
||||
@@ -571,6 +608,15 @@ const bundleApplyPreviewRows = computed(() => [
|
||||
]);
|
||||
const bundleApplyWarnings = computed(() => {
|
||||
const warnings = [];
|
||||
if (isBetaBundleChannel.value) {
|
||||
warnings.push({
|
||||
type: "is-danger is-light",
|
||||
text: trFallback(
|
||||
"bundles.preview.warning_beta_production_data",
|
||||
"Beta uses production data: MariaDB, Redis, and MinIO resolve to production_shared while frontend/API remain beta targets."
|
||||
),
|
||||
});
|
||||
}
|
||||
if (bundleForm.dataset_mode === "attach_existing") {
|
||||
warnings.push({
|
||||
type: "is-warning",
|
||||
@@ -632,16 +678,22 @@ const bundleReviewStack = computed(() => [
|
||||
...["database", "redis", "minio"].map((kind) => {
|
||||
const service = isIsolatedStackMode.value
|
||||
? null
|
||||
: isBetaBundleChannel.value
|
||||
? serviceSetDataService(kind)
|
||||
: selectedServiceSet.value?.data_services?.[kind] || selectedServiceSet.value?.stack?.[kind] || null;
|
||||
return {
|
||||
key: kind,
|
||||
label: trFallback(`values.stack.${kind}`, kind.charAt(0).toUpperCase() + kind.slice(1)),
|
||||
value: isIsolatedStackMode.value
|
||||
? trFallback("bundles.new_empty_data_service", "New empty service")
|
||||
: isBetaBundleChannel.value
|
||||
? "production_shared"
|
||||
: service?.label || service?.resource_name || trFallback("bundles.unassigned_data_service", "Not assigned yet"),
|
||||
detail: service?.replication?.label || service?.replication?.host || service?.availability_state || "",
|
||||
status: isIsolatedStackMode.value
|
||||
? trFallback("bundles.production_data_not_attached", "production data not attached")
|
||||
: isBetaBundleChannel.value
|
||||
? trFallback("bundles.preview.beta_uses_production_data", "Beta uses production data")
|
||||
: service?.replication?.status ||
|
||||
service?.deployment_status ||
|
||||
trFallback("bundles.needs_configuration", "needs configuration"),
|
||||
@@ -2013,32 +2065,55 @@ function deploymentPayloadFromTarget(target) {
|
||||
|
||||
function applyBundleDefaults() {
|
||||
if (!isIsolatedStackMode.value && !bundleForm.source_service_set_id && serviceSets.value[0]) {
|
||||
bundleForm.source_service_set_id = serviceSets.value[0].id;
|
||||
bundleForm.source_service_set_id = isBetaBundleChannel.value
|
||||
? betaProductionDataSourceServiceSet.value?.id || serviceSets.value[0].id
|
||||
: serviceSets.value[0].id;
|
||||
}
|
||||
const frontendDefaultTarget = isBetaBundleChannel.value
|
||||
? targetForBundleChannelApp("frontend") || selectedServiceSet.value?.targets?.frontend
|
||||
: selectedServiceSet.value?.targets?.frontend;
|
||||
const apiDefaultTarget = isBetaBundleChannel.value
|
||||
? targetForBundleChannelApp("api") || selectedServiceSet.value?.targets?.api
|
||||
: selectedServiceSet.value?.targets?.api;
|
||||
if (!bundleForm.channel_id) {
|
||||
bundleForm.channel_id = selectedServiceSet.value?.channel_id || defaultChannel.value?.id || null;
|
||||
}
|
||||
if (!bundleForm.frontend_repository) {
|
||||
bundleForm.frontend_repository =
|
||||
selectedServiceSet.value?.targets?.frontend?.repository ||
|
||||
frontendDefaultTarget?.repository ||
|
||||
targets.value.find((target) => target.app === "frontend")?.repository ||
|
||||
firstAvailable(repositorySuggestions.value.filter((repository) => /front|vue|web/i.test(repository)));
|
||||
}
|
||||
if (!bundleForm.api_repository) {
|
||||
bundleForm.api_repository =
|
||||
selectedServiceSet.value?.targets?.api?.repository ||
|
||||
apiDefaultTarget?.repository ||
|
||||
targets.value.find((target) => target.app === "api")?.repository ||
|
||||
firstAvailable(repositorySuggestions.value.filter((repository) => /api|backend|php/i.test(repository)));
|
||||
}
|
||||
if (!bundleForm.frontend_branch) {
|
||||
bundleForm.frontend_branch = selectedServiceSet.value?.targets?.frontend?.branch || DEFAULT_RELEASE_BRANCH;
|
||||
bundleForm.frontend_branch = frontendDefaultTarget?.branch || DEFAULT_RELEASE_BRANCH;
|
||||
}
|
||||
if (!bundleForm.api_branch) {
|
||||
bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || DEFAULT_RELEASE_BRANCH;
|
||||
bundleForm.api_branch = apiDefaultTarget?.branch || DEFAULT_RELEASE_BRANCH;
|
||||
}
|
||||
applyBundleVersionLabelDefault();
|
||||
}
|
||||
|
||||
function applyBetaBundleCodeTargetDefaults() {
|
||||
if (!isBetaBundleChannel.value) {
|
||||
return;
|
||||
}
|
||||
for (const app of ["frontend", "api"]) {
|
||||
const target = targetForBundleChannelApp(app);
|
||||
if (target?.repository) {
|
||||
bundleForm[`${app}_repository`] = target.repository;
|
||||
}
|
||||
if (target?.branch) {
|
||||
bundleForm[`${app}_branch`] = target.branch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bundleVersionLabelForChannel(channelId = bundleForm.channel_id) {
|
||||
const channelSlug =
|
||||
channels.value.find((channel) => Number(channel.id) === Number(channelId))?.slug ||
|
||||
@@ -2333,6 +2408,9 @@ function serviceSetStackItem(set, item) {
|
||||
}
|
||||
|
||||
function selectDatasetMode(mode) {
|
||||
if (isBetaBundleChannel.value && mode !== "attach_existing") {
|
||||
return;
|
||||
}
|
||||
bundleForm.dataset_mode = mode;
|
||||
}
|
||||
|
||||
@@ -2468,7 +2546,7 @@ function targetLocation(target) {
|
||||
}
|
||||
|
||||
function serviceSetDataService(kind) {
|
||||
const set = selectedServiceSet.value;
|
||||
const set = isBetaBundleChannel.value ? betaProductionDataSourceServiceSet.value : selectedServiceSet.value;
|
||||
return set?.data_services?.[kind] || set?.stack?.[kind] || null;
|
||||
}
|
||||
|
||||
@@ -2564,6 +2642,20 @@ function previewDataRow(kind) {
|
||||
const endpoint = dataServiceEndpoint(service);
|
||||
|
||||
if (bundleForm.dataset_mode === "attach_existing") {
|
||||
if (isBetaBundleChannel.value) {
|
||||
return {
|
||||
key: kind,
|
||||
label,
|
||||
action: trFallback("bundles.preview.action_beta_production_data", "Use production_shared data"),
|
||||
type: "is-danger is-light",
|
||||
source: serviceName,
|
||||
target: "production_shared",
|
||||
endpoint: endpoint || "production_shared",
|
||||
endpointPending: false,
|
||||
location: dataServiceLocation(service) || trFallback("bundles.preview.production_shared_location", "Stable/Master production data services"),
|
||||
dataPolicy: trFallback("bundles.preview.beta_uses_production_data", "Beta uses production data"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: kind,
|
||||
label,
|
||||
@@ -2732,6 +2824,38 @@ function selectedBundleChannel() {
|
||||
);
|
||||
}
|
||||
|
||||
function releaseChannelSlug(entry) {
|
||||
return String(entry?.slug || entry?.channel_slug || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isProductionDataSourceChannel(entry) {
|
||||
return ["stable", "master", "production", "prod"].includes(releaseChannelSlug(entry));
|
||||
}
|
||||
|
||||
function productionDataSourceServiceSet() {
|
||||
if (selectedServiceSet.value && isProductionDataSourceChannel(selectedServiceSet.value)) {
|
||||
return selectedServiceSet.value;
|
||||
}
|
||||
return (
|
||||
serviceSets.value.find((set) => isProductionDataSourceChannel(set) && set.active) ||
|
||||
serviceSets.value.find((set) => isProductionDataSourceChannel(set) && ["ready", "active"].includes(String(set.status || "").toLowerCase())) ||
|
||||
serviceSets.value.find((set) => isProductionDataSourceChannel(set)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function targetForBundleChannelApp(app) {
|
||||
const channelId = Number(bundleForm.channel_id || selectedBundleChannel()?.id || 0);
|
||||
const channelSlug = releaseChannelSlug(selectedBundleChannel());
|
||||
return (
|
||||
targets.value.find((target) => target.app === app && channelId && Number(target.channel_id) === channelId) ||
|
||||
targets.value.find((target) => target.app === app && channelSlug && releaseChannelSlug(target) === channelSlug) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function bundleChannelId() {
|
||||
return bundleForm.channel_id || selectedServiceSet.value?.channel_id || selectedBundleChannel()?.id || null;
|
||||
}
|
||||
@@ -3008,7 +3132,8 @@ function nextReleaseFlowStep() {
|
||||
|
||||
async function serviceSetIdForBundle() {
|
||||
const sourceId = isIsolatedStackMode.value ? null : Number(bundleForm.source_service_set_id || 0) || null;
|
||||
if (bundleForm.dataset_mode === "attach_existing" && sourceId) {
|
||||
const betaDataOnly = isBetaBundleChannel.value;
|
||||
if (bundleForm.dataset_mode === "attach_existing" && sourceId && !betaDataOnly) {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@@ -3032,23 +3157,36 @@ async function serviceSetIdForBundle() {
|
||||
}
|
||||
: null;
|
||||
const serviceSetPayload = {
|
||||
mode: bundleForm.dataset_mode,
|
||||
source_service_set_id: sourceId,
|
||||
mode: betaDataOnly ? "attach_existing" : bundleForm.dataset_mode,
|
||||
source_service_set_id: betaDataOnly ? null : sourceId,
|
||||
data_source_service_set_id: betaDataOnly
|
||||
? Number(betaProductionDataSourceServiceSet.value?.id || sourceId || 0) || null
|
||||
: null,
|
||||
channel_id: bundleChannelId(),
|
||||
name:
|
||||
bundleForm.service_set_name ||
|
||||
`${selectedBundleChannel()?.slug || "release"} ${
|
||||
(betaDataOnly
|
||||
? "beta-production-data"
|
||||
: `${selectedBundleChannel()?.slug || "release"} ${
|
||||
bundleForm.dataset_mode === "isolated_stack"
|
||||
? "isolated stack"
|
||||
: bundleForm.dataset_mode === "fresh_empty"
|
||||
? "fresh data"
|
||||
: "service set"
|
||||
}`,
|
||||
}`),
|
||||
frontend_target_id: isolatedTargets?.frontend?.id || frontendBundleTarget.value?.id || null,
|
||||
api_target_id: isolatedTargets?.api?.id || apiBundleTarget.value?.id || null,
|
||||
create_data_targets: isIsolatedStackMode.value,
|
||||
deploy_data_targets: isIsolatedStackMode.value,
|
||||
metadata: isIsolatedStackMode.value
|
||||
metadata: betaDataOnly
|
||||
? {
|
||||
data_policy: "production_shared",
|
||||
data_service_mode: "production_shared",
|
||||
data_source_channel_slug: releaseChannelSlug(betaProductionDataSourceServiceSet.value) || "stable",
|
||||
production_data_attached: true,
|
||||
production_code_targets_attached: false,
|
||||
}
|
||||
: isIsolatedStackMode.value
|
||||
? {
|
||||
isolated_stack: true,
|
||||
production_data_attached: false,
|
||||
@@ -4054,6 +4192,13 @@ watch(
|
||||
watch(
|
||||
() => bundleForm.source_service_set_id,
|
||||
() => {
|
||||
if (isBetaBundleChannel.value && selectedServiceSet.value && !isProductionDataSourceChannel(selectedServiceSet.value)) {
|
||||
const dataSource = betaProductionDataSourceServiceSet.value;
|
||||
if (dataSource?.id) {
|
||||
bundleForm.source_service_set_id = dataSource.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyBundleDefaults();
|
||||
}
|
||||
);
|
||||
@@ -4061,6 +4206,18 @@ watch(
|
||||
watch(
|
||||
() => bundleForm.channel_id,
|
||||
() => {
|
||||
if (isBetaBundleChannel.value) {
|
||||
bundleForm.dataset_mode = "attach_existing";
|
||||
const dataSource = betaProductionDataSourceServiceSet.value;
|
||||
if (dataSource?.id && (!selectedServiceSet.value || !isProductionDataSourceChannel(selectedServiceSet.value))) {
|
||||
bundleForm.source_service_set_id = dataSource.id;
|
||||
}
|
||||
if (!bundleForm.service_set_name) {
|
||||
bundleForm.service_set_name = "beta-production-data";
|
||||
}
|
||||
applyBetaBundleCodeTargetDefaults();
|
||||
applyBundleDefaults();
|
||||
}
|
||||
applyBundleVersionLabelDefault();
|
||||
}
|
||||
);
|
||||
@@ -4068,6 +4225,10 @@ watch(
|
||||
watch(
|
||||
() => bundleForm.dataset_mode,
|
||||
(mode) => {
|
||||
if (isBetaBundleChannel.value && mode !== "attach_existing") {
|
||||
bundleForm.dataset_mode = "attach_existing";
|
||||
return;
|
||||
}
|
||||
if (mode === "isolated_stack") {
|
||||
bundleForm.source_service_set_id = null;
|
||||
}
|
||||
@@ -5428,8 +5589,9 @@ onMounted(async () => {
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="release-dataset-mode-card"
|
||||
:class="{ 'is-selected': bundleForm.dataset_mode === option.value }"
|
||||
:class="{ 'is-selected': bundleForm.dataset_mode === option.value, 'is-disabled': option.disabled }"
|
||||
:data-testid="`release-dataset-mode-${option.value}`"
|
||||
:disabled="option.disabled"
|
||||
@click="selectDatasetMode(option.value)"
|
||||
>
|
||||
<span class="release-dataset-mode-card__icon">
|
||||
@@ -5448,7 +5610,12 @@ onMounted(async () => {
|
||||
data-testid="release-bundle-dataset-mode"
|
||||
:aria-label="trFallback('bundles.dataset_mode', 'Dataset mode')"
|
||||
>
|
||||
<option v-for="option in datasetModeOptions" :key="`native-${option.value}`" :value="option.value">
|
||||
<option
|
||||
v-for="option in datasetModeOptions"
|
||||
:key="`native-${option.value}`"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.title }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -8462,6 +8629,18 @@ onMounted(async () => {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.release-dataset-mode-card:disabled,
|
||||
.release-dataset-mode-card.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.release-dataset-mode-card:disabled:hover,
|
||||
.release-dataset-mode-card.is-disabled:hover {
|
||||
background: #ffffff;
|
||||
border-color: #d8dee8;
|
||||
}
|
||||
|
||||
.release-dataset-mode-card.is-selected {
|
||||
border-color: #3273dc;
|
||||
box-shadow: 0 0 0 1px rgba(50, 115, 220, 0.28);
|
||||
|
||||
@@ -49,9 +49,10 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("release_channel_selected_slug", "canary");
|
||||
window.localStorage.setItem("release_source_override", "deployment");
|
||||
});
|
||||
|
||||
await page.route("https://api-v2.truckwash.io/canary/api/release/runtime**", async (route) => {
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
runtimeRequests.push(route.request().url());
|
||||
await route.fulfill(json({ data: runtime }));
|
||||
});
|
||||
@@ -95,7 +96,11 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
|
||||
const currentOrigin = await page.evaluate(() => window.location.origin);
|
||||
expect(page.url()).toContain("/shared/passkey-safe-link");
|
||||
expect(page.url()).not.toContain("api-v2.truckwash.io");
|
||||
expect(runtimeRequests).toEqual(["https://api-v2.truckwash.io/canary/api/release/runtime?release_channel=canary"]);
|
||||
expect(runtimeRequests).toHaveLength(1);
|
||||
const runtimeRequestUrl = new URL(runtimeRequests[0]);
|
||||
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(runtimeRequestUrl.pathname);
|
||||
expect(runtimeRequestUrl.pathname).not.toBe("/canary/api/release/runtime");
|
||||
expect(runtimeRequestUrl.searchParams.get("release_channel")).toBe("canary");
|
||||
expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]);
|
||||
await expect.poll(() => releaseApiRequests.length).toBe(1);
|
||||
expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
const json = (body, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
@@ -176,6 +178,121 @@ const runtimeWithFailingBetaSwitch = () => ({
|
||||
],
|
||||
});
|
||||
|
||||
const runtimeWithReadyBetaSelected = () => {
|
||||
const stableRuntime = runtimeWithFailingBetaSwitch();
|
||||
const betaChannel = stableRuntime.available_channels.find((option) => option.channel.slug === "beta").channel;
|
||||
return {
|
||||
...stableRuntime,
|
||||
generated_at: "2026-05-19T09:11:00.000Z",
|
||||
trace_id: "trace-beta-channel",
|
||||
channel: betaChannel,
|
||||
versions: {
|
||||
frontend: { version_label: "frontend-beta", commit_sha: "be7afe" },
|
||||
api: { version_label: "api-beta", commit_sha: "ba5eba11" },
|
||||
bundle_id: 32,
|
||||
},
|
||||
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
|
||||
api_base_url: "https://api-v2.truckwash.io/beta/api",
|
||||
urls: {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/beta/frontend",
|
||||
api_base_url: "https://api-v2.truckwash.io/beta/api",
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
missing: [],
|
||||
status: "ready",
|
||||
},
|
||||
available_channels: stableRuntime.available_channels,
|
||||
};
|
||||
};
|
||||
|
||||
const internalMissingFrontendEntryRuntime = () => ({
|
||||
generated_at: "2026-05-20T17:50:00.000Z",
|
||||
trace_id: "trace-internal-missing-frontend-entry",
|
||||
channel: {
|
||||
id: 4,
|
||||
slug: "internal",
|
||||
name: "Intern",
|
||||
description: "Internal staff and superuser validation channel.",
|
||||
enabled: true,
|
||||
default_channel: false,
|
||||
},
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: {
|
||||
version_label: "api-internal",
|
||||
commit_sha: "e40cf6b6ac31",
|
||||
deployed_at: "2026-05-20T17:50:00.000Z",
|
||||
},
|
||||
bundle_id: null,
|
||||
},
|
||||
frontend_base_url: null,
|
||||
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
||||
urls: {
|
||||
frontend_base_url: null,
|
||||
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
explicit: true,
|
||||
missing: ["frontend_entry"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
available_channels: [
|
||||
{
|
||||
channel: {
|
||||
id: 1,
|
||||
slug: "stable",
|
||||
name: "Stable",
|
||||
description: "Standard production channel.",
|
||||
enabled: true,
|
||||
default_channel: true,
|
||||
},
|
||||
versions: {
|
||||
frontend: { version_label: "frontend-stable", commit_sha: "abc123" },
|
||||
api: { version_label: "api-stable", commit_sha: "def456" },
|
||||
bundle_id: 31,
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
missing: [],
|
||||
status: "ready",
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: {
|
||||
id: 4,
|
||||
slug: "internal",
|
||||
name: "Intern",
|
||||
description: "Internal staff and superuser validation channel.",
|
||||
enabled: true,
|
||||
default_channel: false,
|
||||
},
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: {
|
||||
version_label: "api-internal",
|
||||
commit_sha: "e40cf6b6ac31",
|
||||
deployed_at: "2026-05-20T17:50:00.000Z",
|
||||
},
|
||||
bundle_id: null,
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
explicit: true,
|
||||
missing: ["frontend_entry"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
},
|
||||
],
|
||||
capture_policy: {
|
||||
enabled: false,
|
||||
capture_level: "metadata",
|
||||
all_failure_metadata: true,
|
||||
retention_days: 14,
|
||||
},
|
||||
});
|
||||
|
||||
async function boot(page, runtime = unavailableRuntime, locale = "en") {
|
||||
await page.addInitScript((selectedLocale) => {
|
||||
window.localStorage.setItem("locale", selectedLocale);
|
||||
@@ -313,6 +430,84 @@ test("selected channel auth session 404 shows the release channel guard", async
|
||||
expect(channelApiRequests.every((url) => url.startsWith("https://api-v2.truckwash.io/internal/api/"))).toBe(true);
|
||||
});
|
||||
|
||||
test("login assigned to an unconfigured internal channel shows guard actions without the user-data modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = internalMissingFrontendEntryRuntime();
|
||||
let releaseRuntimeResponse = runtimeWithFailingBetaSwitch();
|
||||
const sessionRequests = [];
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
window.localStorage.removeItem("token");
|
||||
window.localStorage.removeItem("is_subuser");
|
||||
window.localStorage.removeItem("selected_customer_number");
|
||||
window.localStorage.removeItem("release_channel_selected_slug");
|
||||
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
|
||||
window.localStorage.setItem("release_source_override", "deployment");
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
loginToken: "release-login-token",
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
phone: undefined,
|
||||
notifications: undefined,
|
||||
permissions: undefined,
|
||||
economic_customer: undefined,
|
||||
runtime_config: {
|
||||
release: runtime,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
await route.fulfill(json({ data: releaseRuntimeResponse }));
|
||||
});
|
||||
await page.route("https://api-v2.truckwash.io/internal/api/**", async (route) => {
|
||||
await route.fulfill(json({ data: [] }));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "GET" && request.url().includes("/auth/session")) {
|
||||
sessionRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/login?redirect=%2Fuser", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("login-customer-number").fill("12345");
|
||||
await page.getByTestId("login-password").fill("correct horse battery staple");
|
||||
releaseRuntimeResponse = runtime;
|
||||
await page.getByTestId("login-submit").click();
|
||||
|
||||
const guard = page.getByTestId("release-channel-unavailable-page");
|
||||
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
|
||||
await expect(page).toHaveURL(/\/user/);
|
||||
await expect(guard).toContainText("Release channel is not ready");
|
||||
await expect(guard).toContainText("Intern");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend entry");
|
||||
await expect(page.getByTestId("release-channel-option-stable")).toBeVisible();
|
||||
await expect(page.getByTestId("release-channel-ignore")).toBeVisible();
|
||||
await expect(page.getByTestId("release-channel-check-again")).toBeVisible();
|
||||
await expect(guard.getByRole("button", { name: /log\s*out|logout/i })).toBeVisible();
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("release-channel-ignore").click();
|
||||
await expect(guard).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/user/);
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until"));
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(guard).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
|
||||
const sessionRequestCountBeforeLogout = sessionRequests.length;
|
||||
await guard.getByRole("button", { name: /log\s*out|logout/i }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("token"))).toBeNull();
|
||||
await page.waitForTimeout(500);
|
||||
expect(sessionRequests.length).toBe(sessionRequestCountBeforeLogout);
|
||||
});
|
||||
|
||||
test("release channel choices show git commit and release time when available", async ({ page }) => {
|
||||
const runtime = runtimeWithSelectableReleaseDetails();
|
||||
await boot(page, runtime);
|
||||
@@ -340,6 +535,47 @@ test("release channel choices show git commit and release time when available",
|
||||
);
|
||||
});
|
||||
|
||||
test("ready sidebar release channel switches are confirmed through the control runtime", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
|
||||
|
||||
const stableRuntime = runtimeWithFailingBetaSwitch();
|
||||
const betaRuntime = runtimeWithReadyBetaSelected();
|
||||
await boot(page, stableRuntime);
|
||||
const runtimeRequests = [];
|
||||
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const selectedChannel = url.searchParams.get("release_channel") || "";
|
||||
runtimeRequests.push(route.request().url());
|
||||
await route.fulfill(json({ data: selectedChannel === "beta" ? betaRuntime : stableRuntime }));
|
||||
});
|
||||
|
||||
await page.goto("/user", { waitUntil: "domcontentloaded" });
|
||||
const stable = page.getByTestId("release-channel-option-stable");
|
||||
const beta = page.getByTestId("release-channel-option-beta");
|
||||
|
||||
await expect(stable).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
|
||||
await expect(stable).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(beta).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await beta.click();
|
||||
await expect(page.getByTestId("release-channel-switched-page")).toContainText("You are now on Beta");
|
||||
await expect(page.locator(".release-channel-sidebar-selector__error")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug")))
|
||||
.toBe("beta");
|
||||
await page.getByTestId("release-channel-switched-continue").click();
|
||||
|
||||
await expect(beta).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(stable).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
const betaRuntimeRequest = runtimeRequests.find((url) => new URL(url).searchParams.get("release_channel") === "beta");
|
||||
expect(betaRuntimeRequest).toBeTruthy();
|
||||
const betaRuntimeRequestUrl = new URL(betaRuntimeRequest);
|
||||
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(betaRuntimeRequestUrl.pathname);
|
||||
expect(betaRuntimeRequestUrl.pathname).not.toBe("/beta/api/release/runtime");
|
||||
});
|
||||
|
||||
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
|
||||
|
||||
|
||||
@@ -1425,20 +1425,29 @@ async function installReleaseMocks(page, state) {
|
||||
if (pathname.endsWith("/superuser/releases/service-sets") && method === "POST") {
|
||||
const payload = request.postDataJSON?.() || {};
|
||||
const source = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.source_service_set_id));
|
||||
const dataSource = state.serviceSets.find(
|
||||
(entry) => Number(entry.id) === Number(payload.data_source_service_set_id)
|
||||
);
|
||||
const mode = payload.mode || "attach_existing";
|
||||
const serviceSet = {
|
||||
id: state.nextServiceSetId++,
|
||||
channel_id: payload.channel_id || source?.channel_id || 2,
|
||||
channel_slug: channelSlug(state, payload.channel_id || source?.channel_id || 2),
|
||||
name: payload.name || "Fresh release stack",
|
||||
slug: (payload.name || "fresh-release-stack").toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
mode: payload.mode || "attach_existing",
|
||||
mode,
|
||||
data_policy: payload.metadata?.data_policy || (mode === "attach_existing" ? "production_shared" : mode),
|
||||
data_service_mode:
|
||||
payload.metadata?.data_service_mode || (mode === "attach_existing" ? "production_shared" : mode),
|
||||
source_service_set_id: payload.source_service_set_id || null,
|
||||
data_source_service_set_id: payload.data_source_service_set_id || null,
|
||||
data_source_channel_slug: payload.metadata?.data_source_channel_slug || dataSource?.channel_slug || null,
|
||||
status:
|
||||
payload.mode === "clone_existing"
|
||||
mode === "clone_existing"
|
||||
? "provisioning"
|
||||
: payload.mode === "fresh_empty"
|
||||
: mode === "fresh_empty"
|
||||
? "isolated_empty"
|
||||
: payload.mode === "isolated_stack"
|
||||
: mode === "isolated_stack"
|
||||
? "isolated_stack"
|
||||
: "ready",
|
||||
targets: {
|
||||
@@ -1451,16 +1460,18 @@ async function installReleaseMocks(page, state) {
|
||||
source?.targets?.api ||
|
||||
null,
|
||||
},
|
||||
data_services: ["fresh_empty", "isolated_stack"].includes(payload.mode)
|
||||
data_services: ["fresh_empty", "isolated_stack"].includes(mode)
|
||||
? { database: null, redis: null, minio: null }
|
||||
: source?.data_services || {
|
||||
: dataSource?.data_services ||
|
||||
source?.data_services || {
|
||||
database: null,
|
||||
redis: null,
|
||||
minio: null,
|
||||
},
|
||||
metadata: payload.metadata || {},
|
||||
attached_bundles: [],
|
||||
};
|
||||
if (payload.mode === "isolated_stack" && payload.create_data_targets !== false) {
|
||||
if (mode === "isolated_stack" && payload.create_data_targets !== false) {
|
||||
completeIsolatedDataServices(state, serviceSet);
|
||||
}
|
||||
state.serviceSets.unshift(serviceSet);
|
||||
@@ -2294,6 +2305,173 @@ test("superusers manage release settings, assignments, integrations, and sync op
|
||||
await expect(page.getByTestId("release-operation-grid-row").first()).toContainText("canary");
|
||||
});
|
||||
|
||||
test("beta bundle flow locks to production data sharing @beta-production-data", async ({ page }) => {
|
||||
const state = createReleaseState();
|
||||
state.nextTargetId = 40;
|
||||
state.nextServiceSetId = 60;
|
||||
const stableFrontend = {
|
||||
id: 20,
|
||||
channel_id: 1,
|
||||
channel_slug: "stable",
|
||||
app: "frontend",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: DEFAULT_RELEASE_BRANCH,
|
||||
coolify_instance_id: 3,
|
||||
coolify_instance_label: "Production Coolify",
|
||||
coolify_service_uuid: "frontend-stable-service",
|
||||
health_url: "https://app.example.test/health",
|
||||
auto_deploy: true,
|
||||
deploy_context: {
|
||||
coolify_public_url: "https://app.example.test",
|
||||
coolify_project_uuid: "project-main",
|
||||
coolify_environment_name: "production",
|
||||
},
|
||||
};
|
||||
const stableApi = {
|
||||
id: 21,
|
||||
channel_id: 1,
|
||||
channel_slug: "stable",
|
||||
app: "api",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: DEFAULT_RELEASE_BRANCH,
|
||||
coolify_instance_id: 3,
|
||||
coolify_instance_label: "Production Coolify",
|
||||
coolify_service_uuid: "api-stable-service",
|
||||
health_url: "https://api.example.test/ping",
|
||||
auto_deploy: true,
|
||||
deploy_context: {
|
||||
coolify_public_url: "https://api.example.test",
|
||||
coolify_project_uuid: "project-main",
|
||||
coolify_environment_name: "production",
|
||||
},
|
||||
};
|
||||
const betaFrontend = {
|
||||
id: 30,
|
||||
channel_id: 3,
|
||||
channel_slug: "beta",
|
||||
app: "frontend",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: "release/beta",
|
||||
coolify_instance_id: 3,
|
||||
coolify_instance_label: "Production Coolify",
|
||||
coolify_service_uuid: "frontend-beta-service",
|
||||
health_url: "https://beta.example.test/health",
|
||||
auto_deploy: true,
|
||||
deploy_context: {
|
||||
coolify_public_url: "https://beta.example.test",
|
||||
coolify_project_uuid: "project-main",
|
||||
coolify_environment_name: "production",
|
||||
},
|
||||
};
|
||||
const betaApi = {
|
||||
id: 31,
|
||||
channel_id: 3,
|
||||
channel_slug: "beta",
|
||||
app: "api",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "release/beta",
|
||||
coolify_instance_id: 3,
|
||||
coolify_instance_label: "Production Coolify",
|
||||
coolify_service_uuid: "api-beta-service",
|
||||
health_url: "https://api-beta.example.test/ping",
|
||||
auto_deploy: true,
|
||||
deploy_context: {
|
||||
coolify_public_url: "https://api-beta.example.test",
|
||||
coolify_project_uuid: "project-main",
|
||||
coolify_environment_name: "production",
|
||||
},
|
||||
};
|
||||
const stableSet = {
|
||||
id: 50,
|
||||
channel_id: 1,
|
||||
channel_slug: "stable",
|
||||
name: "Stable production stack",
|
||||
slug: "stable-production-stack",
|
||||
mode: "attach_existing",
|
||||
data_policy: "production_shared",
|
||||
data_service_mode: "production_shared",
|
||||
status: "ready",
|
||||
active: true,
|
||||
targets: {
|
||||
frontend: stableFrontend,
|
||||
api: stableApi,
|
||||
},
|
||||
data_services: {
|
||||
database: {
|
||||
id: 70,
|
||||
kind: "database",
|
||||
label: "Production MariaDB",
|
||||
resource_name: "mariadb-production",
|
||||
deployment_status: "running",
|
||||
replication: { label: "prod-mariadb", host: "db.prod.internal", port: 3306, status: "ok" },
|
||||
},
|
||||
redis: {
|
||||
id: 71,
|
||||
kind: "redis",
|
||||
label: "Production Redis",
|
||||
resource_name: "redis-production",
|
||||
deployment_status: "running",
|
||||
replication: { label: "prod-redis", host: "redis.prod.internal", port: 6379, status: "ok" },
|
||||
},
|
||||
minio: {
|
||||
id: 72,
|
||||
kind: "minio",
|
||||
label: "Production MinIO",
|
||||
resource_name: "minio-production",
|
||||
deployment_status: "running",
|
||||
replication: { label: "prod-minio", host: "minio.prod.internal", port: 9000, status: "ok" },
|
||||
},
|
||||
},
|
||||
attached_bundles: [],
|
||||
};
|
||||
state.targets.push(stableFrontend, stableApi, betaFrontend, betaApi);
|
||||
state.serviceSets.unshift(stableSet);
|
||||
|
||||
await boot(page, state);
|
||||
await page.goto("/superuser/configuration/releases/overview?channel=beta&app=all&branch=release/beta", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await selectReleaseTab(page, "Deployments");
|
||||
await expandActiveReleaseCategory(page);
|
||||
|
||||
const bundleFlow = page.getByTestId("release-bundle-flow");
|
||||
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
|
||||
await bundleChannel.selectOption("3");
|
||||
await expect(bundleChannel).toHaveValue("3");
|
||||
await expect(bundleFlow.getByTestId("release-dataset-mode-attach_existing")).not.toBeDisabled();
|
||||
await expect(bundleFlow.getByTestId("release-dataset-mode-clone_existing")).toBeDisabled();
|
||||
await expect(bundleFlow.getByTestId("release-dataset-mode-fresh_empty")).toBeDisabled();
|
||||
await expect(bundleFlow.getByTestId("release-dataset-mode-isolated_stack")).toBeDisabled();
|
||||
await expect(bundleFlow).toContainText("Beta is locked to production-shared data");
|
||||
await expect(bundleFlow).toContainText("Beta uses production data");
|
||||
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toHaveValue("50");
|
||||
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("production_shared");
|
||||
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("api-beta-service");
|
||||
|
||||
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
||||
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
|
||||
await expect(bundleFlow.getByPlaceholder("owner/backend-php", { exact: true })).toHaveValue("truckwash/backend-php");
|
||||
await expect(bundleFlow.getByPlaceholder("master").nth(0)).toHaveValue("release/beta");
|
||||
await expect(bundleFlow.getByPlaceholder("master").nth(1)).toHaveValue("release/beta");
|
||||
|
||||
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
||||
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("production_shared");
|
||||
await page.getByTestId("release-bundle-deploy-submit").click();
|
||||
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
||||
|
||||
const betaSet = state.serviceSets.find((set) => set.name === "beta-production-data");
|
||||
expect(betaSet).toBeTruthy();
|
||||
expect(betaSet.channel_slug).toBe("beta");
|
||||
expect(betaSet.source_service_set_id).toBeNull();
|
||||
expect(betaSet.data_source_service_set_id).toBe(stableSet.id);
|
||||
expect(betaSet.data_policy).toBe("production_shared");
|
||||
expect(betaSet.targets.frontend.id).toBe(betaFrontend.id);
|
||||
expect(betaSet.targets.api.id).toBe(betaApi.id);
|
||||
expect(betaSet.data_services.database.id).toBe(stableSet.data_services.database.id);
|
||||
expect(state.bundlePayloads[0].service_set_id).toBe(betaSet.id);
|
||||
expect(state.bundlePayloads[0].service_set_id).not.toBe(stableSet.id);
|
||||
});
|
||||
|
||||
test("isolated stack mode creates fresh Coolify app and data targets without attaching production data", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -133,9 +133,7 @@ test("@public-live release manifest, shell, and static assets are available", as
|
||||
test("@public-live api-v2 gateway and channel API prefixes serve JSON ping responses", async ({ request }) => {
|
||||
const apiBaseUrl = process.env.PLAYWRIGHT_RELEASE_API_BASE_URL || "https://api-v2.truckwash.io";
|
||||
const apiPingPaths = unique(
|
||||
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/ping,/master/api/ping,/canary/api/ping,/stable/api/ping")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
(process.env.PLAYWRIGHT_RELEASE_API_PING_PATHS || "/master/api/ping").split(",").map((value) => value.trim())
|
||||
);
|
||||
|
||||
for (const apiPingPath of apiPingPaths) {
|
||||
|
||||
@@ -29,15 +29,15 @@ describe("release bootstrap", () => {
|
||||
localStorage.setItem("release_channel_selected_slug", "Canary Preview!");
|
||||
|
||||
expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
|
||||
"https://api-v2.truckwash.io/canary-preview/api/release/runtime?release_channel=canary-preview"
|
||||
"https://api.truckwash.io/release/runtime?release_channel=canary-preview"
|
||||
);
|
||||
});
|
||||
|
||||
it("routes stable runtime requests through the public master API prefix", () => {
|
||||
it("uses the control API for selected stable runtime requests", () => {
|
||||
localStorage.setItem("release_channel_selected_slug", "stable");
|
||||
|
||||
expect(runtimeApiUrl("https://api.truckwash.io")).toBe(
|
||||
"https://api-v2.truckwash.io/master/api/release/runtime?release_channel=stable"
|
||||
"https://api.truckwash.io/release/runtime?release_channel=stable"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -52,16 +52,15 @@ describe("release bootstrap", () => {
|
||||
|
||||
it("attaches release trace, channel, and frontend headers to runtime requests", async () => {
|
||||
localStorage.setItem("release_trace_id", "trace-runtime");
|
||||
localStorage.setItem("release_channel_selected_slug", "Internal");
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { channel: { slug: "internal" } } }),
|
||||
}));
|
||||
|
||||
await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io" });
|
||||
await fetchReleaseRuntime({ fetchFn, apiBaseUrl: "https://api.truckwash.io", selectedChannel: "Internal" });
|
||||
|
||||
const [url, options] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe("https://api-v2.truckwash.io/internal/api/release/runtime?release_channel=internal");
|
||||
expect(url).toBe("https://api.truckwash.io/release/runtime?release_channel=internal");
|
||||
expect(options.headers).toMatchObject({
|
||||
"X-Release-Trace": "trace-runtime",
|
||||
"X-Release-Channel": "internal",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getReleaseChannelUnavailableStatus } from "@/services/releaseChannelAvailability.js";
|
||||
|
||||
describe("internal release channel availability", () => {
|
||||
it("blocks assigned internal channel payloads that are missing a frontend entry", () => {
|
||||
const status = getReleaseChannelUnavailableStatus(
|
||||
{
|
||||
channel: {
|
||||
slug: "internal",
|
||||
name: "Intern",
|
||||
description: "Internal staff and superuser validation channel.",
|
||||
default_channel: false,
|
||||
},
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: { version_label: "api-internal", commit_sha: "e40cf6b6ac31" },
|
||||
bundle_id: null,
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
explicit: true,
|
||||
missing: ["frontend_entry"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
},
|
||||
1_000,
|
||||
0
|
||||
);
|
||||
|
||||
expect(status.shouldBlock).toBe(true);
|
||||
expect(status.channelSlug).toBe("internal");
|
||||
expect(status.channelName).toBe("Intern");
|
||||
expect(status.missing).toEqual(["frontend_entry"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
|
||||
|
||||
describe("session payload normalization", () => {
|
||||
it("defaults optional session fields that old cached payloads can omit", () => {
|
||||
const session = normalizeSessionPayload({
|
||||
id: 42,
|
||||
customer_number: 10042,
|
||||
email: "fleet@example.test",
|
||||
});
|
||||
|
||||
expect(session).toMatchObject({
|
||||
id: 42,
|
||||
customer_number: 10042,
|
||||
email: "fleet@example.test",
|
||||
phone: {
|
||||
number: null,
|
||||
country_code: null,
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: null,
|
||||
sms_notifications_enabled: null,
|
||||
},
|
||||
permissions: [],
|
||||
economic_customer: null,
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: null,
|
||||
default_distribution_department_id: null,
|
||||
},
|
||||
release: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts object-shaped economic customers and release runtime config", () => {
|
||||
const releaseRuntime = {
|
||||
channel: { slug: "internal", name: "Intern", default_channel: false },
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["frontend_entry"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
};
|
||||
|
||||
const session = normalizeSessionPayload({
|
||||
permissions: ["user"],
|
||||
economic_customer: {
|
||||
customerNumber: 12345,
|
||||
name: "Nordic Haul",
|
||||
},
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: "777",
|
||||
default_distribution_department_id: 12,
|
||||
},
|
||||
release: releaseRuntime,
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.permissions).toEqual(["user"]);
|
||||
expect(session.economic_customer).toEqual({
|
||||
customerNumber: 12345,
|
||||
name: "Nordic Haul",
|
||||
});
|
||||
expect(session.runtime_config.economic.transaction_draft_customer_number).toBe("777");
|
||||
expect(session.runtime_config.economic.default_distribution_department_id).toBe(12);
|
||||
expect(session.runtime_config.release).toBe(releaseRuntime);
|
||||
});
|
||||
|
||||
it("uses the first object from legacy economic customer arrays", () => {
|
||||
const session = normalizeSessionPayload({
|
||||
economic_customer: [
|
||||
null,
|
||||
{
|
||||
customerNumber: 12345,
|
||||
name: "Nordic Haul",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(session.economic_customer).toEqual({
|
||||
customerNumber: 12345,
|
||||
name: "Nordic Haul",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user