Adding Organiztion, environment and project. Segment fix, tag fix

This commit is contained in:
2026-04-14 14:10:37 -07:00
parent 4195d384d0
commit b75f5f602e
20 changed files with 3370 additions and 61 deletions

View 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}`);
}

View File

@@ -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> {

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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.
</v-card-text>
</v-card>
</v-col>
</v-row>
<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>
</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>

View File

@@ -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>
<!-- 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-col>
</v-row>
</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>

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View 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');
});
});
});

View 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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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 =>

View File

@@ -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;
}
}