Compare commits
2 Commits
3fff7e3158
...
4195d384d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4195d384d0 | ||
|
|
fc62ea634a |
35
src/admin/src/api/auditLogs.ts
Normal file
35
src/admin/src/api/auditLogs.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client';
|
||||
import type { AuditLogResponse, AuditLogFilter, PaginatedResponse } from '@/types/api';
|
||||
|
||||
function toParams(filter: AuditLogFilter) {
|
||||
return {
|
||||
resourceType: filter.resourceType ?? undefined,
|
||||
action: filter.action ?? undefined,
|
||||
from: filter.from ?? undefined,
|
||||
to: filter.to ?? undefined,
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAuditLogsByOrganization(
|
||||
orgId: number,
|
||||
filter: AuditLogFilter = {},
|
||||
): Promise<PaginatedResponse<AuditLogResponse>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
|
||||
`/v1/organisations/${orgId}/audit-logs`,
|
||||
{ params: toParams(filter) },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listAuditLogsByProject(
|
||||
projectId: number,
|
||||
filter: AuditLogFilter = {},
|
||||
): Promise<PaginatedResponse<AuditLogResponse>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
|
||||
`/v1/projects/${projectId}/audit-logs`,
|
||||
{ params: toParams(filter) },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
63
src/admin/src/api/identities.ts
Normal file
63
src/admin/src/api/identities.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
IdentityResponse,
|
||||
FeatureStateResponse,
|
||||
UpdateFeatureStateRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
export async function listIdentities(
|
||||
envApiKey: string,
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
): Promise<PaginatedResponse<IdentityResponse>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
|
||||
`/v1/environments/${envApiKey}/identities`,
|
||||
{ params: { page, pageSize } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
|
||||
const { data } = await apiClient.get<IdentityResponse>(
|
||||
`/v1/environments/${envApiKey}/identities/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
|
||||
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
|
||||
}
|
||||
|
||||
export async function getIdentityFeatureStates(
|
||||
envApiKey: string,
|
||||
identityId: number,
|
||||
): Promise<FeatureStateResponse[]> {
|
||||
const { data } = await apiClient.get<FeatureStateResponse[]>(
|
||||
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function setIdentityFeatureState(
|
||||
envApiKey: string,
|
||||
identityId: number,
|
||||
featureId: number,
|
||||
request: UpdateFeatureStateRequest,
|
||||
): Promise<FeatureStateResponse> {
|
||||
const { data } = await apiClient.put<FeatureStateResponse>(
|
||||
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteIdentityFeatureState(
|
||||
envApiKey: string,
|
||||
identityId: number,
|
||||
featureId: number,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
|
||||
);
|
||||
}
|
||||
88
src/admin/src/api/webhooks.ts
Normal file
88
src/admin/src/api/webhooks.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
WebhookResponse,
|
||||
WebhookDeliveryLogResponse,
|
||||
CreateWebhookRequest,
|
||||
} from '@/types/api';
|
||||
|
||||
// ─── Organization webhooks ────────────────────────────────────────────────────
|
||||
|
||||
export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> {
|
||||
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisations/${orgId}/webhooks`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createOrgWebhook(
|
||||
orgId: number,
|
||||
request: CreateWebhookRequest,
|
||||
): Promise<WebhookResponse> {
|
||||
const { data } = await apiClient.post<WebhookResponse>(
|
||||
`/v1/organisations/${orgId}/webhooks`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateOrgWebhook(
|
||||
orgId: number,
|
||||
webhookId: number,
|
||||
request: CreateWebhookRequest,
|
||||
): Promise<WebhookResponse> {
|
||||
const { data } = await apiClient.put<WebhookResponse>(
|
||||
`/v1/organisations/${orgId}/webhooks/${webhookId}`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> {
|
||||
await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`);
|
||||
}
|
||||
|
||||
// ─── Environment webhooks ─────────────────────────────────────────────────────
|
||||
|
||||
export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> {
|
||||
const { data } = await apiClient.get<WebhookResponse[]>(
|
||||
`/v1/environments/${envApiKey}/webhooks`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createEnvWebhook(
|
||||
envApiKey: string,
|
||||
request: CreateWebhookRequest,
|
||||
): Promise<WebhookResponse> {
|
||||
const { data } = await apiClient.post<WebhookResponse>(
|
||||
`/v1/environments/${envApiKey}/webhooks`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateEnvWebhook(
|
||||
envApiKey: string,
|
||||
webhookId: number,
|
||||
request: CreateWebhookRequest,
|
||||
): Promise<WebhookResponse> {
|
||||
const { data } = await apiClient.put<WebhookResponse>(
|
||||
`/v1/environments/${envApiKey}/webhooks/${webhookId}`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> {
|
||||
await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`);
|
||||
}
|
||||
|
||||
// ─── Delivery logs ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listWebhookDeliveries(
|
||||
envApiKey: string,
|
||||
webhookId: number,
|
||||
): Promise<WebhookDeliveryLogResponse[]> {
|
||||
const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>(
|
||||
`/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
374
src/admin/src/components/identities/IdentityDetail.vue
Normal file
374
src/admin/src/components/identities/IdentityDetail.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="680"
|
||||
scrollable
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card v-if="identity" rounded="lg" data-testid="identity-detail">
|
||||
<!-- Header -->
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
|
||||
<div>
|
||||
<p class="text-h6 mb-0">{{ identity.identifier }}</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0">ID: {{ identity.id }}</p>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
|
||||
</v-card-title>
|
||||
|
||||
<v-tabs v-model="activeTab" color="primary" class="px-4">
|
||||
<v-tab value="traits" data-testid="tab-traits">Traits</v-tab>
|
||||
<v-tab value="overrides" data-testid="tab-overrides">Feature Overrides</v-tab>
|
||||
</v-tabs>
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-tabs-window v-model="activeTab">
|
||||
<!-- ── Traits tab ────────────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="traits" class="pa-6">
|
||||
<v-alert
|
||||
v-if="traitsError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="traitsError = null"
|
||||
>
|
||||
{{ traitsError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="identity.traits.length === 0" class="text-center py-6 text-medium-emphasis">
|
||||
<v-icon size="32" class="mb-2">mdi-tag-outline</v-icon>
|
||||
<p class="text-body-2">No traits recorded for this identity.</p>
|
||||
</div>
|
||||
|
||||
<v-table v-else density="compact" data-testid="traits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="trait in identity.traits" :key="trait.key" :data-testid="`trait-row-${trait.key}`">
|
||||
<td class="text-body-2 font-weight-medium">{{ trait.key }}</td>
|
||||
<td class="text-body-2">{{ trait.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<v-card variant="outlined" color="error" rounded="lg" class="mt-6">
|
||||
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<p class="text-body-2 mb-3">
|
||||
Permanently delete this identity and all its traits and feature overrides.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="isDeleting"
|
||||
data-testid="delete-identity-btn"
|
||||
@click="onDeleteIdentity"
|
||||
>
|
||||
Delete identity
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- ── Overrides tab ─────────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="overrides" class="pa-6">
|
||||
<v-alert
|
||||
v-if="overridesError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="overridesError = null"
|
||||
>
|
||||
{{ overridesError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="isLoadingOverrides" class="d-flex justify-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Existing overrides -->
|
||||
<div v-if="overrides.length === 0" class="text-center py-4 text-medium-emphasis text-body-2 mb-4" data-testid="no-overrides-message">
|
||||
No feature overrides for this identity.
|
||||
</div>
|
||||
|
||||
<v-table v-else density="compact" class="mb-4" data-testid="overrides-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th style="width: 80px">Enabled</th>
|
||||
<th>Value</th>
|
||||
<th style="width: 40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="override in overrides" :key="override.id" :data-testid="`override-row-${override.featureId}`">
|
||||
<td class="text-body-2 font-weight-medium">{{ featureNameMap.get(override.featureId) ?? `Feature ${override.featureId}` }}</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="override.enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:data-testid="`override-toggle-${override.featureId}`"
|
||||
@update:model-value="onToggleOverride(override, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<v-text-field
|
||||
:model-value="override.value ?? ''"
|
||||
variant="underlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
placeholder="(none)"
|
||||
:data-testid="`override-value-${override.featureId}`"
|
||||
@update:model-value="onUpdateOverrideValue(override, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn
|
||||
icon="mdi-trash-can-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:data-testid="`remove-override-${override.featureId}`"
|
||||
@click="onRemoveOverride(override)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<!-- Add Override -->
|
||||
<v-divider class="mb-4" />
|
||||
<p class="text-body-2 font-weight-medium mb-3">Add Override</p>
|
||||
<div class="d-flex align-center ga-3 flex-wrap">
|
||||
<v-select
|
||||
v-model="newOverrideFeatureId"
|
||||
:items="availableFeatures"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Select feature"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="min-width: 200px; flex: 2"
|
||||
data-testid="add-override-feature-select"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="newOverrideEnabled"
|
||||
color="primary"
|
||||
label="Enabled"
|
||||
hide-details
|
||||
density="compact"
|
||||
data-testid="add-override-enabled-toggle"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="newOverrideValue"
|
||||
label="Value (optional)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="min-width: 140px; flex: 1"
|
||||
data-testid="add-override-value-input"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:disabled="!newOverrideFeatureId"
|
||||
:loading="isAddingOverride"
|
||||
data-testid="add-override-btn"
|
||||
@click="onAddOverride"
|
||||
>
|
||||
Add
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { getIdentityFeatureStates, setIdentityFeatureState, deleteIdentity, deleteIdentityFeatureState } from '@/api/identities';
|
||||
import { listFeatures } from '@/api/features';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { IdentityResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
identity: IdentityResponse | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
deleted: [identityId: number];
|
||||
}>();
|
||||
|
||||
const contextStore = useContextStore();
|
||||
|
||||
const activeTab = ref('traits');
|
||||
const isDeleting = ref(false);
|
||||
const traitsError = ref<string | null>(null);
|
||||
|
||||
const overrides = ref<FeatureStateResponse[]>([]);
|
||||
const allFeatures = ref<FeatureResponse[]>([]);
|
||||
const isLoadingOverrides = ref(false);
|
||||
const overridesError = ref<string | null>(null);
|
||||
const isAddingOverride = ref(false);
|
||||
|
||||
const newOverrideFeatureId = ref<number | null>(null);
|
||||
const newOverrideEnabled = ref(false);
|
||||
const newOverrideValue = ref('');
|
||||
|
||||
// Map featureId → name for display in overrides table
|
||||
const featureNameMap = computed(() => new Map(allFeatures.value.map((f) => [f.id, f.name])));
|
||||
|
||||
// Only show features that don't already have an override
|
||||
const availableFeatures = computed(() => {
|
||||
const overriddenIds = new Set(overrides.value.map((o) => o.featureId));
|
||||
return allFeatures.value.filter((f) => !overriddenIds.has(f.id));
|
||||
});
|
||||
|
||||
async function loadOverridesData(): Promise<void> {
|
||||
if (!props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!envApiKey || !projectId) return;
|
||||
|
||||
isLoadingOverrides.value = true;
|
||||
overridesError.value = null;
|
||||
try {
|
||||
const [states, features] = await Promise.all([
|
||||
getIdentityFeatureStates(envApiKey, props.identity.id),
|
||||
allFeatures.value.length === 0 ? listFeatures(projectId, 1, 100) : Promise.resolve(null),
|
||||
]);
|
||||
overrides.value = states;
|
||||
if (features) allFeatures.value = features.results;
|
||||
} catch {
|
||||
overridesError.value = 'Failed to load feature overrides. Please try again.';
|
||||
} finally {
|
||||
isLoadingOverrides.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
activeTab.value = 'traits';
|
||||
traitsError.value = null;
|
||||
overridesError.value = null;
|
||||
overrides.value = [];
|
||||
newOverrideFeatureId.value = null;
|
||||
newOverrideEnabled.value = false;
|
||||
newOverrideValue.value = '';
|
||||
loadOverridesData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function onToggleOverride(override: FeatureStateResponse, enabled: boolean | null): Promise<void> {
|
||||
if (enabled === null || !props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) return;
|
||||
overridesError.value = null;
|
||||
try {
|
||||
const updated = await setIdentityFeatureState(envApiKey, props.identity.id, override.featureId, {
|
||||
enabled,
|
||||
value: override.value,
|
||||
});
|
||||
const idx = overrides.value.findIndex((o) => o.featureId === override.featureId);
|
||||
if (idx >= 0) overrides.value[idx] = updated;
|
||||
} catch {
|
||||
overridesError.value = 'Failed to update override. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpdateOverrideValue(override: FeatureStateResponse, value: string): Promise<void> {
|
||||
if (!props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) return;
|
||||
overridesError.value = null;
|
||||
try {
|
||||
const updated = await setIdentityFeatureState(envApiKey, props.identity.id, override.featureId, {
|
||||
enabled: override.enabled,
|
||||
value: value || null,
|
||||
});
|
||||
const idx = overrides.value.findIndex((o) => o.featureId === override.featureId);
|
||||
if (idx >= 0) overrides.value[idx] = updated;
|
||||
} catch {
|
||||
overridesError.value = 'Failed to update override value. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveOverride(override: FeatureStateResponse): Promise<void> {
|
||||
if (!props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) return;
|
||||
overridesError.value = null;
|
||||
try {
|
||||
await deleteIdentityFeatureState(envApiKey, props.identity.id, override.featureId);
|
||||
overrides.value = overrides.value.filter((o) => o.featureId !== override.featureId);
|
||||
} catch {
|
||||
overridesError.value = 'Failed to remove override. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddOverride(): Promise<void> {
|
||||
if (!newOverrideFeatureId.value || !props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) return;
|
||||
isAddingOverride.value = true;
|
||||
overridesError.value = null;
|
||||
try {
|
||||
const created = await setIdentityFeatureState(envApiKey, props.identity.id, newOverrideFeatureId.value, {
|
||||
enabled: newOverrideEnabled.value,
|
||||
value: newOverrideValue.value || null,
|
||||
});
|
||||
overrides.value = [...overrides.value, created];
|
||||
newOverrideFeatureId.value = null;
|
||||
newOverrideEnabled.value = false;
|
||||
newOverrideValue.value = '';
|
||||
} catch {
|
||||
overridesError.value = 'Failed to add override. Please try again.';
|
||||
} finally {
|
||||
isAddingOverride.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteIdentity(): Promise<void> {
|
||||
if (!props.identity) return;
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) return;
|
||||
isDeleting.value = true;
|
||||
traitsError.value = null;
|
||||
try {
|
||||
await deleteIdentity(envApiKey, props.identity.id);
|
||||
emit('deleted', props.identity.id);
|
||||
emit('update:modelValue', false);
|
||||
} catch {
|
||||
traitsError.value = 'Failed to delete identity. Please try again.';
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
95
src/admin/src/components/settings/WebhookDeliveryHistory.vue
Normal file
95
src/admin/src/components/settings/WebhookDeliveryHistory.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div data-testid="webhook-delivery-history">
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="errorMessage = null"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="isLoading" class="d-flex justify-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="deliveries.length === 0" class="text-center py-6 text-medium-emphasis text-body-2" data-testid="no-deliveries-message">
|
||||
No delivery history for this webhook.
|
||||
</div>
|
||||
|
||||
<v-table v-else density="compact" data-testid="deliveries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Event</th>
|
||||
<th style="width: 100px">Status</th>
|
||||
<th style="width: 80px">Attempt</th>
|
||||
<th style="width: 100px">HTTP Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="delivery in deliveries" :key="delivery.id" :data-testid="`delivery-row-${delivery.id}`">
|
||||
<td class="text-caption">{{ formatDate(delivery.attemptedAt) }}</td>
|
||||
<td class="text-body-2">{{ delivery.eventType }}</td>
|
||||
<td>
|
||||
<v-chip
|
||||
:color="delivery.success ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
:data-testid="`delivery-status-${delivery.id}`"
|
||||
>
|
||||
{{ delivery.success ? 'Success' : 'Failed' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-body-2 text-medium-emphasis">{{ delivery.attemptNumber }}</td>
|
||||
<td class="text-body-2 text-medium-emphasis">{{ delivery.responseStatusCode ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { listWebhookDeliveries } from '@/api/webhooks';
|
||||
import type { WebhookDeliveryLogResponse } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
envApiKey: string;
|
||||
webhookId: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const deliveries = ref<WebhookDeliveryLogResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
async function loadDeliveries(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
deliveries.value = await listWebhookDeliveries(props.envApiKey, props.webhookId);
|
||||
} catch {
|
||||
deliveries.value = [];
|
||||
errorMessage.value = 'Failed to load delivery history. Please try again.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.webhookId, loadDeliveries);
|
||||
|
||||
onMounted(loadDeliveries);
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
172
src/admin/src/components/settings/WebhookDialog.vue
Normal file
172
src/admin/src/components/settings/WebhookDialog.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="500"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card rounded="lg" data-testid="webhook-dialog">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
|
||||
<span class="text-h6">{{ webhook ? 'Edit Webhook' : 'Add Webhook' }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="errorMessage = null"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="formRef" data-testid="webhook-form">
|
||||
<v-text-field
|
||||
v-model="form.url"
|
||||
label="Endpoint URL"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-3"
|
||||
:rules="[rules.required, rules.url]"
|
||||
placeholder="https://example.com/webhook"
|
||||
data-testid="webhook-url-input"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.secret"
|
||||
label="Secret (optional)"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-3"
|
||||
:type="showSecret ? 'text' : 'password'"
|
||||
:append-inner-icon="showSecret ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
hint="Used to sign the webhook payload"
|
||||
data-testid="webhook-secret-input"
|
||||
@click:append-inner="showSecret = !showSecret"
|
||||
/>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<p class="text-body-2 font-weight-medium mb-0">Enabled</p>
|
||||
<p class="text-caption text-medium-emphasis">Deliver events to this endpoint</p>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="form.enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
data-testid="webhook-enabled-toggle"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isSaving"
|
||||
data-testid="save-webhook-btn"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ webhook ? 'Save' : 'Add' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { createOrgWebhook, updateOrgWebhook, createEnvWebhook, updateEnvWebhook } from '@/api/webhooks';
|
||||
import type { WebhookResponse } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
webhook?: WebhookResponse | null;
|
||||
orgId?: number;
|
||||
envApiKey?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
saved: [webhook: WebhookResponse];
|
||||
}>();
|
||||
|
||||
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
|
||||
const isSaving = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const showSecret = ref(false);
|
||||
|
||||
const form = ref({ url: '', secret: '', enabled: true });
|
||||
|
||||
const rules = {
|
||||
required: (v: string) => !!v || 'Required',
|
||||
url: (v: string) => {
|
||||
try { new URL(v); return true; } catch { return 'Must be a valid URL'; }
|
||||
},
|
||||
};
|
||||
|
||||
function resetForm(): void {
|
||||
form.value = {
|
||||
url: props.webhook?.url ?? '',
|
||||
secret: props.webhook?.secret ?? '',
|
||||
enabled: props.webhook?.enabled ?? true,
|
||||
};
|
||||
errorMessage.value = null;
|
||||
showSecret.value = false;
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) resetForm();
|
||||
});
|
||||
|
||||
watch(() => props.webhook, resetForm, { immediate: true });
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!formRef.value) return;
|
||||
const { valid } = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
isSaving.value = true;
|
||||
errorMessage.value = null;
|
||||
const request = {
|
||||
url: form.value.url,
|
||||
secret: form.value.secret || null,
|
||||
enabled: form.value.enabled,
|
||||
};
|
||||
|
||||
try {
|
||||
let saved: WebhookResponse;
|
||||
if (props.webhook) {
|
||||
if (props.orgId !== undefined) {
|
||||
saved = await updateOrgWebhook(props.orgId, props.webhook.id, request);
|
||||
} else if (props.envApiKey) {
|
||||
saved = await updateEnvWebhook(props.envApiKey, props.webhook.id, request);
|
||||
} else {
|
||||
throw new Error('Missing scope');
|
||||
}
|
||||
} else {
|
||||
if (props.orgId !== undefined) {
|
||||
saved = await createOrgWebhook(props.orgId, request);
|
||||
} else if (props.envApiKey) {
|
||||
saved = await createEnvWebhook(props.envApiKey, request);
|
||||
} else {
|
||||
throw new Error('Missing scope');
|
||||
}
|
||||
}
|
||||
emit('saved', saved);
|
||||
emit('update:modelValue', false);
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to save webhook. Please try again.';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
283
src/admin/src/components/settings/WebhooksPanel.vue
Normal file
283
src/admin/src/components/settings/WebhooksPanel.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div data-testid="webhooks-panel">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<p class="text-body-1 font-weight-medium mb-0">Webhooks</p>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-webhook-btn"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
Add Webhook
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
@click:close="errorMessage = null"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="isLoading" class="d-flex justify-center py-6">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="webhooks.length === 0"
|
||||
class="text-center py-6 text-medium-emphasis text-body-2"
|
||||
data-testid="no-webhooks-message"
|
||||
>
|
||||
No webhooks configured. Add one to start receiving events.
|
||||
</div>
|
||||
|
||||
<v-table v-else density="compact" data-testid="webhooks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th style="width: 80px">Enabled</th>
|
||||
<th style="width: 120px">Created</th>
|
||||
<th style="width: 120px" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="hook in webhooks" :key="hook.id" :data-testid="`webhook-row-${hook.id}`">
|
||||
<td>
|
||||
<span class="text-body-2 font-weight-medium" style="word-break: break-all">
|
||||
{{ hook.url }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="hook.enabled"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:loading="togglingId === hook.id"
|
||||
:data-testid="`webhook-enabled-toggle-${hook.id}`"
|
||||
@update:model-value="onToggleEnabled(hook, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-caption text-medium-emphasis">{{ formatDate(hook.createdAt) }}</td>
|
||||
<td class="text-end">
|
||||
<v-btn
|
||||
icon="mdi-history"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:data-testid="`view-deliveries-${hook.id}`"
|
||||
@click="openDeliveries(hook)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-pencil-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:data-testid="`edit-webhook-${hook.id}`"
|
||||
@click="openEditDialog(hook)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-trash-can-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:data-testid="`delete-webhook-${hook.id}`"
|
||||
@click="openDeleteConfirm(hook)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
|
||||
<!-- Add / Edit dialog -->
|
||||
<WebhookDialog
|
||||
v-model="showDialog"
|
||||
:webhook="editingWebhook"
|
||||
:org-id="orgId"
|
||||
:env-api-key="envApiKey"
|
||||
@saved="onWebhookSaved"
|
||||
/>
|
||||
|
||||
<!-- Delivery history dialog -->
|
||||
<v-dialog v-model="showDeliveries" max-width="700">
|
||||
<v-card rounded="lg" data-testid="deliveries-dialog">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-2">
|
||||
<span class="text-h6">Delivery History</span>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="showDeliveries = false" />
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-4">
|
||||
<WebhookDeliveryHistory
|
||||
v-if="deliveryWebhook && envApiKey"
|
||||
:env-api-key="envApiKey"
|
||||
:webhook-id="deliveryWebhook.id"
|
||||
/>
|
||||
<p v-else class="text-body-2 text-medium-emphasis">
|
||||
Delivery history is only available for environment-scoped webhooks.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card rounded="lg" data-testid="delete-webhook-dialog">
|
||||
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Delete Webhook</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<p class="text-body-2">
|
||||
Are you sure you want to delete the webhook for
|
||||
<strong>{{ deletingWebhook?.url }}</strong>?
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeleteDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="flat"
|
||||
:loading="isDeleting"
|
||||
data-testid="confirm-delete-webhook-btn"
|
||||
@click="onDeleteWebhook"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import {
|
||||
listOrgWebhooks, createOrgWebhook, updateOrgWebhook, deleteOrgWebhook,
|
||||
listEnvWebhooks, updateEnvWebhook, deleteEnvWebhook,
|
||||
} from '@/api/webhooks';
|
||||
import WebhookDialog from './WebhookDialog.vue';
|
||||
import WebhookDeliveryHistory from './WebhookDeliveryHistory.vue';
|
||||
import type { WebhookResponse } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
orgId?: number;
|
||||
envApiKey?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const webhooks = ref<WebhookResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const togglingId = ref<number | null>(null);
|
||||
|
||||
const showDialog = ref(false);
|
||||
const editingWebhook = ref<WebhookResponse | null>(null);
|
||||
|
||||
const showDeliveries = ref(false);
|
||||
const deliveryWebhook = ref<WebhookResponse | null>(null);
|
||||
|
||||
const showDeleteDialog = ref(false);
|
||||
const deletingWebhook = ref<WebhookResponse | null>(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
async function loadWebhooks(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
if (props.envApiKey) {
|
||||
webhooks.value = await listEnvWebhooks(props.envApiKey);
|
||||
} else if (props.orgId !== undefined) {
|
||||
webhooks.value = await listOrgWebhooks(props.orgId);
|
||||
}
|
||||
} catch {
|
||||
webhooks.value = [];
|
||||
errorMessage.value = 'Failed to load webhooks. Please try again.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadWebhooks);
|
||||
|
||||
function openAddDialog(): void {
|
||||
editingWebhook.value = null;
|
||||
showDialog.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(hook: WebhookResponse): void {
|
||||
editingWebhook.value = hook;
|
||||
showDialog.value = true;
|
||||
}
|
||||
|
||||
function openDeliveries(hook: WebhookResponse): void {
|
||||
deliveryWebhook.value = hook;
|
||||
showDeliveries.value = true;
|
||||
}
|
||||
|
||||
function openDeleteConfirm(hook: WebhookResponse): void {
|
||||
deletingWebhook.value = hook;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
async function onToggleEnabled(hook: WebhookResponse, enabled: boolean | null): Promise<void> {
|
||||
if (enabled === null) return;
|
||||
togglingId.value = hook.id;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
const request = { url: hook.url, secret: hook.secret, enabled };
|
||||
let updated: WebhookResponse;
|
||||
if (props.envApiKey) {
|
||||
updated = await updateEnvWebhook(props.envApiKey, hook.id, request);
|
||||
} else if (props.orgId !== undefined) {
|
||||
updated = await updateOrgWebhook(props.orgId, hook.id, request);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const idx = webhooks.value.findIndex((w) => w.id === hook.id);
|
||||
if (idx >= 0) webhooks.value[idx] = updated;
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to update webhook. Please try again.';
|
||||
} finally {
|
||||
togglingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onWebhookSaved(saved: WebhookResponse): void {
|
||||
const idx = webhooks.value.findIndex((w) => w.id === saved.id);
|
||||
if (idx >= 0) {
|
||||
webhooks.value[idx] = saved;
|
||||
} else {
|
||||
webhooks.value.push(saved);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteWebhook(): Promise<void> {
|
||||
if (!deletingWebhook.value) return;
|
||||
isDeleting.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
if (props.envApiKey) {
|
||||
await deleteEnvWebhook(props.envApiKey, deletingWebhook.value.id);
|
||||
} else if (props.orgId !== undefined) {
|
||||
await deleteOrgWebhook(props.orgId, deletingWebhook.value.id);
|
||||
}
|
||||
webhooks.value = webhooks.value.filter((w) => w.id !== deletingWebhook.value!.id);
|
||||
showDeleteDialog.value = false;
|
||||
deletingWebhook.value = null;
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to delete webhook. Please try again.';
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
@@ -300,8 +300,8 @@ export interface AuditLogResponse {
|
||||
export interface AuditLogFilter {
|
||||
resourceType?: string | null;
|
||||
action?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -336,10 +336,15 @@ export interface UpdateWebhookRequest {
|
||||
|
||||
export interface WebhookDeliveryLogResponse {
|
||||
id: number;
|
||||
status: WebhookDeliveryStatus;
|
||||
webhookId: number;
|
||||
eventType: string;
|
||||
success: boolean;
|
||||
responseStatusCode: number | null;
|
||||
responseBody: string | null;
|
||||
errorMessage: string | null;
|
||||
attemptNumber: number;
|
||||
responseCode: number | null;
|
||||
createdAt: string;
|
||||
attemptedAt: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
// ─── Identities ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,18 +1,303 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<h1 class="text-h5 font-weight-bold">Audit Logs</h1>
|
||||
</div>
|
||||
<v-card>
|
||||
<v-card-text class="text-medium-emphasis">
|
||||
Audit logs record all changes made to your feature flags, segments, and environments.
|
||||
Select a project to view audit logs.
|
||||
</v-card-text>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<h1 class="text-h5 font-weight-bold">Audit Logs</h1>
|
||||
</div>
|
||||
|
||||
<!-- No project selected -->
|
||||
<v-card v-if="!contextStore.currentProject" variant="outlined" rounded="lg">
|
||||
<v-card-text class="text-center py-10">
|
||||
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-clipboard-text-clock-outline</v-icon>
|
||||
<p class="text-h6 mb-2">Select a project</p>
|
||||
<p class="text-body-2 text-medium-emphasis">
|
||||
Choose a project from the sidebar to view its audit logs.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- Filters -->
|
||||
<v-card variant="outlined" rounded="lg" class="mb-4 pa-4">
|
||||
<div class="d-flex align-center ga-3 flex-wrap">
|
||||
<v-select
|
||||
v-model="filter.resourceType"
|
||||
:items="resourceTypeOptions"
|
||||
label="Resource type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 160px; flex: 1"
|
||||
data-testid="filter-resource-type"
|
||||
@update:model-value="onFilterChange"
|
||||
/>
|
||||
<v-select
|
||||
v-model="filter.action"
|
||||
:items="actionOptions"
|
||||
label="Action"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="min-width: 140px; flex: 1"
|
||||
data-testid="filter-action"
|
||||
@update:model-value="onFilterChange"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="filter.from"
|
||||
label="From date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
type="date"
|
||||
style="min-width: 160px; flex: 1"
|
||||
data-testid="filter-from"
|
||||
@update:model-value="onFilterChange"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="filter.to"
|
||||
label="To date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
type="date"
|
||||
style="min-width: 160px; flex: 1"
|
||||
data-testid="filter-to"
|
||||
@update:model-value="onFilterChange"
|
||||
/>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="clear-filters-btn"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Table -->
|
||||
<v-card rounded="lg">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="logs"
|
||||
:loading="isLoading"
|
||||
:items-per-page="pageSize"
|
||||
hover
|
||||
data-testid="audit-logs-table"
|
||||
>
|
||||
<!-- Timestamp -->
|
||||
<template #item.createdAt="{ item }: { item: AuditLogResponse }">
|
||||
<span class="text-caption text-medium-emphasis">{{ formatDate(item.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Action chip -->
|
||||
<template #item.action="{ item }: { item: AuditLogResponse }">
|
||||
<v-chip
|
||||
:color="actionColor(item.action)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ item.action }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<!-- Resource type -->
|
||||
<template #item.resourceType="{ item }: { item: AuditLogResponse }">
|
||||
<span class="text-body-2">{{ item.resourceType }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Resource ID -->
|
||||
<template #item.resourceId="{ item }: { item: AuditLogResponse }">
|
||||
<span class="text-body-2 text-medium-emphasis">{{ item.resourceId }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Changes (expandable) -->
|
||||
<template #item.changes="{ item }: { item: AuditLogResponse }">
|
||||
<div v-if="item.changes">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:data-testid="`expand-changes-${item.id}`"
|
||||
@click="toggleChanges(item.id)"
|
||||
>
|
||||
{{ expandedIds.has(item.id) ? 'Hide' : 'Show' }}
|
||||
</v-btn>
|
||||
<pre
|
||||
v-if="expandedIds.has(item.id)"
|
||||
class="text-caption mt-1 pa-2 rounded"
|
||||
style="background: rgba(0,0,0,0.05); white-space: pre-wrap; word-break: break-all; max-width: 300px"
|
||||
:data-testid="`changes-${item.id}`"
|
||||
>{{ formatJson(item.changes) }}</pre>
|
||||
</div>
|
||||
<span v-else class="text-caption text-medium-emphasis">—</span>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-clipboard-text-clock-outline</v-icon>
|
||||
<p class="text-body-2 text-medium-emphasis">No audit log entries found.</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<!-- Manual pagination controls -->
|
||||
<div class="d-flex align-center justify-end pa-3 ga-2">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
Page {{ page }} of {{ totalPages }} ({{ total }} entries)
|
||||
</span>
|
||||
<v-btn
|
||||
icon="mdi-chevron-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="page <= 1 || isLoading"
|
||||
data-testid="prev-page-btn"
|
||||
@click="prevPage"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-chevron-right"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="page >= totalPages || isLoading"
|
||||
data-testid="next-page-btn"
|
||||
@click="nextPage"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<!-- Error snackbar -->
|
||||
<v-snackbar
|
||||
v-model="showErrorSnackbar"
|
||||
color="error"
|
||||
:timeout="4000"
|
||||
location="bottom"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { listAuditLogsByProject } from '@/api/auditLogs';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { AuditLogResponse } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
|
||||
const logs = ref<AuditLogResponse[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const isLoading = ref(false);
|
||||
const expandedIds = ref(new Set<number>());
|
||||
const showErrorSnackbar = ref(false);
|
||||
const snackbarMessage = ref('');
|
||||
|
||||
const filter = ref<{
|
||||
resourceType: string | null;
|
||||
action: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}>({ resourceType: null, action: null, from: null, to: null });
|
||||
|
||||
const resourceTypeOptions = [
|
||||
'Feature', 'FeatureState', 'Segment', 'Environment', 'Project',
|
||||
'Organization', 'Identity', 'ApiKey', 'Webhook',
|
||||
];
|
||||
const actionOptions = ['Created', 'Updated', 'Deleted', 'Enabled', 'Disabled'];
|
||||
|
||||
const headers = [
|
||||
{ title: 'Timestamp', key: 'createdAt', sortable: false, width: '160' },
|
||||
{ title: 'Action', key: 'action', sortable: false, width: '100' },
|
||||
{ title: 'Resource Type', key: 'resourceType', sortable: false, width: '140' },
|
||||
{ title: 'Resource ID', key: 'resourceId', sortable: false, width: '100' },
|
||||
{ title: 'Changes', key: 'changes', sortable: false },
|
||||
];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
|
||||
function showError(message: string): void {
|
||||
snackbarMessage.value = message;
|
||||
showErrorSnackbar.value = true;
|
||||
}
|
||||
|
||||
async function loadLogs(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) {
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await listAuditLogsByProject(projectId, {
|
||||
resourceType: filter.value.resourceType,
|
||||
action: filter.value.action,
|
||||
from: filter.value.from ? `${filter.value.from}T00:00:00Z` : null,
|
||||
to: filter.value.to ? `${filter.value.to}T23:59:59Z` : null,
|
||||
page: page.value,
|
||||
pageSize,
|
||||
});
|
||||
logs.value = result.results;
|
||||
total.value = result.count;
|
||||
} catch {
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
showError('Failed to load audit logs. Please refresh the page.');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => contextStore.currentProject, () => {
|
||||
page.value = 1;
|
||||
loadLogs();
|
||||
}, { immediate: true });
|
||||
|
||||
function onFilterChange(): void {
|
||||
page.value = 1;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
filter.value = { resourceType: null, action: null, from: null, to: null };
|
||||
page.value = 1;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function prevPage(): void {
|
||||
if (page.value > 1) { page.value--; loadLogs(); }
|
||||
}
|
||||
|
||||
function nextPage(): void {
|
||||
if (page.value < totalPages.value) { page.value++; loadLogs(); }
|
||||
}
|
||||
|
||||
function toggleChanges(id: number): void {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) { next.delete(id); } else { next.add(id); }
|
||||
expandedIds.value = next;
|
||||
}
|
||||
|
||||
function formatJson(raw: string): string {
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function actionColor(action: string): string {
|
||||
if (action === 'Created') return 'success';
|
||||
if (action === 'Deleted') return 'error';
|
||||
return 'primary';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,175 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<h1 class="text-h5 font-weight-bold">Identities</h1>
|
||||
</div>
|
||||
<v-card>
|
||||
<v-card-text class="text-medium-emphasis">
|
||||
Identities represent the individual users or services that consume your feature flags.
|
||||
Select an environment to manage identities.
|
||||
</v-card-text>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<h1 class="text-h5 font-weight-bold">Identities</h1>
|
||||
</div>
|
||||
|
||||
<!-- No environment selected -->
|
||||
<v-card v-if="!contextStore.currentEnvironment" variant="outlined" rounded="lg">
|
||||
<v-card-text class="text-center py-10">
|
||||
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-account-outline</v-icon>
|
||||
<p class="text-h6 mb-2">Select an environment</p>
|
||||
<p class="text-body-2 text-medium-emphasis">
|
||||
Choose an environment to view and manage identities.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- Search bar -->
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="Search identities"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="mb-4"
|
||||
data-testid="identity-search"
|
||||
/>
|
||||
|
||||
<!-- Identities table -->
|
||||
<v-card rounded="lg">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredIdentities"
|
||||
:loading="isLoading"
|
||||
:items-per-page="20"
|
||||
hover
|
||||
data-testid="identities-table"
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<!-- Identifier -->
|
||||
<template #item.identifier="{ item }: { item: IdentityResponse }">
|
||||
<span class="text-body-2 font-weight-medium">{{ item.identifier }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Trait count -->
|
||||
<template #item.traits="{ item }: { item: IdentityResponse }">
|
||||
<span class="text-body-2 text-medium-emphasis">{{ item.traits.length }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Created at -->
|
||||
<template #item.createdAt="{ item }: { item: IdentityResponse }">
|
||||
<span class="text-caption text-medium-emphasis">{{ formatDate(item.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Row actions -->
|
||||
<template #item.actions="{ item }: { item: IdentityResponse }">
|
||||
<v-btn
|
||||
icon="mdi-eye-outline"
|
||||
size="small"
|
||||
variant="text"
|
||||
:data-testid="`view-identity-${item.id}`"
|
||||
@click.stop="openDetail(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template #no-data>
|
||||
<div class="text-center py-8">
|
||||
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-account-outline</v-icon>
|
||||
<p class="text-body-2 text-medium-emphasis">No identities found in this environment.</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<!-- Identity detail dialog -->
|
||||
<IdentityDetail
|
||||
v-model="showDetail"
|
||||
:identity="selectedIdentity"
|
||||
@deleted="onIdentityDeleted"
|
||||
/>
|
||||
|
||||
<!-- Error snackbar -->
|
||||
<v-snackbar
|
||||
v-model="showErrorSnackbar"
|
||||
color="error"
|
||||
:timeout="4000"
|
||||
location="bottom"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { listIdentities } from '@/api/identities';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
||||
import type { IdentityResponse } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
|
||||
const identities = ref<IdentityResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const search = ref('');
|
||||
const showErrorSnackbar = ref(false);
|
||||
const snackbarMessage = ref('');
|
||||
|
||||
const showDetail = ref(false);
|
||||
const selectedIdentity = ref<IdentityResponse | null>(null);
|
||||
|
||||
const headers = [
|
||||
{ title: 'Identifier', key: 'identifier', sortable: true },
|
||||
{ title: 'Traits', key: 'traits', sortable: false, width: '80' },
|
||||
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
|
||||
{ title: '', key: 'actions', sortable: false, width: '50', align: 'end' as const },
|
||||
];
|
||||
|
||||
function showError(message: string): void {
|
||||
snackbarMessage.value = message;
|
||||
showErrorSnackbar.value = true;
|
||||
}
|
||||
|
||||
const filteredIdentities = computed(() => {
|
||||
if (!search.value) return identities.value;
|
||||
const q = search.value.toLowerCase();
|
||||
return identities.value.filter((i) => i.identifier.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
async function loadIdentities(): Promise<void> {
|
||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||
if (!envApiKey) {
|
||||
identities.value = [];
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await listIdentities(envApiKey, 1, 100);
|
||||
identities.value = result.results;
|
||||
} catch {
|
||||
identities.value = [];
|
||||
showError('Failed to load identities. Please refresh the page.');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => contextStore.currentEnvironment, loadIdentities, { immediate: true });
|
||||
|
||||
function openDetail(identity: IdentityResponse): void {
|
||||
selectedIdentity.value = identity;
|
||||
showDetail.value = true;
|
||||
}
|
||||
|
||||
function onRowClick(_event: Event, { item }: { item: IdentityResponse }): void {
|
||||
openDetail(item);
|
||||
}
|
||||
|
||||
function onIdentityDeleted(identityId: number): void {
|
||||
identities.value = identities.value.filter((i) => i.id !== identityId);
|
||||
showDetail.value = false;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { EnvironmentResponse, IdentityResponse, FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/identities');
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as identitiesApi from '@/api/identities';
|
||||
import * as featuresApi from '@/api/features';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockEnv: EnvironmentResponse = {
|
||||
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockIdentity: IdentityResponse = {
|
||||
id: 1,
|
||||
identifier: 'user-alice',
|
||||
environmentId: 100,
|
||||
traits: [{ key: 'plan', value: 'pro' }, { key: 'country', value: 'US' }],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockFeatures: FeatureResponse[] = [
|
||||
{ id: 10, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 20, name: 'beta_feature', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
const mockOverride: FeatureStateResponse = {
|
||||
id: 500, featureId: 10, environmentId: 100, identityId: 1, featureSegmentId: null,
|
||||
enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountDetail(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(IdentityDetail, {
|
||||
props: { modelValue: true, identity: mockIdentity, ...props },
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('IdentityDetail', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([]);
|
||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||
});
|
||||
|
||||
describe('WhenOpenedWithIdentity', () => {
|
||||
it('ThenDetailCardIsRendered', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="identity-detail"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenIdentifierIsDisplayed', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('user-alice');
|
||||
});
|
||||
|
||||
it('ThenTraitsAndOverridesTabsArePresent', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="tab-traits"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tab-overrides"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenTraitsTabIsActive', () => {
|
||||
it('ThenTraitsTableIsRendered', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenTraitRowsAreDisplayed', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="trait-row-country"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('plan');
|
||||
expect(wrapper.text()).toContain('pro');
|
||||
});
|
||||
|
||||
it('ThenEmptyMessageIsShownWhenNoTraits', async () => {
|
||||
const identityWithNoTraits: IdentityResponse = { ...mockIdentity, traits: [] };
|
||||
const wrapper = mountDetail({ identity: identityWithNoTraits });
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDeleteIdentityIsClicked', () => {
|
||||
it('ThenDeleteIdentityApiIsCalled', async () => {
|
||||
jest.mocked(identitiesApi.deleteIdentity).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="delete-identity-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.deleteIdentity).toHaveBeenCalledWith(mockEnv.apiKey, mockIdentity.id);
|
||||
});
|
||||
|
||||
it('ThenDeletedEventIsEmitted', async () => {
|
||||
jest.mocked(identitiesApi.deleteIdentity).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="delete-identity-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('deleted')).toBeTruthy();
|
||||
expect(wrapper.emitted('deleted')![0]).toEqual([mockIdentity.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOverridesTabIsActive', () => {
|
||||
it('ThenNoOverridesMessageIsShownWhenEmpty', async () => {
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-overrides-message"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenExistingOverridesAreDisplayed', async () => {
|
||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(true);
|
||||
expect(wrapper.find(`[data-testid="override-row-${mockOverride.featureId}"]`).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenFeatureNameIsShownInOverrideRow', async () => {
|
||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('dark_mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOverrideToggleIsChanged', () => {
|
||||
it('ThenSetIdentityFeatureStateIsCalledWithNewEnabled', async () => {
|
||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]);
|
||||
jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue({ ...mockOverride, enabled: false });
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent(`[data-testid="override-toggle-${mockOverride.featureId}"]`) as VueWrapper<any>).vm.$emit('update:modelValue', false);
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.setIdentityFeatureState).toHaveBeenCalledWith(
|
||||
mockEnv.apiKey,
|
||||
mockIdentity.id,
|
||||
mockOverride.featureId,
|
||||
{ enabled: false, value: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenRemoveOverrideIsClicked', () => {
|
||||
it('ThenDeleteIdentityFeatureStateIsCalledAndOverrideIsRemoved', async () => {
|
||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([mockOverride]);
|
||||
jest.mocked(identitiesApi.deleteIdentityFeatureState).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find(`[data-testid="remove-override-${mockOverride.featureId}"]`).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.deleteIdentityFeatureState).toHaveBeenCalledWith(
|
||||
mockEnv.apiKey,
|
||||
mockIdentity.id,
|
||||
mockOverride.featureId,
|
||||
);
|
||||
expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="no-overrides-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenAddOverrideIsSubmitted', () => {
|
||||
it('ThenSetIdentityFeatureStateIsCalledWithSelectedFeature', async () => {
|
||||
const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true };
|
||||
jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue(newOverride);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="add-override-feature-select"]') as VueWrapper<any>).vm.$emit('update:modelValue', 20);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="add-override-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.setIdentityFeatureState).toHaveBeenCalledWith(
|
||||
mockEnv.apiKey,
|
||||
mockIdentity.id,
|
||||
20,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenOverrideIsAddedToList', async () => {
|
||||
const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true };
|
||||
jest.mocked(identitiesApi.setIdentityFeatureState).mockResolvedValue(newOverride);
|
||||
|
||||
const wrapper = mountDetail();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-overrides"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="add-override-feature-select"]') as VueWrapper<any>).vm.$emit('update:modelValue', 20);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="add-override-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="overrides-table"]').exists()).toBe(true);
|
||||
expect(wrapper.find(`[data-testid="override-row-20"]`).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import WebhookDeliveryHistory from '@/components/settings/WebhookDeliveryHistory.vue';
|
||||
import type { WebhookDeliveryLogResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/webhooks');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as webhooksApi from '@/api/webhooks';
|
||||
|
||||
const mockDeliveries: WebhookDeliveryLogResponse[] = [
|
||||
{
|
||||
id: 1, webhookId: 10, eventType: 'feature.updated', success: true,
|
||||
responseStatusCode: 200, responseBody: 'OK', errorMessage: null,
|
||||
attemptNumber: 1, attemptedAt: '2026-01-10T10:00:00Z', duration: '00:00:00.123',
|
||||
},
|
||||
{
|
||||
id: 2, webhookId: 10, eventType: 'feature.created', success: false,
|
||||
responseStatusCode: 500, responseBody: null, errorMessage: 'Internal Server Error',
|
||||
attemptNumber: 3, attemptedAt: '2026-01-11T11:00:00Z', duration: '00:00:01.500',
|
||||
},
|
||||
];
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(WebhookDeliveryHistory, {
|
||||
props: { envApiKey: 'env-dev', webhookId: 10, ...props },
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebhookDeliveryHistory', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenMounted', () => {
|
||||
it('ThenDeliveriesAreLoaded', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.listWebhookDeliveries).toHaveBeenCalledWith('env-dev', 10);
|
||||
});
|
||||
|
||||
it('ThenNoDeliveriesMessageIsShownWhenEmpty', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-deliveries-message"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenDeliveriesTableIsRendered', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="deliveries-table"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="delivery-row-1"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="delivery-row-2"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenRenderingDeliveryStatuses', () => {
|
||||
it('ThenSuccessfulDeliveryShowsSuccessChip', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const successChip = wrapper.find('[data-testid="delivery-status-1"]');
|
||||
expect(successChip.exists()).toBe(true);
|
||||
expect(successChip.text()).toContain('Success');
|
||||
});
|
||||
|
||||
it('ThenFailedDeliveryShowsFailedChip', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const failedChip = wrapper.find('[data-testid="delivery-status-2"]');
|
||||
expect(failedChip.exists()).toBe(true);
|
||||
expect(failedChip.text()).toContain('Failed');
|
||||
});
|
||||
|
||||
it('ThenEventTypesAreDisplayed', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('feature.updated');
|
||||
expect(wrapper.text()).toContain('feature.created');
|
||||
});
|
||||
|
||||
it('ThenAttemptNumbersAreDisplayed', async () => {
|
||||
jest.mocked(webhooksApi.listWebhookDeliveries).mockResolvedValue(mockDeliveries);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('3'); // attemptNumber of second delivery
|
||||
});
|
||||
});
|
||||
});
|
||||
131
src/admin/tests/unit/components/settings/WebhookDialog.spec.ts
Normal file
131
src/admin/tests/unit/components/settings/WebhookDialog.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import WebhookDialog from '@/components/settings/WebhookDialog.vue';
|
||||
import type { WebhookResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/webhooks');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as webhooksApi from '@/api/webhooks';
|
||||
|
||||
const mockWebhook: WebhookResponse = {
|
||||
id: 1, url: 'https://example.com/hook', secret: 'mysecret', scope: 'Organization',
|
||||
enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(WebhookDialog, {
|
||||
props: { modelValue: true, orgId: 1, ...props },
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebhookDialog', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenOpenedForCreate', () => {
|
||||
it('ThenDialogIsRendered', () => {
|
||||
const wrapper = mountDialog();
|
||||
expect(wrapper.find('[data-testid="webhook-dialog"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenTitleShowsAddWebhook', () => {
|
||||
const wrapper = mountDialog();
|
||||
expect(wrapper.text()).toContain('Add Webhook');
|
||||
});
|
||||
|
||||
it('ThenCreateOrgWebhookIsCalledOnSave', async () => {
|
||||
const created = { ...mockWebhook, id: 99 };
|
||||
jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created);
|
||||
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.createOrgWebhook).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ url: 'https://example.com/new' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenSavedEventIsEmitted', async () => {
|
||||
const created = { ...mockWebhook, id: 99 };
|
||||
jest.mocked(webhooksApi.createOrgWebhook).mockResolvedValue(created);
|
||||
|
||||
const wrapper = mountDialog();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
expect(wrapper.emitted('saved')![0]).toEqual([created]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOpenedForEdit', () => {
|
||||
it('ThenTitleShowsEditWebhook', () => {
|
||||
const wrapper = mountDialog({ webhook: mockWebhook });
|
||||
expect(wrapper.text()).toContain('Edit Webhook');
|
||||
});
|
||||
|
||||
it('ThenFormIsPrePopulatedWithWebhookData', () => {
|
||||
const wrapper = mountDialog({ webhook: mockWebhook });
|
||||
const urlInput = wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>;
|
||||
expect(urlInput.props('modelValue')).toBe(mockWebhook.url);
|
||||
});
|
||||
|
||||
it('ThenUpdateOrgWebhookIsCalledOnSave', async () => {
|
||||
const updated = { ...mockWebhook, url: 'https://example.com/updated' };
|
||||
jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue(updated);
|
||||
|
||||
const wrapper = mountDialog({ webhook: mockWebhook });
|
||||
|
||||
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith(
|
||||
1, mockWebhook.id,
|
||||
expect.objectContaining({ url: mockWebhook.url }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvScopedDialogIsUsed', () => {
|
||||
it('ThenCreateEnvWebhookIsCalledOnSave', async () => {
|
||||
const created = { ...mockWebhook, id: 99, scope: 'Environment' as const };
|
||||
jest.mocked(webhooksApi.createEnvWebhook).mockResolvedValue(created);
|
||||
|
||||
const wrapper = mountDialog({ orgId: undefined, envApiKey: 'env-dev' });
|
||||
|
||||
await (wrapper.findComponent('[data-testid="webhook-url-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'https://example.com/new');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-webhook-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.createEnvWebhook).toHaveBeenCalledWith(
|
||||
'env-dev',
|
||||
expect.objectContaining({ url: 'https://example.com/new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
src/admin/tests/unit/components/settings/WebhooksPanel.spec.ts
Normal file
149
src/admin/tests/unit/components/settings/WebhooksPanel.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import WebhooksPanel from '@/components/settings/WebhooksPanel.vue';
|
||||
import type { WebhookResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/webhooks');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as webhooksApi from '@/api/webhooks';
|
||||
|
||||
const mockOrgWebhooks: WebhookResponse[] = [
|
||||
{
|
||||
id: 1, url: 'https://example.com/hook1', secret: null, scope: 'Organization',
|
||||
enabled: true, environmentId: null, organizationId: 1, createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2, url: 'https://example.com/hook2', secret: 'abc', scope: 'Organization',
|
||||
enabled: false, environmentId: null, organizationId: 1, createdAt: '2026-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountPanel(props: Record<string, unknown> = {}) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(WebhooksPanel, {
|
||||
props: { orgId: 1, ...props },
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebhooksPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenMountedWithOrgScope', () => {
|
||||
it('ThenOrgWebhooksAreLoaded', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
|
||||
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.listOrgWebhooks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('ThenWebhooksTableIsRendered', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="webhooks-table"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('https://example.com/hook1');
|
||||
});
|
||||
|
||||
it('ThenNoWebhooksMessageIsShownWhenEmpty', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="no-webhooks-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenMountedWithEnvScope', () => {
|
||||
it('ThenEnvWebhooksAreLoaded', async () => {
|
||||
jest.mocked(webhooksApi.listEnvWebhooks).mockResolvedValue([]);
|
||||
|
||||
mountPanel({ orgId: undefined, envApiKey: 'env-dev' });
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.listEnvWebhooks).toHaveBeenCalledWith('env-dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnabledToggleIsChanged', () => {
|
||||
it('ThenUpdateOrgWebhookIsCalledWithToggledEnabled', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue(mockOrgWebhooks);
|
||||
jest.mocked(webhooksApi.updateOrgWebhook).mockResolvedValue({ ...mockOrgWebhooks[0], enabled: false });
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
await (wrapper.findComponent(`[data-testid="webhook-enabled-toggle-1"]`) as VueWrapper<any>).vm.$emit('update:modelValue', false);
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.updateOrgWebhook).toHaveBeenCalledWith(
|
||||
1, 1,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDeleteIsConfirmed', () => {
|
||||
it('ThenDeleteOrgWebhookIsCalledAndWebhookIsRemoved', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]);
|
||||
jest.mocked(webhooksApi.deleteOrgWebhook).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="delete-webhook-1"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="confirm-delete-webhook-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(webhooksApi.deleteOrgWebhook).toHaveBeenCalledWith(1, 1);
|
||||
expect(wrapper.text()).not.toContain('https://example.com/hook1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenWebhookIsSaved', () => {
|
||||
it('ThenNewWebhookIsAddedToList', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const newHook: WebhookResponse = { ...mockOrgWebhooks[0], id: 99, url: 'https://new.example.com/hook' };
|
||||
await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', newHook);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('https://new.example.com/hook');
|
||||
});
|
||||
|
||||
it('ThenExistingWebhookIsUpdatedInList', async () => {
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([...mockOrgWebhooks]);
|
||||
|
||||
const wrapper = mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const updated: WebhookResponse = { ...mockOrgWebhooks[0], url: 'https://updated.example.com/hook' };
|
||||
await wrapper.findComponent({ name: 'WebhookDialog' }).vm.$emit('saved', updated);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('https://updated.example.com/hook');
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/admin/tests/unit/views/AuditLogsView.spec.ts
Normal file
212
src/admin/tests/unit/views/AuditLogsView.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AuditLogsView from '@/views/AuditLogsView.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { ProjectResponse, AuditLogResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/auditLogs');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as auditLogsApi from '@/api/auditLogs';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockLogs: AuditLogResponse[] = [
|
||||
{
|
||||
id: 1, resourceType: 'Feature', resourceId: '42', action: 'Created',
|
||||
changes: null, organizationId: 1, projectId: 10, environmentId: null,
|
||||
actorUserId: 5, createdAt: '2026-01-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2, resourceType: 'FeatureState', resourceId: '99', action: 'Updated',
|
||||
changes: '{"enabled":true}', organizationId: 1, projectId: 10, environmentId: 100,
|
||||
actorUserId: 5, createdAt: '2026-01-11T11:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
function mountView() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(AuditLogsView, { global: { plugins: [router] } });
|
||||
}
|
||||
|
||||
describe('AuditLogsView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenNoProjectIsSelected', () => {
|
||||
it('ThenEmptyStateIsShown', () => {
|
||||
const wrapper = mountView();
|
||||
expect(wrapper.find('[data-testid="audit-logs-table"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Select a project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSelected', () => {
|
||||
it('ThenAuditLogsAreLoaded', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockLogs,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ page: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ThenAuditLogsTableIsRendered', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockLogs,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="audit-logs-table"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Feature');
|
||||
expect(wrapper.text()).toContain('Created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenResourceTypeFilterIsApplied', () => {
|
||||
it('ThenLogsAreReloadedWithFilter', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 1, next: null, previous: null, results: [mockLogs[0]],
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="filter-resource-type"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Feature');
|
||||
await flushPromises();
|
||||
|
||||
expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ resourceType: 'Feature', page: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenActionFilterIsApplied', () => {
|
||||
it('ThenLogsAreReloadedWithActionFilter', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 1, next: null, previous: null, results: [mockLogs[0]],
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="filter-action"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Created');
|
||||
await flushPromises();
|
||||
|
||||
expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ action: 'Created', page: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDateRangeFilterIsApplied', () => {
|
||||
it('ThenLogsAreReloadedWithDateRange', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 1, next: null, previous: null, results: [mockLogs[0]],
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="filter-from"]') as VueWrapper<any>).vm.$emit('update:modelValue', '2026-01-01');
|
||||
await flushPromises();
|
||||
|
||||
expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ from: '2026-01-01T00:00:00Z' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenClearFiltersIsClicked', () => {
|
||||
it('ThenFiltersAreResetAndLogsReloaded', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockLogs,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockClear();
|
||||
|
||||
await wrapper.find('[data-testid="clear-filters-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(auditLogsApi.listAuditLogsByProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ resourceType: null, action: null, from: null, to: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenChangesExpandButtonIsClicked', () => {
|
||||
it('ThenChangesContentIsDisplayed', async () => {
|
||||
jest.mocked(auditLogsApi.listAuditLogsByProject).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockLogs,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// log id=2 has changes
|
||||
await wrapper.find('[data-testid="expand-changes-2"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="changes-2"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="changes-2"]').text()).toContain('enabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
180
src/admin/tests/unit/views/IdentitiesView.spec.ts
Normal file
180
src/admin/tests/unit/views/IdentitiesView.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import IdentitiesView from '@/views/IdentitiesView.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type { ProjectResponse, EnvironmentResponse, IdentityResponse } from '@/types/api';
|
||||
|
||||
jest.mock('@/api/identities');
|
||||
jest.mock('@/api/features');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as identitiesApi from '@/api/identities';
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockEnv: EnvironmentResponse = {
|
||||
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const mockIdentities: IdentityResponse[] = [
|
||||
{ id: 1, identifier: 'user-alice', environmentId: 100, traits: [{ key: 'plan', value: 'pro' }], createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 2, identifier: 'user-bob', environmentId: 100, traits: [], createdAt: '2026-01-02T00:00:00Z' },
|
||||
];
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountView() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(IdentitiesView, {
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('IdentitiesView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenNoEnvironmentIsSelected', () => {
|
||||
it('ThenEmptyStateIsShown', () => {
|
||||
const wrapper = mountView();
|
||||
expect(wrapper.find('[data-testid="identities-table"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Select an environment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvironmentIsSelected', () => {
|
||||
it('ThenIdentitiesAreLoaded', async () => {
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockIdentities,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.listIdentities).toHaveBeenCalledWith(mockEnv.apiKey, 1, 100);
|
||||
});
|
||||
|
||||
it('ThenIdentitiesTableIsRendered', async () => {
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockIdentities,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="identities-table"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('user-alice');
|
||||
expect(wrapper.text()).toContain('user-bob');
|
||||
});
|
||||
|
||||
it('ThenSearchFilterIsApplied', async () => {
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockIdentities,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
// Set search value
|
||||
const searchInput = wrapper.find('[data-testid="identity-search"]');
|
||||
await searchInput.find('input').setValue('alice');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain('user-alice');
|
||||
expect(wrapper.text()).not.toContain('user-bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenViewButtonIsClicked', () => {
|
||||
it('ThenIdentityDetailIsOpened', async () => {
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: mockIdentities,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="view-identity-1"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const detail = wrapper.findComponent({ name: 'IdentityDetail' });
|
||||
expect(detail.props('identity')).toEqual(mockIdentities[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenIdentityIsDeleted', () => {
|
||||
it('ThenIdentityIsRemovedFromList', async () => {
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 2, next: null, previous: null, results: [...mockIdentities],
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.findComponent({ name: 'IdentityDetail' }).vm.$emit('deleted', 1);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).not.toContain('user-alice');
|
||||
expect(wrapper.text()).toContain('user-bob');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvironmentChanges', () => {
|
||||
it('ThenIdentitiesAreReloaded', async () => {
|
||||
const mockEnv2: EnvironmentResponse = {
|
||||
id: 200, name: 'Production', apiKey: 'env-prod', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
jest.mocked(identitiesApi.listIdentities).mockResolvedValue({
|
||||
count: 0, next: null, previous: null, results: [],
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
|
||||
contextStore.setProject(mockProject);
|
||||
contextStore.setEnvironment(mockEnv);
|
||||
|
||||
mountView();
|
||||
await flushPromises();
|
||||
|
||||
contextStore.setEnvironment(mockEnv2);
|
||||
await flushPromises();
|
||||
|
||||
expect(identitiesApi.listIdentities).toHaveBeenCalledTimes(2);
|
||||
expect(identitiesApi.listIdentities).toHaveBeenLastCalledWith(mockEnv2.apiKey, 1, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user