Compare commits

...

3 Commits

Author SHA1 Message Date
James Wampler
8dfcb107d0 Adding Feature flag management 2026-04-13 16:04:23 -07:00
James Wampler
f2816b6900 Building out navigation in the admin site 2026-04-13 15:27:09 -07:00
James Wampler
334b6cf3e1 Auth tweaks, Audit implementation, Webhooks 2026-04-13 14:17:45 -07:00
124 changed files with 10026 additions and 309 deletions

40
.gitignore vendored
View File

@@ -429,3 +429,43 @@ FodyWeavers.xsd
# JetBrains IDEs
.idea/
# ── Node.js / Vue.js / TypeScript ────────────────────────────────────────────
# Webpack / Vite build output
dist/
dist-ssr/
# TypeScript incremental build cache
*.tsbuildinfo
# Jest test coverage reports
coverage/
.nyc_output/
# Local environment variable overrides (may contain secrets)
.env.local
.env.development.local
.env.test.local
.env.production.local
# Package manager debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Optional: Yarn v2+ cache (not committed by default)
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# macOS metadata
.DS_Store
# Windows thumbnail cache
Thumbs.db
ehthumbs.db

View File

@@ -8,7 +8,8 @@ MicCheck is an open source project written in asp.net, c#, typescript and VueJS
## Planned Structure
- `src/admin/` — administrative components
- `src/admin/` — administrative components in VueJS
- `src/api/` - api and restful endpoints in .NET
- `tests/` — test suite
- `docs/` — documentation
@@ -22,15 +23,18 @@ MicCheck is an open source project written in asp.net, c#, typescript and VueJS
# Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
- Coding should match formating and style rules in the .editorconfig file
## Testing
- Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
- Mock any external dependencies using Moq.
- Do not name any mocked objects with the work Mock in them
- Do not name any mocked objects with the word Mock in them
- Do not include any Arrange / Act / Assert comments in the code
## Claude
- Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
- Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
- When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
- When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end

View File

@@ -1,6 +1,8 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db:
image: postgres:15-alpine
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
@@ -10,11 +12,12 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck"]
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 5
retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api:
build:
context: .
@@ -22,14 +25,32 @@ services:
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=db;Database=miccheck;Username=miccheck;Password=password
- Jwt__SecretKey=change-this-secret-key-in-production-must-be-32-chars
- Jwt__Issuer=MicCheck
- Jwt__Audience=MicCheck
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 0"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── Vue Admin Site (nginx) ───────────────────────────────────────────────────
admin:
build:
context: src/admin
dockerfile: Dockerfile
ports:
- "3000:80"
depends_on:
api:
condition: service_started
volumes:
postgres_data:

Binary file not shown.

View File

@@ -0,0 +1,483 @@
# MicCheck Admin Site Plan
## Overview
Build a fully functional VueJS 3 + Vuetify 3 + TypeScript single-page admin application for the MicCheck feature flag platform. The site manages the complete lifecycle of feature flags across the Organization → Project → Environment hierarchy exposed by the MicCheck API.
---
## Architecture Decisions
- **State Management**: Pinia (replaces Vuex; first-class Vue 3 support)
- **HTTP Client**: Axios with request/response interceptors for auth token injection and 401 refresh handling
- **Component Library**: Vuetify 3 (already configured)
- **Router**: Vue Router 4 (already configured)
- **Testing**: Jest + Vue Test Utils (already configured)
- **Code Style**: Composition API (`<script setup>`), TypeScript throughout
### Context Model
The app operates in a three-level context:
1. **Organization** — selected at login/org-switcher; stored globally
2. **Project** — selected via project switcher in nav; stored globally
3. **Environment** — selected via environment dropdown in nav; stored globally
All feature, segment, identity, and audit views are scoped to the current org/project/environment context.
---
## Chunk 1: Foundation — API Client, Auth & Stores
### Goals
Set up the core infrastructure that all subsequent chunks depend on: typed API client, Pinia stores, authentication flows, and route guards.
### Tasks
#### 1.1 Install Pinia
- Add `pinia` to `package.json`
- Register Pinia in `main.ts`
#### 1.2 Typed API Client (`src/api/`)
Create an `ApiClient` class using Axios:
- Base URL configured from environment variable (`VITE_API_BASE_URL` or webpack `DefinePlugin`)
- Request interceptor: inject `Authorization: Bearer <token>` for admin calls, or `X-Environment-Key` for flags API calls
- Response interceptor: on 401, attempt token refresh via `POST /api/v1/auth/refresh`; on refresh failure, redirect to `/login`
- Typed methods for each API domain (auth, organizations, projects, environments, features, featureStates, segments, tags, auditLogs, webhooks, identities, apiKeys)
- All responses typed using TypeScript interfaces mirroring the API response models
#### 1.3 Auth Store (`src/stores/auth.ts`)
Pinia store with:
- State: `user`, `accessToken`, `refreshToken`, `expiresAt`, `isAuthenticated`
- Actions: `login(email, password)`, `register(...)`, `logout()`, `refreshToken()`, `loadFromStorage()`
- Persist tokens to `localStorage`; restore on app init
#### 1.4 Context Store (`src/stores/context.ts`)
Pinia store with:
- State: `currentOrganization`, `currentProject`, `currentEnvironment`
- Actions: `setOrganization(org)`, `setProject(project)`, `setEnvironment(env)`
- Persist selections to `localStorage`
#### 1.5 Login & Register Views
- `LoginView.vue`: email/password form → calls auth store `login()` → redirects to dashboard
- `RegisterView.vue`: email, password, first name, last name, organization name → calls `register()` → redirects to dashboard
- Both views displayed without the main `AppLayout` (full-page centered card)
- Client-side validation with Vuetify's built-in form validation
#### 1.6 Route Guards
In `router/index.ts`:
- `beforeEach` guard: if route requires auth and user is not authenticated, redirect to `/login`
- If authenticated user navigates to `/login`, redirect to `/`
- Mark public routes (`/login`, `/register`) with `meta: { public: true }`
#### 1.7 Unit Tests
- Auth store: login success, login failure, token refresh, logout
- API client: interceptors, token injection, refresh on 401
---
## Chunk 2: Navigation & Context Selectors
### Goals
Build the main app shell with a context-aware navigation bar that allows switching between organizations, projects, and environments.
### Tasks
#### 2.1 Update `AppLayout.vue`
- Left sidebar (Vuetify `v-navigation-drawer`) with:
- MicCheck logo at top
- Organization selector (dropdown showing org name)
- Project selector (dropdown scoped to selected org)
- Environment pill/tab selector (shows all environments for current project)
- Navigation links: Features, Segments, Identities, Audit Logs
- Bottom: Settings link
- Top `v-app-bar` with:
- Page title / breadcrumb
- User name and avatar icon → `/profile`
- Gear icon → `/settings`
#### 2.2 Organization Selector Component (`src/components/OrgSelector.vue`)
- Fetches `GET /api/v1/organisations` on mount
- Displays current org name as button; opens dropdown to switch
- On select: updates context store, resets project and environment selection
#### 2.3 Project Selector Component (`src/components/ProjectSelector.vue`)
- Fetches `GET /api/v1/projects?organizationId=` when org changes
- Displays current project name; opens dropdown to switch
- On select: updates context store, resets environment selection
#### 2.4 Environment Tab Bar Component (`src/components/EnvironmentTabBar.vue`)
- Fetches `GET /api/v1/environments?projectId=` when project changes
- Displays environments as tabs (Vuetify `v-tabs`)
- Highlights selected environment; updates context store on click
- "Add Environment" button at the end of the tabs
#### 2.5 Empty States
- If no organization: prompt to create one
- If no project: prompt to create first project
- If no environment: prompt to create first environment
#### 2.6 Update Router
Add routes:
```
/login → LoginView (public)
/register → RegisterView (public)
/ → DashboardView (auth required)
/features → FeaturesView (auth required)
/segments → SegmentsView (auth required)
/identities → IdentitiesView (auth required)
/audit-logs → AuditLogsView (auth required)
/settings → SettingsView (auth required)
/profile → ProfileView (auth required)
```
#### 2.7 Dashboard View
Simple landing page showing:
- Selected org/project/environment summary cards
- Quick stats: number of features, segments, enabled flags in current environment
- Links to main sections
#### 2.8 Unit Tests
- OrgSelector: loads orgs, emits selection
- ProjectSelector: loads projects on org change
- EnvironmentTabBar: loads envs, highlights selected, emits selection
- Route guard: unauthenticated redirect, authenticated bypass
---
## Chunk 3: Feature Flags Management
### Goals
Full CRUD for features (project-level) and their per-environment states (toggle on/off, set value).
### Tasks
#### 3.1 Features List View (`FeaturesView.vue`)
- Table/data-grid using Vuetify `v-data-table-server` with server-side pagination
- Columns: Name, Type (STANDARD/MULTIVARIATE), Description, Default Enabled, Created At, Actions
- Per-row toggle switch for the current environment's feature state (`PATCH /api/v1/environments/{apiKey}/featurestates/{id}`)
- Search/filter by name
- "Create Feature" button → opens dialog
#### 3.2 Feature Create/Edit Dialog (`src/components/features/FeatureDialog.vue`)
- Fields:
- **Name** (text input; validates `^[a-zA-Z0-9_-]+$`, max 150)
- **Type** (select: STANDARD / MULTIVARIATE)
- **Initial Value** (text area; shown for MULTIVARIATE; max 20,000 chars)
- **Description** (text area; optional)
- On create: `POST /api/v1/projects/{projectId}/features`
- On edit: `PUT /api/v1/projects/{projectId}/features/{id}` (name + description only)
- On save: refresh feature list
#### 3.3 Feature Detail Panel / Drawer (`src/components/features/FeatureDetail.vue`)
Slide-out `v-navigation-drawer` or dialog opened on row click:
- **Value tab**:
- Toggle enabled/disabled for current environment (`PATCH featurestates/{id}`)
- Edit value field for current environment
- Tags chip display and management
- **Settings tab**:
- Edit name and description
- `defaultEnabled` toggle (via PATCH feature)
- Danger zone: Delete feature (confirmation requiring re-type of flag name)
#### 3.4 Tags Management (`src/components/features/TagsChipInput.vue`)
- Inline chip input on feature forms
- Autocomplete from `GET /api/v1/projects/{projectId}/tags`
- Create new tag inline (label + color picker)
- `POST /api/v1/projects/{projectId}/tags` on new tag
- Delete unused tags via `DELETE /api/v1/projects/{projectId}/tags/{id}`
#### 3.5 Feature State Value Editor (`src/components/features/FeatureValueEditor.vue`)
- Detects value type (boolean/number/JSON/string) and renders appropriate input
- JSON values get a code editor (simple `v-textarea` with monospace font)
- Saves via `PUT /api/v1/environments/{apiKey}/featurestates/{id}`
#### 3.6 Unit Tests
- FeaturesView: renders list, pagination, toggle state
- FeatureDialog: validation, create call, edit call
- TagsChipInput: loads existing tags, creates new tag
- FeatureDetail: tab switching, delete confirmation
---
## Chunk 4: Segments
### Goals
Full CRUD for project-level segments with a visual rule builder, and segment-level feature state overrides per environment.
### Tasks
#### 4.1 Segments List View (`SegmentsView.vue`)
- Table: Name, Rule Count, Created At, Actions (Edit, Delete)
- "Create Segment" button → opens Segment Editor
- Delete with confirmation dialog
#### 4.2 Segment Editor Dialog (`src/components/segments/SegmentEditor.vue`)
Full-featured rule builder:
- **Name** field
- **Rules section**: List of rule groups (AND/OR type selector)
- Each rule group contains **Conditions**:
- Property (trait key, text input)
- Operator (select from: Equal, NotEqual, Contains, NotContains, Regex, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, IsTrue, IsFalse, In, NotIn, IsSet, IsNotSet, PercentageSplit)
- Value (text input; hidden for IsTrue/IsFalse/IsSet/IsNotSet operators; percentage slider for PercentageSplit)
- Add/remove conditions within a group
- Add/remove rule groups
- Nested child rules supported (recursive component)
- On save: `POST` or `PUT /api/v1/projects/{projectId}/segments`
- Constraint display: shows "X / 100 conditions used"
#### 4.3 Segment Overrides on Features
Within the Feature Detail drawer (Chunk 3):
- **Overrides tab**: list of segments that have overrides for this feature in current environment
- Each override shows: Segment Name, Enabled toggle, Value
- "Add Segment Override" button → select segment from dropdown → configure enabled/value
- Saves via Feature State API (`featureSegmentId` populated)
- Delete override button
#### 4.4 Unit Tests
- SegmentsView: list, delete
- SegmentEditor: rule builder add/remove conditions, add/remove groups, validation
- Segment overrides: add, remove, toggle
---
## Chunk 5: Identities
### Goals
Admin view of identities in the current environment, with the ability to view traits and override feature states per identity.
### Tasks
#### 5.1 Identities List View (`IdentitiesView.vue`)
- Server-side paginated table: Identifier, Trait Count, Created At, Actions
- Search by identifier
- Click row → Identity Detail
#### 5.2 Identity Detail View (`src/components/identities/IdentityDetail.vue`)
Slide-out panel or separate route `/identities/{id}`:
- **Traits section**:
- Table of key/value traits
- (Read-only in admin view; traits are set via SDK)
- **Feature Overrides section**:
- Table of all features with current state for this identity
- Columns: Feature Name, Environment Default, Identity Override (toggle + value), Override Active
- "Add Override" — select feature → set enabled/value → `PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}`
- "Remove Override" → `DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}`
- **Danger Zone**: Delete identity (`DELETE /api/v1/environments/{apiKey}/identities/{id}`)
#### 5.3 Unit Tests
- IdentitiesView: pagination, search
- IdentityDetail: traits display, override toggle, delete identity
---
## Chunk 6: Audit Logs & Webhooks
### Goals
Read-only audit log viewer with filtering and webhook management at organization and environment level.
### Tasks
#### 6.1 Audit Logs View (`AuditLogsView.vue`)
Accessible from both org-level sidebar and within project/environment context:
- Server-side paginated table:
- Columns: Timestamp, Actor, Action, Resource Type, Resource ID, Project, Environment
- The `changes` JSON field expandable inline (show diff)
- Filters (Vuetify filter chips / inputs):
- Date range picker (start/end)
- Resource Type (multi-select: Feature, Environment, Segment, Identity, Organization, Project, ApiKey)
- Action (multi-select: Created, Updated, Deleted)
- Scoped by current context: uses org/project/environment audit-logs endpoint appropriately
#### 6.2 Webhooks Management (`src/components/settings/WebhooksPanel.vue`)
Used in both Settings view (org-level) and Environment settings:
- Table: URL (truncated), Enabled toggle, Created At, Actions
- "Add Webhook" button → Webhook Dialog
- Enabled toggle → `PUT` to update `enabled` field
- Delete with confirmation
#### 6.3 Webhook Dialog (`src/components/settings/WebhookDialog.vue`)
- Fields: URL (required), Secret (optional, masked input), Enabled (toggle)
- On create: `POST /api/v1/organisations/{id}/webhooks` or `POST /api/v1/environments/{apiKey}/webhooks`
- On edit: `PUT`
#### 6.4 Webhook Delivery History (`src/components/settings/WebhookDeliveryHistory.vue`)
- Accessible via "View Deliveries" action on each webhook row
- Table: Timestamp, Status (chip: Success/Failed/Pending), Attempt #, HTTP Response Code
- `GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries`
#### 6.5 Unit Tests
- AuditLogsView: renders data, filter by resource type, filter by date range
- WebhooksPanel: list, toggle enabled, delete
- WebhookDialog: validation, create, edit
- WebhookDeliveryHistory: renders statuses
---
## Chunk 7: Settings, Permissions & API Keys
### Goals
Organization settings, project-level user permissions, and API key management.
### Tasks
#### 7.1 Settings View (`SettingsView.vue`)
Tabbed layout:
**Tab: Organization**
- Edit organization name → `PUT /api/v1/organisations/{id}`
- Organization webhook management (reuse `WebhooksPanel` component from Chunk 6)
- Audit log link (scoped to org)
**Tab: Project**
- Edit project name and `hideDisabledFlags` toggle → `PUT /api/v1/projects/{id}`
- User Permissions table:
- Columns: User ID, Is Admin, Permissions (chips)
- "Add User" button → `POST /api/v1/projects/{id}/user-permissions`
- Edit permissions inline → `PUT /api/v1/projects/{id}/user-permissions/{userId}`
- Remove user → `DELETE /api/v1/projects/{id}/user-permissions/{userId}`
- Permission checkboxes: ViewProject, CreateFeature, EditFeature, DeleteFeature, CreateEnvironment, EditEnvironment, DeleteEnvironment, CreateSegment, EditSegment, DeleteSegment, ManageWebhooks, ViewAuditLog
**Tab: Environments**
- List of environments for current project
- Create environment: name input → `POST /api/v1/environments`
- Clone environment button → dialog asking for new name → `POST /api/v1/environments/{apiKey}/clone`
- Rename environment → `PUT /api/v1/environments/{apiKey}`
- Delete environment (with confirmation) → `DELETE`
- Environment webhook management (reuse `WebhooksPanel`)
**Tab: API Keys**
- Table: Name, Prefix (e.g. `org-key-ab`), Active, Expires At, Created At, Actions (Delete)
- "Create API Key" button → dialog:
- Name field (required)
- Expiry date picker (optional)
- `POST /api/v1/organisations/{id}/api-keys`
- On success: show `rawKey` once in a copy-to-clipboard dialog with warning "This will not be shown again"
- Delete key → `DELETE`
#### 7.2 Profile View (`ProfileView.vue`)
- Display name and email (read-only for now; no user update endpoint exists in current API)
- Logout button → calls auth store `logout()` → redirect to `/login`
- Display current organization memberships
#### 7.3 Organization Members Panel
In Organization tab:
- List members: `GET /api/v1/organisations/{id}/users` → table of userId + role
- Invite user: `POST /api/v1/organisations/{id}/users/invite` (userId + role)
- Remove member: `DELETE /api/v1/organisations/{id}/users/{userId}`
#### 7.4 Unit Tests
- API key creation: shows raw key once, copy to clipboard
- Permission editor: checkboxes update correctly, admin flag overrides individual permissions
- Environment settings: clone, rename, delete
- Profile: logout flow
---
## Shared Components & Utilities
### `src/components/common/`
- `ConfirmDialog.vue` — reusable confirm dialog (message, confirm label, danger variant)
- `PaginatedTable.vue` — wraps `v-data-table-server` with standard pagination controls
- `CopyTextField.vue` — input with copy-to-clipboard button (used for API keys, env keys)
- `StatusChip.vue` — colored chip for Success/Failed/Pending/Enabled/Disabled
- `DateRangePicker.vue` — reusable date range filter input
- `EmptyState.vue` — centered icon + message + action button for empty lists
### `src/composables/`
- `usePagination.ts` — manages page/pageSize state and server-side data fetching
- `useConfirm.ts` — returns a promise-based confirm dialog trigger
- `useNotifications.ts` — wraps Vuetify `v-snackbar` for success/error toasts
### `src/types/api.ts`
Complete TypeScript interfaces for all API response and request models.
---
## File Structure Target
```
src/admin/src/
├── api/
│ ├── client.ts # Axios instance + interceptors
│ ├── auth.ts
│ ├── organizations.ts
│ ├── projects.ts
│ ├── environments.ts
│ ├── features.ts
│ ├── featureStates.ts
│ ├── segments.ts
│ ├── tags.ts
│ ├── identities.ts
│ ├── auditLogs.ts
│ ├── webhooks.ts
│ └── apiKeys.ts
├── stores/
│ ├── auth.ts
│ └── context.ts
├── types/
│ └── api.ts
├── composables/
│ ├── usePagination.ts
│ ├── useConfirm.ts
│ └── useNotifications.ts
├── components/
│ ├── common/
│ │ ├── ConfirmDialog.vue
│ │ ├── PaginatedTable.vue
│ │ ├── CopyTextField.vue
│ │ ├── StatusChip.vue
│ │ ├── DateRangePicker.vue
│ │ └── EmptyState.vue
│ ├── nav/
│ │ ├── OrgSelector.vue
│ │ ├── ProjectSelector.vue
│ │ └── EnvironmentTabBar.vue
│ ├── features/
│ │ ├── FeatureDialog.vue
│ │ ├── FeatureDetail.vue
│ │ ├── FeatureValueEditor.vue
│ │ └── TagsChipInput.vue
│ ├── segments/
│ │ ├── SegmentEditor.vue
│ │ ├── RuleGroupEditor.vue
│ │ └── ConditionEditor.vue
│ ├── identities/
│ │ └── IdentityDetail.vue
│ └── settings/
│ ├── WebhooksPanel.vue
│ ├── WebhookDialog.vue
│ ├── WebhookDeliveryHistory.vue
│ └── PermissionsEditor.vue
├── views/
│ ├── LoginView.vue
│ ├── RegisterView.vue
│ ├── DashboardView.vue
│ ├── FeaturesView.vue
│ ├── SegmentsView.vue
│ ├── IdentitiesView.vue
│ ├── AuditLogsView.vue
│ ├── SettingsView.vue
│ └── ProfileView.vue
├── plugins/
│ └── vuetify.ts
├── router/
│ └── index.ts
├── App.vue
└── main.ts
```
---
## Build Order Summary
| Chunk | Description | Depends On |
|-------|-------------|------------|
| 1 | Foundation: API client, Auth, Pinia stores, Login/Register | — |
| 2 | Navigation, context selectors, route guards, Dashboard | Chunk 1 |
| 3 | Feature Flags (list, CRUD, env state toggles, tags) | Chunk 2 |
| 4 | Segments (list, rule builder, overrides) | Chunk 3 |
| 5 | Identities (list, detail, per-identity overrides) | Chunk 3 |
| 6 | Audit Logs & Webhooks | Chunk 2 |
| 7 | Settings, Permissions, API Keys, Profile | Chunk 2 |
Each chunk can be built and committed independently after Chunk 2 is complete.

View File

@@ -0,0 +1,81 @@
# Step 7 Output: Audit Logging & Webhooks
## Summary
Implemented the full audit logging and webhook system for MicCheck.
## What Was Built
### Audit Logging
- **`AuditService`** — `RecordAsync` captures before/after state as `{"before":{...},"after":{...}}` JSON in `AuditLog.Changes`. Serializes with `System.Text.Json` (camelCase). Enqueues `AUDIT_LOG_CREATED` webhook event after persisting. Methods are `virtual` for Moq compatibility.
- **`AuditLogQueryService`** — Separate query service with `ListByOrganizationAsync`, `ListByProjectAsync`, and `ListByEnvironmentAsync`. All return `(int Total, IReadOnlyList<AuditLog> Items)` tuples. Filtering via `AuditLogFilter` record (date range, resource type, action, project/environment/actor).
- **`AuditLogFilter`** — Record with optional filter fields and pagination (`Page`, `PageSize`).
- **`AuditLogsController`** — Updated to accept `[FromQuery] AuditLogFilter` and return `PaginatedResponse<AuditLogResponse>`.
- **`EnvironmentsController`** — Added `GET /{apiKey}/audit-logs` endpoint.
### Webhooks
- **`WebhookEvent`** / **`WebhookEventTypes`** — Event model with `EventType`, `EnvironmentId`, `OrganizationId`, `Data`. Three event types: `FLAG_UPDATED`, `FLAG_DELETED`, `AUDIT_LOG_CREATED`.
- **`WebhookPayload`** — Snake_case serialized payload with `data` and `event_type` fields. Nested records: `FlagUpdatedData`, `FlagDeletedData`, `FlagStateSnapshot`, `FeatureSummary`, `EnvironmentSummary`.
- **`WebhookQueue`** — Singleton `System.Threading.Channels`-backed queue. `EnqueueAsync` is non-blocking; `ReadAllAsync` consumed by background service.
- **`WebhookDispatcher`** — Scoped service. Finds active webhooks by environment + org scope, serializes payload, signs with HMAC-SHA256 (`X-Flagsmith-Signature: sha256=<hex>`), POSTs with 10s timeout via `IHttpClientFactory`, logs `WebhookDeliveryLog` with `AttemptNumber`.
- **`WebhookBackgroundService`** — `BackgroundService` reading from queue, dispatching via scoped `WebhookDispatcher`.
- **`WebhookRetryBackgroundService`** — Polls every 60s. Retries failed deliveries: attempt 1→2 after 5 min, 2→3 after 30 min, max 3 total.
- **`WebhookDeliveryLog`** — Added `AttemptNumber` property.
- **`WebhookService`** — Added `ListByOrganizationAsync`, `CreateForOrganizationAsync`, `ListDeliveriesAsync`.
- **`WebhookDeliveryLogResponse`** — New response DTO.
### Admin Endpoints Added
```
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries
GET /api/v1/organisations/{id}/webhooks
POST /api/v1/organisations/{id}/webhooks
PUT /api/v1/organisations/{id}/webhooks/{id}
DELETE /api/v1/organisations/{id}/webhooks/{id}
```
### Service Updates
- **`FeatureService`** — Constructor now takes `WebhookQueue`. `DeleteAsync` fires `FLAG_DELETED` events per environment. `RecordAsync` used with before/after.
- **`FeatureStateService`** — Constructor now takes `WebhookQueue` and `AuditService`. `UpdateAsync` and `PatchAsync` capture before state and dispatch `FLAG_UPDATED` + audit record.
### Program.cs
Added registrations:
- `WebhookDispatcher` (scoped)
- `WebhookQueue` (singleton)
- `WebhookBackgroundService` (hosted)
- `WebhookRetryBackgroundService` (hosted)
- `AddHttpClient("Webhooks")` with `User-Agent` header
- `JsonStringEnumConverter` for string enum deserialization
- `ApiBehaviorOptions` for 422 validation error format
### Migration
Added `AddWebhookDeliveryAttemptNumber` migration for the new `AttemptNumber` column on `WebhookDeliveryLog`.
## Tests Added
| File | Tests |
|------|-------|
| `Audit/AuditServiceTests.cs` | 5 — null changes, after-only diff, before+after diff, metadata persistence, webhook event enqueued |
| `Webhooks/WebhookDispatcherTests.cs` | 6 — signature computation, delivery without webhooks, delivery logging |
| `Webhooks/WebhookRetryTests.cs` | 4 — retry delay values, max attempts, eligibility |
**Total: 166 tests passing.**
## Acceptance Criteria Status
- [x] Every create/update/delete in the Admin API produces an `AuditLog` record
- [x] Audit logs correctly capture before/after state for updates
- [x] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
- [x] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs
- [x] Webhooks fire within 1 second of a flag state change (async via Channel)
- [x] HMAC-SHA256 signature is correct and verifiable by receivers
- [x] Failed webhook deliveries are logged with status code and response body
- [x] Retry logic attempts delivery 3 times with backoff (5 min, 30 min)
- [x] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
- [x] Unit tests cover: signature computation, payload serialization, retry scheduling
- [x] Unit tests cover: audit diff generation for create/update/delete scenarios

13
dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.5",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

5
src/admin/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
*.md
tests

19
src/admin/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# ── Stage 1: Build ────────────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
# Install dependencies first (cached layer unless package files change)
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# ── Stage 2: Serve ────────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,40 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="MicCheck microphone logo">
<title>MicCheck</title>
<!-- Microphone capsule body -->
<rect x="22" y="6" width="20" height="28" rx="10" ry="10"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Microphone grille lines -->
<line x1="22" y1="16" x2="42" y2="16" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="22" x2="42" y2="22" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="28" x2="42" y2="28" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<!-- Neck / stem going down from capsule -->
<rect x="30" y="34" width="4" height="8" rx="1"
fill="#BBDEFB"/>
<!-- Curved arm (yoke) of the stand -->
<path d="M18 26 Q18 44 32 44 Q46 44 46 26"
fill="none" stroke="#BBDEFB" stroke-width="2.5" stroke-linecap="round"/>
<!-- Vertical stand pole -->
<rect x="30" y="44" width="4" height="12" rx="1"
fill="#BBDEFB"/>
<!-- Horizontal base -->
<rect x="18" y="56" width="28" height="4" rx="2"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Sound wave arcs (left) -->
<path d="M15 18 Q10 26 15 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M10 14 Q3 26 10 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
<!-- Sound wave arcs (right) -->
<path d="M49 18 Q54 26 49 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M54 14 Q61 26 54 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><title>MicCheck Admin</title></head><body><div id="app"></div><script defer="defer" src="/js/929.737c1237.js"></script><script defer="defer" src="/js/main.c5e9ac61.js"></script></body></html>

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +0,0 @@
/*!
* vue-router v4.6.4
* (c) 2025 Eduardo San Martin Morote
* @license MIT
*/
/**
* WCAG 3.0 APCA perceptual contrast algorithm from https://github.com/Myndex/SAPC-APCA
* @licence https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* @see https://www.w3.org/WAI/GL/task-forces/silver/wiki/Visual_Contrast_of_Text_Subgroup
*/
/**
* @vue/reactivity v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/**
* @vue/runtime-dom v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/**
* @vue/shared v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

42
src/admin/nginx.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy all /api/ calls to the API container.
# The API controller routes include the /api prefix, so the path is
# forwarded unchanged: /api/v1/organisations → http://api:8080/api/v1/organisations
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# SPA fallback — all other paths serve index.html so Vue Router handles them
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin" always;
# Cache static assets aggressively (webpack hashes file names)
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Never cache the SPA entry point
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}

View File

@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@mdi/font": "^7.4.47",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vuetify": "^3.6.0"
@@ -3879,6 +3881,28 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz",
@@ -4418,8 +4442,17 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
@@ -4672,6 +4705,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -4848,7 +4889,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -5088,7 +5128,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -5217,6 +5256,20 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"dev": true
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js-compat": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -5583,7 +5636,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -5753,7 +5805,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -5920,7 +5971,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -5929,7 +5979,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -5944,7 +5993,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -5956,7 +6004,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -6536,7 +6583,6 @@
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [
{
"type": "individual",
@@ -6584,7 +6630,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -6638,7 +6683,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -6665,7 +6709,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -6698,7 +6741,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -6778,7 +6820,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -6841,7 +6882,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -6853,7 +6893,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@@ -6874,7 +6913,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -6891,6 +6929,11 @@
"he": "bin/he"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
},
"node_modules/hpack.js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
@@ -7414,6 +7457,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
@@ -9482,7 +9536,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -9584,7 +9637,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"devOptional": true,
"engines": {
"node": ">= 0.6"
}
@@ -9593,7 +9645,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"devOptional": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -9646,6 +9697,11 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -10154,6 +10210,11 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"dev": true
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10171,6 +10232,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -10397,6 +10486,14 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -10706,6 +10803,11 @@
"node": ">= 4"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -11209,6 +11311,14 @@
"wbuf": "^1.7.3"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -11367,6 +11477,17 @@
"webpack": "^5.27.0"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",

View File

@@ -10,6 +10,8 @@
},
"dependencies": {
"@mdi/font": "^7.4.47",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vuetify": "^3.6.0"

View File

@@ -1,13 +1,13 @@
<template>
<AppLayout />
<v-app theme="light">
<router-view />
</v-app>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import AppLayout from '@/components/AppLayout.vue';
export default defineComponent({
name: 'App',
components: { AppLayout },
});
</script>

27
src/admin/src/api/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import apiClient from './client';
import type {
LoginRequest,
RegisterRequest,
TokenResponse,
RefreshRequest,
LogoutRequest,
} from '@/types/api';
export async function login(request: LoginRequest): Promise<TokenResponse> {
const { data } = await apiClient.post<TokenResponse>('/v1/auth/login', request);
return data;
}
export async function register(request: RegisterRequest): Promise<TokenResponse> {
const { data } = await apiClient.post<TokenResponse>('/v1/auth/register', request);
return data;
}
export async function refreshToken(request: RefreshRequest): Promise<TokenResponse> {
const { data } = await apiClient.post<TokenResponse>('/v1/auth/refresh', request);
return data;
}
export async function logout(request: LogoutRequest): Promise<void> {
await apiClient.post('/v1/auth/logout', request);
}

120
src/admin/src/api/client.ts Normal file
View File

@@ -0,0 +1,120 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { TokenResponse, RefreshRequest } from '@/types/api';
// ─── Token State ──────────────────────────────────────────────────────────────
// Stored at module level so the client never imports the auth store,
// avoiding circular dependencies. The auth store calls setAuthTokens()
// after login/logout/loadFromStorage.
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
let _isRefreshing = false;
let _refreshQueue: Array<(token: string) => void> = [];
export function setAuthTokens(access: string | null, refresh: string | null): void {
_accessToken = access;
_refreshToken = refresh;
}
export function clearAuthTokens(): void {
_accessToken = null;
_refreshToken = null;
}
export function getAccessToken(): string | null {
return _accessToken;
}
// ─── Axios Instance ───────────────────────────────────────────────────────────
const API_BASE_URL =
typeof process !== 'undefined' && process.env.API_BASE_URL
? process.env.API_BASE_URL
: '/api';
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
timeout: 15000,
});
// ─── Request Interceptor ──────────────────────────────────────────────────────
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (_accessToken) {
config.headers.Authorization = `Bearer ${_accessToken}`;
}
return config;
},
(error) => Promise.reject(error),
);
// ─── Response Interceptor (401 Refresh) ───────────────────────────────────────
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
if (error.response?.status !== 401 || originalRequest._retried || !_refreshToken) {
return Promise.reject(error);
}
if (_isRefreshing) {
// Queue requests while a refresh is in flight
return new Promise((resolve, reject) => {
_refreshQueue.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(apiClient(originalRequest));
});
// Store reject so the queue can be drained on failure
void reject;
});
}
originalRequest._retried = true;
_isRefreshing = true;
try {
const payload: RefreshRequest = { token: _refreshToken! };
const { data } = await axios.post<TokenResponse>(
`${API_BASE_URL}/v1/auth/refresh`,
payload,
{ headers: { 'Content-Type': 'application/json' } },
);
setAuthTokens(data.accessToken, data.refreshToken);
// Persist updated tokens to localStorage so auth store stays in sync
localStorage.setItem('mic_access_token', data.accessToken);
localStorage.setItem('mic_refresh_token', data.refreshToken);
localStorage.setItem('mic_expires_at', data.expiresAt);
// Drain the queue
_refreshQueue.forEach((cb) => cb(data.accessToken));
_refreshQueue = [];
// Retry the original request
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return apiClient(originalRequest);
} catch {
// Refresh failed — clear tokens and redirect to login
clearAuthTokens();
localStorage.removeItem('mic_access_token');
localStorage.removeItem('mic_refresh_token');
localStorage.removeItem('mic_expires_at');
_refreshQueue = [];
// Redirect to login without importing the router (avoids circular deps)
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(error);
} finally {
_isRefreshing = false;
}
},
);
export default apiClient;

View File

@@ -0,0 +1,43 @@
import apiClient from './client';
import type {
EnvironmentResponse,
CreateEnvironmentRequest,
UpdateEnvironmentRequest,
CloneEnvironmentRequest,
PaginatedResponse,
} from '@/types/api';
export async function listEnvironments(projectId: number, page = 1, pageSize = 100): Promise<EnvironmentResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<EnvironmentResponse>>(
'/v1/environments',
{ params: { projectId, page, pageSize } },
);
return data.results;
}
export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> {
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environments/${apiKey}`);
return data;
}
export async function createEnvironment(request: CreateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>('/v1/environments', request);
return data;
}
export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environments/${apiKey}`, request);
return data;
}
export async function deleteEnvironment(apiKey: string): Promise<void> {
await apiClient.delete(`/v1/environments/${apiKey}`);
}
export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>(
`/v1/environments/${apiKey}/clone`,
request,
);
return data;
}

View File

@@ -0,0 +1,44 @@
import apiClient from './client';
import type {
FeatureStateResponse,
UpdateFeatureStateRequest,
PatchFeatureStateRequest,
} from '@/types/api';
export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/featurestates`,
);
return data;
}
export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> {
const { data } = await apiClient.get<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
);
return data;
}
export async function updateFeatureState(
envApiKey: string,
id: number,
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
request,
);
return data;
}
export async function patchFeatureState(
envApiKey: string,
id: number,
request: PatchFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.patch<FeatureStateResponse>(
`/v1/environments/${envApiKey}/featurestates/${id}`,
request,
);
return data;
}

View File

@@ -0,0 +1,66 @@
import apiClient from './client';
import type {
FeatureResponse,
CreateFeatureRequest,
UpdateFeatureRequest,
PatchFeatureRequest,
PaginatedResponse,
} from '@/types/api';
export async function listFeatures(
projectId: number,
page = 1,
pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/projects/${projectId}/features`,
{ params: { page, pageSize } },
);
return data;
}
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
);
return data;
}
export async function createFeature(
projectId: number,
request: CreateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>(
`/v1/projects/${projectId}/features`,
request,
);
return data;
}
export async function updateFeature(
projectId: number,
id: number,
request: UpdateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
request,
);
return data;
}
export async function patchFeature(
projectId: number,
id: number,
request: PatchFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
request,
);
return data;
}
export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
}

View File

@@ -0,0 +1,51 @@
import apiClient from './client';
import type {
OrganizationResponse,
CreateOrganizationRequest,
UpdateOrganizationRequest,
OrganizationMemberResponse,
InviteUserRequest,
PaginatedResponse,
} from '@/types/api';
export async function listOrganizations(page = 1, pageSize = 100): Promise<OrganizationResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<OrganizationResponse>>(
'/v1/organisations',
{ params: { page, pageSize } },
);
return data.results;
}
export async function getOrganization(id: number): Promise<OrganizationResponse> {
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisations/${id}`);
return data;
}
export async function createOrganization(request: CreateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.post<OrganizationResponse>('/v1/organisations', request);
return data;
}
export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisations/${id}`, request);
return data;
}
export async function deleteOrganization(id: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${id}`);
}
export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> {
const { data } = await apiClient.get<OrganizationMemberResponse[]>(
`/v1/organisations/${organizationId}/users`,
);
return data;
}
export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> {
await apiClient.post(`/v1/organisations/${organizationId}/users/invite`, request);
}
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
}

View File

@@ -0,0 +1,70 @@
import apiClient from './client';
import type {
ProjectResponse,
CreateProjectRequest,
UpdateProjectRequest,
UserPermissionResponse,
SetUserPermissionsRequest,
PaginatedResponse,
} from '@/types/api';
export async function listProjects(organizationId: number, page = 1, pageSize = 100): Promise<ProjectResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<ProjectResponse>>(
'/v1/projects',
{ params: { organizationId, page, pageSize } },
);
return data.results;
}
export async function getProject(id: number): Promise<ProjectResponse> {
const { data } = await apiClient.get<ProjectResponse>(`/v1/projects/${id}`);
return data;
}
export async function createProject(request: CreateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.post<ProjectResponse>('/v1/projects', request);
return data;
}
export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.put<ProjectResponse>(`/v1/projects/${id}`, request);
return data;
}
export async function deleteProject(id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${id}`);
}
export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> {
const { data } = await apiClient.get<UserPermissionResponse[]>(
`/v1/projects/${projectId}/user-permissions`,
);
return data;
}
export async function setProjectUserPermissions(
projectId: number,
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.post<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions`,
request,
);
return data;
}
export async function updateProjectUserPermissions(
projectId: number,
userId: number,
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.put<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions/${userId}`,
request,
);
return data;
}
export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/user-permissions/${userId}`);
}

16
src/admin/src/api/tags.ts Normal file
View File

@@ -0,0 +1,16 @@
import apiClient from './client';
import type { TagResponse, CreateTagRequest } from '@/types/api';
export async function listTags(projectId: number): Promise<TagResponse[]> {
const { data } = await apiClient.get<TagResponse[]>(`/v1/projects/${projectId}/tags`);
return data;
}
export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> {
const { data } = await apiClient.post<TagResponse>(`/v1/projects/${projectId}/tags`, request);
return data;
}
export async function deleteTag(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`);
}

View File

@@ -1,16 +1,12 @@
<template>
<v-app-bar
color="primary"
elevation="2"
height="64"
>
<v-app-bar color="primary" elevation="2" height="56">
<template #prepend>
<v-app-bar-title class="d-flex align-center">
<img
:src="logoUrl"
alt="MicCheck logo"
height="40"
width="40"
height="32"
width="32"
class="mr-3"
data-testid="app-logo"
/>
@@ -18,21 +14,6 @@
</v-app-bar-title>
</template>
<!-- Center: Main navigation links (hidden on xs/sm) -->
<div class="d-none d-md-flex align-center ga-2 mx-auto">
<v-btn
v-for="link in navLinks"
:key="link.route"
:to="link.route"
variant="text"
color="white"
rounded="lg"
:data-testid="`nav-link-${link.label.toLowerCase()}`"
>
{{ link.label }}
</v-btn>
</div>
<template #append>
<div class="d-flex align-center ga-2 mr-2">
<v-btn
@@ -43,7 +24,7 @@
data-testid="profile-link"
rounded="lg"
>
John Doe
Account
</v-btn>
<v-btn
@@ -67,13 +48,7 @@ import logoUrl from '@/assets/logo.svg';
export default defineComponent({
name: 'AppHeader',
setup() {
const navLinks = [
{ label: 'Features', route: '/features' },
{ label: 'Environments', route: '/environments' },
{ label: 'Projects', route: '/projects' },
];
return { navLinks, logoUrl };
return { logoUrl };
},
});
</script>

View File

@@ -1,14 +1,72 @@
<template>
<v-app :theme="theme">
<AppHeader />
<div>
<!-- Top App Bar -->
<v-app-bar color="primary" elevation="2" height="56">
<v-app-bar-nav-icon
variant="text"
color="white"
@click="rail = !rail"
data-testid="nav-toggle"
/>
<v-app-bar-title>
<span class="text-body-1 font-weight-medium text-white" data-testid="page-title">
{{ pageTitle }}
</span>
</v-app-bar-title>
<template #append>
<div class="d-flex align-center ga-1 mr-2">
<v-btn
to="/profile"
variant="text"
color="white"
prepend-icon="mdi-account-circle"
rounded="lg"
data-testid="profile-link"
>
{{ userDisplayName }}
</v-btn>
<v-btn
to="/settings"
icon
variant="text"
color="white"
data-testid="settings-link"
>
<v-icon>mdi-cog-outline</v-icon>
</v-btn>
</div>
</template>
</v-app-bar>
<!-- Navigation Drawer -->
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
color="white"
color="surface"
width="240"
>
<v-list density="compact" nav>
<!-- Logo -->
<div class="d-flex align-center pa-4 pb-3" data-testid="sidebar-logo">
<img :src="logoUrl" alt="MicCheck" height="32" width="32" class="flex-shrink-0" />
<span v-if="!rail" class="text-h6 font-weight-bold ml-3">MicCheck</span>
</div>
<v-divider />
<!-- Context Selectors (hidden in rail mode) -->
<div v-if="!rail" class="pa-3 d-flex flex-column ga-3" data-testid="context-selectors">
<OrgSelector />
<ProjectSelector />
<EnvironmentTabBar />
</div>
<v-divider v-if="!rail" />
<!-- Navigation Links -->
<v-list density="compact" nav class="mt-1">
<v-list-item
v-for="link in navLinks"
:key="link.route"
@@ -17,48 +75,68 @@
:title="link.label"
active-color="primary"
rounded="lg"
:data-testid="`nav-link-${link.label.toLowerCase().replace(' ', '-')}`"
/>
</v-list>
<!-- Settings at bottom -->
<template #append>
<v-list density="compact" nav>
<v-divider />
<v-list density="compact" nav class="mb-1">
<v-list-item
:prepend-icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
title="Collapse"
@click="rail = !rail"
to="/settings"
prepend-icon="mdi-cog-outline"
title="Settings"
active-color="primary"
rounded="lg"
data-testid="nav-link-settings"
/>
</v-list>
</template>
</v-navigation-drawer>
<!-- Main Content -->
<v-main>
<v-container fluid class="pa-6">
<router-view />
</v-container>
</v-main>
</v-app>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import AppHeader from '@/components/AppHeader.vue';
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import OrgSelector from '@/components/nav/OrgSelector.vue';
import ProjectSelector from '@/components/nav/ProjectSelector.vue';
import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
import logoUrl from '@/assets/logo.svg';
export default defineComponent({
name: 'AppLayout',
components: { AppHeader },
setup() {
const drawer = ref(true);
const rail = ref(false);
const theme = ref('light');
const route = useRoute();
const navLinks = [
{ label: 'Dashboard', route: '/', icon: 'mdi-view-dashboard' },
{ label: 'Features', route: '/features', icon: 'mdi-flag-outline' },
{ label: 'Environments', route: '/environments', icon: 'mdi-server-outline' },
{ label: 'Projects', route: '/projects', icon: 'mdi-folder-outline' },
];
const drawer = ref(true);
const rail = ref(false);
return { drawer, rail, theme, navLinks };
},
});
// Placeholder until a /me endpoint is available
const userDisplayName = 'Account';
const navLinks = [
{ label: 'Dashboard', route: '/', icon: 'mdi-view-dashboard' },
{ label: 'Features', route: '/features', icon: 'mdi-flag-outline' },
{ label: 'Segments', route: '/segments', icon: 'mdi-account-group-outline' },
{ label: 'Identities', route: '/identities', icon: 'mdi-badge-account-outline' },
{ label: 'Audit Logs', route: '/audit-logs', icon: 'mdi-history' },
];
const routeTitleMap: Record<string, string> = {
'/': 'Dashboard',
'/features': 'Features',
'/segments': 'Segments',
'/identities': 'Identities',
'/audit-logs': 'Audit Logs',
'/settings': 'Settings',
'/profile': 'Profile',
};
const pageTitle = computed(() => routeTitleMap[route.path] ?? 'MicCheck');
</script>

View File

@@ -0,0 +1,370 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="620"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card v-if="feature" rounded="lg" data-testid="feature-detail">
<!-- Header -->
<v-card-title class="d-flex align-center justify-space-between pa-6 pb-3">
<div class="d-flex align-center ga-2">
<v-chip
:color="feature.type === 'MULTIVARIATE' ? 'secondary' : 'primary'"
size="small"
variant="tonal"
>
{{ feature.type }}
</v-chip>
<span class="text-h6">{{ feature.name }}</span>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="$emit('update:modelValue', false)" />
</v-card-title>
<v-tabs v-model="activeTab" color="primary" class="px-4">
<v-tab value="value" data-testid="tab-value">Value</v-tab>
<v-tab value="settings" data-testid="tab-settings">Settings</v-tab>
</v-tabs>
<v-divider />
<v-card-text class="pa-0">
<v-tabs-window v-model="activeTab">
<!-- Value tab -->
<v-tabs-window-item value="value" class="pa-6">
<v-alert
v-if="valueErrorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="valueErrorMessage = null"
>
{{ valueErrorMessage }}
</v-alert>
<div v-if="!featureState" class="text-center py-6 text-medium-emphasis">
<v-icon size="32" class="mb-2">mdi-server-outline</v-icon>
<p>Select an environment to manage feature state.</p>
</div>
<template v-else>
<!-- Enabled toggle -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<p class="text-body-1 font-weight-medium mb-1">Enabled</p>
<p class="text-body-2 text-medium-emphasis">
{{ featureState.enabled ? 'On in this environment' : 'Off in this environment' }}
</p>
</div>
<v-switch
:model-value="featureState.enabled"
color="primary"
hide-details
:loading="isTogglingEnabled"
data-testid="feature-enabled-toggle"
@update:model-value="onToggleEnabled"
/>
</div>
<v-divider class="mb-5" />
<!-- Value editor -->
<p class="text-body-2 font-weight-medium mb-2">Value</p>
<FeatureValueEditor
v-model="editedValue"
class="mb-4"
/>
<div class="d-flex justify-end">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="isSavingValue"
:disabled="editedValue === featureState.value"
data-testid="save-value-btn"
@click="onSaveValue"
>
Save value
</v-btn>
</div>
<v-divider class="my-5" />
<!-- Tags -->
<TagsChipInput />
</template>
</v-tabs-window-item>
<!-- Settings tab -->
<v-tabs-window-item value="settings" class="pa-6">
<v-alert
v-if="settingsErrorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-4"
@click:close="settingsErrorMessage = null"
>
{{ settingsErrorMessage }}
</v-alert>
<v-form ref="settingsFormRef" data-testid="settings-form">
<v-text-field
v-model="settingsForm.name"
label="Name"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.nameFormat]"
:counter="150"
data-testid="settings-name-input"
/>
<v-textarea
v-model="settingsForm.description"
label="Description"
variant="outlined"
density="comfortable"
rows="2"
class="mb-3"
data-testid="settings-description-input"
/>
<div class="d-flex align-center justify-space-between mb-4">
<div>
<p class="text-body-2 font-weight-medium">Default enabled</p>
<p class="text-caption text-medium-emphasis">Applies to new environments</p>
</div>
<v-switch
v-model="settingsForm.defaultEnabled"
color="primary"
hide-details
data-testid="settings-default-enabled-toggle"
/>
</div>
<div class="d-flex justify-end mb-6">
<v-btn
color="primary"
variant="flat"
size="small"
:loading="isSavingSettings"
data-testid="save-settings-btn"
@click="onSaveSettings"
>
Save settings
</v-btn>
</div>
</v-form>
<!-- Danger zone -->
<v-card variant="outlined" color="error" rounded="lg">
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
<v-card-text class="pa-4 pt-0">
<p class="text-body-2 mb-3">
Permanently delete this feature flag. This cannot be undone and will remove
the flag from all environments.
</p>
<p class="text-body-2 mb-2">
Type <strong>{{ feature.name }}</strong> to confirm:
</p>
<v-text-field
v-model="deleteConfirmName"
variant="outlined"
density="compact"
hide-details
:placeholder="feature.name"
class="mb-3"
data-testid="delete-confirm-input"
/>
<v-btn
color="error"
variant="flat"
size="small"
:disabled="deleteConfirmName !== feature.name"
:loading="isDeleting"
data-testid="delete-feature-btn"
@click="onDelete"
>
Delete feature
</v-btn>
</v-card-text>
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { patchFeature, deleteFeature } from '@/api/features';
import { patchFeatureState } from '@/api/featureStates';
import { useContextStore } from '@/stores/context';
import FeatureValueEditor from './FeatureValueEditor.vue';
import TagsChipInput from './TagsChipInput.vue';
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
interface Props {
modelValue: boolean;
feature: FeatureResponse | null;
featureState: FeatureStateResponse | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
updated: [feature: FeatureResponse];
deleted: [featureId: number];
stateUpdated: [state: FeatureStateResponse];
}>();
const contextStore = useContextStore();
const activeTab = ref('value');
const isTogglingEnabled = ref(false);
const isSavingValue = ref(false);
const isSavingSettings = ref(false);
const isDeleting = ref(false);
const deleteConfirmName = ref('');
const editedValue = ref<string | null>(null);
const settingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const valueErrorMessage = ref<string | null>(null);
const settingsErrorMessage = ref<string | null>(null);
const settingsForm = ref({
name: '',
description: '',
defaultEnabled: false,
});
const rules = {
required: (v: string) => !!v || 'Required',
nameFormat: (v: string) =>
/^[a-zA-Z0-9_-]+$/.test(v) || 'Only letters, numbers, hyphens and underscores',
};
function syncSettingsForm(f: FeatureResponse | null): void {
if (f) {
settingsForm.value = {
name: f.name,
description: f.description ?? '',
defaultEnabled: f.defaultEnabled,
};
deleteConfirmName.value = '';
}
}
// Sync form when feature changes
watch(() => props.feature, syncSettingsForm, { immediate: true });
// Sync edited value when feature state changes
watch(
() => props.featureState,
(s) => {
editedValue.value = s?.value ?? null;
},
{ immediate: true },
);
// Reset UI state each time the dialog opens
watch(
() => props.modelValue,
(open) => {
if (open) {
activeTab.value = 'value';
valueErrorMessage.value = null;
settingsErrorMessage.value = null;
syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null;
}
},
);
async function onToggleEnabled(enabled: boolean | null): Promise<void> {
if (!props.featureState || enabled === null) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
isTogglingEnabled.value = true;
valueErrorMessage.value = null;
try {
const updated = await patchFeatureState(envApiKey, props.featureState.id, { enabled });
emit('stateUpdated', updated);
} catch {
valueErrorMessage.value = 'Failed to update enabled state. Please try again.';
} finally {
isTogglingEnabled.value = false;
}
}
async function onSaveValue(): Promise<void> {
if (!props.featureState) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) return;
if (editedValue.value) {
const trimmed = editedValue.value.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(trimmed);
} catch {
valueErrorMessage.value = 'Value contains invalid JSON.';
return;
}
}
}
isSavingValue.value = true;
valueErrorMessage.value = null;
try {
const updated = await patchFeatureState(envApiKey, props.featureState.id, {
value: editedValue.value,
});
emit('stateUpdated', updated);
} catch {
valueErrorMessage.value = 'Failed to save value. Please try again.';
} finally {
isSavingValue.value = false;
}
}
async function onSaveSettings(): Promise<void> {
if (!settingsFormRef.value) return;
const { valid } = await settingsFormRef.value.validate();
if (!valid || !props.feature) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isSavingSettings.value = true;
settingsErrorMessage.value = null;
try {
const updated = await patchFeature(projectId, props.feature.id, {
name: settingsForm.value.name,
description: settingsForm.value.description || null,
defaultEnabled: settingsForm.value.defaultEnabled,
});
emit('updated', updated);
} catch {
settingsErrorMessage.value = 'Failed to save settings. Please try again.';
} finally {
isSavingSettings.value = false;
}
}
async function onDelete(): Promise<void> {
if (!props.feature || deleteConfirmName.value !== props.feature.name) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isDeleting.value = true;
settingsErrorMessage.value = null;
try {
await deleteFeature(projectId, props.feature.id);
emit('deleted', props.feature.id);
emit('update:modelValue', false);
} catch {
settingsErrorMessage.value = 'Failed to delete feature. Please try again.';
} finally {
isDeleting.value = false;
}
}
</script>

View File

@@ -0,0 +1,217 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="560"
persistent
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card rounded="lg">
<v-card-title class="pa-6 pb-2 text-h6">
{{ isEditing ? 'Edit Feature' : 'Create Feature' }}
</v-card-title>
<v-card-text class="pa-6 pt-2">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-form ref="formRef" @submit.prevent="onSubmit" data-testid="feature-form">
<!-- Name -->
<v-text-field
v-model="form.name"
label="Name"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.nameFormat]"
:counter="150"
:maxlength="150"
:disabled="isEditing"
hint="Letters, numbers, hyphens and underscores only"
persistent-hint
data-testid="feature-name-input"
/>
<!-- Type -->
<v-select
v-model="form.type"
label="Type"
:items="typeOptions"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required]"
:disabled="isEditing"
data-testid="feature-type-select"
/>
<!-- Initial Value (MULTIVARIATE only, create mode only) -->
<v-textarea
v-if="form.type === 'MULTIVARIATE' && !isEditing"
v-model="form.initialValue"
label="Initial Value"
variant="outlined"
density="comfortable"
class="mb-3"
rows="3"
:counter="20000"
:maxlength="20000"
hint="Default value delivered to all environments"
persistent-hint
data-testid="feature-initial-value-input"
/>
<!-- Description -->
<v-textarea
v-model="form.description"
label="Description"
variant="outlined"
density="comfortable"
rows="2"
data-testid="feature-description-input"
/>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
:disabled="isSaving"
data-testid="feature-dialog-cancel"
@click="onCancel"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
data-testid="feature-dialog-save"
@click="onSubmit"
>
{{ isEditing ? 'Save' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { createFeature, updateFeature } from '@/api/features';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, FeatureType } from '@/types/api';
interface Props {
modelValue: boolean;
feature?: FeatureResponse | null;
}
const props = withDefaults(defineProps<Props>(), {
feature: null,
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [feature: FeatureResponse];
}>();
const contextStore = useContextStore();
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const isSaving = ref(false);
const errorMessage = ref<string | null>(null);
const form = ref({
name: '',
type: 'STANDARD' as FeatureType,
initialValue: '',
description: '',
});
const isEditing = computed(() => props.feature !== null);
const typeOptions = [
{ title: 'Standard (boolean/string)', value: 'STANDARD' },
{ title: 'Multivariate (A/B variants)', value: 'MULTIVARIATE' },
];
const rules = {
required: (v: string) => !!v || 'This field is required',
nameFormat: (v: string) =>
/^[a-zA-Z0-9_-]+$/.test(v) || 'Only letters, numbers, hyphens and underscores allowed',
};
function resetForm(): void {
if (props.feature) {
form.value = {
name: props.feature.name,
type: props.feature.type,
initialValue: props.feature.initialValue ?? '',
description: props.feature.description ?? '',
};
} else {
form.value = { name: '', type: 'STANDARD', initialValue: '', description: '' };
}
}
// Populate form when editing an existing feature
watch(() => props.feature, resetForm, { immediate: true });
// Reset form and clear errors each time the dialog opens
watch(
() => props.modelValue,
(open) => {
if (open) {
errorMessage.value = null;
resetForm();
}
},
);
async function onSubmit(): Promise<void> {
if (!formRef.value) return;
const { valid } = await formRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isSaving.value = true;
errorMessage.value = null;
try {
let saved: FeatureResponse;
if (isEditing.value && props.feature) {
saved = await updateFeature(projectId, props.feature.id, {
name: form.value.name,
description: form.value.description || null,
});
} else {
saved = await createFeature(projectId, {
name: form.value.name,
type: form.value.type,
initialValue: form.value.initialValue || null,
description: form.value.description || null,
});
}
emit('saved', saved);
emit('update:modelValue', false);
} catch {
errorMessage.value = 'Failed to save feature. Please try again.';
} finally {
isSaving.value = false;
}
}
function onCancel(): void {
emit('update:modelValue', false);
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div data-testid="feature-value-editor">
<!-- Type selector -->
<v-btn-toggle
v-model="detectedMode"
density="compact"
variant="outlined"
class="mb-3"
data-testid="value-mode-toggle"
>
<v-btn value="text" size="small">Text</v-btn>
<v-btn value="json" size="small">JSON</v-btn>
</v-btn-toggle>
<!-- JSON mode -->
<v-textarea
v-if="detectedMode === 'json'"
:model-value="modelValue ?? ''"
label="Value (JSON)"
variant="outlined"
density="comfortable"
rows="5"
style="font-family: monospace; font-size: 0.85rem"
:rules="[rules.validJson]"
data-testid="json-value-input"
@update:model-value="$emit('update:modelValue', $event || null)"
/>
<!-- Text mode -->
<v-text-field
v-else
:model-value="modelValue ?? ''"
label="Value"
variant="outlined"
density="comfortable"
clearable
data-testid="text-value-input"
@update:model-value="$emit('update:modelValue', $event || null)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
interface Props {
modelValue: string | null;
}
const props = defineProps<Props>();
defineEmits<{
'update:modelValue': [value: string | null];
}>();
type ValueMode = 'text' | 'json';
function detectMode(value: string | null): ValueMode {
if (!value) return 'text';
const trimmed = value.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json';
return 'text';
}
const detectedMode = ref<ValueMode>(detectMode(props.modelValue));
// Re-detect when value changes externally (e.g. a different feature is opened)
watch(
() => props.modelValue,
(v) => {
detectedMode.value = detectMode(v);
},
);
const rules = {
validJson: (v: string) => {
if (!v) return true;
try {
JSON.parse(v);
return true;
} catch {
return 'Invalid JSON';
}
},
};
</script>

View File

@@ -0,0 +1,175 @@
<template>
<div data-testid="tags-chip-input">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 font-weight-medium">Project Tags</span>
<v-btn
size="x-small"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
:loading="isCreating"
data-testid="create-tag-btn"
@click="showCreateForm = !showCreateForm"
>
New tag
</v-btn>
</div>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
closable
class="mb-2"
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<!-- Create form -->
<v-expand-transition>
<v-card v-if="showCreateForm" variant="outlined" rounded="lg" class="mb-3 pa-3">
<v-form ref="createFormRef" @submit.prevent="onCreateTag">
<div class="d-flex align-center ga-2">
<v-text-field
v-model="newTagLabel"
label="Tag name"
variant="outlined"
density="compact"
hide-details
:rules="[rules.required]"
data-testid="new-tag-label-input"
/>
<input
v-model="newTagColor"
type="color"
style="width:36px;height:36px;border:none;padding:2px;cursor:pointer;border-radius:4px"
title="Tag colour"
data-testid="new-tag-color-input"
/>
<v-btn
icon="mdi-check"
size="small"
color="primary"
variant="flat"
type="submit"
:loading="isCreating"
data-testid="confirm-create-tag-btn"
@click.prevent="onCreateTag"
/>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="showCreateForm = false"
/>
</div>
</v-form>
</v-card>
</v-expand-transition>
<!-- Tag chips -->
<div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" />
</div>
<div v-else-if="tags.length > 0" class="d-flex flex-wrap ga-1" data-testid="tag-chips">
<v-chip
v-for="tag in tags"
:key="tag.id"
size="small"
closable
:color="tag.color"
:data-testid="`tag-chip-${tag.label}`"
@click:close="onDeleteTag(tag)"
>
{{ tag.label }}
</v-chip>
</div>
<p
v-else
class="text-body-2 text-medium-emphasis"
data-testid="tags-empty-message"
>
No tags yet. Create one to organise your features.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags';
import { useContextStore } from '@/stores/context';
import type { TagResponse } from '@/types/api';
const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]);
const isLoading = ref(false);
const isCreating = ref(false);
const showCreateForm = ref(false);
const newTagLabel = ref('');
const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const rules = {
required: (v: string) => !!v || 'Tag name is required',
};
async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isLoading.value = true;
try {
tags.value = await listTags(projectId);
} catch {
tags.value = [];
errorMessage.value = 'Failed to load tags.';
} finally {
isLoading.value = false;
}
}
async function onCreateTag(): Promise<void> {
if (!createFormRef.value) return;
const { valid } = await createFormRef.value.validate();
if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isCreating.value = true;
errorMessage.value = null;
try {
const created = await createTag(projectId, {
label: newTagLabel.value.trim(),
color: newTagColor.value,
});
tags.value.push(created);
newTagLabel.value = '';
newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch {
errorMessage.value = 'Failed to create tag. Please try again.';
} finally {
isCreating.value = false;
}
}
async function onDeleteTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
errorMessage.value = null;
try {
await deleteTag(projectId, tag.id);
tags.value = tags.value.filter((t) => t.id !== tag.id);
} catch {
errorMessage.value = 'Failed to delete tag. Please try again.';
}
}
onMounted(fetchTags);
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div data-testid="environment-tab-bar">
<p class="text-caption text-medium-emphasis mb-1 px-1">Environment</p>
<div v-if="isLoading" class="d-flex justify-center py-2">
<v-progress-circular indeterminate size="20" width="2" color="primary" />
</div>
<template v-else-if="environments.length > 0">
<v-chip-group
:model-value="selectedIndex"
column
mandatory
selected-class="text-primary"
data-testid="env-chip-group"
@update:model-value="onEnvironmentSelected"
>
<v-chip
v-for="(env, index) in environments"
:key="env.id"
:value="index"
size="small"
variant="tonal"
:data-testid="`env-chip-${env.name.toLowerCase()}`"
>
{{ env.name }}
</v-chip>
</v-chip-group>
</template>
<p
v-else-if="contextStore.currentProject"
class="text-caption text-medium-emphasis px-1"
data-testid="env-empty-message"
>
No environments found.
</p>
<p
v-else
class="text-caption text-medium-emphasis px-1"
data-testid="env-no-project-message"
>
Select a project first.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useContextStore } from '@/stores/context';
import { listEnvironments } from '@/api/environments';
import type { EnvironmentResponse } from '@/types/api';
const contextStore = useContextStore();
const environments = ref<EnvironmentResponse[]>([]);
const isLoading = ref(false);
const selectedIndex = computed(() => {
if (!contextStore.currentEnvironment) return undefined;
const idx = environments.value.findIndex((e) => e.id === contextStore.currentEnvironment!.id);
return idx >= 0 ? idx : undefined;
});
async function fetchEnvironments(projectId: number): Promise<void> {
isLoading.value = true;
try {
environments.value = await listEnvironments(projectId);
// Auto-select the first environment when none is currently selected
if (environments.value.length > 0 && !contextStore.currentEnvironment) {
contextStore.setEnvironment(environments.value[0]);
}
} finally {
isLoading.value = false;
}
}
function onEnvironmentSelected(index: number): void {
const env = environments.value[index];
if (env) {
contextStore.setEnvironment(env);
}
}
watch(
() => contextStore.currentProject,
(project) => {
environments.value = [];
if (project) {
fetchEnvironments(project.id);
}
},
{ immediate: true },
);
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div data-testid="org-selector">
<v-select
:model-value="contextStore.currentOrganization"
:items="organizations"
item-title="name"
return-object
label="Organization"
density="compact"
variant="outlined"
hide-details
:loading="isLoading"
:disabled="isLoading"
no-data-text="No organizations found"
prepend-inner-icon="mdi-domain"
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"
>
No organizations available.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useContextStore } from '@/stores/context';
import { listOrganizations } from '@/api/organizations';
import type { OrganizationResponse } from '@/types/api';
const contextStore = useContextStore();
const organizations = ref<OrganizationResponse[]>([]);
const isLoading = ref(false);
async function fetchOrganizations(): Promise<void> {
isLoading.value = true;
try {
organizations.value = await listOrganizations();
} catch {
organizations.value = [];
} finally {
isLoading.value = false;
}
}
function onOrgSelected(org: OrganizationResponse | null): void {
contextStore.setOrganization(org);
}
onMounted(fetchOrganizations);
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div data-testid="project-selector">
<v-select
:model-value="contextStore.currentProject"
:items="projects"
item-title="name"
return-object
label="Project"
density="compact"
variant="outlined"
hide-details
:loading="isLoading"
:disabled="isLoading || !contextStore.currentOrganization"
:placeholder="contextStore.currentOrganization ? 'Select a project' : 'Select an organization first'"
no-data-text="No projects found"
prepend-inner-icon="mdi-folder-outline"
data-testid="project-select"
@update:model-value="onProjectSelected"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useContextStore } from '@/stores/context';
import { listProjects } from '@/api/projects';
import type { ProjectResponse } from '@/types/api';
const contextStore = useContextStore();
const projects = ref<ProjectResponse[]>([]);
const isLoading = ref(false);
async function fetchProjects(organizationId: number): Promise<void> {
isLoading.value = true;
try {
projects.value = await listProjects(organizationId);
} finally {
isLoading.value = false;
}
}
function onProjectSelected(project: ProjectResponse | null): void {
contextStore.setProject(project);
}
watch(
() => contextStore.currentOrganization,
(org) => {
projects.value = [];
if (org) {
fetchProjects(org.id);
}
},
{ immediate: true },
);
</script>

View File

@@ -1,10 +1,24 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.css';
import { useAuthStore } from './stores/auth';
import { useContextStore } from './stores/context';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(vuetify);
// Restore persisted auth and context state before the first route navigation
const authStore = useAuthStore();
authStore.loadFromStorage();
const contextStore = useContextStore();
contextStore.loadFromStorage();
app.mount('#app');

View File

@@ -1,42 +1,88 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { getAccessToken } from '@/api/client';
import AppLayout from '@/components/AppLayout.vue';
import LoginView from '@/views/LoginView.vue';
import RegisterView from '@/views/RegisterView.vue';
import DashboardView from '@/views/DashboardView.vue';
import FeaturesView from '@/views/FeaturesView.vue';
import SegmentsView from '@/views/SegmentsView.vue';
import IdentitiesView from '@/views/IdentitiesView.vue';
import AuditLogsView from '@/views/AuditLogsView.vue';
import EnvironmentsView from '@/views/EnvironmentsView.vue';
import ProjectsView from '@/views/ProjectsView.vue';
import ProfileView from '@/views/ProfileView.vue';
import SettingsView from '@/views/SettingsView.vue';
const routes: RouteRecordRaw[] = [
// ─── Public routes (no layout) ────────────────────────────────────────────
{
path: '/login',
name: 'Login',
component: LoginView,
meta: { public: true },
},
{
path: '/register',
name: 'Register',
component: RegisterView,
meta: { public: true },
},
// ─── Authenticated routes (wrapped in AppLayout) ───────────────────────────
{
path: '/',
name: 'Dashboard',
component: DashboardView,
},
{
path: '/features',
name: 'Features',
component: FeaturesView,
},
{
path: '/environments',
name: 'Environments',
component: EnvironmentsView,
},
{
path: '/projects',
name: 'Projects',
component: ProjectsView,
},
{
path: '/profile',
name: 'Profile',
component: ProfileView,
},
{
path: '/settings',
name: 'Settings',
component: SettingsView,
component: AppLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: DashboardView,
},
{
path: 'features',
name: 'Features',
component: FeaturesView,
},
{
path: 'segments',
name: 'Segments',
component: SegmentsView,
},
{
path: 'identities',
name: 'Identities',
component: IdentitiesView,
},
{
path: 'audit-logs',
name: 'AuditLogs',
component: AuditLogsView,
},
{
path: 'environments',
name: 'Environments',
component: EnvironmentsView,
},
{
path: 'projects',
name: 'Projects',
component: ProjectsView,
},
{
path: 'profile',
name: 'Profile',
component: ProfileView,
},
{
path: 'settings',
name: 'Settings',
component: SettingsView,
},
],
},
// ─── Fallback ─────────────────────────────────────────────────────────────
{
path: '/:pathMatch(.*)*',
redirect: '/',
@@ -48,4 +94,22 @@ const router = createRouter({
routes,
});
// ─── Auth Guard ───────────────────────────────────────────────────────────────
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isPublic = to.matched.some((record) => record.meta.public);
if (requiresAuth && !isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } };
}
if (isPublic && isAuthenticated) {
return { name: 'Dashboard' };
}
return true;
});
export default router;

View File

@@ -0,0 +1,94 @@
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
import { setAuthTokens, clearAuthTokens } from '@/api/client';
import * as authApi from '@/api/auth';
import type { LoginRequest, RegisterRequest } from '@/types/api';
const STORAGE_KEYS = {
accessToken: 'mic_access_token',
refreshToken: 'mic_refresh_token',
expiresAt: 'mic_expires_at',
} as const;
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(null);
const refreshToken = ref<string | null>(null);
const expiresAt = ref<string | null>(null);
const isAuthenticated = computed(
() => accessToken.value !== null && accessToken.value !== '',
);
function persistTokens(access: string, refresh: string, expiry: string): void {
accessToken.value = access;
refreshToken.value = refresh;
expiresAt.value = expiry;
localStorage.setItem(STORAGE_KEYS.accessToken, access);
localStorage.setItem(STORAGE_KEYS.refreshToken, refresh);
localStorage.setItem(STORAGE_KEYS.expiresAt, expiry);
setAuthTokens(access, refresh);
}
function clearPersistedTokens(): void {
accessToken.value = null;
refreshToken.value = null;
expiresAt.value = null;
localStorage.removeItem(STORAGE_KEYS.accessToken);
localStorage.removeItem(STORAGE_KEYS.refreshToken);
localStorage.removeItem(STORAGE_KEYS.expiresAt);
clearAuthTokens();
}
function loadFromStorage(): void {
const access = localStorage.getItem(STORAGE_KEYS.accessToken);
const refresh = localStorage.getItem(STORAGE_KEYS.refreshToken);
const expiry = localStorage.getItem(STORAGE_KEYS.expiresAt);
if (access && refresh) {
accessToken.value = access;
refreshToken.value = refresh;
expiresAt.value = expiry;
setAuthTokens(access, refresh);
}
}
async function login(email: string, password: string): Promise<void> {
const request: LoginRequest = { email, password };
const response = await authApi.login(request);
persistTokens(response.accessToken, response.refreshToken, response.expiresAt);
}
async function register(
email: string,
password: string,
firstName: string,
lastName: string,
organizationName: string,
): Promise<void> {
const request: RegisterRequest = { email, password, firstName, lastName, organizationName };
const response = await authApi.register(request);
persistTokens(response.accessToken, response.refreshToken, response.expiresAt);
}
async function logout(): Promise<void> {
if (refreshToken.value) {
try {
await authApi.logout({ token: refreshToken.value });
} catch {
// Proceed with local logout even if the server call fails
}
}
clearPersistedTokens();
}
return {
accessToken,
refreshToken,
expiresAt,
isAuthenticated,
loadFromStorage,
login,
register,
logout,
};
});

View File

@@ -0,0 +1,85 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { OrganizationResponse, ProjectResponse, EnvironmentResponse } from '@/types/api';
const STORAGE_KEYS = {
organization: 'mic_ctx_organization',
project: 'mic_ctx_project',
environment: 'mic_ctx_environment',
} as const;
export const useContextStore = defineStore('context', () => {
const currentOrganization = ref<OrganizationResponse | null>(null);
const currentProject = ref<ProjectResponse | null>(null);
const currentEnvironment = ref<EnvironmentResponse | null>(null);
function setOrganization(org: OrganizationResponse | null): void {
currentOrganization.value = org;
currentProject.value = null;
currentEnvironment.value = null;
if (org) {
localStorage.setItem(STORAGE_KEYS.organization, JSON.stringify(org));
} else {
localStorage.removeItem(STORAGE_KEYS.organization);
}
localStorage.removeItem(STORAGE_KEYS.project);
localStorage.removeItem(STORAGE_KEYS.environment);
}
function setProject(project: ProjectResponse | null): void {
currentProject.value = project;
currentEnvironment.value = null;
if (project) {
localStorage.setItem(STORAGE_KEYS.project, JSON.stringify(project));
} else {
localStorage.removeItem(STORAGE_KEYS.project);
}
localStorage.removeItem(STORAGE_KEYS.environment);
}
function setEnvironment(environment: EnvironmentResponse | null): void {
currentEnvironment.value = environment;
if (environment) {
localStorage.setItem(STORAGE_KEYS.environment, JSON.stringify(environment));
} else {
localStorage.removeItem(STORAGE_KEYS.environment);
}
}
function loadFromStorage(): void {
try {
const orgRaw = localStorage.getItem(STORAGE_KEYS.organization);
const projectRaw = localStorage.getItem(STORAGE_KEYS.project);
const envRaw = localStorage.getItem(STORAGE_KEYS.environment);
if (orgRaw) currentOrganization.value = JSON.parse(orgRaw) as OrganizationResponse;
if (projectRaw) currentProject.value = JSON.parse(projectRaw) as ProjectResponse;
if (envRaw) currentEnvironment.value = JSON.parse(envRaw) as EnvironmentResponse;
} catch {
// Corrupt storage — clear it
localStorage.removeItem(STORAGE_KEYS.organization);
localStorage.removeItem(STORAGE_KEYS.project);
localStorage.removeItem(STORAGE_KEYS.environment);
}
}
function clearContext(): void {
currentOrganization.value = null;
currentProject.value = null;
currentEnvironment.value = null;
localStorage.removeItem(STORAGE_KEYS.organization);
localStorage.removeItem(STORAGE_KEYS.project);
localStorage.removeItem(STORAGE_KEYS.environment);
}
return {
currentOrganization,
currentProject,
currentEnvironment,
setOrganization,
setProject,
setEnvironment,
loadFromStorage,
clearContext,
};
});

398
src/admin/src/types/api.ts Normal file
View File

@@ -0,0 +1,398 @@
// ─── Auth ─────────────────────────────────────────────────────────────────────
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
firstName: string;
lastName: string;
organizationName: string;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresAt: string;
}
export interface RefreshRequest {
token: string;
}
export interface LogoutRequest {
token: string;
}
// ─── Pagination ───────────────────────────────────────────────────────────────
export interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
// ─── Organizations ────────────────────────────────────────────────────────────
export interface OrganizationResponse {
id: number;
name: string;
createdAt: string;
}
export interface CreateOrganizationRequest {
name: string;
}
export interface UpdateOrganizationRequest {
name: string;
}
export interface OrganizationMemberResponse {
userId: number;
role: 'Admin' | 'User';
}
export interface InviteUserRequest {
userId: number;
role: 'Admin' | 'User';
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface CreateApiKeyRequest {
name: string;
expiresAt?: string | null;
}
export interface CreateApiKeyResponse {
id: number;
name: string;
rawKey: string;
prefix: string;
expiresAt: string | null;
}
export interface ApiKeyResponse {
id: number;
name: string;
prefix: string;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
}
// ─── Projects ─────────────────────────────────────────────────────────────────
export interface ProjectResponse {
id: number;
name: string;
organizationId: number;
hideDisabledFlags: boolean;
createdAt: string;
}
export interface CreateProjectRequest {
organizationId: number;
name: string;
}
export interface UpdateProjectRequest {
name: string;
hideDisabledFlags: boolean;
}
export type ProjectPermission =
| 'ViewProject'
| 'CreateFeature'
| 'EditFeature'
| 'DeleteFeature'
| 'CreateEnvironment'
| 'EditEnvironment'
| 'DeleteEnvironment'
| 'CreateSegment'
| 'EditSegment'
| 'DeleteSegment'
| 'ManageWebhooks'
| 'ViewAuditLog';
export interface UserPermissionResponse {
userId: number;
projectId: number;
isAdmin: boolean;
permissions: ProjectPermission[];
}
export interface SetUserPermissionsRequest {
userId: number;
isAdmin: boolean;
permissions: ProjectPermission[];
}
// ─── Environments ─────────────────────────────────────────────────────────────
export interface EnvironmentResponse {
id: number;
name: string;
apiKey: string;
projectId: number;
createdAt: string;
}
export interface CreateEnvironmentRequest {
projectId: number;
name: string;
}
export interface UpdateEnvironmentRequest {
name: string;
}
export interface CloneEnvironmentRequest {
name: string;
}
// ─── Features ─────────────────────────────────────────────────────────────────
export type FeatureType = 'STANDARD' | 'MULTIVARIATE';
export interface FeatureResponse {
id: number;
name: string;
type: FeatureType;
initialValue: string | null;
description: string | null;
defaultEnabled: boolean;
projectId: number;
createdAt: string;
}
export interface CreateFeatureRequest {
name: string;
type: FeatureType;
initialValue?: string | null;
description?: string | null;
}
export interface UpdateFeatureRequest {
name: string;
description?: string | null;
}
export interface PatchFeatureRequest {
name?: string | null;
description?: string | null;
defaultEnabled?: boolean | null;
}
// ─── Feature States ───────────────────────────────────────────────────────────
export interface FeatureStateResponse {
id: number;
featureId: number;
environmentId: number;
identityId: number | null;
featureSegmentId: number | null;
enabled: boolean;
value: string | null;
createdAt: string;
updatedAt: string;
}
export interface UpdateFeatureStateRequest {
enabled: boolean;
value?: string | null;
}
export interface PatchFeatureStateRequest {
enabled?: boolean | null;
value?: string | null;
}
// ─── Segments ─────────────────────────────────────────────────────────────────
export type SegmentConditionOperator =
| 'Equal'
| 'NotEqual'
| 'Contains'
| 'NotContains'
| 'Regex'
| 'GreaterThan'
| 'GreaterThanOrEqual'
| 'LessThan'
| 'LessThanOrEqual'
| 'IsTrue'
| 'IsFalse'
| 'In'
| 'NotIn'
| 'IsSet'
| 'IsNotSet'
| 'PercentageSplit'
| 'ModuloValueDivisorRemainder';
export type SegmentRuleType = 'AND' | 'OR';
export interface SegmentCondition {
id?: number;
property: string;
operator: SegmentConditionOperator;
value: string;
}
export interface SegmentRule {
id?: number;
type: SegmentRuleType;
conditions: SegmentCondition[];
childRules?: SegmentRule[];
}
export interface SegmentResponse {
id: number;
name: string;
projectId: number;
createdAt: string;
rules: SegmentRule[];
}
export interface CreateSegmentRequest {
name: string;
rules: SegmentRule[];
}
export interface UpdateSegmentRequest {
name: string;
rules: SegmentRule[];
}
// ─── Tags ─────────────────────────────────────────────────────────────────────
export interface TagResponse {
id: number;
label: string;
color: string;
projectId: number;
}
export interface CreateTagRequest {
label: string;
color: string;
}
// ─── Audit Logs ───────────────────────────────────────────────────────────────
export interface AuditLogResponse {
id: number;
resourceType: string;
resourceId: string;
action: string;
changes: string | null;
organizationId: number;
projectId: number | null;
environmentId: number | null;
actorUserId: number | null;
createdAt: string;
}
export interface AuditLogFilter {
resourceType?: string | null;
action?: string | null;
startDate?: string | null;
endDate?: string | null;
page?: number;
pageSize?: number;
}
// ─── Webhooks ─────────────────────────────────────────────────────────────────
export type WebhookScope = 'Organization' | 'Environment';
export type WebhookDeliveryStatus = 'Pending' | 'Success' | 'Failed';
export interface WebhookResponse {
id: number;
url: string;
secret: string | null;
scope: WebhookScope;
enabled: boolean;
environmentId: number | null;
organizationId: number | null;
createdAt: string;
}
export interface CreateWebhookRequest {
url: string;
secret?: string | null;
enabled: boolean;
}
export interface UpdateWebhookRequest {
url: string;
secret?: string | null;
enabled: boolean;
}
export interface WebhookDeliveryLogResponse {
id: number;
status: WebhookDeliveryStatus;
attemptNumber: number;
responseCode: number | null;
createdAt: string;
}
// ─── Identities ───────────────────────────────────────────────────────────────
export interface TraitResponse {
key: string;
value: string | number | boolean | null;
}
export interface IdentityResponse {
id: number;
identifier: string;
environmentId: number;
traits: TraitResponse[];
createdAt: string;
}
export interface TraitRequest {
traitKey: string;
traitValue: string | number | boolean | null;
}
export interface IdentityRequest {
identifier: string;
traits?: TraitRequest[] | null;
}
export interface IdentityWithFlagsResponse {
traits: TraitResponse[];
flags: FlagResponse[];
}
// ─── Flags (SDK) ──────────────────────────────────────────────────────────────
export interface FlagFeatureInfo {
id: number;
name: string;
type: FeatureType;
}
export interface FlagResponse {
id: number;
feature: FlagFeatureInfo;
enabled: boolean;
featureStateValue: string | null;
}
// ─── Errors ───────────────────────────────────────────────────────────────────
export interface ApiValidationError {
errors: Record<string, string[]>;
}
export interface ApiError {
error: string;
}

View File

@@ -0,0 +1,18 @@
<template>
<v-row>
<v-col cols="12">
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Audit Logs</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Audit logs record all changes made to your feature flags, segments, and environments.
Select a project to view audit logs.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
</script>

View File

@@ -1,18 +1,153 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Dashboard</h1>
<v-card>
<v-card-text>
Welcome to MicCheck. Use the navigation to manage your features,
environments, and projects.
</v-card-text>
</v-card>
</v-col>
</v-row>
<div>
<h1 class="text-h5 font-weight-bold mb-4">Dashboard</h1>
<!-- Context summary -->
<v-row class="mb-4" data-testid="context-summary">
<v-col cols="12" sm="4">
<v-card
:color="contextStore.currentOrganization ? 'primary' : undefined"
:variant="contextStore.currentOrganization ? 'tonal' : 'outlined'"
rounded="lg"
data-testid="org-card"
>
<v-card-item>
<template #prepend>
<v-icon :color="contextStore.currentOrganization ? 'primary' : 'medium-emphasis'">
mdi-domain
</v-icon>
</template>
<v-card-title class="text-body-2 text-medium-emphasis">Organization</v-card-title>
<v-card-subtitle class="text-body-1 font-weight-medium mt-1">
{{ contextStore.currentOrganization?.name ?? 'Not selected' }}
</v-card-subtitle>
</v-card-item>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card
:color="contextStore.currentProject ? 'primary' : undefined"
:variant="contextStore.currentProject ? 'tonal' : 'outlined'"
rounded="lg"
data-testid="project-card"
>
<v-card-item>
<template #prepend>
<v-icon :color="contextStore.currentProject ? 'primary' : 'medium-emphasis'">
mdi-folder-outline
</v-icon>
</template>
<v-card-title class="text-body-2 text-medium-emphasis">Project</v-card-title>
<v-card-subtitle class="text-body-1 font-weight-medium mt-1">
{{ contextStore.currentProject?.name ?? 'Not selected' }}
</v-card-subtitle>
</v-card-item>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card
:color="contextStore.currentEnvironment ? 'primary' : undefined"
:variant="contextStore.currentEnvironment ? 'tonal' : 'outlined'"
rounded="lg"
data-testid="environment-card"
>
<v-card-item>
<template #prepend>
<v-icon :color="contextStore.currentEnvironment ? 'primary' : 'medium-emphasis'">
mdi-server-outline
</v-icon>
</template>
<v-card-title class="text-body-2 text-medium-emphasis">Environment</v-card-title>
<v-card-subtitle class="text-body-1 font-weight-medium mt-1">
{{ contextStore.currentEnvironment?.name ?? 'Not selected' }}
</v-card-subtitle>
</v-card-item>
</v-card>
</v-col>
</v-row>
<!-- Quick-nav cards -->
<v-row v-if="contextStore.currentProject">
<v-col
v-for="section in sections"
:key="section.route"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card
:to="section.route"
rounded="lg"
hover
data-testid="section-card"
>
<v-card-item>
<template #prepend>
<v-icon color="primary" size="28">{{ section.icon }}</v-icon>
</template>
<v-card-title class="text-body-1 font-weight-medium">{{ section.label }}</v-card-title>
<v-card-subtitle class="text-caption">{{ section.description }}</v-card-subtitle>
</v-card-item>
</v-card>
</v-col>
</v-row>
<!-- Empty state when no org selected -->
<v-card v-else-if="!contextStore.currentOrganization" variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-4">mdi-domain</v-icon>
<p class="text-h6 mb-2">Get started</p>
<p class="text-body-2 text-medium-emphasis">
Select or create an organization using the sidebar to begin managing your feature flags.
</p>
</v-card-text>
</v-card>
<!-- Empty state when org selected but no project -->
<v-card v-else variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-4">mdi-folder-outline</v-icon>
<p class="text-h6 mb-2">Select a project</p>
<p class="text-body-2 text-medium-emphasis">
Choose a project from the sidebar to start managing feature flags, segments, and identities.
</p>
</v-card-text>
</v-card>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'DashboardView' });
<script setup lang="ts">
import { useContextStore } from '@/stores/context';
const contextStore = useContextStore();
const sections = [
{
label: 'Features',
description: 'Manage feature flags and their values per environment',
route: '/features',
icon: 'mdi-flag-outline',
},
{
label: 'Segments',
description: 'Define user segments to target specific audiences',
route: '/segments',
icon: 'mdi-account-group-outline',
},
{
label: 'Identities',
description: 'View and override flags for individual users',
route: '/identities',
icon: 'mdi-badge-account-outline',
},
{
label: 'Audit Logs',
description: 'Review a full history of changes across the project',
route: '/audit-logs',
icon: 'mdi-history',
},
];
</script>

View File

@@ -1,17 +1,317 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Features</h1>
<v-card>
<v-card-text>
Manage feature flags across projects and environments.
</v-card-text>
<div>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Features</h1>
<v-btn
v-if="contextStore.currentProject"
color="primary"
prepend-icon="mdi-plus"
data-testid="create-feature-btn"
@click="openCreateDialog"
>
Create Feature
</v-btn>
</div>
<!-- No project selected -->
<v-card v-if="!contextStore.currentProject" variant="outlined" rounded="lg">
<v-card-text class="text-center py-10">
<v-icon size="48" color="medium-emphasis" class="mb-3">mdi-flag-outline</v-icon>
<p class="text-h6 mb-2">Select a project</p>
<p class="text-body-2 text-medium-emphasis">
Choose a project from the sidebar to manage its feature flags.
</p>
</v-card-text>
</v-card>
<template v-else>
<!-- Search bar -->
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search features"
variant="outlined"
density="comfortable"
clearable
hide-details
class="mb-4"
data-testid="feature-search"
/>
<!-- Features table -->
<v-card rounded="lg">
<v-data-table
:headers="headers"
:items="filteredFeatures"
:loading="isLoading"
:items-per-page="20"
:search="search"
hover
data-testid="features-table"
@click:row="onRowClick"
>
<!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }">
<div>
<span class="text-body-2 font-weight-medium">{{ item.name }}</span>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }}
</p>
</div>
</template>
<!-- Type chip -->
<template #item.type="{ item }: { item: FeatureResponse }">
<v-chip
:color="item.type === 'MULTIVARIATE' ? 'secondary' : 'primary'"
size="x-small"
variant="tonal"
>
{{ item.type }}
</v-chip>
</template>
<!-- Enabled toggle for current environment -->
<template #item.enabled="{ item }: { item: FeatureResponse }">
<div v-if="!contextStore.currentEnvironment" class="text-caption text-medium-emphasis">
</div>
<v-switch
v-else
:model-value="featureStateMap.get(item.id)?.enabled ?? false"
color="primary"
hide-details
density="compact"
:loading="togglingFeatureId === item.id"
:data-testid="`toggle-${item.id}`"
@update:model-value="onToggleEnabled(item.id, $event)"
@click.stop
/>
</template>
<!-- Created at -->
<template #item.createdAt="{ item }: { item: FeatureResponse }">
<span class="text-caption text-medium-emphasis">
{{ formatDate(item.createdAt) }}
</span>
</template>
<!-- Row actions -->
<template #item.actions="{ item }: { item: FeatureResponse }">
<v-btn
icon="mdi-pencil-outline"
size="small"
variant="text"
:data-testid="`edit-feature-${item.id}`"
@click.stop="openEditDialog(item)"
/>
</template>
<!-- Empty state -->
<template #no-data>
<div class="text-center py-8">
<v-icon size="40" color="medium-emphasis" class="mb-2">mdi-flag-outline</v-icon>
<p class="text-body-2 text-medium-emphasis">No features yet. Create your first flag.</p>
</div>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</template>
<!-- Create / Edit dialog -->
<FeatureDialog
v-model="showDialog"
:feature="editingFeature"
@saved="onFeatureSaved"
/>
<!-- Feature detail drawer -->
<FeatureDetail
v-model="showDetail"
:feature="selectedFeature"
:feature-state="selectedFeatureState"
@updated="onFeatureUpdated"
@deleted="onFeatureDeleted"
@state-updated="onStateUpdated"
/>
<!-- Error snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="4000"
location="bottom"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="showErrorSnackbar = false">Dismiss</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'FeaturesView' });
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { listFeatures } from '@/api/features';
import { listFeatureStates, patchFeatureState } from '@/api/featureStates';
import { useContextStore } from '@/stores/context';
import FeatureDialog from '@/components/features/FeatureDialog.vue';
import FeatureDetail from '@/components/features/FeatureDetail.vue';
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
const contextStore = useContextStore();
// ─── State ────────────────────────────────────────────────────────────────────
const features = ref<FeatureResponse[]>([]);
const featureStateMap = ref<Map<number, FeatureStateResponse>>(new Map());
const isLoading = ref(false);
const search = ref('');
const togglingFeatureId = ref<number | null>(null);
// Error snackbar
const showErrorSnackbar = ref(false);
const snackbarMessage = ref('');
function showError(message: string): void {
snackbarMessage.value = message;
showErrorSnackbar.value = true;
}
// Dialog / detail state
const showDialog = ref(false);
const editingFeature = ref<FeatureResponse | null>(null);
const showDetail = ref(false);
const selectedFeature = ref<FeatureResponse | null>(null);
const selectedFeatureState = computed(() =>
selectedFeature.value ? (featureStateMap.value.get(selectedFeature.value.id) ?? null) : null,
);
// ─── Table config ─────────────────────────────────────────────────────────────
const headers = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Type', key: 'type', sortable: true, width: '140' },
{ title: 'Enabled', key: 'enabled', sortable: false, width: '100' },
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
{ title: '', key: 'actions', sortable: false, width: '50', align: 'end' as const },
];
// ─── Computed ─────────────────────────────────────────────────────────────────
const filteredFeatures = computed(() => {
if (!search.value) return features.value;
const q = search.value.toLowerCase();
return features.value.filter(
(f) => f.name.toLowerCase().includes(q) || (f.description ?? '').toLowerCase().includes(q),
);
});
// ─── Data loading ─────────────────────────────────────────────────────────────
async function loadFeatures(): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) {
features.value = [];
return;
}
isLoading.value = true;
try {
const result = await listFeatures(projectId, 1, 100);
features.value = result.results;
} catch {
features.value = [];
showError('Failed to load features. Please refresh the page.');
} finally {
isLoading.value = false;
}
}
async function loadFeatureStates(): Promise<void> {
const envApiKey = contextStore.currentEnvironment?.apiKey;
if (!envApiKey) {
featureStateMap.value = new Map();
return;
}
try {
const states = await listFeatureStates(envApiKey);
featureStateMap.value = new Map(states.map((s) => [s.featureId, s]));
} catch {
featureStateMap.value = new Map();
showError('Failed to load feature states. Please refresh the page.');
}
}
async function loadData(): Promise<void> {
await Promise.all([loadFeatures(), loadFeatureStates()]);
}
// Reload when project or environment changes
watch(() => contextStore.currentProject, loadData, { immediate: true });
watch(() => contextStore.currentEnvironment, loadFeatureStates);
// ─── Actions ──────────────────────────────────────────────────────────────────
function openCreateDialog(): void {
editingFeature.value = null;
showDialog.value = true;
}
function openEditDialog(feature: FeatureResponse): void {
editingFeature.value = feature;
showDialog.value = true;
}
function onRowClick(_event: Event, { item }: { item: FeatureResponse }): void {
selectedFeature.value = item;
showDetail.value = true;
}
async function onFeatureSaved(saved: FeatureResponse): Promise<void> {
const idx = features.value.findIndex((f) => f.id === saved.id);
if (idx >= 0) {
features.value[idx] = saved;
} else {
features.value.unshift(saved);
// New feature: load its state for the current environment
await loadFeatureStates();
}
}
function onFeatureUpdated(updated: FeatureResponse): void {
const idx = features.value.findIndex((f) => f.id === updated.id);
if (idx >= 0) features.value[idx] = updated;
if (selectedFeature.value?.id === updated.id) selectedFeature.value = updated;
}
function onFeatureDeleted(featureId: number): void {
features.value = features.value.filter((f) => f.id !== featureId);
featureStateMap.value.delete(featureId);
featureStateMap.value = new Map(featureStateMap.value);
showDetail.value = false;
}
function onStateUpdated(state: FeatureStateResponse): void {
featureStateMap.value.set(state.featureId, state);
// Trigger reactivity on the Map
featureStateMap.value = new Map(featureStateMap.value);
}
async function onToggleEnabled(featureId: number, enabled: boolean | null): Promise<void> {
if (enabled === null) return;
const envApiKey = contextStore.currentEnvironment?.apiKey;
const state = featureStateMap.value.get(featureId);
if (!envApiKey || !state) return;
togglingFeatureId.value = featureId;
try {
const updated = await patchFeatureState(envApiKey, state.id, { enabled });
onStateUpdated(updated);
} catch {
showError('Failed to update feature state. Please try again.');
} finally {
togglingFeatureId.value = null;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<v-row>
<v-col cols="12">
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Identities</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Identities represent the individual users or services that consume your feature flags.
Select an environment to manage identities.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,125 @@
<template>
<v-container fluid class="fill-height auth-bg">
<v-row justify="center" align="center" class="fill-height">
<v-col cols="12" sm="8" md="5" lg="4" xl="3">
<div class="d-flex justify-center mb-6">
<img :src="logoUrl" alt="MicCheck logo" height="52" width="52" class="mr-3" />
<span class="text-h4 font-weight-bold align-self-center">MicCheck</span>
</div>
<v-card elevation="4" rounded="lg">
<v-card-title class="pa-6 pb-2 text-h6">Sign in</v-card-title>
<v-card-text class="pa-6 pt-2">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
data-testid="login-error"
>
{{ errorMessage }}
</v-alert>
<v-form ref="formRef" @submit.prevent="onSubmit" data-testid="login-form">
<v-text-field
v-model="email"
label="Email"
type="email"
autocomplete="email"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.email]"
data-testid="email-input"
/>
<v-text-field
v-model="password"
label="Password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
variant="outlined"
density="comfortable"
class="mb-4"
:rules="[rules.required]"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="showPassword = !showPassword"
data-testid="password-input"
/>
<v-btn
type="submit"
color="primary"
size="large"
block
:loading="isLoading"
data-testid="login-submit"
>
Sign in
</v-btn>
</v-form>
</v-card-text>
<v-divider />
<v-card-text class="pa-4 text-center">
<span class="text-body-2 text-medium-emphasis">Don't have an account?</span>
<v-btn
variant="text"
color="primary"
size="small"
to="/register"
class="ml-1"
data-testid="register-link"
>
Register
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import logoUrl from '@/assets/logo.svg';
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const rules = {
required: (v: string) => !!v || 'This field is required',
email: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Enter a valid email address',
};
async function onSubmit(): Promise<void> {
errorMessage.value = '';
const form = formRef.value;
if (!form) return;
const { valid } = await form.validate();
if (!valid) return;
isLoading.value = true;
try {
await authStore.login(email.value, password.value);
await router.push('/');
} catch {
errorMessage.value = 'Invalid email or password. Please try again.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.auth-bg {
background: rgb(var(--v-theme-background));
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<v-container fluid class="fill-height auth-bg">
<v-row justify="center" align="center" class="fill-height">
<v-col cols="12" sm="8" md="6" lg="5" xl="4">
<div class="d-flex justify-center mb-6">
<img :src="logoUrl" alt="MicCheck logo" height="52" width="52" class="mr-3" />
<span class="text-h4 font-weight-bold align-self-center">MicCheck</span>
</div>
<v-card elevation="4" rounded="lg">
<v-card-title class="pa-6 pb-2 text-h6">Create your account</v-card-title>
<v-card-text class="pa-6 pt-2">
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
data-testid="register-error"
>
{{ errorMessage }}
</v-alert>
<v-form ref="formRef" @submit.prevent="onSubmit" data-testid="register-form">
<v-row>
<v-col cols="12" sm="6" class="pb-0">
<v-text-field
v-model="firstName"
label="First name"
autocomplete="given-name"
variant="outlined"
density="comfortable"
:rules="[rules.required]"
data-testid="first-name-input"
/>
</v-col>
<v-col cols="12" sm="6" class="pb-0">
<v-text-field
v-model="lastName"
label="Last name"
autocomplete="family-name"
variant="outlined"
density="comfortable"
:rules="[rules.required]"
data-testid="last-name-input"
/>
</v-col>
</v-row>
<v-text-field
v-model="email"
label="Email"
type="email"
autocomplete="email"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.email]"
data-testid="email-input"
/>
<v-text-field
v-model="organizationName"
label="Organization name"
autocomplete="organization"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required]"
data-testid="org-name-input"
/>
<v-text-field
v-model="password"
label="Password"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.minLength]"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="showPassword = !showPassword"
data-testid="password-input"
/>
<v-text-field
v-model="confirmPassword"
label="Confirm password"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
class="mb-4"
:rules="[rules.required, rules.passwordMatch]"
data-testid="confirm-password-input"
/>
<v-btn
type="submit"
color="primary"
size="large"
block
:loading="isLoading"
data-testid="register-submit"
>
Create account
</v-btn>
</v-form>
</v-card-text>
<v-divider />
<v-card-text class="pa-4 text-center">
<span class="text-body-2 text-medium-emphasis">Already have an account?</span>
<v-btn
variant="text"
color="primary"
size="small"
to="/login"
class="ml-1"
data-testid="login-link"
>
Sign in
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import logoUrl from '@/assets/logo.svg';
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const firstName = ref('');
const lastName = ref('');
const email = ref('');
const organizationName = ref('');
const password = ref('');
const confirmPassword = ref('');
const showPassword = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const rules = {
required: (v: string) => !!v || 'This field is required',
email: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Enter a valid email address',
minLength: (v: string) => v.length >= 8 || 'Password must be at least 8 characters',
passwordMatch: (v: string) => v === password.value || 'Passwords do not match',
};
async function onSubmit(): Promise<void> {
errorMessage.value = '';
const form = formRef.value;
if (!form) return;
const { valid } = await form.validate();
if (!valid) return;
isLoading.value = true;
try {
await authStore.register(
email.value,
password.value,
firstName.value,
lastName.value,
organizationName.value,
);
await router.push('/');
} catch {
errorMessage.value = 'Registration failed. Please check your details and try again.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.auth-bg {
background: rgb(var(--v-theme-background));
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<v-row>
<v-col cols="12">
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h5 font-weight-bold">Segments</h1>
</div>
<v-card>
<v-card-text class="text-medium-emphasis">
Segments let you target specific groups of users based on their traits.
Select a project to manage segments.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
</script>

View File

@@ -10,6 +10,24 @@ global.ResizeObserver = class ResizeObserver {
disconnect() {}
};
// Stub visualViewport which jsdom doesn't implement (required by Vuetify VOverlay)
if (!window.visualViewport) {
Object.defineProperty(window, 'visualViewport', {
value: {
width: 1024,
height: 768,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: () => {},
removeEventListener: () => {},
},
writable: true,
});
}
// Stub CSS.supports
Object.defineProperty(window, 'CSS', {
value: { supports: () => false },

View File

@@ -8,9 +8,6 @@ function makeRouter() {
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/features', component: { template: '<div />' } },
{ path: '/environments', component: { template: '<div />' } },
{ path: '/projects', component: { template: '<div />' } },
{ path: '/profile', component: { template: '<div />' } },
{ path: '/settings', component: { template: '<div />' } },
],
@@ -24,41 +21,21 @@ describe('AppHeader', () => {
const router = makeRouter();
await router.push('/');
// v-app-bar requires a v-app layout context — wrap the component under test
wrapper = mount(
{ template: '<v-app><AppHeader /></v-app>', components: { AppHeader } },
{ global: { plugins: [router] } },
);
});
it('renders the Features navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-features"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Features');
});
it('renders the Environments navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-environments"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Environments');
});
it('renders the Projects navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-projects"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Projects');
});
it('renders the SVG microphone logo image', () => {
const logo = wrapper.find('[data-testid="app-logo"]');
expect(logo.exists()).toBe(true);
expect(logo.attributes('alt')).toBe('MicCheck logo');
});
it('renders the profile link with John Doe text', () => {
it('renders the profile link', () => {
const link = wrapper.find('[data-testid="profile-link"]');
expect(link.exists()).toBe(true);
expect(link.text()).toContain('John Doe');
});
it('renders the settings gear icon link', () => {

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
// The interceptor internals rely on axios, which is hard to unit-test without
// a real HTTP stack. These tests focus on the exported token-management helpers
// that the interceptors and auth store both depend on.
describe('ApiClient token management', () => {
beforeEach(() => {
clearAuthTokens();
localStorage.clear();
});
afterEach(() => {
clearAuthTokens();
});
describe('WhenTokensAreSet', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
setAuthTokens('access-abc', 'refresh-xyz');
expect(getAccessToken()).toBe('access-abc');
});
});
describe('WhenTokensAreCleared', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens('access-abc', 'refresh-xyz');
clearAuthTokens();
expect(getAccessToken()).toBeNull();
});
});
describe('WhenSetAuthTokensIsCalledWithNull', () => {
it('ThenGetAccessTokenReturnsNull', () => {
setAuthTokens(null, null);
expect(getAccessToken()).toBeNull();
});
});
describe('WhenTokensAreUpdated', () => {
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
setAuthTokens('first-token', 'first-refresh');
setAuthTokens('second-token', 'second-refresh');
expect(getAccessToken()).toBe('second-token');
});
});
});

View File

@@ -0,0 +1,150 @@
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 EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, EnvironmentResponse } from '@/types/api';
jest.mock('@/api/environments');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as envApi from '@/api/environments';
const mockProject: ProjectResponse = {
id: 10,
name: 'Website',
organizationId: 1,
hideDisabledFlags: false,
createdAt: '2026-01-01T00:00:00Z',
};
const mockEnvironments: EnvironmentResponse[] = [
{ id: 100, name: 'Development', apiKey: 'env-key-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 101, name: 'Production', apiKey: 'env-key-prod', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
];
function mountComponent() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
});
return mount(EnvironmentTabBar, {
global: { plugins: [router] },
});
}
describe('EnvironmentTabBar', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenNoProjectIsSelected', () => {
it('ThenEnvironmentsAreNotFetched', async () => {
mountComponent();
await flushPromises();
expect(envApi.listEnvironments).not.toHaveBeenCalled();
});
it('ThenNoProjectMessageIsShown', async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="env-no-project-message"]').exists()).toBe(true);
});
});
describe('WhenProjectIsSetInContextStore', () => {
it('ThenEnvironmentsAreFetchedForThatProject', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
expect(envApi.listEnvironments).toHaveBeenCalledWith(mockProject.id);
});
it('ThenEnvironmentChipsAreRendered', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
const wrapper = mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
expect(wrapper.find('[data-testid="env-chip-development"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="env-chip-production"]').exists()).toBe(true);
});
it('ThenFirstEnvironmentIsAutoSelected', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[0]);
});
});
describe('WhenEnvironmentChipIsClicked', () => {
it('ThenContextStoreIsUpdatedWithSelectedEnvironment', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
const wrapper = mountComponent();
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]);
});
});
describe('WhenProjectChanges', () => {
it('ThenEnvironmentsAreReloaded', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
const newProject: ProjectResponse = { ...mockProject, id: 20, name: 'Mobile' };
contextStore.setProject(newProject);
await flushPromises();
expect(envApi.listEnvironments).toHaveBeenCalledTimes(2);
expect(envApi.listEnvironments).toHaveBeenLastCalledWith(newProject.id);
});
});
describe('WhenApiReturnsNoEnvironments', () => {
it('ThenEmptyMessageIsDisplayed', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
const contextStore = useContextStore();
const wrapper = mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
expect(wrapper.find('[data-testid="env-empty-message"]').exists()).toBe(true);
});
});
});

View File

@@ -0,0 +1,98 @@
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 OrgSelector from '@/components/nav/OrgSelector.vue';
import { useContextStore } from '@/stores/context';
import type { OrganizationResponse } from '@/types/api';
jest.mock('@/api/organizations');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as orgsApi from '@/api/organizations';
const mockOrgs: OrganizationResponse[] = [
{ id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' },
];
function mountComponent() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
});
return mount(OrgSelector, {
global: { plugins: [router] },
});
}
describe('OrgSelector', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenComponentMounts', () => {
it('ThenOrganizationsAreFetchedFromTheApi', async () => {
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
mountComponent();
await flushPromises();
expect(orgsApi.listOrganizations).toHaveBeenCalledTimes(1);
});
it('ThenOrganizationsAreRenderedInTheSelect', async () => {
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
const wrapper = mountComponent();
await flushPromises();
const select = wrapper.find('[data-testid="org-select"]');
expect(select.exists()).toBe(true);
});
});
describe('WhenOrganizationIsSelected', () => {
it('ThenContextStoreIsUpdated', async () => {
jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs);
const wrapper = mountComponent();
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]);
});
});
describe('WhenApiReturnsNoOrganizations', () => {
it('ThenEmptyMessageIsDisplayed', 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);
});
});
describe('WhenApiCallFails', () => {
it('ThenOrganizationListRemainsEmpty', async () => {
jest.mocked(orgsApi.listOrganizations).mockRejectedValue(new Error('Network error'));
const wrapper = mountComponent();
await flushPromises();
// No crash — component stays rendered
expect(wrapper.find('[data-testid="org-selector"]').exists()).toBe(true);
});
});
});

View File

@@ -0,0 +1,98 @@
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 ProjectSelector from '@/components/nav/ProjectSelector.vue';
import { useContextStore } from '@/stores/context';
import type { OrganizationResponse, ProjectResponse } from '@/types/api';
jest.mock('@/api/projects');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as projectsApi from '@/api/projects';
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' },
{ id: 11, name: 'Mobile App', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
];
function mountComponent() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
});
return mount(ProjectSelector, {
global: { plugins: [router] },
});
}
describe('ProjectSelector', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenNoOrganizationIsSelected', () => {
it('ThenProjectsAreNotFetched', async () => {
const wrapper = mountComponent();
await flushPromises();
expect(projectsApi.listProjects).not.toHaveBeenCalled();
expect(wrapper.find('[data-testid="project-selector"]').exists()).toBe(true);
});
});
describe('WhenOrganizationIsSetInContextStore', () => {
it('ThenProjectsAreFetchedForThatOrganization', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
const contextStore = useContextStore();
mountComponent();
contextStore.setOrganization(mockOrg);
await flushPromises();
expect(projectsApi.listProjects).toHaveBeenCalledWith(mockOrg.id);
});
});
describe('WhenProjectIsSelected', () => {
it('ThenContextStoreIsUpdated', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
const wrapper = mountComponent();
await flushPromises();
await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockProjects[0]);
expect(contextStore.currentProject).toEqual(mockProjects[0]);
});
});
describe('WhenOrganizationChanges', () => {
it('ThenProjectsAreReloaded', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
const contextStore = useContextStore();
mountComponent();
contextStore.setOrganization(mockOrg);
await flushPromises();
const newOrg: OrganizationResponse = { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' };
contextStore.setOrganization(newOrg);
await flushPromises();
expect(projectsApi.listProjects).toHaveBeenCalledTimes(2);
expect(projectsApi.listProjects).toHaveBeenLastCalledWith(newOrg.id);
});
});
});

View File

@@ -0,0 +1,238 @@
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 FeatureDetail from '@/components/features/FeatureDetail.vue';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
import * as featureStatesApi from '@/api/featureStates';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeature: FeatureResponse = {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeatureState: FeatureStateResponse = {
id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null,
enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
};
const dialogStub = { template: '<div><slot /></div>' };
const tagsStub = { template: '<div data-testid="tags-chip-input" />' };
function mountDetail(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeatureDetail, {
props: { modelValue: true, feature: mockFeature, featureState: null, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub, TagsChipInput: tagsStub } },
});
}
describe('FeatureDetail', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment({ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '' });
});
describe('WhenOpenedWithFeature', () => {
it('ThenFeatureDetailCardIsRendered', () => {
const wrapper = mountDetail();
expect(wrapper.find('[data-testid="feature-detail"]').exists()).toBe(true);
});
it('ThenFeatureNameIsDisplayed', () => {
const wrapper = mountDetail();
expect(wrapper.text()).toContain('dark_mode');
});
it('ThenValueAndSettingsTabsArePresent', () => {
const wrapper = mountDetail();
expect(wrapper.find('[data-testid="tab-value"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tab-settings"]').exists()).toBe(true);
});
});
describe('WhenNoEnvironmentIsSelected', () => {
it('ThenSelectEnvironmentMessageIsShown', () => {
const wrapper = mountDetail({ featureState: null });
expect(wrapper.text()).toContain('Select an environment');
});
});
describe('WhenFeatureStateIsProvided', () => {
it('ThenEnabledToggleIsRendered', () => {
const wrapper = mountDetail({ featureState: mockFeatureState });
expect(wrapper.find('[data-testid="feature-enabled-toggle"]').exists()).toBe(true);
});
it('ThenPatchFeatureStateIsCalledOnToggle', async () => {
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockFeatureState, enabled: true });
const wrapper = mountDetail({ featureState: mockFeatureState });
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', mockFeatureState.id, { enabled: true });
});
it('ThenStateUpdatedEventIsEmittedAfterToggle', async () => {
const updatedState = { ...mockFeatureState, enabled: true };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updatedState);
const wrapper = mountDetail({ featureState: mockFeatureState });
await (wrapper.find('[data-testid="feature-enabled-toggle"]').findComponent({ name: 'VSwitch' }) as VueWrapper<any>).vm.$emit('update:modelValue', true);
await flushPromises();
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updatedState]);
});
});
describe('WhenSaveValueIsClicked', () => {
it('ThenPatchFeatureStateIsCalledWithEditedValue', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
const updated = { ...stateWithValue, value: 'new_value' };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith('env-dev', stateWithValue.id, { value: 'new_value' });
});
it('ThenStateUpdatedEventIsEmittedAfterValueSave', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: 'old_value' };
const updated = { ...stateWithValue, value: 'new_value' };
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue(updated);
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', 'new_value');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('stateUpdated')).toBeTruthy();
expect(wrapper.emitted('stateUpdated')![0]).toEqual([updated]);
});
it('ThenSaveIsBlockedWhenValueIsInvalidJson', async () => {
const stateWithValue: FeatureStateResponse = { ...mockFeatureState, value: '{"key": "val"}' };
const wrapper = mountDetail({ featureState: stateWithValue });
await (wrapper.findComponent({ name: 'FeatureValueEditor' }) as VueWrapper<any>).vm.$emit('update:modelValue', '{bad json}');
await wrapper.find('[data-testid="save-value-btn"]').trigger('click');
await flushPromises();
expect(featureStatesApi.patchFeatureState).not.toHaveBeenCalled();
});
});
describe('WhenSettingsFormIsSubmitted', () => {
it('ThenPatchFeatureIsCalledWithFormValues', async () => {
const updated = { ...mockFeature, description: 'Updated desc' };
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
const wrapper = mountDetail();
// Switch to settings tab
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="settings-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated desc');
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
await flushPromises();
expect(featuresApi.patchFeature).toHaveBeenCalledWith(
mockProject.id,
mockFeature.id,
expect.objectContaining({ name: mockFeature.name }),
);
});
it('ThenUpdatedEventIsEmittedAfterSave', async () => {
const updated = { ...mockFeature, description: 'Updated desc' };
jest.mocked(featuresApi.patchFeature).mockResolvedValue(updated);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="save-settings-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('updated')).toBeTruthy();
});
});
describe('WhenDeleteConfirmationIsEntered', () => {
it('ThenDeleteButtonIsDisabledWhenNameDoesNotMatch', async () => {
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
const deleteBtn = wrapper.findComponent('[data-testid="delete-feature-btn"]') as VueWrapper<any>;
expect(deleteBtn.props('disabled')).toBe(true);
});
it('ThenDeleteFeatureIsCalledWhenNameMatches', async () => {
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
// Type the feature name into the confirm input
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
await flushPromises();
expect(featuresApi.deleteFeature).toHaveBeenCalledWith(mockProject.id, mockFeature.id);
});
it('ThenDeletedEventIsEmittedAfterDeletion', async () => {
jest.mocked(featuresApi.deleteFeature).mockResolvedValue(undefined);
const wrapper = mountDetail();
await wrapper.find('[data-testid="tab-settings"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', mockFeature.name);
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-feature-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('deleted')).toBeTruthy();
expect(wrapper.emitted('deleted')![0]).toEqual([mockFeature.id]);
});
});
});

View File

@@ -0,0 +1,133 @@
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 FeatureDialog from '@/components/features/FeatureDialog.vue';
import { useContextStore } from '@/stores/context';
import type { FeatureResponse, ProjectResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeature: FeatureResponse = {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null,
description: 'Enables dark mode', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const savedFeature: FeatureResponse = { ...mockFeature, id: 99, name: 'new_flag' };
const dialogStub = { template: '<div><slot /></div>' };
function mountDialog(props: Record<string, unknown> = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeatureDialog, {
props: { modelValue: true, ...props },
global: { plugins: [router], stubs: { 'v-dialog': dialogStub } },
});
}
describe('FeatureDialog', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
});
describe('WhenOpenedInCreateMode', () => {
it('ThenNameTypeAndDescriptionFieldsArePresent', () => {
const wrapper = mountDialog();
expect(wrapper.find('[data-testid="feature-name-input"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="feature-type-select"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="feature-description-input"]').exists()).toBe(true);
});
it('ThenNameAndTypeFieldsAreEnabled', () => {
const wrapper = mountDialog();
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
expect(nameInput.props('disabled')).toBeFalsy();
});
});
describe('WhenOpenedInEditMode', () => {
it('ThenNameAndTypeFieldsAreDisabled', () => {
const wrapper = mountDialog({ feature: mockFeature });
const nameInput = wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>;
expect(nameInput.props('disabled')).toBe(true);
});
it('ThenFormIsPopulatedWithFeatureData', () => {
const wrapper = mountDialog({ feature: mockFeature });
const descInput = wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>;
expect(descInput.props('modelValue')).toBe(mockFeature.description);
});
});
describe('WhenFormIsSubmittedInCreateMode', () => {
it('ThenCreateFeatureApiIsCalled', async () => {
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
const wrapper = mountDialog();
// Fill in name
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(featuresApi.createFeature).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ name: 'new_flag', type: 'STANDARD' }),
);
});
it('ThenSavedEventIsEmitted', async () => {
jest.mocked(featuresApi.createFeature).mockResolvedValue(savedFeature);
const wrapper = mountDialog();
await (wrapper.findComponent('[data-testid="feature-name-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'new_flag');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('saved')).toBeTruthy();
expect(wrapper.emitted('saved')![0]).toEqual([savedFeature]);
});
});
describe('WhenFormIsSubmittedInEditMode', () => {
it('ThenUpdateFeatureApiIsCalled', async () => {
jest.mocked(featuresApi.updateFeature).mockResolvedValue({ ...mockFeature, description: 'Updated' });
const wrapper = mountDialog({ feature: mockFeature });
await (wrapper.findComponent('[data-testid="feature-description-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Updated');
await wrapper.find('[data-testid="feature-dialog-save"]').trigger('click');
await flushPromises();
expect(featuresApi.updateFeature).toHaveBeenCalledWith(
mockProject.id,
mockFeature.id,
expect.objectContaining({ name: mockFeature.name }),
);
});
});
describe('WhenCancelIsClicked', () => {
it('ThenDialogIsClosedWithoutApiCall', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="feature-dialog-cancel"]').trigger('click');
expect(featuresApi.createFeature).not.toHaveBeenCalled();
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
expect(wrapper.emitted('update:modelValue')![0]).toEqual([false]);
});
});
});

View File

@@ -0,0 +1,139 @@
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 TagsChipInput from '@/components/features/TagsChipInput.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, TagResponse } from '@/types/api';
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as tagsApi from '@/api/tags';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockTags: TagResponse[] = [
{ id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
{ id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
];
function mountComponent() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(TagsChipInput, { global: { plugins: [router] } });
}
describe('TagsChipInput', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
});
describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent();
await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
});
it('ThenExistingTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
});
it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
});
});
describe('WhenCreateTagIsSubmitted', () => {
it('ThenCreateTagApiIsCalled', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
const wrapper = mountComponent();
await flushPromises();
// Open the create form
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// Fill in the label
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
// Submit
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith(
mockProject.id,
expect.objectContaining({ label: 'api' }),
);
});
it('ThenNewTagAppearsInTheList', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
jest.mocked(tagsApi.listTags).mockResolvedValue([]);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
const wrapper = mountComponent();
await flushPromises();
await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
});
});
describe('WhenTagChipIsDeleted', () => {
it('ThenDeleteTagApiIsCalled', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
});
it('ThenDeletedTagIsRemovedFromTheList', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false);
});
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { createRouter, createMemoryHistory } from 'vue-router';
import { setActivePinia, createPinia } from 'pinia';
import { setAuthTokens, clearAuthTokens } from '@/api/client';
// Minimal stub components for route testing
const Stub = { template: '<div />' };
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import { getAccessToken } from '@/api/client';
function buildRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } },
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } },
{
path: '/',
component: Stub,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Dashboard', component: Stub },
{ path: 'features', name: 'Features', component: Stub },
],
},
],
});
}
describe('Router auth guard', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
afterEach(() => {
clearAuthTokens();
});
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenTheyAreRedirectedToLogin', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
// Wire up the same guard logic as the real router
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features');
expect(router.currentRoute.value.name).toBe('Login');
expect(router.currentRoute.value.query.redirect).toBe('/features');
});
});
describe('WhenAuthenticatedUserNavigatesToLogin', () => {
it('ThenTheyAreRedirectedToDashboard', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
// Start authenticated on dashboard, then try to go to login
await router.push('/');
await router.push('/login');
expect(router.currentRoute.value.name).toBe('Dashboard');
});
});
describe('WhenAuthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenNavigationSucceeds', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features');
expect(router.currentRoute.value.name).toBe('Features');
});
});
});

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { setActivePinia, createPinia } from 'pinia';
import { useAuthStore } from '@/stores/auth';
import * as authApi from '@/api/auth';
import * as clientModule from '@/api/client';
jest.mock('@/api/auth');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
const mockTokenResponse = {
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
expiresAt: '2026-12-31T00:00:00Z',
};
describe('useAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
localStorage.clear();
jest.clearAllMocks();
});
describe('WhenUserLogsInWithValidCredentials', () => {
it('ThenTokensAreStoredInStateAndLocalStorage', async () => {
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
const store = useAuthStore();
await store.login('user@example.com', 'password123');
expect(store.isAuthenticated).toBe(true);
expect(store.accessToken).toBe(mockTokenResponse.accessToken);
expect(store.refreshToken).toBe(mockTokenResponse.refreshToken);
expect(store.expiresAt).toBe(mockTokenResponse.expiresAt);
expect(localStorage.getItem('mic_access_token')).toBe(mockTokenResponse.accessToken);
expect(localStorage.getItem('mic_refresh_token')).toBe(mockTokenResponse.refreshToken);
expect(localStorage.getItem('mic_expires_at')).toBe(mockTokenResponse.expiresAt);
});
it('ThenApiClientTokensAreUpdated', async () => {
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
const store = useAuthStore();
await store.login('user@example.com', 'password123');
expect(clientModule.setAuthTokens).toHaveBeenCalledWith(
mockTokenResponse.accessToken,
mockTokenResponse.refreshToken,
);
});
});
describe('WhenUserLogsInWithInvalidCredentials', () => {
it('ThenAuthStateRemainsUnauthenticated', async () => {
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
const store = useAuthStore();
await expect(store.login('bad@example.com', 'wrong')).rejects.toThrow('Unauthorized');
expect(store.isAuthenticated).toBe(false);
expect(store.accessToken).toBeNull();
expect(localStorage.getItem('mic_access_token')).toBeNull();
});
});
describe('WhenUserRegistersSuccessfully', () => {
it('ThenTokensAreStoredAndUserIsAuthenticated', async () => {
jest.mocked(authApi.register).mockResolvedValue(mockTokenResponse);
const store = useAuthStore();
await store.register('user@example.com', 'password123', 'Jane', 'Doe', 'Acme Corp');
expect(store.isAuthenticated).toBe(true);
expect(store.accessToken).toBe(mockTokenResponse.accessToken);
});
it('ThenRegisterApiIsCalledWithCorrectPayload', async () => {
jest.mocked(authApi.register).mockResolvedValue(mockTokenResponse);
const store = useAuthStore();
await store.register('user@example.com', 'password123', 'Jane', 'Doe', 'Acme Corp');
expect(authApi.register).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
firstName: 'Jane',
lastName: 'Doe',
organizationName: 'Acme Corp',
});
});
});
describe('WhenUserLogsOut', () => {
it('ThenTokensAreRemovedFromStateAndLocalStorage', async () => {
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
jest.mocked(authApi.logout).mockResolvedValue(undefined);
const store = useAuthStore();
await store.login('user@example.com', 'password123');
await store.logout();
expect(store.isAuthenticated).toBe(false);
expect(store.accessToken).toBeNull();
expect(store.refreshToken).toBeNull();
expect(localStorage.getItem('mic_access_token')).toBeNull();
expect(localStorage.getItem('mic_refresh_token')).toBeNull();
});
it('ThenApiClientTokensAreCleared', async () => {
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
jest.mocked(authApi.logout).mockResolvedValue(undefined);
const store = useAuthStore();
await store.login('user@example.com', 'password123');
jest.clearAllMocks();
await store.logout();
expect(clientModule.clearAuthTokens).toHaveBeenCalled();
});
it('ThenLocalLogoutCompletesEvenWhenServerCallFails', async () => {
jest.mocked(authApi.login).mockResolvedValue(mockTokenResponse);
jest.mocked(authApi.logout).mockRejectedValue(new Error('Network error'));
const store = useAuthStore();
await store.login('user@example.com', 'password123');
await store.logout();
expect(store.isAuthenticated).toBe(false);
expect(localStorage.getItem('mic_access_token')).toBeNull();
});
});
describe('WhenLoadFromStorageIsCalledWithSavedTokens', () => {
it('ThenAuthStateIsRestoredFromLocalStorage', () => {
localStorage.setItem('mic_access_token', 'stored-access');
localStorage.setItem('mic_refresh_token', 'stored-refresh');
localStorage.setItem('mic_expires_at', '2026-12-31T00:00:00Z');
const store = useAuthStore();
store.loadFromStorage();
expect(store.isAuthenticated).toBe(true);
expect(store.accessToken).toBe('stored-access');
expect(store.refreshToken).toBe('stored-refresh');
});
it('ThenApiClientTokensAreSet', () => {
localStorage.setItem('mic_access_token', 'stored-access');
localStorage.setItem('mic_refresh_token', 'stored-refresh');
const store = useAuthStore();
store.loadFromStorage();
expect(clientModule.setAuthTokens).toHaveBeenCalledWith('stored-access', 'stored-refresh');
});
});
describe('WhenLoadFromStorageIsCalledWithNoSavedTokens', () => {
it('ThenUserRemainsUnauthenticated', () => {
const store = useAuthStore();
store.loadFromStorage();
expect(store.isAuthenticated).toBe(false);
expect(store.accessToken).toBeNull();
});
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { setActivePinia, createPinia } from 'pinia';
import { useContextStore } from '@/stores/context';
import type { OrganizationResponse, ProjectResponse, EnvironmentResponse } from '@/types/api';
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 mockEnv: EnvironmentResponse = {
id: 100,
name: 'Production',
apiKey: 'env-key-abc',
projectId: 10,
createdAt: '2026-01-01T00:00:00Z',
};
describe('useContextStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
localStorage.clear();
});
describe('WhenOrganizationIsSelected', () => {
it('ThenCurrentOrganizationIsUpdated', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
expect(store.currentOrganization).toEqual(mockOrg);
});
it('ThenOrganizationIsPersistedToLocalStorage', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
const stored = JSON.parse(localStorage.getItem('mic_ctx_organization')!);
expect(stored).toEqual(mockOrg);
});
it('ThenProjectAndEnvironmentAreReset', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
store.setProject(mockProject);
store.setEnvironment(mockEnv);
store.setOrganization({ ...mockOrg, id: 2 });
expect(store.currentProject).toBeNull();
expect(store.currentEnvironment).toBeNull();
});
});
describe('WhenProjectIsSelected', () => {
it('ThenCurrentProjectIsUpdated', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
store.setProject(mockProject);
expect(store.currentProject).toEqual(mockProject);
});
it('ThenEnvironmentIsReset', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
store.setProject(mockProject);
store.setEnvironment(mockEnv);
store.setProject({ ...mockProject, id: 11 });
expect(store.currentEnvironment).toBeNull();
});
});
describe('WhenEnvironmentIsSelected', () => {
it('ThenCurrentEnvironmentIsUpdated', () => {
const store = useContextStore();
store.setEnvironment(mockEnv);
expect(store.currentEnvironment).toEqual(mockEnv);
});
it('ThenEnvironmentIsPersistedToLocalStorage', () => {
const store = useContextStore();
store.setEnvironment(mockEnv);
const stored = JSON.parse(localStorage.getItem('mic_ctx_environment')!);
expect(stored).toEqual(mockEnv);
});
});
describe('WhenLoadFromStorageIsCalledWithSavedContext', () => {
it('ThenContextIsRestoredFromLocalStorage', () => {
localStorage.setItem('mic_ctx_organization', JSON.stringify(mockOrg));
localStorage.setItem('mic_ctx_project', JSON.stringify(mockProject));
localStorage.setItem('mic_ctx_environment', JSON.stringify(mockEnv));
const store = useContextStore();
store.loadFromStorage();
expect(store.currentOrganization).toEqual(mockOrg);
expect(store.currentProject).toEqual(mockProject);
expect(store.currentEnvironment).toEqual(mockEnv);
});
});
describe('WhenLoadFromStorageEncountersCorruptData', () => {
it('ThenContextRemainsNullAndStorageIsCleared', () => {
localStorage.setItem('mic_ctx_organization', 'not-valid-json{{');
const store = useContextStore();
store.loadFromStorage();
expect(store.currentOrganization).toBeNull();
expect(localStorage.getItem('mic_ctx_organization')).toBeNull();
});
});
describe('WhenClearContextIsCalled', () => {
it('ThenAllContextIsRemovedFromStateAndStorage', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
store.setProject(mockProject);
store.setEnvironment(mockEnv);
store.clearContext();
expect(store.currentOrganization).toBeNull();
expect(store.currentProject).toBeNull();
expect(store.currentEnvironment).toBeNull();
expect(localStorage.getItem('mic_ctx_organization')).toBeNull();
});
});
});

View File

@@ -0,0 +1,132 @@
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 FeaturesView from '@/views/FeaturesView.vue';
import { useContextStore } from '@/stores/context';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
jest.mock('@/api/features');
jest.mock('@/api/featureStates');
jest.mock('@/api/tags');
jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(),
getAccessToken: jest.fn(),
}));
import * as featuresApi from '@/api/features';
import * as featureStatesApi from '@/api/featureStates';
const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
};
const mockEnv: EnvironmentResponse = {
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
};
const mockFeatures: FeatureResponse[] = [
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
];
const mockStates: FeatureStateResponse[] = [
{ id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
{ id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
];
function mountView() {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(FeaturesView, { global: { plugins: [router] } });
}
describe('FeaturesView', () => {
beforeEach(() => {
setActivePinia(createPinia());
jest.clearAllMocks();
});
describe('WhenNoProjectIsSelected', () => {
it('ThenEmptyStateIsShown', () => {
const wrapper = mountView();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(false);
expect(wrapper.text()).toContain('Select a project');
});
});
describe('WhenProjectIsSelected', () => {
it('ThenFeaturesAndStatesAreLoaded', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
mountView();
await flushPromises();
expect(featuresApi.listFeatures).toHaveBeenCalledWith(mockProject.id, 1, 100);
expect(featureStatesApi.listFeatureStates).toHaveBeenCalledWith(mockEnv.apiKey);
});
it('ThenFeaturesTableIsRendered', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="create-feature-btn"]').exists()).toBe(true);
});
});
describe('WhenToggleEnabledIsClicked', () => {
it('ThenPatchFeatureStateIsCalled', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockStates[0], enabled: true });
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
const wrapper = mountView();
await flushPromises();
// Find the toggle for feature id=1 and simulate a change
const toggle = wrapper.find('[data-testid="toggle-1"]');
expect(toggle.exists()).toBe(true);
await toggle.findComponent({ name: 'VSwitch' }).vm.$emit('update:modelValue', true);
await flushPromises();
expect(featureStatesApi.patchFeatureState).toHaveBeenCalledWith(mockEnv.apiKey, 101, { enabled: true });
});
});
describe('WhenCreateButtonIsClicked', () => {
it('ThenFeatureDialogIsOpened', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 0, next: null, previous: null, results: [] });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="create-feature-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// FeatureDialog should now be open (v-dialog with model-value=true)
const dialog = wrapper.findComponent({ name: 'FeatureDialog' });
expect(dialog.exists()).toBe(true);
});
});
});

View File

@@ -0,0 +1,13 @@
namespace MicCheck.Api.Audit;
public record AuditLogFilter(
DateTimeOffset? From = null,
DateTimeOffset? To = null,
string? ResourceType = null,
string? Action = null,
int? ProjectId = null,
int? EnvironmentId = null,
int? ActorUserId = null,
int Page = 1,
int PageSize = 20
);

View File

@@ -5,27 +5,53 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db)
{
public async Task<IReadOnlyList<AuditLog>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.OrganizationId == organizationId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByProjectAsync(int projectId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.ProjectId == projectId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<IReadOnlyList<AuditLog>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{
return await db.AuditLogs
.Where(l => l.EnvironmentId == environmentId)
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
private static async Task<(int Total, IReadOnlyList<AuditLog> Items)> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{
if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value);
if (filter.To.HasValue)
query = query.Where(l => l.CreatedAt <= filter.To.Value);
if (!string.IsNullOrEmpty(filter.ResourceType))
query = query.Where(l => l.ResourceType == filter.ResourceType);
if (!string.IsNullOrEmpty(filter.Action))
query = query.Where(l => l.Action == filter.Action);
if (filter.ProjectId.HasValue)
query = query.Where(l => l.ProjectId == filter.ProjectId);
if (filter.EnvironmentId.HasValue)
query = query.Where(l => l.EnvironmentId == filter.EnvironmentId);
if (filter.ActorUserId.HasValue)
query = query.Where(l => l.ActorUserId == filter.ActorUserId);
var total = await query.CountAsync(ct);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
var items = await query
.OrderByDescending(l => l.CreatedAt)
.Skip((filter.Page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (total, items);
}
}

View File

@@ -1,4 +1,6 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using MicCheck.Api.Environments;
using MicCheck.Api.Projects;
using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization;
@@ -13,25 +15,45 @@ namespace MicCheck.Api.Audit;
public class AuditLogsController(
AuditLogQueryService auditLogQueryService,
OrganizationService organizationService,
ProjectService projectService) : ControllerBase
ProjectService projectService,
EnvironmentService environmentService) : ControllerBase
{
[HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByOrganization(int id, CancellationToken ct)
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound();
var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByProject(int projectId, CancellationToken ct)
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound();
var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct);
return Ok(logs.Select(AuditLogResponse.From).ToList());
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.Select(AuditLogResponse.From).ToList()));
}
}

View File

@@ -1,26 +1,41 @@
using System.Text.Json;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit;
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor)
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{
public virtual async Task LogAsync(
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public virtual async Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
object? before = null,
object? after = null,
CancellationToken ct = default)
{
int? actorUserId = null;
string? changes = null;
if (before is not null || after is not null)
{
changes = JsonSerializer.Serialize(new
{
before,
after
}, JsonOptions);
}
var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
if (int.TryParse(userIdClaim, out var parsedId))
actorUserId = parsedId;
var actorUserId = ResolveActorUserId();
db.AuditLogs.Add(new AuditLog
var log = new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
@@ -31,8 +46,75 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext
ActorUserId = actorUserId,
Changes = changes,
CreatedAt = DateTimeOffset.UtcNow
});
};
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = environmentId,
OrganizationId = organizationId,
Data = new
{
audit_log_id = log.Id,
resource_type = resourceType,
resource_id = resourceId,
action,
created_at = log.CreatedAt
}
});
}
// Backward-compatible overload used by existing callers
public virtual async Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default)
{
var actorUserId = ResolveActorUserId();
var log = new AuditLog
{
ResourceType = resourceType,
ResourceId = resourceId,
Action = action,
OrganizationId = organizationId,
ProjectId = projectId,
EnvironmentId = environmentId,
ActorUserId = actorUserId,
Changes = changes,
CreatedAt = DateTimeOffset.UtcNow
};
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = environmentId,
OrganizationId = organizationId,
Data = new
{
audit_log_id = log.Id,
resource_type = resourceType,
resource_id = resourceId,
action,
created_at = log.CreatedAt
}
});
}
private int? ResolveActorUserId()
{
var claim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
return int.TryParse(claim, out var id) ? id : null;
}
}

View File

@@ -1,3 +0,0 @@
namespace MicCheck.Api.Auth;
public record TokenRequest(string Username, string Password);

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public static class AuthEndpoints
{

View File

@@ -5,7 +5,7 @@ using MicCheck.Api.Users;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public class AuthService(
MicCheckDbContext db,

View File

@@ -1,6 +1,6 @@
using MicCheck.Api.Users;
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public interface ITokenService
{

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record LoginRequest(string Email, string Password);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record LogoutRequest(string Token);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record RefreshRequest(string Token);

View File

@@ -1,4 +1,4 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record RegisterRequest(
string Email,

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public record TokenResponse(string Token, DateTime ExpiresAt);

View File

@@ -4,7 +4,7 @@ using System.Text;
using MicCheck.Api.Users;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Auth;
namespace MicCheck.Api.Authorization;
public class TokenService(IConfiguration configuration) : ITokenService
{

View File

@@ -5,7 +5,6 @@ EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/api/MicCheck.Api/MicCheck.Api.csproj", "src/api/MicCheck.Api/"]
COPY ["src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj", "src/infrastructure/MicCheck.Infrastructure/"]
RUN dotnet restore "src/api/MicCheck.Api/MicCheck.Api.csproj"
COPY . .
RUN dotnet build "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/build

View File

@@ -106,6 +106,20 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(WebhookResponse.From(updated));
}
[HttpGet("{apiKey}/webhooks/{id}/deliveries")]
public async Task<ActionResult<IReadOnlyList<WebhookDeliveryLogResponse>>> ListWebhookDeliveries(
string apiKey, int id, CancellationToken ct)
{
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var webhook = await webhookService.FindByIdAsync(id, ct);
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
var logs = await webhookService.ListDeliveriesAsync(id, ct);
return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList());
}
[HttpDelete("{apiKey}/webhooks/{id}")]
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
{
@@ -128,7 +142,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound();
var logs = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, ct);
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(logs.Select(Audit.AuditLogResponse.From).ToList());
}

View File

@@ -1,11 +1,12 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Common;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureService(MicCheckDbContext db, AuditService auditService)
public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue)
{
private const int MaxFeaturesPerProject = 400;
@@ -67,8 +68,11 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId, ct: ct);
await auditService.RecordAsync(
"Feature", feature.Id.ToString(), "created",
project.OrganizationId, projectId,
after: new { feature.Name, Type = feature.Type.ToString(), feature.InitialValue, feature.Description },
ct: ct);
return feature;
}
@@ -79,14 +83,20 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
var before = new { feature.Name, feature.Description };
feature.Name = name;
feature.Description = description;
await db.SaveChangesAsync(ct);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
if (project is not null)
await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId, ct: ct);
await auditService.RecordAsync(
"Feature", feature.Id.ToString(), "updated",
project.OrganizationId, feature.ProjectId,
before: before,
after: new { feature.Name, feature.Description },
ct: ct);
return feature;
}
@@ -96,7 +106,33 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService)
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
if (feature is null) return;
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
db.Features.Remove(feature);
await db.SaveChangesAsync(ct);
if (project is not null)
{
await auditService.RecordAsync(
"Feature", id.ToString(), "deleted",
project.OrganizationId, feature.ProjectId,
before: new { feature.Name, Type = feature.Type.ToString() },
ct: ct);
var environments = await db.Environments.Where(e => e.ProjectId == feature.ProjectId).ToListAsync(ct);
foreach (var env in environments)
{
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagDeleted,
EnvironmentId = env.Id,
OrganizationId = project.OrganizationId,
Data = new FlagDeletedData(
null,
DateTimeOffset.UtcNow,
new FeatureSummary(feature.Id, feature.Name))
});
}
}
}
}

View File

@@ -1,9 +1,11 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureStateService(MicCheckDbContext db)
public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService)
{
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
{
@@ -23,11 +25,15 @@ public class FeatureStateService(MicCheckDbContext db)
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
var before = SnapshotState(state);
state.Enabled = enabled;
state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
await DispatchFlagUpdatedAsync(state, before, ct);
return state;
}
@@ -36,11 +42,60 @@ public class FeatureStateService(MicCheckDbContext db)
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
var before = SnapshotState(state);
if (enabled.HasValue) state.Enabled = enabled.Value;
if (value is not null) state.Value = value;
state.UpdatedAt = DateTimeOffset.UtcNow;
state.Version++;
await db.SaveChangesAsync(ct);
await DispatchFlagUpdatedAsync(state, before, ct);
return state;
}
private async Task DispatchFlagUpdatedAsync(FeatureState state, FeatureStateSnapshot before, CancellationToken ct)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == state.FeatureId, ct);
var environment = await db.Environments.FirstOrDefaultAsync(e => e.Id == state.EnvironmentId, ct);
var project = environment is not null
? await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct)
: null;
var featureSummary = feature is not null
? new FeatureSummary(feature.Id, feature.Name)
: new FeatureSummary(state.FeatureId, string.Empty);
var environmentSummary = environment is not null
? new EnvironmentSummary(environment.Id, environment.Name)
: new EnvironmentSummary(state.EnvironmentId, string.Empty);
var after = new FlagStateSnapshot(state.Id, state.Enabled, state.Value, featureSummary, environmentSummary);
var previousSnapshot = new FlagStateSnapshot(state.Id, before.Enabled, before.Value, featureSummary, environmentSummary);
var data = new FlagUpdatedData(null, DateTimeOffset.UtcNow, after, previousSnapshot);
await webhookQueue.EnqueueAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
EnvironmentId = state.EnvironmentId,
OrganizationId = project?.OrganizationId ?? 0,
Data = data
});
if (project is not null)
{
await auditService.RecordAsync(
"FeatureState", state.Id.ToString(), "updated",
project.OrganizationId, environment!.ProjectId, state.EnvironmentId,
before: new { before.Enabled, before.Value },
after: new { state.Enabled, state.Value },
ct: ct);
}
}
private static FeatureStateSnapshot SnapshotState(FeatureState state) =>
new(state.Enabled, state.Value);
private record FeatureStateSnapshot(bool Enabled, string? Value);
}

View File

@@ -0,0 +1,895 @@
// <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("20260408214714_InitialCreate")]
partial class InitialCreate
{
/// <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("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<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("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.Features.Feature", null)
.WithMany("Tags")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.NoAction)
.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");
b.Navigation("Tags");
});
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
}
}
}

Some files were not shown because too many files have changed in this diff Show More