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:
Jeppe Bundgaard
2026-05-26 17:28:21 +02:00
parent 8ab8915429
commit 184ef5670c
12 changed files with 1038 additions and 226 deletions
+19 -2
View File
@@ -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) {
+142 -110
View File
@@ -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() {
+14 -23
View File
@@ -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",
});
+53
View File
@@ -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),
},
};
};
@@ -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,65 +473,90 @@ const datasetModeMessage = computed(() =>
"Reuse services, clone them through replicas, or start isolated empty data services."
)
);
const datasetModeOptions = computed(() => [
{
value: "attach_existing",
icon: "recycle",
title: trFallback("bundles.modes.attach_existing", "Reuse existing services"),
description: 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"),
type: "is-warning is-light",
},
{
value: "clone_existing",
icon: "copy",
title: trFallback("bundles.modes.clone_existing", "Clone existing services"),
description: trFallback(
"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",
},
{
value: "fresh_empty",
icon: "database",
title: trFallback("bundles.modes.fresh_empty", "Create fresh empty datasets"),
description: trFallback(
"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",
},
{
value: "isolated_stack",
icon: "shield-alt",
title: trFallback("bundles.modes.isolated_stack", "Create isolated stack (safe)"),
description: trFallback(
"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",
},
]);
const datasetModeOptions = computed(() => {
const betaLocked = isBetaBundleChannel.value;
return [
{
value: "attach_existing",
icon: "recycle",
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: "production_shared",
type: "is-warning is-light",
disabled: false,
},
{
value: "clone_existing",
icon: "copy",
title: trFallback("bundles.modes.clone_existing", "Clone existing services"),
description: trFallback(
"bundles.modes.clone_existing_description",
"Create a new service set that plans replica clones from the selected source data services."
),
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",
icon: "database",
title: trFallback("bundles.modes.fresh_empty", "Create fresh empty datasets"),
description: trFallback(
"bundles.modes.fresh_empty_description",
"Create a new service set with empty or unattached MariaDB, Redis, and MinIO services."
),
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",
icon: "shield-alt",
title: trFallback("bundles.modes.isolated_stack", "Create isolated stack (safe)"),
description: trFallback(
"bundles.modes.isolated_stack_description",
"Create new Coolify frontend/API services plus isolated MariaDB, Redis, and MinIO services."
),
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"} ${
bundleForm.dataset_mode === "isolated_stack"
? "isolated stack"
: bundleForm.dataset_mode === "fresh_empty"
? "fresh data"
: "service set"
}`,
(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);
+7 -2
View File
@@ -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.");
+185 -7
View File
@@ -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,
}) => {
+1 -3
View File
@@ -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) {
+5 -6
View File
@@ -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"]);
});
});
+88
View File
@@ -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",
});
});
});