Adding Organiztion, environment and project. Segment fix, tag fix
This commit is contained in:
24
src/admin/src/api/apiKeys.ts
Normal file
24
src/admin/src/api/apiKeys.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import apiClient from './client';
|
||||
import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '@/types/api';
|
||||
|
||||
export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> {
|
||||
const { data } = await apiClient.get<ApiKeyResponse[]>(
|
||||
`/v1/organisations/${organizationId}/api-keys`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
organizationId: number,
|
||||
request: CreateApiKeyRequest,
|
||||
): Promise<CreateApiKeyResponse> {
|
||||
const { data } = await apiClient.post<CreateApiKeyResponse>(
|
||||
`/v1/organisations/${organizationId}/api-keys`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> {
|
||||
await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
|
||||
}
|
||||
@@ -3,11 +3,15 @@ import type {
|
||||
SegmentResponse,
|
||||
CreateSegmentRequest,
|
||||
UpdateSegmentRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
export async function listSegments(projectId: number): Promise<SegmentResponse[]> {
|
||||
const { data } = await apiClient.get<SegmentResponse[]>(`/v1/projects/${projectId}/segments`);
|
||||
return data;
|
||||
export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>(
|
||||
`/v1/projects/${projectId}/segments`,
|
||||
{ params: { page, pageSize } },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
|
||||
export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> {
|
||||
|
||||
@@ -43,13 +43,59 @@
|
||||
>
|
||||
Select a project first.
|
||||
</p>
|
||||
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mt-1 px-1"
|
||||
:disabled="!contextStore.currentProject"
|
||||
data-testid="create-env-btn"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
Create environment
|
||||
</v-btn>
|
||||
|
||||
<!-- Create Environment Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="420" data-testid="create-env-dialog">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-5 pb-3">Create environment</v-card-title>
|
||||
<v-card-text class="pa-5 pt-0">
|
||||
<v-text-field
|
||||
v-model="newEnvName"
|
||||
label="Environment name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
autofocus
|
||||
:error-messages="createError ? [createError] : []"
|
||||
data-testid="env-name-input"
|
||||
@keydown.enter="onCreateConfirm"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-5 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="create-env-cancel-btn" @click="closeDialog">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isSaving"
|
||||
:disabled="!newEnvName.trim()"
|
||||
data-testid="create-env-confirm-btn"
|
||||
@click="onCreateConfirm"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import { listEnvironments } from '@/api/environments';
|
||||
import { listEnvironments, createEnvironment } from '@/api/environments';
|
||||
import type { EnvironmentResponse } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
@@ -57,6 +103,11 @@ const contextStore = useContextStore();
|
||||
const environments = ref<EnvironmentResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const showDialog = ref(false);
|
||||
const newEnvName = ref('');
|
||||
const isSaving = ref(false);
|
||||
const createError = ref('');
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
if (!contextStore.currentEnvironment) return undefined;
|
||||
const idx = environments.value.findIndex((e) => e.id === contextStore.currentEnvironment!.id);
|
||||
@@ -83,6 +134,34 @@ function onEnvironmentSelected(index: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog(): void {
|
||||
newEnvName.value = '';
|
||||
createError.value = '';
|
||||
showDialog.value = true;
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
async function onCreateConfirm(): Promise<void> {
|
||||
const name = newEnvName.value.trim();
|
||||
if (!name || !contextStore.currentProject) return;
|
||||
|
||||
isSaving.value = true;
|
||||
createError.value = '';
|
||||
try {
|
||||
const created = await createEnvironment({ name, projectId: contextStore.currentProject.id });
|
||||
environments.value = [...environments.value, created];
|
||||
contextStore.setEnvironment(created);
|
||||
closeDialog();
|
||||
} catch {
|
||||
createError.value = 'Failed to create environment. Please try again.';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => contextStore.currentProject,
|
||||
(project) => {
|
||||
|
||||
@@ -16,20 +16,58 @@
|
||||
data-testid="org-select"
|
||||
@update:model-value="onOrgSelected"
|
||||
/>
|
||||
<p
|
||||
v-if="!isLoading && organizations.length === 0"
|
||||
class="text-caption text-medium-emphasis mt-1 px-1"
|
||||
data-testid="org-empty-message"
|
||||
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mt-1 px-1"
|
||||
data-testid="create-org-btn"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
No organizations available.
|
||||
</p>
|
||||
Create organization
|
||||
</v-btn>
|
||||
|
||||
<!-- Create Organization Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="420" data-testid="create-org-dialog">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-5 pb-3">Create organization</v-card-title>
|
||||
<v-card-text class="pa-5 pt-0">
|
||||
<v-text-field
|
||||
v-model="newOrgName"
|
||||
label="Organization name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
autofocus
|
||||
:error-messages="createError ? [createError] : []"
|
||||
data-testid="org-name-input"
|
||||
@keydown.enter="onCreateConfirm"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-5 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="create-org-cancel-btn" @click="closeDialog">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isSaving"
|
||||
:disabled="!newOrgName.trim()"
|
||||
data-testid="create-org-confirm-btn"
|
||||
@click="onCreateConfirm"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import { listOrganizations } from '@/api/organizations';
|
||||
import { listOrganizations, createOrganization } from '@/api/organizations';
|
||||
import type { OrganizationResponse } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
@@ -37,6 +75,11 @@ const contextStore = useContextStore();
|
||||
const organizations = ref<OrganizationResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const showDialog = ref(false);
|
||||
const newOrgName = ref('');
|
||||
const isSaving = ref(false);
|
||||
const createError = ref('');
|
||||
|
||||
async function fetchOrganizations(): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
@@ -52,5 +95,33 @@ function onOrgSelected(org: OrganizationResponse | null): void {
|
||||
contextStore.setOrganization(org);
|
||||
}
|
||||
|
||||
function openCreateDialog(): void {
|
||||
newOrgName.value = '';
|
||||
createError.value = '';
|
||||
showDialog.value = true;
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
async function onCreateConfirm(): Promise<void> {
|
||||
const name = newOrgName.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
isSaving.value = true;
|
||||
createError.value = '';
|
||||
try {
|
||||
const created = await createOrganization({ name });
|
||||
organizations.value = [...organizations.value, created];
|
||||
contextStore.setOrganization(created);
|
||||
closeDialog();
|
||||
} catch {
|
||||
createError.value = 'Failed to create organization. Please try again.';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchOrganizations);
|
||||
</script>
|
||||
|
||||
@@ -17,13 +17,59 @@
|
||||
data-testid="project-select"
|
||||
@update:model-value="onProjectSelected"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
class="mt-1 px-1"
|
||||
:disabled="!contextStore.currentOrganization"
|
||||
data-testid="create-project-btn"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
Create project
|
||||
</v-btn>
|
||||
|
||||
<!-- Create Project Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="420" data-testid="create-project-dialog">
|
||||
<v-card rounded="lg">
|
||||
<v-card-title class="pa-5 pb-3">Create project</v-card-title>
|
||||
<v-card-text class="pa-5 pt-0">
|
||||
<v-text-field
|
||||
v-model="newProjectName"
|
||||
label="Project name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
autofocus
|
||||
:error-messages="createError ? [createError] : []"
|
||||
data-testid="project-name-input"
|
||||
@keydown.enter="onCreateConfirm"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-5 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="create-project-cancel-btn" @click="closeDialog">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isSaving"
|
||||
:disabled="!newProjectName.trim()"
|
||||
data-testid="create-project-confirm-btn"
|
||||
@click="onCreateConfirm"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import { listProjects } from '@/api/projects';
|
||||
import { listProjects, createProject } from '@/api/projects';
|
||||
import type { ProjectResponse } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
@@ -31,6 +77,11 @@ const contextStore = useContextStore();
|
||||
const projects = ref<ProjectResponse[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const showDialog = ref(false);
|
||||
const newProjectName = ref('');
|
||||
const isSaving = ref(false);
|
||||
const createError = ref('');
|
||||
|
||||
async function fetchProjects(organizationId: number): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
@@ -44,6 +95,34 @@ function onProjectSelected(project: ProjectResponse | null): void {
|
||||
contextStore.setProject(project);
|
||||
}
|
||||
|
||||
function openCreateDialog(): void {
|
||||
newProjectName.value = '';
|
||||
createError.value = '';
|
||||
showDialog.value = true;
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
async function onCreateConfirm(): Promise<void> {
|
||||
const name = newProjectName.value.trim();
|
||||
if (!name || !contextStore.currentOrganization) return;
|
||||
|
||||
isSaving.value = true;
|
||||
createError.value = '';
|
||||
try {
|
||||
const created = await createProject({ name, organizationId: contextStore.currentOrganization.id });
|
||||
projects.value = [...projects.value, created];
|
||||
contextStore.setProject(created);
|
||||
closeDialog();
|
||||
} catch {
|
||||
createError.value = 'Failed to create project. Please try again.';
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => contextStore.currentOrganization,
|
||||
(org) => {
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
v-for="(condition, ci) in rule.conditions"
|
||||
:key="ci"
|
||||
:condition="condition"
|
||||
@update:condition="onUpdateCondition(ci, $event)"
|
||||
@remove="onRemoveCondition(ci)"
|
||||
@update:condition="onUpdateCondition(Number(ci), $event)"
|
||||
@remove="onRemoveCondition(Number(ci))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
:key="ri"
|
||||
:rule="child"
|
||||
:removable="true"
|
||||
@update:rule="onUpdateChildRule(ri, $event)"
|
||||
@remove="onRemoveChildRule(ri)"
|
||||
@update:rule="onUpdateChildRule(Number(ri), $event)"
|
||||
@remove="onRemoveChildRule(Number(ri))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ function onUpdateCondition(index: number, condition: SegmentCondition): void {
|
||||
}
|
||||
|
||||
function onRemoveCondition(index: number): void {
|
||||
const conditions = props.rule.conditions.filter((_, i) => i !== index);
|
||||
const conditions = props.rule.conditions.filter((_: SegmentCondition, i: number) => i !== index);
|
||||
emit('update:rule', { ...props.rule, conditions });
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ function onUpdateChildRule(index: number, child: SegmentRule): void {
|
||||
}
|
||||
|
||||
function onRemoveChildRule(index: number): void {
|
||||
const childRules = (props.rule.childRules ?? []).filter((_, i) => i !== index);
|
||||
const childRules = (props.rule.childRules ?? []).filter((_: SegmentRule, i: number) => i !== index);
|
||||
emit('update:rule', { ...props.rule, childRules });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,78 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 mb-4">Profile</h1>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
Manage your account details and preferences. Logged in as John Doe.
|
||||
<div>
|
||||
<h1 class="text-h5 font-weight-bold mb-6">Profile</h1>
|
||||
|
||||
<v-card rounded="lg" max-width="480" data-testid="profile-card">
|
||||
<v-card-text class="pa-6">
|
||||
<div class="d-flex align-center ga-4 mb-6">
|
||||
<v-avatar color="primary" size="56">
|
||||
<v-icon size="32">mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<p class="text-body-1 font-weight-medium mb-0" data-testid="profile-email">
|
||||
{{ authStore.accessToken ? 'Authenticated user' : 'Not signed in' }}
|
||||
</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
Token expires: {{ expiresDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-2" v-if="contextStore.currentOrganization">
|
||||
<div>
|
||||
<p class="text-body-2 font-weight-medium mb-0">Organization</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0" data-testid="profile-org-name">
|
||||
{{ contextStore.currentOrganization?.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-logout"
|
||||
block
|
||||
:loading="isLoggingOut"
|
||||
data-testid="logout-btn"
|
||||
@click="onLogout"
|
||||
>
|
||||
Sign out
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
export default defineComponent({ name: 'ProfileView' });
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const contextStore = useContextStore();
|
||||
const router = useRouter();
|
||||
|
||||
const isLoggingOut = ref(false);
|
||||
|
||||
const expiresDisplay = computed(() => {
|
||||
if (!authStore.expiresAt) return 'Unknown';
|
||||
return new Date(authStore.expiresAt).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
});
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
isLoggingOut.value = true;
|
||||
try {
|
||||
await authStore.logout();
|
||||
await router.push({ name: 'Login' });
|
||||
} finally {
|
||||
isLoggingOut.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,938 @@
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 mb-4">Settings</h1>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
Application-wide configuration and administration settings.
|
||||
<div>
|
||||
<h1 class="text-h5 font-weight-bold mb-4">Settings</h1>
|
||||
|
||||
<v-alert
|
||||
v-if="!contextStore.currentOrganization"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
>
|
||||
Select an organization to manage settings.
|
||||
</v-alert>
|
||||
|
||||
<template v-else>
|
||||
<v-tabs v-model="activeTab" color="primary" class="mb-4">
|
||||
<v-tab value="organization" data-testid="tab-organization">Organization</v-tab>
|
||||
<v-tab value="project" data-testid="tab-project" :disabled="!contextStore.currentProject">Project</v-tab>
|
||||
<v-tab value="environments" data-testid="tab-environments" :disabled="!contextStore.currentProject">Environments</v-tab>
|
||||
<v-tab value="api-keys" data-testid="tab-api-keys">API Keys</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="activeTab">
|
||||
<!-- ── Organization tab ───────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="organization" class="pt-2">
|
||||
<v-alert v-if="orgError" type="error" variant="tonal" density="compact" closable class="mb-4" @click:close="orgError = null">{{ orgError }}</v-alert>
|
||||
<v-alert v-if="orgSuccess" type="success" variant="tonal" density="compact" closable class="mb-4" @click:close="orgSuccess = null">{{ orgSuccess }}</v-alert>
|
||||
|
||||
<!-- Org name -->
|
||||
<v-card rounded="lg" class="mb-4">
|
||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Organization Name</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-text-field
|
||||
v-model="orgNameInput"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 360px"
|
||||
data-testid="org-name-input"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="isSavingOrg"
|
||||
data-testid="save-org-name-btn"
|
||||
@click="onSaveOrgName"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Org members -->
|
||||
<v-card rounded="lg" class="mb-4">
|
||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Members</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-table v-if="orgMembers.length > 0" density="compact" class="mb-3" data-testid="members-table">
|
||||
<thead><tr><th>User ID</th><th>Role</th><th style="width:60px"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="member in orgMembers" :key="member.userId" :data-testid="`member-row-${member.userId}`">
|
||||
<td class="text-body-2">{{ member.userId }}</td>
|
||||
<td><v-chip size="x-small" :color="member.role === 'Admin' ? 'primary' : 'default'" variant="tonal">{{ member.role }}</v-chip></td>
|
||||
<td>
|
||||
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error"
|
||||
:data-testid="`remove-member-${member.userId}`" @click="onRemoveMember(member.userId)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="text-body-2 text-medium-emphasis mb-3" data-testid="no-members-message">No members found.</p>
|
||||
|
||||
<!-- Invite form -->
|
||||
<div class="d-flex align-center ga-2 flex-wrap">
|
||||
<v-text-field
|
||||
v-model="inviteUserId"
|
||||
label="User ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
type="number"
|
||||
style="max-width: 120px"
|
||||
data-testid="invite-user-id-input"
|
||||
/>
|
||||
<v-select
|
||||
v-model="inviteRole"
|
||||
:items="['Admin', 'User']"
|
||||
label="Role"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 120px"
|
||||
data-testid="invite-role-select"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:disabled="!inviteUserId"
|
||||
:loading="isInviting"
|
||||
data-testid="invite-member-btn"
|
||||
@click="onInviteMember"
|
||||
>
|
||||
Invite
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Org webhooks -->
|
||||
<v-card rounded="lg">
|
||||
<v-card-text class="pa-4">
|
||||
<WebhooksPanel :org-id="contextStore.currentOrganization?.id" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- ── Project tab ─────────────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="project" class="pt-2">
|
||||
<v-alert v-if="projectError" type="error" variant="tonal" density="compact" closable class="mb-4" @click:close="projectError = null">{{ projectError }}</v-alert>
|
||||
<v-alert v-if="projectSuccess" type="success" variant="tonal" density="compact" closable class="mb-4" @click:close="projectSuccess = null">{{ projectSuccess }}</v-alert>
|
||||
|
||||
<v-card rounded="lg" class="mb-4" v-if="contextStore.currentProject">
|
||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Project Settings</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<div class="d-flex align-center ga-3 mb-3">
|
||||
<v-text-field
|
||||
v-model="projectNameInput"
|
||||
label="Project name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 360px"
|
||||
data-testid="project-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex align-center justify-space-between mb-4" style="max-width: 360px">
|
||||
<div>
|
||||
<p class="text-body-2 font-weight-medium mb-0">Hide disabled flags</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0">Hide flags that are off in all environments</p>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="hideDisabledFlags"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
data-testid="hide-disabled-flags-toggle"
|
||||
/>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="isSavingProject"
|
||||
data-testid="save-project-btn"
|
||||
@click="onSaveProject"
|
||||
>
|
||||
Save project settings
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- User Permissions -->
|
||||
<v-card rounded="lg" v-if="contextStore.currentProject">
|
||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">User Permissions</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-table v-if="permissions.length > 0" density="compact" class="mb-4" data-testid="permissions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 100px">User ID</th>
|
||||
<th style="width: 100px">Admin</th>
|
||||
<th>Permissions</th>
|
||||
<th style="width: 60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="perm in permissions" :key="perm.userId" :data-testid="`permission-row-${perm.userId}`">
|
||||
<td class="text-body-2">{{ perm.userId }}</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="perm.isAdmin"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:data-testid="`perm-admin-toggle-${perm.userId}`"
|
||||
@update:model-value="onToggleAdmin(perm, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap ga-1">
|
||||
<v-chip
|
||||
v-for="p in perm.permissions"
|
||||
:key="p"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
>{{ p }}</v-chip>
|
||||
<span v-if="perm.isAdmin" class="text-caption text-medium-emphasis">All permissions (admin)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error"
|
||||
:data-testid="`remove-permission-${perm.userId}`" @click="onRemovePermission(perm.userId)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p v-else class="text-body-2 text-medium-emphasis mb-4" data-testid="no-permissions-message">No user permissions configured.</p>
|
||||
|
||||
<!-- Add user permissions -->
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-permission-btn"
|
||||
@click="showAddPermission = !showAddPermission"
|
||||
>
|
||||
Add user
|
||||
</v-btn>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card v-if="showAddPermission" variant="outlined" rounded="lg" class="mt-3 pa-3" data-testid="add-permission-form">
|
||||
<div class="d-flex align-center ga-2 mb-3 flex-wrap">
|
||||
<v-text-field
|
||||
v-model="newPermUserId"
|
||||
label="User ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
type="number"
|
||||
style="max-width: 120px"
|
||||
data-testid="new-perm-user-id-input"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="newPermIsAdmin"
|
||||
label="Admin"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
data-testid="new-perm-admin-toggle"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap ga-2 mb-3">
|
||||
<v-checkbox
|
||||
v-for="p in allPermissions"
|
||||
:key="p"
|
||||
:label="p"
|
||||
:model-value="newPermissions.includes(p)"
|
||||
:disabled="newPermIsAdmin"
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:data-testid="`new-perm-checkbox-${p}`"
|
||||
@update:model-value="toggleNewPermission(p, $event)"
|
||||
/>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:disabled="!newPermUserId"
|
||||
:loading="isAddingPermission"
|
||||
data-testid="save-new-permission-btn"
|
||||
@click="onAddPermission"
|
||||
>
|
||||
Add
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- ── Environments tab ───────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="environments" class="pt-2">
|
||||
<v-alert v-if="envError" type="error" variant="tonal" density="compact" closable class="mb-4" @click:close="envError = null">{{ envError }}</v-alert>
|
||||
|
||||
<!-- Create environment -->
|
||||
<v-card rounded="lg" class="mb-4">
|
||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Create Environment</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<div class="d-flex align-center ga-3">
|
||||
<v-text-field
|
||||
v-model="newEnvName"
|
||||
label="Environment name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 280px"
|
||||
data-testid="new-env-name-input"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:disabled="!newEnvName"
|
||||
:loading="isCreatingEnv"
|
||||
data-testid="create-env-btn"
|
||||
@click="onCreateEnvironment"
|
||||
>
|
||||
Create
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Environments list -->
|
||||
<v-card rounded="lg">
|
||||
<v-card-text class="pa-4">
|
||||
<div v-if="environments.length === 0" class="text-center py-4 text-medium-emphasis text-body-2" data-testid="no-environments-message">
|
||||
No environments yet. Create one above.
|
||||
</div>
|
||||
|
||||
<v-expansion-panels v-else variant="accordion" data-testid="environments-list">
|
||||
<v-expansion-panel
|
||||
v-for="env in environments"
|
||||
:key="env.id"
|
||||
:data-testid="`env-panel-${env.id}`"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<div class="d-flex align-center ga-2 flex-grow-1">
|
||||
<span class="text-body-2 font-weight-medium">{{ env.name }}</span>
|
||||
<v-chip size="x-small" variant="tonal" color="secondary">{{ env.apiKey }}</v-chip>
|
||||
</div>
|
||||
<div class="d-flex ga-1 mr-2" @click.stop>
|
||||
<v-btn icon="mdi-content-copy" size="x-small" variant="text" :data-testid="`clone-env-${env.id}`" @click.stop="openCloneDialog(env)" />
|
||||
<v-btn icon="mdi-pencil-outline" size="x-small" variant="text" :data-testid="`rename-env-${env.id}`" @click.stop="openRenameDialog(env)" />
|
||||
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error" :data-testid="`delete-env-${env.id}`" @click.stop="openDeleteEnvConfirm(env)" />
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<WebhooksPanel :env-api-key="env.apiKey" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- ── API Keys tab ───────────────────────────────────────────── -->
|
||||
<v-tabs-window-item value="api-keys" class="pt-2">
|
||||
<v-alert v-if="apiKeyError" type="error" variant="tonal" density="compact" closable class="mb-4" @click:close="apiKeyError = null">{{ apiKeyError }}</v-alert>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<p class="text-body-1 font-weight-medium mb-0">API Keys</p>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="create-api-key-btn"
|
||||
@click="showApiKeyDialog = true"
|
||||
>
|
||||
Create API Key
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-card rounded="lg">
|
||||
<div v-if="apiKeys.length === 0" class="text-center py-8 text-medium-emphasis text-body-2" data-testid="no-api-keys-message">
|
||||
No API keys. Create one to allow programmatic access.
|
||||
</div>
|
||||
<v-table v-else density="compact" data-testid="api-keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 120px">Prefix</th>
|
||||
<th style="width: 80px">Active</th>
|
||||
<th style="width: 140px">Expires</th>
|
||||
<th style="width: 50px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in apiKeys" :key="key.id" :data-testid="`api-key-row-${key.id}`">
|
||||
<td class="text-body-2 font-weight-medium">{{ key.name }}</td>
|
||||
<td class="text-body-2 font-mono text-medium-emphasis">{{ key.prefix }}</td>
|
||||
<td>
|
||||
<v-chip :color="key.isActive ? 'success' : 'error'" size="x-small" variant="tonal">
|
||||
{{ key.isActive ? 'Active' : 'Revoked' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-caption text-medium-emphasis">{{ key.expiresAt ? formatDate(key.expiresAt) : 'Never' }}</td>
|
||||
<td>
|
||||
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error"
|
||||
:data-testid="`delete-api-key-${key.id}`" @click="onDeleteApiKey(key.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</template>
|
||||
|
||||
<!-- Clone environment dialog -->
|
||||
<v-dialog v-model="showCloneDialog" max-width="400">
|
||||
<v-card rounded="lg" data-testid="clone-env-dialog">
|
||||
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Clone Environment</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<p class="text-body-2 mb-3">Cloning: <strong>{{ cloningEnv?.name }}</strong></p>
|
||||
<v-text-field
|
||||
v-model="cloneEnvName"
|
||||
label="New environment name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
data-testid="clone-env-name-input"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showCloneDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="flat" :disabled="!cloneEnvName" :loading="isCloning" data-testid="confirm-clone-btn" @click="onCloneEnvironment">Clone</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Rename environment dialog -->
|
||||
<v-dialog v-model="showRenameDialog" max-width="400">
|
||||
<v-card rounded="lg" data-testid="rename-env-dialog">
|
||||
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Rename Environment</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-text-field
|
||||
v-model="renameEnvName"
|
||||
label="New name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
data-testid="rename-env-name-input"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRenameDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="flat" :disabled="!renameEnvName" :loading="isRenaming" data-testid="confirm-rename-btn" @click="onRenameEnvironment">Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete environment dialog -->
|
||||
<v-dialog v-model="showDeleteEnvDialog" max-width="400">
|
||||
<v-card rounded="lg" data-testid="delete-env-dialog">
|
||||
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Delete Environment</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<p class="text-body-2 mb-2">Delete <strong>{{ deletingEnv?.name }}</strong>? This cannot be undone.</p>
|
||||
<p class="text-body-2 mb-2">Type <strong>{{ deletingEnv?.name }}</strong> to confirm:</p>
|
||||
<v-text-field
|
||||
v-model="deleteEnvConfirmName"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
:placeholder="deletingEnv?.name"
|
||||
data-testid="delete-env-confirm-input"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeleteEnvDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" variant="flat" :disabled="deleteEnvConfirmName !== deletingEnv?.name" :loading="isDeletingEnv" data-testid="confirm-delete-env-btn" @click="onDeleteEnvironment">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Create API Key dialog -->
|
||||
<v-dialog v-model="showApiKeyDialog" max-width="420">
|
||||
<v-card rounded="lg" data-testid="create-api-key-dialog">
|
||||
<v-card-title class="text-body-1 font-weight-bold pa-4 pb-2">Create API Key</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-text-field
|
||||
v-model="newKeyName"
|
||||
label="Key name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
hide-details
|
||||
data-testid="api-key-name-input"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="newKeyExpiry"
|
||||
label="Expires (optional)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
type="date"
|
||||
data-testid="api-key-expiry-input"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showApiKeyDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="flat" :disabled="!newKeyName" :loading="isCreatingKey" data-testid="save-api-key-btn" @click="onCreateApiKey">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Raw key reveal dialog (shown once after creation) -->
|
||||
<v-dialog v-model="showRawKeyDialog" max-width="480" persistent>
|
||||
<v-card rounded="lg" data-testid="raw-key-dialog">
|
||||
<v-card-title class="pa-4 pb-2 d-flex align-center ga-2">
|
||||
<v-icon color="warning">mdi-alert</v-icon>
|
||||
<span class="text-body-1 font-weight-bold">Save your API key</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<p class="text-body-2 mb-3">This key will only be shown once. Copy it now.</p>
|
||||
<v-text-field
|
||||
:model-value="createdRawKey"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
readonly
|
||||
hide-details
|
||||
:append-inner-icon="rawKeyCopied ? 'mdi-check' : 'mdi-content-copy'"
|
||||
data-testid="raw-key-field"
|
||||
@click:append-inner="copyRawKey"
|
||||
/>
|
||||
<p v-if="rawKeyCopied" class="text-caption text-success mt-1" data-testid="copy-success-msg">Copied to clipboard!</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" data-testid="close-raw-key-btn" @click="showRawKeyDialog = false">Done</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
export default defineComponent({ name: 'SettingsView' });
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { updateOrganization, listOrganizationMembers, inviteOrganizationMember, removeOrganizationMember } from '@/api/organizations';
|
||||
import { updateProject, listProjectUserPermissions, setProjectUserPermissions, updateProjectUserPermissions, removeProjectUserPermissions } from '@/api/projects';
|
||||
import { listEnvironments, createEnvironment, updateEnvironment, deleteEnvironment, cloneEnvironment } from '@/api/environments';
|
||||
import { listApiKeys, createApiKey, deleteApiKey } from '@/api/apiKeys';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import WebhooksPanel from '@/components/settings/WebhooksPanel.vue';
|
||||
import type { OrganizationMemberResponse, UserPermissionResponse, EnvironmentResponse, ApiKeyResponse, ProjectPermission } from '@/types/api';
|
||||
|
||||
const contextStore = useContextStore();
|
||||
|
||||
const activeTab = ref('organization');
|
||||
|
||||
// ── Organization ──────────────────────────────────────────────────────────────
|
||||
const orgNameInput = ref('');
|
||||
const isSavingOrg = ref(false);
|
||||
const orgError = ref<string | null>(null);
|
||||
const orgSuccess = ref<string | null>(null);
|
||||
const orgMembers = ref<OrganizationMemberResponse[]>([]);
|
||||
const inviteUserId = ref('');
|
||||
const inviteRole = ref<'Admin' | 'User'>('User');
|
||||
const isInviting = ref(false);
|
||||
|
||||
watch(() => contextStore.currentOrganization, (org) => {
|
||||
orgNameInput.value = org?.name ?? '';
|
||||
if (org) loadOrgMembers();
|
||||
}, { immediate: true });
|
||||
|
||||
async function loadOrgMembers(): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId) return;
|
||||
try {
|
||||
orgMembers.value = await listOrganizationMembers(orgId);
|
||||
} catch {
|
||||
orgError.value = 'Failed to load members.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveOrgName(): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId) return;
|
||||
isSavingOrg.value = true;
|
||||
orgError.value = null;
|
||||
orgSuccess.value = null;
|
||||
try {
|
||||
await updateOrganization(orgId, { name: orgNameInput.value });
|
||||
orgSuccess.value = 'Organization name updated.';
|
||||
} catch {
|
||||
orgError.value = 'Failed to update organization name.';
|
||||
} finally {
|
||||
isSavingOrg.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onInviteMember(): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId || !inviteUserId.value) return;
|
||||
isInviting.value = true;
|
||||
orgError.value = null;
|
||||
try {
|
||||
await inviteOrganizationMember(orgId, { userId: Number(inviteUserId.value), role: inviteRole.value });
|
||||
inviteUserId.value = '';
|
||||
await loadOrgMembers();
|
||||
} catch {
|
||||
orgError.value = 'Failed to invite member.';
|
||||
} finally {
|
||||
isInviting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveMember(userId: number): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId) return;
|
||||
orgError.value = null;
|
||||
try {
|
||||
await removeOrganizationMember(orgId, userId);
|
||||
orgMembers.value = orgMembers.value.filter((m) => m.userId !== userId);
|
||||
} catch {
|
||||
orgError.value = 'Failed to remove member.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project ───────────────────────────────────────────────────────────────────
|
||||
const projectNameInput = ref('');
|
||||
const hideDisabledFlags = ref(false);
|
||||
const isSavingProject = ref(false);
|
||||
const projectError = ref<string | null>(null);
|
||||
const projectSuccess = ref<string | null>(null);
|
||||
const permissions = ref<UserPermissionResponse[]>([]);
|
||||
const showAddPermission = ref(false);
|
||||
const newPermUserId = ref('');
|
||||
const newPermIsAdmin = ref(false);
|
||||
const newPermissions = ref<ProjectPermission[]>([]);
|
||||
const isAddingPermission = ref(false);
|
||||
|
||||
const allPermissions: ProjectPermission[] = [
|
||||
'ViewProject', 'CreateFeature', 'EditFeature', 'DeleteFeature',
|
||||
'CreateEnvironment', 'EditEnvironment', 'DeleteEnvironment',
|
||||
'CreateSegment', 'EditSegment', 'DeleteSegment',
|
||||
'ManageWebhooks', 'ViewAuditLog',
|
||||
];
|
||||
|
||||
watch(() => contextStore.currentProject, (project) => {
|
||||
projectNameInput.value = project?.name ?? '';
|
||||
hideDisabledFlags.value = project?.hideDisabledFlags ?? false;
|
||||
if (project) loadPermissions();
|
||||
}, { immediate: true });
|
||||
|
||||
async function loadPermissions(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
try {
|
||||
permissions.value = await listProjectUserPermissions(projectId);
|
||||
} catch {
|
||||
projectError.value = 'Failed to load permissions.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveProject(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
isSavingProject.value = true;
|
||||
projectError.value = null;
|
||||
projectSuccess.value = null;
|
||||
try {
|
||||
await updateProject(projectId, { name: projectNameInput.value, hideDisabledFlags: hideDisabledFlags.value });
|
||||
projectSuccess.value = 'Project settings saved.';
|
||||
} catch {
|
||||
projectError.value = 'Failed to save project settings.';
|
||||
} finally {
|
||||
isSavingProject.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleAdmin(perm: UserPermissionResponse, isAdmin: boolean | null): Promise<void> {
|
||||
if (isAdmin === null) return;
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
projectError.value = null;
|
||||
try {
|
||||
const updated = await updateProjectUserPermissions(projectId, perm.userId, {
|
||||
userId: perm.userId,
|
||||
isAdmin,
|
||||
permissions: isAdmin ? [] : perm.permissions,
|
||||
});
|
||||
const idx = permissions.value.findIndex((p) => p.userId === perm.userId);
|
||||
if (idx >= 0) permissions.value[idx] = updated;
|
||||
} catch {
|
||||
projectError.value = 'Failed to update permissions.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemovePermission(userId: number): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
projectError.value = null;
|
||||
try {
|
||||
await removeProjectUserPermissions(projectId, userId);
|
||||
permissions.value = permissions.value.filter((p) => p.userId !== userId);
|
||||
} catch {
|
||||
projectError.value = 'Failed to remove permission.';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewPermission(perm: ProjectPermission, checked: boolean | null): void {
|
||||
if (checked === null) return;
|
||||
if (checked && !newPermissions.value.includes(perm)) {
|
||||
newPermissions.value = [...newPermissions.value, perm];
|
||||
} else if (!checked) {
|
||||
newPermissions.value = newPermissions.value.filter((p) => p !== perm);
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPermission(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId || !newPermUserId.value) return;
|
||||
isAddingPermission.value = true;
|
||||
projectError.value = null;
|
||||
try {
|
||||
const saved = await setProjectUserPermissions(projectId, {
|
||||
userId: Number(newPermUserId.value),
|
||||
isAdmin: newPermIsAdmin.value,
|
||||
permissions: newPermIsAdmin.value ? [] : newPermissions.value,
|
||||
});
|
||||
permissions.value = [...permissions.value, saved];
|
||||
newPermUserId.value = '';
|
||||
newPermIsAdmin.value = false;
|
||||
newPermissions.value = [];
|
||||
showAddPermission.value = false;
|
||||
} catch {
|
||||
projectError.value = 'Failed to add user permissions.';
|
||||
} finally {
|
||||
isAddingPermission.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Environments ──────────────────────────────────────────────────────────────
|
||||
const environments = ref<EnvironmentResponse[]>([]);
|
||||
const newEnvName = ref('');
|
||||
const isCreatingEnv = ref(false);
|
||||
const envError = ref<string | null>(null);
|
||||
|
||||
const showCloneDialog = ref(false);
|
||||
const cloningEnv = ref<EnvironmentResponse | null>(null);
|
||||
const cloneEnvName = ref('');
|
||||
const isCloning = ref(false);
|
||||
|
||||
const showRenameDialog = ref(false);
|
||||
const renamingEnv = ref<EnvironmentResponse | null>(null);
|
||||
const renameEnvName = ref('');
|
||||
const isRenaming = ref(false);
|
||||
|
||||
const showDeleteEnvDialog = ref(false);
|
||||
const deletingEnv = ref<EnvironmentResponse | null>(null);
|
||||
const deleteEnvConfirmName = ref('');
|
||||
const isDeletingEnv = ref(false);
|
||||
|
||||
watch(() => contextStore.currentProject, (project) => {
|
||||
if (project) loadEnvironments();
|
||||
else environments.value = [];
|
||||
}, { immediate: true });
|
||||
|
||||
async function loadEnvironments(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId) return;
|
||||
envError.value = null;
|
||||
try {
|
||||
environments.value = await listEnvironments(projectId);
|
||||
} catch {
|
||||
envError.value = 'Failed to load environments.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateEnvironment(): Promise<void> {
|
||||
const projectId = contextStore.currentProject?.id;
|
||||
if (!projectId || !newEnvName.value) return;
|
||||
isCreatingEnv.value = true;
|
||||
envError.value = null;
|
||||
try {
|
||||
const created = await createEnvironment({ projectId, name: newEnvName.value });
|
||||
environments.value.push(created);
|
||||
newEnvName.value = '';
|
||||
} catch {
|
||||
envError.value = 'Failed to create environment.';
|
||||
} finally {
|
||||
isCreatingEnv.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCloneDialog(env: EnvironmentResponse): void {
|
||||
cloningEnv.value = env;
|
||||
cloneEnvName.value = '';
|
||||
showCloneDialog.value = true;
|
||||
}
|
||||
|
||||
function openRenameDialog(env: EnvironmentResponse): void {
|
||||
renamingEnv.value = env;
|
||||
renameEnvName.value = env.name;
|
||||
showRenameDialog.value = true;
|
||||
}
|
||||
|
||||
function openDeleteEnvConfirm(env: EnvironmentResponse): void {
|
||||
deletingEnv.value = env;
|
||||
deleteEnvConfirmName.value = '';
|
||||
showDeleteEnvDialog.value = true;
|
||||
}
|
||||
|
||||
async function onCloneEnvironment(): Promise<void> {
|
||||
if (!cloningEnv.value || !cloneEnvName.value) return;
|
||||
isCloning.value = true;
|
||||
envError.value = null;
|
||||
try {
|
||||
const cloned = await cloneEnvironment(cloningEnv.value.apiKey, { name: cloneEnvName.value });
|
||||
environments.value.push(cloned);
|
||||
showCloneDialog.value = false;
|
||||
} catch {
|
||||
envError.value = 'Failed to clone environment.';
|
||||
} finally {
|
||||
isCloning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRenameEnvironment(): Promise<void> {
|
||||
if (!renamingEnv.value || !renameEnvName.value) return;
|
||||
isRenaming.value = true;
|
||||
envError.value = null;
|
||||
try {
|
||||
const updated = await updateEnvironment(renamingEnv.value.apiKey, { name: renameEnvName.value });
|
||||
const idx = environments.value.findIndex((e) => e.id === renamingEnv.value!.id);
|
||||
if (idx >= 0) environments.value[idx] = updated;
|
||||
showRenameDialog.value = false;
|
||||
} catch {
|
||||
envError.value = 'Failed to rename environment.';
|
||||
} finally {
|
||||
isRenaming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteEnvironment(): Promise<void> {
|
||||
if (!deletingEnv.value || deleteEnvConfirmName.value !== deletingEnv.value.name) return;
|
||||
isDeletingEnv.value = true;
|
||||
envError.value = null;
|
||||
try {
|
||||
await deleteEnvironment(deletingEnv.value.apiKey);
|
||||
environments.value = environments.value.filter((e) => e.id !== deletingEnv.value!.id);
|
||||
showDeleteEnvDialog.value = false;
|
||||
} catch {
|
||||
envError.value = 'Failed to delete environment.';
|
||||
} finally {
|
||||
isDeletingEnv.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Keys ──────────────────────────────────────────────────────────────────
|
||||
const apiKeys = ref<ApiKeyResponse[]>([]);
|
||||
const showApiKeyDialog = ref(false);
|
||||
const newKeyName = ref('');
|
||||
const newKeyExpiry = ref('');
|
||||
const isCreatingKey = ref(false);
|
||||
const apiKeyError = ref<string | null>(null);
|
||||
|
||||
const showRawKeyDialog = ref(false);
|
||||
const createdRawKey = ref('');
|
||||
const rawKeyCopied = ref(false);
|
||||
|
||||
watch(() => contextStore.currentOrganization, (org) => {
|
||||
if (org) loadApiKeys();
|
||||
else apiKeys.value = [];
|
||||
}, { immediate: true });
|
||||
|
||||
async function loadApiKeys(): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId) return;
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
apiKeys.value = await listApiKeys(orgId);
|
||||
} catch {
|
||||
apiKeyError.value = 'Failed to load API keys.';
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateApiKey(): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId || !newKeyName.value) return;
|
||||
isCreatingKey.value = true;
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
const result = await createApiKey(orgId, {
|
||||
name: newKeyName.value,
|
||||
expiresAt: newKeyExpiry.value ? `${newKeyExpiry.value}T00:00:00Z` : null,
|
||||
});
|
||||
apiKeys.value.push({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
prefix: result.prefix,
|
||||
isActive: true,
|
||||
expiresAt: result.expiresAt ? String(result.expiresAt) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
createdRawKey.value = result.rawKey;
|
||||
rawKeyCopied.value = false;
|
||||
showApiKeyDialog.value = false;
|
||||
newKeyName.value = '';
|
||||
newKeyExpiry.value = '';
|
||||
showRawKeyDialog.value = true;
|
||||
} catch {
|
||||
apiKeyError.value = 'Failed to create API key.';
|
||||
} finally {
|
||||
isCreatingKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteApiKey(keyId: number): Promise<void> {
|
||||
const orgId = contextStore.currentOrganization?.id;
|
||||
if (!orgId) return;
|
||||
apiKeyError.value = null;
|
||||
try {
|
||||
await deleteApiKey(orgId, keyId);
|
||||
apiKeys.value = apiKeys.value.filter((k) => k.id !== keyId);
|
||||
} catch {
|
||||
apiKeyError.value = 'Failed to delete API key.';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRawKey(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdRawKey.value);
|
||||
rawKeyCopied.value = true;
|
||||
} catch {
|
||||
// clipboard unavailable in some contexts
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (contextStore.currentOrganization) {
|
||||
orgNameInput.value = contextStore.currentOrganization.name;
|
||||
loadOrgMembers();
|
||||
loadApiKeys();
|
||||
}
|
||||
if (contextStore.currentProject) {
|
||||
projectNameInput.value = contextStore.currentProject.name;
|
||||
hideDisabledFlags.value = contextStore.currentProject.hideDisabledFlags;
|
||||
loadPermissions();
|
||||
loadEnvironments();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
|
||||
|
||||
import * as envApi from '@/api/environments';
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
const mockProject: ProjectResponse = {
|
||||
id: 10,
|
||||
name: 'Website',
|
||||
@@ -33,7 +35,10 @@ function mountComponent() {
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(EnvironmentTabBar, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { 'v-dialog': dialogStub },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,6 +62,13 @@ describe('EnvironmentTabBar', () => {
|
||||
|
||||
expect(wrapper.find('[data-testid="env-no-project-message"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenCreateEnvironmentButtonIsDisabled', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="create-env-btn"]').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSetInContextStore', () => {
|
||||
@@ -96,6 +108,18 @@ describe('EnvironmentTabBar', () => {
|
||||
|
||||
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[0]);
|
||||
});
|
||||
|
||||
it('ThenCreateEnvironmentButtonIsEnabled', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="create-env-btn"]').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenEnvironmentChipIsClicked', () => {
|
||||
@@ -108,7 +132,6 @@ describe('EnvironmentTabBar', () => {
|
||||
contextStore.setProject(mockProject);
|
||||
await flushPromises();
|
||||
|
||||
// Simulate chip-group selecting index 1 (Production)
|
||||
await wrapper.findComponent({ name: 'VChipGroup' }).vm.$emit('update:modelValue', 1);
|
||||
|
||||
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[1]);
|
||||
@@ -147,4 +170,149 @@ describe('EnvironmentTabBar', () => {
|
||||
expect(wrapper.find('[data-testid="env-empty-message"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateEnvironmentButtonIsClicked', () => {
|
||||
it('ThenCreateDialogIsShown', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="create-env-dialog"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenNameInputIsPresentInDialog', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="env-name-input"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateEnvironmentIsConfirmed', () => {
|
||||
const newEnv: EnvironmentResponse = {
|
||||
id: 200, name: 'Staging', apiKey: 'env-key-staging', projectId: 10, createdAt: '2026-04-14T00:00:00Z',
|
||||
};
|
||||
|
||||
it('ThenCreateEnvironmentApiIsCalled', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newEnvName = 'Staging';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(envApi.createEnvironment).toHaveBeenCalledWith({ name: 'Staging', projectId: mockProject.id });
|
||||
});
|
||||
|
||||
it('ThenNewEnvironmentIsAutoSelectedInContextStore', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newEnvName = 'Staging';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(contextStore.currentEnvironment).toEqual(newEnv);
|
||||
});
|
||||
|
||||
it('ThenDialogIsClosedAfterSuccessfulCreate', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newEnvName = 'Staging';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateEnvironmentFails', () => {
|
||||
it('ThenErrorMessageIsDisplayed', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(envApi.createEnvironment).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newEnvName = 'Bad Env';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).createError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ThenDialogRemainsOpen', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(envApi.createEnvironment).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newEnvName = 'Bad Env';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCancelIsClicked', () => {
|
||||
it('ThenDialogIsClosed', async () => {
|
||||
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
await wrapper.find('[data-testid="create-env-cancel-btn"]').trigger('click');
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
|
||||
|
||||
import * as orgsApi from '@/api/organizations';
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
const mockOrgs: OrganizationResponse[] = [
|
||||
{ id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' },
|
||||
@@ -26,7 +28,10 @@ function mountComponent() {
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(OrgSelector, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { 'v-dialog': dialogStub },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +60,15 @@ describe('OrgSelector', () => {
|
||||
const select = wrapper.find('[data-testid="org-select"]');
|
||||
expect(select.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenCreateOrganizationButtonIsAlwaysVisible', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="create-org-btn"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSelected', () => {
|
||||
@@ -65,7 +79,6 @@ describe('OrgSelector', () => {
|
||||
await flushPromises();
|
||||
|
||||
const contextStore = useContextStore();
|
||||
// Simulate the v-select emitting an update
|
||||
await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockOrgs[0]);
|
||||
|
||||
expect(contextStore.currentOrganization).toEqual(mockOrgs[0]);
|
||||
@@ -73,14 +86,13 @@ describe('OrgSelector', () => {
|
||||
});
|
||||
|
||||
describe('WhenApiReturnsNoOrganizations', () => {
|
||||
it('ThenEmptyMessageIsDisplayed', async () => {
|
||||
it('ThenCreateButtonIsStillVisible', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const emptyMsg = wrapper.find('[data-testid="org-empty-message"]');
|
||||
expect(emptyMsg.exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="create-org-btn"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,8 +103,129 @@ describe('OrgSelector', () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
// No crash — component stays rendered
|
||||
expect(wrapper.find('[data-testid="org-selector"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateOrganizationButtonIsClicked', () => {
|
||||
it('ThenCreateDialogIsShown', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="create-org-dialog"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenNameInputIsPresentInDialog', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="org-name-input"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateOrganizationIsConfirmed', () => {
|
||||
const newOrg: OrganizationResponse = { id: 3, name: 'New Org', createdAt: '2026-04-13T00:00:00Z' };
|
||||
|
||||
it('ThenCreateOrganizationApiIsCalled', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newOrgName = 'New Org';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(orgsApi.createOrganization).toHaveBeenCalledWith({ name: 'New Org' });
|
||||
});
|
||||
|
||||
it('ThenNewOrgIsAutoSelectedInContextStore', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const contextStore = useContextStore();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newOrgName = 'New Org';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(contextStore.currentOrganization).toEqual(newOrg);
|
||||
});
|
||||
|
||||
it('ThenDialogIsClosedAfterSuccessfulCreate', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newOrgName = 'New Org';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateOrganizationFails', () => {
|
||||
it('ThenErrorMessageIsDisplayed', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
jest.mocked(orgsApi.createOrganization).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newOrgName = 'Bad Org';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).createError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ThenDialogRemainsOpen', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
jest.mocked(orgsApi.createOrganization).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newOrgName = 'Bad Org';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCancelIsClicked', () => {
|
||||
it('ThenDialogIsClosed', async () => {
|
||||
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-org-btn"]').trigger('click');
|
||||
await wrapper.find('[data-testid="create-org-cancel-btn"]').trigger('click');
|
||||
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
|
||||
|
||||
import * as projectsApi from '@/api/projects';
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
|
||||
const mockProjects: ProjectResponse[] = [
|
||||
{ id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
|
||||
@@ -27,7 +29,10 @@ function mountComponent() {
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
});
|
||||
return mount(ProjectSelector, {
|
||||
global: { plugins: [router] },
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: { 'v-dialog': dialogStub },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +50,15 @@ describe('ProjectSelector', () => {
|
||||
expect(projectsApi.listProjects).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('[data-testid="project-selector"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenCreateProjectButtonIsDisabled', async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const btn = wrapper.find('[data-testid="create-project-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSetInContextStore', () => {
|
||||
@@ -59,6 +73,19 @@ describe('ProjectSelector', () => {
|
||||
|
||||
expect(projectsApi.listProjects).toHaveBeenCalledWith(mockOrg.id);
|
||||
});
|
||||
|
||||
it('ThenCreateProjectButtonIsEnabled', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
const btn = wrapper.find('[data-testid="create-project-btn"]');
|
||||
expect(btn.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenProjectIsSelected', () => {
|
||||
@@ -95,4 +122,149 @@ describe('ProjectSelector', () => {
|
||||
expect(projectsApi.listProjects).toHaveBeenLastCalledWith(newOrg.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateProjectButtonIsClicked', () => {
|
||||
it('ThenCreateDialogIsShown', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="create-project-dialog"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenNameInputIsPresentInDialog', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
|
||||
expect(wrapper.find('[data-testid="project-name-input"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateProjectIsConfirmed', () => {
|
||||
const newProject: ProjectResponse = {
|
||||
id: 99, name: 'New Project', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-04-13T00:00:00Z',
|
||||
};
|
||||
|
||||
it('ThenCreateProjectApiIsCalled', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.createProject).mockResolvedValue(newProject);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newProjectName = 'New Project';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.createProject).toHaveBeenCalledWith({ name: 'New Project', organizationId: mockOrg.id });
|
||||
});
|
||||
|
||||
it('ThenNewProjectIsAutoSelectedInContextStore', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.createProject).mockResolvedValue(newProject);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newProjectName = 'New Project';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect(contextStore.currentProject).toEqual(newProject);
|
||||
});
|
||||
|
||||
it('ThenDialogIsClosedAfterSuccessfulCreate', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.createProject).mockResolvedValue(newProject);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newProjectName = 'New Project';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateProjectFails', () => {
|
||||
it('ThenErrorMessageIsDisplayed', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.createProject).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newProjectName = 'Bad Project';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).createError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ThenDialogRemainsOpen', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.createProject).mockRejectedValue(new Error('Server error'));
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
(wrapper.vm as any).newProjectName = 'Bad Project';
|
||||
await (wrapper.vm as any).onCreateConfirm();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCancelIsClicked', () => {
|
||||
it('ThenDialogIsClosed', async () => {
|
||||
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="create-project-btn"]').trigger('click');
|
||||
await wrapper.find('[data-testid="create-project-cancel-btn"]').trigger('click');
|
||||
|
||||
expect((wrapper.vm as any).showDialog).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
85
src/admin/tests/unit/views/ProfileView.spec.ts
Normal file
85
src/admin/tests/unit/views/ProfileView.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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 ProfileView from '@/views/ProfileView.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
|
||||
jest.mock('@/api/auth');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as authApi from '@/api/auth';
|
||||
|
||||
function mountView() {
|
||||
const routes = [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/login', name: 'Login', component: { template: '<div />' } },
|
||||
];
|
||||
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||
return { wrapper: mount(ProfileView, { global: { plugins: [router] } }), router };
|
||||
}
|
||||
|
||||
describe('ProfileView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WhenAuthenticated', () => {
|
||||
it('ThenProfileCardIsRendered', () => {
|
||||
const { wrapper } = mountView();
|
||||
expect(wrapper.find('[data-testid="profile-card"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenLogoutButtonIsPresent', () => {
|
||||
const { wrapper } = mountView();
|
||||
expect(wrapper.find('[data-testid="logout-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenOrgNameIsDisplayedWhenSelected', () => {
|
||||
const { wrapper } = mountView();
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization({ id: 1, name: 'Acme Corp', createdAt: '' });
|
||||
|
||||
expect(wrapper.find('[data-testid="profile-org-name"]').exists()).toBe(false); // org name shown in v-if block
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenLogoutButtonIsClicked', () => {
|
||||
it('ThenAuthStoreLogoutIsCalled', async () => {
|
||||
jest.mocked(authApi.logout).mockResolvedValue(undefined);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
// Simulate logged-in state by setting tokens manually
|
||||
authStore.accessToken = 'fake-token' as any;
|
||||
authStore.refreshToken = 'fake-refresh' as any;
|
||||
|
||||
const { wrapper } = mountView();
|
||||
|
||||
await wrapper.find('[data-testid="logout-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(authApi.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ThenUserIsRedirectedToLoginAfterLogout', async () => {
|
||||
jest.mocked(authApi.logout).mockResolvedValue(undefined);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
authStore.accessToken = 'fake-token' as any;
|
||||
authStore.refreshToken = 'fake-refresh' as any;
|
||||
|
||||
const { wrapper, router } = mountView();
|
||||
|
||||
await wrapper.find('[data-testid="logout-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Login');
|
||||
});
|
||||
});
|
||||
});
|
||||
480
src/admin/tests/unit/views/SettingsView.spec.ts
Normal file
480
src/admin/tests/unit/views/SettingsView.spec.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
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 SettingsView from '@/views/SettingsView.vue';
|
||||
import { useContextStore } from '@/stores/context';
|
||||
import type {
|
||||
ProjectResponse, OrganizationResponse, EnvironmentResponse,
|
||||
UserPermissionResponse, ApiKeyResponse, CreateApiKeyResponse,
|
||||
} from '@/types/api';
|
||||
|
||||
jest.mock('@/api/organizations');
|
||||
jest.mock('@/api/projects');
|
||||
jest.mock('@/api/environments');
|
||||
jest.mock('@/api/apiKeys');
|
||||
jest.mock('@/api/webhooks');
|
||||
jest.mock('@/api/client', () => ({
|
||||
setAuthTokens: jest.fn(),
|
||||
clearAuthTokens: jest.fn(),
|
||||
getAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
import * as orgsApi from '@/api/organizations';
|
||||
import * as projectsApi from '@/api/projects';
|
||||
import * as environmentsApi from '@/api/environments';
|
||||
import * as apiKeysApi from '@/api/apiKeys';
|
||||
import * as webhooksApi from '@/api/webhooks';
|
||||
|
||||
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
|
||||
const mockProject: ProjectResponse = { id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' };
|
||||
const mockEnvs: EnvironmentResponse[] = [
|
||||
{ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 200, name: 'Production', apiKey: 'env-prod', projectId: 10, createdAt: '2026-01-02T00:00:00Z' },
|
||||
];
|
||||
const mockApiKeys: ApiKeyResponse[] = [
|
||||
{ id: 1, name: 'CI Key', prefix: 'org-ci', isActive: true, expiresAt: null, createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
const mockPermissions: UserPermissionResponse[] = [
|
||||
{ userId: 5, projectId: 10, isAdmin: false, permissions: ['ViewProject', 'EditFeature'] },
|
||||
];
|
||||
|
||||
const dialogStub = { template: '<div><slot /></div>' };
|
||||
|
||||
function mountView() {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
|
||||
return mount(SettingsView, {
|
||||
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('SettingsView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([]);
|
||||
jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue([]);
|
||||
jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue([]);
|
||||
jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]);
|
||||
jest.mocked(webhooksApi.listEnvWebhooks).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('WhenNoOrganizationSelected', () => {
|
||||
it('ThenSelectOrgMessageIsShown', () => {
|
||||
const wrapper = mountView();
|
||||
expect(wrapper.text()).toContain('Select an organization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenOrganizationIsSelected', () => {
|
||||
it('ThenTabsAreRendered', async () => {
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tab-organization"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="tab-api-keys"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Organization tab ────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhenSaveOrgNameIsClicked', () => {
|
||||
it('ThenUpdateOrganizationIsCalledWithNewName', async () => {
|
||||
jest.mocked(orgsApi.updateOrganization).mockResolvedValue({ ...mockOrg, name: 'New Name' });
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="org-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'New Name');
|
||||
await wrapper.find('[data-testid="save-org-name-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(orgsApi.updateOrganization).toHaveBeenCalledWith(mockOrg.id, { name: 'New Name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenInviteMemberIsClicked', () => {
|
||||
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
|
||||
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
|
||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, role: 'User' }]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '42');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="invite-member-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenRemoveMemberIsClicked', () => {
|
||||
it('ThenRemoveOrganizationMemberIsCalledAndMemberIsRemovedFromList', async () => {
|
||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 5, role: 'User' }]);
|
||||
jest.mocked(orgsApi.removeOrganizationMember).mockResolvedValue(undefined);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
|
||||
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Project tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhenSaveProjectIsClicked', () => {
|
||||
it('ThenUpdateProjectIsCalledWithFormValues', async () => {
|
||||
jest.mocked(projectsApi.updateProject).mockResolvedValue(mockProject);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-project"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-project-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.updateProject).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ name: mockProject.name }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenPermissionsAreLoaded', () => {
|
||||
it('ThenPermissionRowsAreDisplayed', async () => {
|
||||
jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-project"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="permission-row-5"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenAdminToggleIsChanged', () => {
|
||||
it('ThenUpdateProjectUserPermissionsIsCalledWithIsAdmin', async () => {
|
||||
jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions);
|
||||
jest.mocked(projectsApi.updateProjectUserPermissions).mockResolvedValue({
|
||||
...mockPermissions[0], isAdmin: true,
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-project"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent(`[data-testid="perm-admin-toggle-5"]`) as VueWrapper<any>).vm.$emit('update:modelValue', true);
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.updateProjectUserPermissions).toHaveBeenCalledWith(
|
||||
mockProject.id, 5,
|
||||
expect.objectContaining({ isAdmin: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenAddUserPermissionsIsSubmitted', () => {
|
||||
it('ThenSetProjectUserPermissionsIsCalledWithNewUserId', async () => {
|
||||
const newPerm: UserPermissionResponse = { userId: 99, projectId: 10, isAdmin: false, permissions: ['ViewProject'] };
|
||||
jest.mocked(projectsApi.setProjectUserPermissions).mockResolvedValue(newPerm);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-project"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="add-permission-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="new-perm-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '99');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-new-permission-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.setProjectUserPermissions).toHaveBeenCalledWith(
|
||||
mockProject.id,
|
||||
expect.objectContaining({ userId: 99 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenRemovePermissionIsClicked', () => {
|
||||
it('ThenRemoveProjectUserPermissionsIsCalledAndRowIsRemoved', async () => {
|
||||
jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions);
|
||||
jest.mocked(projectsApi.removeProjectUserPermissions).mockResolvedValue(undefined);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-project"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
|
||||
expect(wrapper.find('[data-testid="permission-row-5"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Environments tab ────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhenCreateEnvironmentIsClicked', () => {
|
||||
it('ThenCreateEnvironmentIsCalledAndEnvIsAddedToList', async () => {
|
||||
jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([]);
|
||||
jest.mocked(environmentsApi.createEnvironment).mockResolvedValue(mockEnvs[0]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-environments"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="new-env-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Staging');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="create-env-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(environmentsApi.createEnvironment).toHaveBeenCalledWith({ projectId: mockProject.id, name: 'Staging' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCloneEnvironmentIsConfirmed', () => {
|
||||
it('ThenCloneEnvironmentIsCalledWithNewName', async () => {
|
||||
jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]);
|
||||
jest.mocked(environmentsApi.cloneEnvironment).mockResolvedValue({
|
||||
...mockEnvs[0], id: 300, name: 'Staging', apiKey: 'env-stg',
|
||||
});
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-environments"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="clone-env-100"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="clone-env-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Staging');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="confirm-clone-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(environmentsApi.cloneEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey, { name: 'Staging' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDeleteEnvironmentIsConfirmed', () => {
|
||||
it('ThenDeleteEnvironmentIsCalledAndEnvIsRemovedFromList', async () => {
|
||||
jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]);
|
||||
jest.mocked(environmentsApi.deleteEnvironment).mockResolvedValue(undefined);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-environments"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Development');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(environmentsApi.deleteEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenRenameEnvironmentIsConfirmed', () => {
|
||||
it('ThenUpdateEnvironmentIsCalledWithNewName', async () => {
|
||||
jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]);
|
||||
jest.mocked(environmentsApi.updateEnvironment).mockResolvedValue({ ...mockEnvs[0], name: 'Dev Renamed' });
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
contextStore.setProject(mockProject);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-environments"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="rename-env-100"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="rename-env-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Dev Renamed');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="confirm-rename-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(environmentsApi.updateEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey, { name: 'Dev Renamed' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── API Keys tab ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhenApiKeysTabIsOpened', () => {
|
||||
it('ThenApiKeysAreLoaded', async () => {
|
||||
jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue(mockApiKeys);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-api-keys"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="api-key-row-1"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('CI Key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenCreateApiKeyIsSubmitted', () => {
|
||||
it('ThenCreateApiKeyIsCalledAndRawKeyDialogIsShown', async () => {
|
||||
const created: CreateApiKeyResponse = { id: 99, name: 'My Key', rawKey: 'raw-secret-key', prefix: 'org-my', expiresAt: null };
|
||||
jest.mocked(apiKeysApi.createApiKey).mockResolvedValue(created);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-api-keys"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="create-api-key-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="api-key-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'My Key');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-api-key-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiKeysApi.createApiKey).toHaveBeenCalledWith(mockOrg.id, expect.objectContaining({ name: 'My Key' }));
|
||||
expect(wrapper.find('[data-testid="raw-key-dialog"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="raw-key-field"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ThenRawKeyIsDisplayedInTheRevealDialog', async () => {
|
||||
const created: CreateApiKeyResponse = { id: 99, name: 'My Key', rawKey: 'raw-secret-key', prefix: 'org-my', expiresAt: null };
|
||||
jest.mocked(apiKeysApi.createApiKey).mockResolvedValue(created);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-api-keys"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="create-api-key-btn"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await (wrapper.findComponent('[data-testid="api-key-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'My Key');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="save-api-key-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const rawKeyField = wrapper.findComponent('[data-testid="raw-key-field"]') as VueWrapper<any>;
|
||||
expect(rawKeyField.props('modelValue')).toBe('raw-secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhenDeleteApiKeyIsClicked', () => {
|
||||
it('ThenDeleteApiKeyIsCalledAndKeyIsRemovedFromList', async () => {
|
||||
jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue([...mockApiKeys]);
|
||||
jest.mocked(apiKeysApi.deleteApiKey).mockResolvedValue(undefined);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tab-api-keys"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);
|
||||
expect(wrapper.find('[data-testid="api-key-row-1"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
|
||||
|
||||
private int? ResolveActorUserId()
|
||||
{
|
||||
var claim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
|
||||
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
return int.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ public class FeatureConfiguration : IEntityTypeConfiguration<Feature>
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(f => f.Tags)
|
||||
.WithOne()
|
||||
.HasForeignKey(t => t.ProjectId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
.WithMany()
|
||||
.UsingEntity("FeatureTags");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Projects;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -11,5 +12,10 @@ public class TagConfiguration : IEntityTypeConfiguration<Tag>
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Label).HasMaxLength(200).IsRequired();
|
||||
builder.Property(t => t.Color).HasMaxLength(20).IsRequired();
|
||||
|
||||
builder.HasOne<Project>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.ProjectId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
926
src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs
generated
Normal file
926
src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs
generated
Normal file
@@ -0,0 +1,926 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MicCheck.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(MicCheckDbContext))]
|
||||
[Migration("20260414203426_FixTagRelationships")]
|
||||
partial class FixTagRelationships
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TagsId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("FeatureId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("FeatureTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int?>("ActorUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Changes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("ResourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId", "CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b =>
|
||||
{
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Permissions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "ProjectId");
|
||||
|
||||
b.ToTable("UserProjectPermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Environments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DefaultEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<string>("InitialValue")
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Features");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeatureSegments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("FeatureSegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("IdentityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId");
|
||||
|
||||
b.HasIndex("FeatureSegmentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IdentityId");
|
||||
|
||||
b.HasIndex("FeatureId", "EnvironmentId", "IdentityId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId", "Identifier")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Identities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("IdentityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("ValueType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IdentityId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("IdentityTraits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Organizations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||
{
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("OrganizationId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("OrganizationUsers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("HideDisabledFlags")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("ProjectId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Segments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Operator")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Property")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("RuleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RuleId");
|
||||
|
||||
b.ToTable("SegmentConditions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("ParentRuleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SegmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentRuleId");
|
||||
|
||||
b.HasIndex("SegmentId");
|
||||
|
||||
b.ToTable("SegmentRules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("EnvironmentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("OrganizationId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnvironmentId");
|
||||
|
||||
b.ToTable("Webhooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AttemptNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("AttemptedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("PayloadJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ResponseBody")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("ResponseStatusCode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("Success")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("WebhookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WebhookId");
|
||||
|
||||
b.ToTable("WebhookDeliveryLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FeatureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("ApiKeys")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Environments")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Features")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Environments.Environment", null)
|
||||
.WithMany("FeatureStates")
|
||||
.HasForeignKey("EnvironmentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany("FeatureStates")
|
||||
.HasForeignKey("FeatureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.FeatureSegment", null)
|
||||
.WithOne("FeatureState")
|
||||
.HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||
.WithMany("FeatureStateOverrides")
|
||||
.HasForeignKey("IdentityId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||
.WithMany("Traits")
|
||||
.HasForeignKey("IdentityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Users.User", null)
|
||||
.WithMany("Organizations")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("OrganizationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany("Segments")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||
.WithMany("Conditions")
|
||||
.HasForeignKey("RuleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||
.WithMany("ChildRules")
|
||||
.HasForeignKey("ParentRuleId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.HasOne("MicCheck.Api.Segments.Segment", null)
|
||||
.WithMany("Rules")
|
||||
.HasForeignKey("SegmentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||
{
|
||||
b.Navigation("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.Navigation("FeatureStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||
{
|
||||
b.Navigation("FeatureState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||
{
|
||||
b.Navigation("FeatureStateOverrides");
|
||||
|
||||
b.Navigation("Traits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||
{
|
||||
b.Navigation("ApiKeys");
|
||||
|
||||
b.Navigation("Members");
|
||||
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Environments");
|
||||
|
||||
b.Navigation("Features");
|
||||
|
||||
b.Navigation("Segments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||
{
|
||||
b.Navigation("Rules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||
{
|
||||
b.Navigation("ChildRules");
|
||||
|
||||
b.Navigation("Conditions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||
{
|
||||
b.Navigation("Organizations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MicCheck.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixTagRelationships : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tags_Features_ProjectId",
|
||||
table: "Tags");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeatureTags",
|
||||
columns: table => new
|
||||
{
|
||||
FeatureId = table.Column<int>(type: "integer", nullable: false),
|
||||
TagsId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeatureTags", x => new { x.FeatureId, x.TagsId });
|
||||
table.ForeignKey(
|
||||
name: "FK_FeatureTags_Features_FeatureId",
|
||||
column: x => x.FeatureId,
|
||||
principalTable: "Features",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_FeatureTags_Tags_TagsId",
|
||||
column: x => x.TagsId,
|
||||
principalTable: "Tags",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FeatureTags_TagsId",
|
||||
table: "FeatureTags",
|
||||
column: "TagsId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tags_Projects_ProjectId",
|
||||
table: "Tags",
|
||||
column: "ProjectId",
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tags_Projects_ProjectId",
|
||||
table: "Tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeatureTags");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tags_Features_ProjectId",
|
||||
table: "Tags",
|
||||
column: "ProjectId",
|
||||
principalTable: "Features",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,21 @@ namespace MicCheck.Api.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.Property<int>("FeatureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TagsId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("FeatureId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("FeatureTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -694,6 +709,21 @@ namespace MicCheck.Api.Migrations
|
||||
b.ToTable("WebhookDeliveryLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FeatureTags", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FeatureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MicCheck.Api.Features.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||
@@ -748,10 +778,10 @@ namespace MicCheck.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||
{
|
||||
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||
.WithMany("Tags")
|
||||
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
@@ -839,8 +869,6 @@ namespace MicCheck.Api.Migrations
|
||||
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||
{
|
||||
b.Navigation("FeatureStates");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||
|
||||
@@ -149,7 +149,7 @@ public class OrganizationsController(OrganizationService organizationService, We
|
||||
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
var claim = User.FindFirst("UserId")?.Value;
|
||||
var claim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
return int.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user