From b75f5f602ee1158fa92fd1317a9ad546ee2a62fa Mon Sep 17 00:00:00 2001
From: James Wampler
Date: Tue, 14 Apr 2026 14:10:37 -0700
Subject: [PATCH] Adding Organiztion, environment and project. Segment fix,
tag fix
---
src/admin/src/api/apiKeys.ts | 24 +
src/admin/src/api/segments.ts | 10 +-
.../src/components/nav/EnvironmentTabBar.vue | 81 +-
src/admin/src/components/nav/OrgSelector.vue | 85 +-
.../src/components/nav/ProjectSelector.vue | 81 +-
.../components/segments/RuleGroupEditor.vue | 12 +-
src/admin/src/views/ProfileView.vue | 87 +-
src/admin/src/views/SettingsView.vue | 943 +++++++++++++++++-
.../unit/components/EnvironmentTabBar.spec.ts | 172 +++-
.../tests/unit/components/OrgSelector.spec.ts | 145 ++-
.../unit/components/ProjectSelector.spec.ts | 174 +++-
.../tests/unit/views/ProfileView.spec.ts | 85 ++
.../tests/unit/views/SettingsView.spec.ts | 480 +++++++++
src/api/MicCheck.Api/Audit/AuditService.cs | 2 +-
.../Configurations/FeatureConfiguration.cs | 5 +-
.../Data/Configurations/TagConfiguration.cs | 6 +
...0414203426_FixTagRelationships.Designer.cs | 926 +++++++++++++++++
.../20260414203426_FixTagRelationships.cs | 73 ++
.../MicCheckDbContextModelSnapshot.cs | 38 +-
.../Organizations/OrganizationsController.cs | 2 +-
20 files changed, 3370 insertions(+), 61 deletions(-)
create mode 100644 src/admin/src/api/apiKeys.ts
create mode 100644 src/admin/tests/unit/views/ProfileView.spec.ts
create mode 100644 src/admin/tests/unit/views/SettingsView.spec.ts
create mode 100644 src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs
create mode 100644 src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs
diff --git a/src/admin/src/api/apiKeys.ts b/src/admin/src/api/apiKeys.ts
new file mode 100644
index 0000000..75aa472
--- /dev/null
+++ b/src/admin/src/api/apiKeys.ts
@@ -0,0 +1,24 @@
+import apiClient from './client';
+import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '@/types/api';
+
+export async function listApiKeys(organizationId: number): Promise {
+ const { data } = await apiClient.get(
+ `/v1/organisations/${organizationId}/api-keys`,
+ );
+ return data;
+}
+
+export async function createApiKey(
+ organizationId: number,
+ request: CreateApiKeyRequest,
+): Promise {
+ const { data } = await apiClient.post(
+ `/v1/organisations/${organizationId}/api-keys`,
+ request,
+ );
+ return data;
+}
+
+export async function deleteApiKey(organizationId: number, keyId: number): Promise {
+ await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
+}
diff --git a/src/admin/src/api/segments.ts b/src/admin/src/api/segments.ts
index 16ac5d1..32a4b05 100644
--- a/src/admin/src/api/segments.ts
+++ b/src/admin/src/api/segments.ts
@@ -3,11 +3,15 @@ import type {
SegmentResponse,
CreateSegmentRequest,
UpdateSegmentRequest,
+ PaginatedResponse,
} from '@/types/api';
-export async function listSegments(projectId: number): Promise {
- const { data } = await apiClient.get(`/v1/projects/${projectId}/segments`);
- return data;
+export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise {
+ const { data } = await apiClient.get>(
+ `/v1/projects/${projectId}/segments`,
+ { params: { page, pageSize } },
+ );
+ return data.results;
}
export async function getSegment(projectId: number, id: number): Promise {
diff --git a/src/admin/src/components/nav/EnvironmentTabBar.vue b/src/admin/src/components/nav/EnvironmentTabBar.vue
index dc9dd76..868bc6e 100644
--- a/src/admin/src/components/nav/EnvironmentTabBar.vue
+++ b/src/admin/src/components/nav/EnvironmentTabBar.vue
@@ -43,13 +43,59 @@
>
Select a project first.
+
+
+ Create environment
+
+
+
+
+
+ Create environment
+
+
+
+
+
+ Cancel
+
+ Create
+
+
+
+
diff --git a/src/admin/src/components/nav/ProjectSelector.vue b/src/admin/src/components/nav/ProjectSelector.vue
index 8396cac..54de5d3 100644
--- a/src/admin/src/components/nav/ProjectSelector.vue
+++ b/src/admin/src/components/nav/ProjectSelector.vue
@@ -17,13 +17,59 @@
data-testid="project-select"
@update:model-value="onProjectSelected"
/>
+
+
+ Create project
+
+
+
+
+
+ Create project
+
+
+
+
+
+ Cancel
+
+ Create
+
+
+
+
diff --git a/src/admin/src/views/ProfileView.vue b/src/admin/src/views/ProfileView.vue
index 50b7a99..2fbb88f 100644
--- a/src/admin/src/views/ProfileView.vue
+++ b/src/admin/src/views/ProfileView.vue
@@ -1,17 +1,78 @@
-
-
- Profile
-
-
- Manage your account details and preferences. Logged in as John Doe.
-
-
-
-
+
+
Profile
+
+
+
+
+
+ mdi-account
+
+
+
+ {{ authStore.accessToken ? 'Authenticated user' : 'Not signed in' }}
+
+
+ Token expires: {{ expiresDisplay }}
+
+
+
+
+
+
+
+
+
Organization
+
+ {{ contextStore.currentOrganization?.name }}
+
+
+
+
+
+
+
+ Sign out
+
+
+
+
-
diff --git a/src/admin/src/views/SettingsView.vue b/src/admin/src/views/SettingsView.vue
index 7fbfa9b..576fe50 100644
--- a/src/admin/src/views/SettingsView.vue
+++ b/src/admin/src/views/SettingsView.vue
@@ -1,17 +1,938 @@
-
-
- Settings
-
-
- Application-wide configuration and administration settings.
+
+
Settings
+
+
+ Select an organization to manage settings.
+
+
+
+
+ Organization
+ Project
+ Environments
+ API Keys
+
+
+
+
+
+ {{ orgError }}
+ {{ orgSuccess }}
+
+
+
+ Organization Name
+
+
+
+
+ Save
+
+
+
+
+
+
+
+ Members
+
+
+ | User ID | Role | |
+
+
+ | {{ member.userId }} |
+ {{ member.role }} |
+
+
+ |
+
+
+
+ No members found.
+
+
+
+
+
+
+ Invite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ projectError }}
+ {{ projectSuccess }}
+
+
+ Project Settings
+
+
+
+
+
+
+
Hide disabled flags
+
Hide flags that are off in all environments
+
+
+
+
+ Save project settings
+
+
+
+
+
+
+ User Permissions
+
+
+
+
+ | User ID |
+ Admin |
+ Permissions |
+ |
+
+
+
+
+ | {{ perm.userId }} |
+
+
+ |
+
+
+ {{ p }}
+ All permissions (admin)
+
+ |
+
+
+ |
+
+
+
+ No user permissions configured.
+
+
+
+ Add user
+
+
+
+
+
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
+
+ {{ envError }}
+
+
+
+ Create Environment
+
+
+
+
+ Create
+
+
+
+
+
+
+
+
+
+ No environments yet. Create one above.
+
+
+
+
+
+
+ {{ env.name }}
+ {{ env.apiKey }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ apiKeyError }}
+
+
+
API Keys
+
+ Create API Key
+
+
+
+
+
+ No API keys. Create one to allow programmatic access.
+
+
+
+
+ | Name |
+ Prefix |
+ Active |
+ Expires |
+ |
+
+
+
+
+ | {{ key.name }} |
+ {{ key.prefix }} |
+
+
+ {{ key.isActive ? 'Active' : 'Revoked' }}
+
+ |
+ {{ key.expiresAt ? formatDate(key.expiresAt) : 'Never' }} |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+ Clone Environment
+
+ Cloning: {{ cloningEnv?.name }}
+
+
+
+ Cancel
+ Clone
+
-
-
+
+
+
+
+
+ Rename Environment
+
+
+
+
+
+ Cancel
+ Save
+
+
+
+
+
+
+
+ Delete Environment
+
+ Delete {{ deletingEnv?.name }}? This cannot be undone.
+ Type {{ deletingEnv?.name }} to confirm:
+
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
+
+ Create API Key
+
+
+
+
+
+
+ Cancel
+ Create
+
+
+
+
+
+
+
+
+ mdi-alert
+ Save your API key
+
+
+ This key will only be shown once. Copy it now.
+
+ Copied to clipboard!
+
+
+
+ Done
+
+
+
+
-
diff --git a/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts b/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
index fa50a34..e2f3272 100644
--- a/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
+++ b/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
import * as envApi from '@/api/environments';
+const dialogStub = { template: '
' };
+
const mockProject: ProjectResponse = {
id: 10,
name: 'Website',
@@ -33,7 +35,10 @@ function mountComponent() {
routes: [{ path: '/', component: { template: '' } }],
});
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);
+ });
+ });
});
diff --git a/src/admin/tests/unit/components/OrgSelector.spec.ts b/src/admin/tests/unit/components/OrgSelector.spec.ts
index 343e8cd..32fe763 100644
--- a/src/admin/tests/unit/components/OrgSelector.spec.ts
+++ b/src/admin/tests/unit/components/OrgSelector.spec.ts
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
import * as orgsApi from '@/api/organizations';
+const dialogStub = { template: '
' };
+
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: '' } }],
});
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);
+ });
+ });
});
diff --git a/src/admin/tests/unit/components/ProjectSelector.spec.ts b/src/admin/tests/unit/components/ProjectSelector.spec.ts
index 4844978..06d8ce5 100644
--- a/src/admin/tests/unit/components/ProjectSelector.spec.ts
+++ b/src/admin/tests/unit/components/ProjectSelector.spec.ts
@@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({
import * as projectsApi from '@/api/projects';
+const dialogStub = { template: '
' };
+
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: '' } }],
});
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);
+ });
+ });
});
diff --git a/src/admin/tests/unit/views/ProfileView.spec.ts b/src/admin/tests/unit/views/ProfileView.spec.ts
new file mode 100644
index 0000000..60a722a
--- /dev/null
+++ b/src/admin/tests/unit/views/ProfileView.spec.ts
@@ -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: '' } },
+ { path: '/login', name: 'Login', component: { template: '' } },
+ ];
+ 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');
+ });
+ });
+});
diff --git a/src/admin/tests/unit/views/SettingsView.spec.ts b/src/admin/tests/unit/views/SettingsView.spec.ts
new file mode 100644
index 0000000..fcbb38e
--- /dev/null
+++ b/src/admin/tests/unit/views/SettingsView.spec.ts
@@ -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: '
' };
+
+function mountView() {
+ const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '' } }] });
+ 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).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).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).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).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).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).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).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).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).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).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;
+ 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);
+ });
+ });
+});
diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs
index 770e0a3..fd027c5 100644
--- a/src/api/MicCheck.Api/Audit/AuditService.cs
+++ b/src/api/MicCheck.Api/Audit/AuditService.cs
@@ -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;
}
}
diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs
index 2e5e101..72975d3 100644
--- a/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs
+++ b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs
@@ -22,8 +22,7 @@ public class FeatureConfiguration : IEntityTypeConfiguration
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(f => f.Tags)
- .WithOne()
- .HasForeignKey(t => t.ProjectId)
- .OnDelete(DeleteBehavior.NoAction);
+ .WithMany()
+ .UsingEntity("FeatureTags");
}
}
diff --git a/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
index 17aeebf..f996185 100644
--- a/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
+++ b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
@@ -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
builder.HasKey(t => t.Id);
builder.Property(t => t.Label).HasMaxLength(200).IsRequired();
builder.Property(t => t.Color).HasMaxLength(20).IsRequired();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(t => t.ProjectId)
+ .OnDelete(DeleteBehavior.Cascade);
}
}
diff --git a/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs
new file mode 100644
index 0000000..013f6e4
--- /dev/null
+++ b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs
@@ -0,0 +1,926 @@
+//
+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
+ {
+ ///
+ 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("FeatureId")
+ .HasColumnType("integer");
+
+ b.Property("TagsId")
+ .HasColumnType("integer");
+
+ b.HasKey("FeatureId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("FeatureTags");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ActorUserId")
+ .HasColumnType("integer");
+
+ b.Property("Changes")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("integer");
+
+ b.Property("OrganizationId")
+ .HasColumnType("integer");
+
+ b.Property("ProjectId")
+ .HasColumnType("integer");
+
+ b.Property("ResourceId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("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("UserId")
+ .HasColumnType("integer");
+
+ b.Property("ProjectId")
+ .HasColumnType("integer");
+
+ b.Property("IsAdmin")
+ .HasColumnType("boolean");
+
+ b.Property("Permissions")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "ProjectId");
+
+ b.ToTable("UserProjectPermissions");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DefaultEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("InitialValue")
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("ProjectId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId", "Name")
+ .IsUnique();
+
+ b.ToTable("Features");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("EnvironmentId")
+ .HasColumnType("integer");
+
+ b.Property("FeatureId")
+ .HasColumnType("integer");
+
+ b.Property("Priority")
+ .HasColumnType("integer");
+
+ b.Property("SegmentId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("FeatureSegments");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("integer");
+
+ b.Property("FeatureId")
+ .HasColumnType("integer");
+
+ b.Property("FeatureSegmentId")
+ .HasColumnType("integer");
+
+ b.Property("IdentityId")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Value")
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ProjectId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Tags");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IdentityId")
+ .HasColumnType("integer");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("ValueType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IdentityId", "Key")
+ .IsUnique();
+
+ b.ToTable("IdentityTraits");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Organizations");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
+ {
+ b.Property("OrganizationId")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.HasKey("OrganizationId", "UserId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("OrganizationUsers");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HideDisabledFlags")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OrganizationId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ProjectId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.ToTable("Segments");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Operator")
+ .HasColumnType("integer");
+
+ b.Property("Property")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RuleId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ParentRuleId")
+ .HasColumnType("integer");
+
+ b.Property("SegmentId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentRuleId");
+
+ b.HasIndex("SegmentId");
+
+ b.ToTable("SegmentRules");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsTwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("integer");
+
+ b.Property("OrganizationId")
+ .HasColumnType("integer");
+
+ b.Property("Scope")
+ .HasColumnType("integer");
+
+ b.Property("Secret")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttemptNumber")
+ .HasColumnType("integer");
+
+ b.Property("AttemptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Duration")
+ .HasColumnType("interval");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("text");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("PayloadJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ResponseBody")
+ .HasColumnType("text");
+
+ b.Property("ResponseStatusCode")
+ .HasColumnType("integer");
+
+ b.Property("Success")
+ .HasColumnType("boolean");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs
new file mode 100644
index 0000000..080ea6e
--- /dev/null
+++ b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs
@@ -0,0 +1,73 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace MicCheck.Api.Migrations
+{
+ ///
+ public partial class FixTagRelationships : Migration
+ {
+ ///
+ 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(type: "integer", nullable: false),
+ TagsId = table.Column(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);
+ }
+
+ ///
+ 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");
+ }
+ }
+}
diff --git a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs
index 84d3817..c92522d 100644
--- a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs
+++ b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs
@@ -22,6 +22,21 @@ namespace MicCheck.Api.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("FeatureTags", b =>
+ {
+ b.Property("FeatureId")
+ .HasColumnType("integer");
+
+ b.Property("TagsId")
+ .HasColumnType("integer");
+
+ b.HasKey("FeatureId", "TagsId");
+
+ b.HasIndex("TagsId");
+
+ b.ToTable("FeatureTags");
+ });
+
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
{
b.Property("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 =>
diff --git a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs
index 6d0abb6..fbe9776 100644
--- a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs
+++ b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs
@@ -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;
}
}