feat(bird): add Control Plane configuration (#237)

Adds a safe Bird Control Plane configuration surface with read-only health, credential redaction, protected capability switches, canonical workspace support, and focused frontend coverage.

Required CI passed on head cc04117d6b against master 3de5215b5e.
This commit is contained in:
Jeppe B
2026-07-29 19:19:17 +02:00
committed by GitHub
parent 3de5215b5e
commit c85a82b9ac
7 changed files with 929 additions and 131 deletions
@@ -35,6 +35,12 @@ export const Bird = {
},
config: Config,
functions: {
getHealth: async () => {
return authenticatedRequest(
"/bird/health",
"GET"
);
},
createVoiceCall: async (workspaceId, channelId, payload) => {
return authenticatedRequest(
"/bird/voice/calls" + withWorkspaceAndChannel(workspaceId, channelId),
@@ -44,6 +44,15 @@ export const Config = {
return Config.set("server_url", value);
},
},
workspaceId: {
get: async () => {
return Config.get("workspaceId");
},
set: async (value) => {
return Config.set("workspaceId", value);
},
},
// Kept for installations that have not migrated the historical typo yet.
workplaceId: {
get: async () => {
return Config.get("workplaceId");
@@ -60,6 +69,54 @@ export const Config = {
return Config.set("channelId", value);
},
},
participantId: {
get: async () => Config.get("participantId"),
set: async (value) => Config.set("participantId", value),
},
allowed_channel_ids_json: {
get: async () => Config.get("allowed_channel_ids_json"),
set: async (value) => Config.set("allowed_channel_ids_json", value),
},
control_plane_token: {
get: async () => Config.get("control_plane_token"),
set: async (value) => Config.set("control_plane_token", value),
},
webhook_signing_key: {
get: async () => Config.get("webhook_signing_key"),
set: async (value) => Config.set("webhook_signing_key", value),
},
webhook_public_url: {
get: async () => Config.get("webhook_public_url"),
set: async (value) => Config.set("webhook_public_url", value),
},
webhook_replay_window_seconds: {
get: async () => Config.get("webhook_replay_window_seconds"),
set: async (value) => Config.set("webhook_replay_window_seconds", value),
},
flow_shared_secret: {
get: async () => Config.get("flow_shared_secret"),
set: async (value) => Config.set("flow_shared_secret", value),
},
flow_policy_json: {
get: async () => Config.get("flow_policy_json"),
set: async (value) => Config.set("flow_policy_json", value),
},
template_policy_json: {
get: async () => Config.get("template_policy_json"),
set: async (value) => Config.set("template_policy_json", value),
},
control_plane_enabled: {
set: async (value) => Config.set("control_plane_enabled", value),
},
flow_enabled: {
set: async (value) => Config.set("flow_enabled", value),
},
operations_actions_enabled: {
set: async (value) => Config.set("operations_actions_enabled", value),
},
outbound_messages_enabled: {
set: async (value) => Config.set("outbound_messages_enabled", value),
},
},
enabled: {
get: async () => {
+64
View File
@@ -1189,6 +1189,70 @@
"zip": "@:{'templates.generated.compat.common.zip'}"
},
"configuration": {
"bird": {
"api_key": "API key",
"api_key_desc": "The Bird access key. The saved value is never displayed.",
"api_settings": "API connection",
"api_settings_desc": "Credentials and identifiers used by the Pleno backend to connect to Bird.",
"allowed_channels": "Allowed channel IDs (JSON)",
"allowed_channels_desc": "An explicit JSON array of Bird channel IDs owned by Pleno. The legacy default channel is used only while this list is empty.",
"automation": "Flows and controlled writes",
"automation_desc": "All switches are disabled by default. Enable them only after the protected activation review.",
"channel_id": "Default channel ID",
"channel_id_desc": "The default Bird channel used by existing voice operations.",
"channels": "Available channels",
"config_unavailable": "Bird configuration could not be loaded. No settings were changed.",
"control_plane": "Control Plane gateway",
"control_plane_desc": "Authenticate the local Control Plane through the Pleno backend. Never place the Bird access key in Control Plane.",
"control_plane_enable": "Enable the Control Plane gateway",
"control_plane_enable_desc": "Permit the narrow service-bearer API after credentials and channel ownership are verified.",
"control_plane_token": "Control Plane service token",
"control_plane_token_desc": "The narrow Pleno service bearer. The saved value is never displayed.",
"enable": "Enable Bird",
"enable_desc": "Allow the Pleno backend to use the configured Bird integration.",
"general_settings": "General settings",
"general_settings_desc": "Enable or disable Bird without changing saved credentials.",
"flow_enable": "Enable deterministic Bird Flow decisions",
"flow_enable_desc": "Allow only policy-matched tag, assign, snooze, and close decisions.",
"flow_policy": "Flow policy (JSON)",
"flow_policy_desc": "Versioned deterministic rules evaluated by the Pleno backend.",
"flow_secret": "Flow shared secret",
"flow_secret_desc": "Authenticates timestamp-bound Bird Flow requests. The saved value is never displayed.",
"health_title": "Connection health",
"not_available": "Not available",
"not_configured": "Not configured",
"operations_enable": "Enable confirmed voice operations",
"operations_enable_desc": "Permit only typed, explicitly confirmed, idempotent operations exposed by the gateway.",
"outbound_enable": "Enable confirmed conversation replies",
"outbound_enable_desc": "Permit approved replies and separately allowlisted immutable templates through the gateway.",
"participant_id": "Bird participant ID",
"participant_id_desc": "The Bird participant identity used as the sender for confirmed replies and immutable templates.",
"provider_check": "Provider check",
"read_only_health": "This read-only check does not place calls, send messages, or change Bird data.",
"refresh_health": "Refresh health",
"server_url": "Server URL",
"server_url_desc": "The Bird API base URL.",
"state": {
"degraded": "Degraded",
"disabled": "Disabled",
"healthy": "Healthy",
"needs_configuration": "Needs configuration",
"unavailable": "Unavailable"
},
"subtitle": "Configure Bird credentials and inspect read-only connection and channel health.",
"title": "Bird configuration",
"template_policy": "Immutable template allowlist (JSON)",
"template_policy_desc": "Exact template project IDs, versions, locales, and structured parameter rules.",
"unnamed_channel": "Unnamed channel",
"replay_window": "Webhook replay window (seconds)",
"replay_window_desc": "Maximum accepted Bird webhook timestamp skew.",
"webhook_key": "Webhook signing key",
"webhook_key_desc": "Verifies Bird notification signatures. The saved value is never displayed.",
"webhook_url": "Exact public webhook URL",
"webhook_url_desc": "The HTTPS URL included exactly in Bird signature verification.",
"workspace_id": "Workspace ID",
"workspace_id_desc": "The canonical Bird workspace identifier. Existing workplaceId values remain readable during migration."
},
"backups": {
"active_jobs": "@:{'templates.generated.compat.configuration.backups.active_jobs'}",
"actions": "@:{'templates.generated.compat.configuration.backups.actions'}",
@@ -0,0 +1,68 @@
{
"configuration": {
"bird": {
"api_key": "API key",
"api_key_desc": "The Bird access key. The saved value is never displayed.",
"api_settings": "API connection",
"api_settings_desc": "Credentials and identifiers used by the Pleno backend to connect to Bird.",
"allowed_channels": "Allowed channel IDs (JSON)",
"allowed_channels_desc": "An explicit JSON array of Bird channel IDs owned by Pleno. The legacy default channel is used only while this list is empty.",
"automation": "Flows and controlled writes",
"automation_desc": "All switches are disabled by default. Enable them only after the protected activation review.",
"channel_id": "Default channel ID",
"channel_id_desc": "The default Bird channel used by existing voice operations.",
"channels": "Available channels",
"config_unavailable": "Bird configuration could not be loaded. No settings were changed.",
"control_plane": "Control Plane gateway",
"control_plane_desc": "Authenticate the local Control Plane through the Pleno backend. Never place the Bird access key in Control Plane.",
"control_plane_enable": "Enable the Control Plane gateway",
"control_plane_enable_desc": "Permit the narrow service-bearer API after credentials and channel ownership are verified.",
"control_plane_token": "Control Plane service token",
"control_plane_token_desc": "The narrow Pleno service bearer. The saved value is never displayed.",
"enable": "Enable Bird",
"enable_desc": "Allow the Pleno backend to use the configured Bird integration.",
"general_settings": "General settings",
"general_settings_desc": "Enable or disable Bird without changing saved credentials.",
"flow_enable": "Enable deterministic Bird Flow decisions",
"flow_enable_desc": "Allow only policy-matched tag, assign, snooze, and close decisions.",
"flow_policy": "Flow policy (JSON)",
"flow_policy_desc": "Versioned deterministic rules evaluated by the Pleno backend.",
"flow_secret": "Flow shared secret",
"flow_secret_desc": "Authenticates timestamp-bound Bird Flow requests. The saved value is never displayed.",
"health_title": "Connection health",
"not_available": "Not available",
"not_configured": "Not configured",
"operations_enable": "Enable confirmed voice operations",
"operations_enable_desc": "Permit only typed, explicitly confirmed, idempotent operations exposed by the gateway.",
"outbound_enable": "Enable confirmed conversation replies",
"outbound_enable_desc": "Permit approved replies and separately allowlisted immutable templates through the gateway.",
"participant_id": "Bird participant ID",
"participant_id_desc": "The Bird participant identity used as the sender for confirmed replies and immutable templates.",
"provider_check": "Provider check",
"read_only_health": "This read-only check does not place calls, send messages, or change Bird data.",
"refresh_health": "Refresh health",
"server_url": "Server URL",
"server_url_desc": "The Bird API base URL.",
"state": {
"degraded": "Degraded",
"disabled": "Disabled",
"healthy": "Healthy",
"needs_configuration": "Needs configuration",
"unavailable": "Unavailable"
},
"subtitle": "Configure Bird credentials and inspect read-only connection and channel health.",
"title": "Bird configuration",
"template_policy": "Immutable template allowlist (JSON)",
"template_policy_desc": "Exact template project IDs, versions, locales, and structured parameter rules.",
"unnamed_channel": "Unnamed channel",
"replay_window": "Webhook replay window (seconds)",
"replay_window_desc": "Maximum accepted Bird webhook timestamp skew.",
"webhook_key": "Webhook signing key",
"webhook_key_desc": "Verifies Bird notification signatures. The saved value is never displayed.",
"webhook_url": "Exact public webhook URL",
"webhook_url_desc": "The HTTPS URL included exactly in Bird signature verification.",
"workspace_id": "Workspace ID",
"workspace_id_desc": "The canonical Bird workspace identifier. Existing workplaceId values remain readable during migration."
}
}
}
@@ -1,169 +1,470 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref } from 'vue';
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
// Get the department from the route
const moduleConfig = ref([]);
const BIRD_SECRET_VARIABLES = new Set([
"api_key",
"control_plane_token",
"webhook_signing_key",
"flow_shared_secret",
]);
const configLoading = ref(true);
const configUnavailable = ref(false);
const healthLoading = ref(false);
const healthError = ref(false);
const health = ref({
enabled: false,
configured: false,
healthy: false,
workspaceId: "",
channelId: "",
check: "",
channels: [],
});
const getModuleConfigEntry = (variable) =>
moduleConfig.value.find((config) => config.variable === variable);
const enabled = ref(false);
const module_config = ref([]);
const getModuleConfigValue = (variable) =>
getModuleConfigEntry(variable)?.value ?? "";
const getEnabled = async () => {
await SessionUser.superUser.modules.bird.config.enabled.get().then((response) => {
enabled.value = response.data.data;
}).catch((error) => {
console.log(error);
});
const isModuleSecretSet = (variable) => {
const config = getModuleConfigEntry(variable);
return config?.isSecret === true
? config.isSet === true
: config?.value !== null && config?.value !== undefined && config.value !== "";
};
const configuredWorkspaceId = computed(
() => getModuleConfigValue("workspaceId") || getModuleConfigValue("workplaceId")
);
const normalizedChannels = computed(() =>
Array.isArray(health.value.channels)
? health.value.channels.filter((channel) => channel && typeof channel === "object")
: []
);
const healthState = computed(() => {
if (healthError.value) {
return "unavailable";
}
if (!health.value.enabled) {
return "disabled";
}
if (!health.value.configured) {
return "needs_configuration";
}
return health.value.healthy ? "healthy" : "degraded";
});
const healthStateClass = computed(() => ({
"is-success": healthState.value === "healthy",
"is-warning": ["disabled", "needs_configuration"].includes(healthState.value),
"is-danger": ["degraded", "unavailable"].includes(healthState.value),
}));
const normalizeHealth = (response) => {
const data = response?.data?.data ?? response?.data ?? {};
return {
enabled: data.enabled === true,
configured: data.configured === true,
healthy: data.providerReachable === true || data.healthy === true,
workspaceId: typeof data.workspaceId === "string" ? data.workspaceId : "",
channelId: typeof data.channelId === "string" ? data.channelId : "",
check: typeof data.check === "string" ? data.check : "",
channels: Array.isArray(data.channels) ? data.channels : [],
};
};
const getModuleConfig = async () => {
await SessionUser.superUser.modules.bird.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
let tmp_module_config_array = [];
for (const value of Object.values(tmp_module_config)) {
console.log(`${value.variable}: ${value.value}`);
tmp_module_config_array.push({
variable: value.variable,
value: value.value,
configLoading.value = true;
configUnavailable.value = false;
try {
const response = await SessionUser.superUser.modules.bird.config.get_all();
const config = response?.data?.data ?? {};
moduleConfig.value = Object.values(config)
.filter((value) => value && typeof value.variable === "string")
.map((value) => {
// The explicit names protect staggered deployments where an older API
// does not yet provide isSecret metadata.
const isSecret = value.isSecret === true
|| BIRD_SECRET_VARIABLES.has(value.variable);
return {
variable: value.variable,
value: isSecret ? "" : value.value,
isSecret,
isSet: value.isSet === true || (isSecret && value.value !== ""),
};
});
}
module_config.value = tmp_module_config_array;
console.log(module_config.value);
}).catch((error) => {
console.log(error);
});
} catch {
moduleConfig.value = [];
configUnavailable.value = true;
} finally {
configLoading.value = false;
}
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((config) => config.variable === variable);
if (config) {
console.log(`Found value for ${variable}: ${config.value}`);
} else {
console.log(`No value found for ${variable}`);
const refreshHealth = async () => {
healthLoading.value = true;
healthError.value = false;
try {
const response = await SessionUser.superUser.modules.bird.functions.getHealth();
health.value = normalizeHealth(response);
} catch {
healthError.value = true;
health.value = {
enabled: false,
configured: false,
healthy: false,
workspaceId: "",
channelId: "",
check: "",
channels: [],
};
} finally {
healthLoading.value = false;
}
return config ? config.value : '';
};
const load = async () => {
await getEnabled();
await getModuleConfig();
await Promise.all([getModuleConfig(), refreshHealth()]);
};
const onClickTestConnection = async () => {
const workspaceId = getModuleConfigValue('workplaceId') || null;
const channelId = getModuleConfigValue('channelId') || null;
SessionUser.superUser.modules.bird.functions.testOutboundVoiceCall(workspaceId, channelId).then((response) => {
console.log(response);
Swal.fire({
icon: 'success',
title: 'Connection successful',
text: 'The connection to Bird API was successful.',
});
}).catch((error) => {
console.log(error);
Swal.fire({
icon: 'error',
title: 'Connection failed',
text: 'The connection to Bird API failed.',
});
});
};
load();
onMounted(load);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<ConfigurationSubPageWrapper>
<template #title>
<PageTitle title="Bird configuration" subtitle="The configuration of the Bird integration" />
<PageTitle
:title="$t('configuration.bird.title')"
:subtitle="$t('configuration.bird.subtitle')"
/>
</template>
<template v-if="module_config.length > 0" #content>
<ConfigurationCategory
class="mt-2"
module="Bird"
title="General settings"
description="The general settings for the Bird integration."
icon="fas fa-cogs"
>
<ConfigurationSwitch
class="mt-2"
module="Bird"
title="Enable Bird"
description="Enable or disable the Bird integration."
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.enabled.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Bird"
title="API connection settings"
description="The Bird API connection settings."
icon="fas fa-cogs"
>
<ConfigurationSecretKey
class="mt-2"
module="Bird"
title="API key"
description="The API key for the Bird integration."
icon="fas fa-key"
:isSet="getModuleConfigValue('api_key') !== ''"
:on-save="SessionUser.superUser.modules.bird.config.keys.api_key.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
title="Server URL"
description="The Bird API server URL."
icon="fas fa-link"
:value="getModuleConfigValue('server_url')"
:on-save="SessionUser.superUser.modules.bird.config.keys.server_url.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
title="Workplace ID"
description="The Bird workplace ID used for voice call APIs."
icon="fas fa-building"
:value="getModuleConfigValue('workplaceId')"
:on-save="SessionUser.superUser.modules.bird.config.keys.workplaceId.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
title="Channel ID"
description="The Bird channel ID used for voice call APIs."
icon="fas fa-broadcast-tower"
:value="getModuleConfigValue('channelId')"
:on-save="SessionUser.superUser.modules.bird.config.keys.channelId.set"
/>
</ConfigurationCategory>
<div class="buttons">
<button class="button is-dark" @click="onClickTestConnection">
<span class="icon">
<i class="fas fa-sync"></i>
</span>
<span>Test connection</span>
</button>
<template #content>
<div v-if="configUnavailable" class="message is-danger" data-testid="bird-config-unavailable">
<div class="message-body">{{ $t("configuration.bird.config_unavailable") }}</div>
</div>
<ConfigurationCategory
class="mt-2"
module="Bird"
:title="$t('configuration.bird.health_title')"
:subtitle="$t(`configuration.bird.state.${healthState}`)"
icon="fas fa-heartbeat"
:default-expanded="true"
>
<div class="bird-health">
<div>
<span
class="tag is-medium"
:class="healthStateClass"
data-testid="bird-health-state"
>
{{ $t(`configuration.bird.state.${healthState}`) }}
</span>
<p class="help mt-2">{{ $t("configuration.bird.read_only_health") }}</p>
</div>
<dl class="bird-health__facts">
<div>
<dt>{{ $t("configuration.bird.workspace_id") }}</dt>
<dd>{{ health.workspaceId || configuredWorkspaceId || $t("configuration.bird.not_configured") }}</dd>
</div>
<div>
<dt>{{ $t("configuration.bird.channel_id") }}</dt>
<dd>{{ health.channelId || getModuleConfigValue("channelId") || $t("configuration.bird.not_configured") }}</dd>
</div>
<div>
<dt>{{ $t("configuration.bird.provider_check") }}</dt>
<dd>{{ health.check || $t("configuration.bird.not_available") }}</dd>
</div>
</dl>
<ul v-if="normalizedChannels.length > 0" class="bird-channels">
<li
v-for="(channel, channelIndex) in normalizedChannels"
:key="channel.id || channel.channelId || channel.name || channelIndex"
class="bird-channels__item"
>
<span>{{ channel.name || channel.id || channel.channelId || $t("configuration.bird.unnamed_channel") }}</span>
<span v-if="channel.platform" class="tag is-light">{{ channel.platform }}</span>
<span v-if="channel.status" class="tag is-light">{{ channel.status }}</span>
</li>
</ul>
<button
class="button is-dark mt-3"
:class="{ 'is-loading': healthLoading }"
:disabled="healthLoading"
data-testid="bird-refresh-health"
@click="refreshHealth"
>
<span class="icon"><i class="fas fa-sync"></i></span>
<span>{{ $t("configuration.bird.refresh_health") }}</span>
</button>
</div>
</ConfigurationCategory>
<template v-if="!configLoading && moduleConfig.length > 0">
<ConfigurationCategory
class="mt-2"
module="Bird"
:title="$t('configuration.bird.general_settings')"
:subtitle="$t('configuration.bird.general_settings_desc')"
icon="fas fa-cogs"
>
<ConfigurationSwitch
class="mt-2"
module="Bird"
:title="$t('configuration.bird.enable')"
:description="$t('configuration.bird.enable_desc')"
icon="fas fa-power-off"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.enabled.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Bird"
:title="$t('configuration.bird.api_settings')"
:subtitle="$t('configuration.bird.api_settings_desc')"
icon="fas fa-plug"
>
<ConfigurationSecretKey
class="mt-2"
module="Bird"
:title="$t('configuration.bird.api_key')"
:description="$t('configuration.bird.api_key_desc')"
icon="fas fa-key"
:isSet="isModuleSecretSet('api_key')"
:on-save="SessionUser.superUser.modules.bird.config.keys.api_key.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.server_url')"
:description="$t('configuration.bird.server_url_desc')"
icon="fas fa-link"
:value="getModuleConfigValue('server_url')"
:on-save="SessionUser.superUser.modules.bird.config.keys.server_url.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.workspace_id')"
:description="$t('configuration.bird.workspace_id_desc')"
icon="fas fa-building"
:value="configuredWorkspaceId"
:on-save="SessionUser.superUser.modules.bird.config.keys.workspaceId.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.channel_id')"
:description="$t('configuration.bird.channel_id_desc')"
icon="fas fa-broadcast-tower"
:value="getModuleConfigValue('channelId')"
:on-save="SessionUser.superUser.modules.bird.config.keys.channelId.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.participant_id')"
:description="$t('configuration.bird.participant_id_desc')"
icon="fas fa-user-tag"
:value="getModuleConfigValue('participantId')"
:on-save="SessionUser.superUser.modules.bird.config.keys.participantId.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.allowed_channels')"
:description="$t('configuration.bird.allowed_channels_desc')"
icon="fas fa-list-check"
:value="getModuleConfigValue('allowed_channel_ids_json')"
:on-save="SessionUser.superUser.modules.bird.config.keys.allowed_channel_ids_json.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Bird"
:title="$t('configuration.bird.control_plane')"
:subtitle="$t('configuration.bird.control_plane_desc')"
icon="fas fa-shield-halved"
>
<ConfigurationSwitch
class="mt-2"
module="Bird"
:title="$t('configuration.bird.control_plane_enable')"
:description="$t('configuration.bird.control_plane_enable_desc')"
icon="fas fa-network-wired"
:value="getModuleConfigValue('control_plane_enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.keys.control_plane_enabled.set"
/>
<ConfigurationSecretKey
class="mt-2"
module="Bird"
:title="$t('configuration.bird.control_plane_token')"
:description="$t('configuration.bird.control_plane_token_desc')"
icon="fas fa-key"
:isSet="isModuleSecretSet('control_plane_token')"
:on-save="SessionUser.superUser.modules.bird.config.keys.control_plane_token.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.webhook_url')"
:description="$t('configuration.bird.webhook_url_desc')"
icon="fas fa-link"
:value="getModuleConfigValue('webhook_public_url')"
:on-save="SessionUser.superUser.modules.bird.config.keys.webhook_public_url.set"
/>
<ConfigurationSecretKey
class="mt-2"
module="Bird"
:title="$t('configuration.bird.webhook_key')"
:description="$t('configuration.bird.webhook_key_desc')"
icon="fas fa-signature"
:isSet="isModuleSecretSet('webhook_signing_key')"
:on-save="SessionUser.superUser.modules.bird.config.keys.webhook_signing_key.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.replay_window')"
:description="$t('configuration.bird.replay_window_desc')"
icon="fas fa-clock"
:value="getModuleConfigValue('webhook_replay_window_seconds')"
:on-save="SessionUser.superUser.modules.bird.config.keys.webhook_replay_window_seconds.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Bird"
:title="$t('configuration.bird.automation')"
:subtitle="$t('configuration.bird.automation_desc')"
icon="fas fa-diagram-project"
>
<ConfigurationSwitch
class="mt-2"
module="Bird"
:title="$t('configuration.bird.flow_enable')"
:description="$t('configuration.bird.flow_enable_desc')"
icon="fas fa-code-branch"
:value="getModuleConfigValue('flow_enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.keys.flow_enabled.set"
/>
<ConfigurationSecretKey
class="mt-2"
module="Bird"
:title="$t('configuration.bird.flow_secret')"
:description="$t('configuration.bird.flow_secret_desc')"
icon="fas fa-key"
:isSet="isModuleSecretSet('flow_shared_secret')"
:on-save="SessionUser.superUser.modules.bird.config.keys.flow_shared_secret.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.flow_policy')"
:description="$t('configuration.bird.flow_policy_desc')"
icon="fas fa-code"
:value="getModuleConfigValue('flow_policy_json')"
:on-save="SessionUser.superUser.modules.bird.config.keys.flow_policy_json.set"
/>
<ConfigurationSwitch
class="mt-2"
module="Bird"
:title="$t('configuration.bird.operations_enable')"
:description="$t('configuration.bird.operations_enable_desc')"
icon="fas fa-phone-slash"
:value="getModuleConfigValue('operations_actions_enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.keys.operations_actions_enabled.set"
/>
<ConfigurationSwitch
class="mt-2"
module="Bird"
:title="$t('configuration.bird.outbound_enable')"
:description="$t('configuration.bird.outbound_enable_desc')"
icon="fas fa-paper-plane"
:value="getModuleConfigValue('outbound_messages_enabled') === true"
:on-switch="SessionUser.superUser.modules.bird.config.keys.outbound_messages_enabled.set"
/>
<ConfigurationInput
class="mt-2"
module="Bird"
:title="$t('configuration.bird.template_policy')"
:description="$t('configuration.bird.template_policy_desc')"
icon="fas fa-file-code"
:value="getModuleConfigValue('template_policy_json')"
:on-save="SessionUser.superUser.modules.bird.config.keys.template_policy_json.set"
/>
</ConfigurationCategory>
</template>
</template>
</ConfigurationSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.bird-health {
display: grid;
gap: 1rem;
}
.bird-health__facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.75rem;
margin: 0;
}
.bird-health__facts > div {
border: 1px solid hsl(0deg 0% 86%);
border-radius: 0.5rem;
padding: 0.75rem;
}
.bird-health__facts dt {
color: hsl(0deg 0% 48%);
font-size: 0.8rem;
}
.bird-health__facts dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
}
.bird-channels {
display: grid;
gap: 0.5rem;
list-style: none;
margin: 0;
}
.bird-channels__item {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const birdMocks = vi.hoisted(() => ({
getAll: vi.fn(),
getStatus: vi.fn(),
}));
vi.mock("@/components/displays/superuser/configuration/ConfigurationCategory.vue", () => ({
default: {
props: ["title", "subtitle"],
template: '<section class="configuration-category"><h2>{{ title }}</h2><p>{{ subtitle }}</p><slot /></section>',
},
}));
vi.mock("@/components/displays/superuser/configuration/ConfigurationInput.vue", () => ({
default: {
props: ["title", "value"],
template: '<div class="configuration-input">{{ title }}:{{ value }}</div>',
},
}));
vi.mock("@/components/displays/superuser/configuration/ConfigurationSecretKey.vue", () => ({
default: {
props: ["isSet"],
template: '<div data-testid="bird-secret-state">{{ isSet ? "set" : "unset" }}</div>',
},
}));
vi.mock("@/components/displays/superuser/configuration/ConfigurationSwitch.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/global/PageTitle.vue", () => ({
default: { template: "<header />" },
}));
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue", () => ({
default: { template: '<main><slot name="title" /><slot name="content" /></main>' },
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
canAccessSuperUser: () => true,
superUser: {
modules: {
bird: {
config: {
get_all: birdMocks.getAll,
enabled: { set: vi.fn() },
keys: {
api_key: { set: vi.fn() },
server_url: { set: vi.fn() },
workspaceId: { set: vi.fn() },
channelId: { set: vi.fn() },
participantId: { set: vi.fn() },
allowed_channel_ids_json: { set: vi.fn() },
control_plane_enabled: { set: vi.fn() },
control_plane_token: { set: vi.fn() },
webhook_signing_key: { set: vi.fn() },
webhook_public_url: { set: vi.fn() },
webhook_replay_window_seconds: { set: vi.fn() },
flow_enabled: { set: vi.fn() },
flow_shared_secret: { set: vi.fn() },
flow_policy_json: { set: vi.fn() },
operations_actions_enabled: { set: vi.fn() },
outbound_messages_enabled: { set: vi.fn() },
template_policy_json: { set: vi.fn() },
},
},
functions: {
getHealth: birdMocks.getStatus,
},
},
},
},
},
}));
import ConfigurationBird from "@/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue";
const global = {
mocks: {
$t: (key) => key,
},
};
describe("Bird configuration", () => {
beforeEach(() => {
birdMocks.getAll.mockReset();
birdMocks.getStatus.mockReset();
});
it("renders healthy read-only status and provider channel state", async () => {
birdMocks.getAll.mockResolvedValue({
data: {
data: {
enabled: { variable: "enabled", value: true },
workspaceId: { variable: "workspaceId", value: "workspace-current" },
},
},
});
birdMocks.getStatus.mockResolvedValue({
data: {
data: {
enabled: true,
configured: true,
providerReachable: true,
workspaceId: "workspace-current",
channelId: "channel-1",
check: "channels.list",
channels: [{ id: "channel-1", name: "Customer WhatsApp", platform: "whatsapp", status: "active" }],
},
},
});
const wrapper = mount(ConfigurationBird, { global });
await flushPromises();
expect(wrapper.get('[data-testid="bird-health-state"]').text()).toBe("configuration.bird.state.healthy");
expect(wrapper.text()).toContain("configuration.bird.read_only_health");
expect(wrapper.text()).toContain("channels.list");
expect(wrapper.text()).toContain("Customer WhatsApp");
expect(wrapper.text()).toContain("whatsapp");
expect(wrapper.text()).toContain("active");
});
it("uses workplaceId only as a display fallback while exposing the canonical field", async () => {
birdMocks.getAll.mockResolvedValue({
data: {
data: {
enabled: { variable: "enabled", value: false },
workplaceId: { variable: "workplaceId", value: "workspace-legacy" },
},
},
});
birdMocks.getStatus.mockResolvedValue({
data: {
data: {
enabled: false,
configured: false,
healthy: false,
},
},
});
const wrapper = mount(ConfigurationBird, { global });
await flushPromises();
expect(wrapper.get('[data-testid="bird-health-state"]').text()).toBe("configuration.bird.state.disabled");
expect(wrapper.text()).toContain("configuration.bird.workspace_id:workspace-legacy");
});
it("fails closed to an unavailable health state", async () => {
birdMocks.getAll.mockRejectedValue(new Error("Config unavailable"));
birdMocks.getStatus.mockRejectedValue(new Error("Health unavailable"));
const wrapper = mount(ConfigurationBird, { global });
await flushPromises();
expect(wrapper.get('[data-testid="bird-config-unavailable"]').text()).toBe("configuration.bird.config_unavailable");
expect(wrapper.get('[data-testid="bird-health-state"]').text()).toBe("configuration.bird.state.unavailable");
});
it("uses redacted secret metadata and never renders an accidentally returned secret value", async () => {
birdMocks.getAll.mockResolvedValue({
data: {
data: {
enabled: { variable: "enabled", value: true },
api_key: {
variable: "api_key",
value: "must-not-reach-the-dom",
isSecret: true,
isSet: true,
},
},
},
});
birdMocks.getStatus.mockResolvedValue({
data: {
data: {
enabled: true,
configured: true,
healthy: true,
channels: [],
},
},
});
const wrapper = mount(ConfigurationBird, { global });
await flushPromises();
expect(wrapper.get('[data-testid="bird-secret-state"]').text()).toBe("set");
expect(wrapper.text()).not.toContain("must-not-reach-the-dom");
});
it("redacts the legacy API key even before isSecret metadata is deployed", async () => {
birdMocks.getAll.mockResolvedValue({
data: {
data: {
enabled: { variable: "enabled", value: true },
api_key: { variable: "api_key", value: "legacy-raw-secret" },
},
},
});
birdMocks.getStatus.mockResolvedValue({
data: { data: { enabled: true, configured: true, healthy: true, channels: [] } },
});
const wrapper = mount(ConfigurationBird, { global });
await flushPromises();
expect(wrapper.get('[data-testid="bird-secret-state"]').text()).toBe("set");
expect(wrapper.text()).not.toContain("legacy-raw-secret");
});
});
+86
View File
@@ -0,0 +1,86 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const authenticatedRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: authenticatedRequestMock,
}));
import { Bird } from "@/components/session/token/superUser/modules/bird/Bird.vue";
import { Config } from "@/components/session/token/superUser/modules/bird/Config.vue";
const root = process.cwd();
const pageSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue"),
"utf8"
);
describe("Bird module contract", () => {
beforeEach(() => {
authenticatedRequestMock.mockReset();
authenticatedRequestMock.mockResolvedValue({ data: { data: {} } });
});
it("uses the end-user read-only health endpoint for configuration health", async () => {
await Bird.functions.getHealth();
expect(authenticatedRequestMock).toHaveBeenCalledOnce();
expect(authenticatedRequestMock).toHaveBeenCalledWith("/bird/health", "GET");
});
it("writes the canonical workspaceId while retaining the legacy workplaceId adapter", async () => {
await Config.keys.workspaceId.set("workspace-canonical");
await Config.keys.workplaceId.get();
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(1, "/bird/config", "POST", {
variable: "workspaceId",
value: "workspace-canonical",
});
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(2, "/bird/config?variable=workplaceId", "GET");
});
it("exposes protected gateway and channel configuration through typed config keys", async () => {
await Config.keys.allowed_channel_ids_json.set('["channel-1"]');
await Config.keys.control_plane_enabled.set(true);
await Config.keys.webhook_signing_key.set("rotated-secret");
await Config.keys.participantId.set("participant-1");
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(1, "/bird/config", "POST", {
variable: "allowed_channel_ids_json",
value: '["channel-1"]',
});
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(2, "/bird/config", "POST", {
variable: "control_plane_enabled",
value: true,
});
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(3, "/bird/config", "POST", {
variable: "webhook_signing_key",
value: "rotated-secret",
});
expect(authenticatedRequestMock).toHaveBeenNthCalledWith(4, "/bird/config", "POST", {
variable: "participantId",
value: "participant-1",
});
});
it("does not place a fixed-number call from the configuration page", () => {
expect(pageSource).toContain("getHealth");
expect(pageSource).not.toContain("getControlPlaneStatus");
expect(pageSource).toContain('getModuleConfigValue("workspaceId") || getModuleConfigValue("workplaceId")');
expect(pageSource).toContain("config.keys.workspaceId.set");
expect(pageSource).toContain("BIRD_SECRET_VARIABLES.has(value.variable)");
expect(pageSource).toContain("isModuleSecretSet('api_key')");
expect(pageSource).not.toContain("testOutboundVoiceCall");
expect(pageSource).not.toContain("console.");
});
it("renders health, disabled, channel, and read-only states through i18n keys", () => {
expect(pageSource).toContain("configuration.bird.health_title");
expect(pageSource).toContain("configuration.bird.read_only_health");
expect(pageSource).toContain("configuration.bird.state.");
expect(pageSource).toContain("normalizedChannels");
expect(pageSource).toContain('data-testid="bird-health-state"');
});
});