Compare commits
3 Commits
b9a04df861
...
8dfcb107d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dfcb107d0 | ||
|
|
f2816b6900 | ||
|
|
334b6cf3e1 |
40
.gitignore
vendored
40
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
BIN
docs/.08_testing_strategy.md.kate-swp
Normal file
BIN
docs/.08_testing_strategy.md.kate-swp
Normal file
Binary file not shown.
483
docs/admin/01-admin-site_plan.md
Normal file
483
docs/admin/01-admin-site_plan.md
Normal 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.
|
||||
81
docs/api/07_audit_and_webhooks_output.md
Normal file
81
docs/api/07_audit_and_webhooks_output.md
Normal 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
13
dotnet-tools.json
Normal 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
5
src/admin/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
tests
|
||||
19
src/admin/Dockerfile
Normal file
19
src/admin/Dockerfile
Normal 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;"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
40
src/admin/dist/img/logo.c6e64ce5.svg
vendored
40
src/admin/dist/img/logo.c6e64ce5.svg
vendored
@@ -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 |
1
src/admin/dist/index.html
vendored
1
src/admin/dist/index.html
vendored
@@ -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>
|
||||
3
src/admin/dist/js/929.737c1237.js
vendored
3
src/admin/dist/js/929.737c1237.js
vendored
File diff suppressed because one or more lines are too long
29
src/admin/dist/js/929.737c1237.js.LICENSE.txt
vendored
29
src/admin/dist/js/929.737c1237.js.LICENSE.txt
vendored
@@ -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
|
||||
**/
|
||||
1
src/admin/dist/js/929.737c1237.js.map
vendored
1
src/admin/dist/js/929.737c1237.js.map
vendored
File diff suppressed because one or more lines are too long
2
src/admin/dist/js/main.c5e9ac61.js
vendored
2
src/admin/dist/js/main.c5e9ac61.js
vendored
File diff suppressed because one or more lines are too long
1
src/admin/dist/js/main.c5e9ac61.js.map
vendored
1
src/admin/dist/js/main.c5e9ac61.js.map
vendored
File diff suppressed because one or more lines are too long
42
src/admin/nginx.conf
Normal file
42
src/admin/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
165
src/admin/package-lock.json
generated
165
src/admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
27
src/admin/src/api/auth.ts
Normal 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
120
src/admin/src/api/client.ts
Normal 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;
|
||||
43
src/admin/src/api/environments.ts
Normal file
43
src/admin/src/api/environments.ts
Normal 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;
|
||||
}
|
||||
44
src/admin/src/api/featureStates.ts
Normal file
44
src/admin/src/api/featureStates.ts
Normal 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;
|
||||
}
|
||||
66
src/admin/src/api/features.ts
Normal file
66
src/admin/src/api/features.ts
Normal 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}`);
|
||||
}
|
||||
51
src/admin/src/api/organizations.ts
Normal file
51
src/admin/src/api/organizations.ts
Normal 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}`);
|
||||
}
|
||||
70
src/admin/src/api/projects.ts
Normal file
70
src/admin/src/api/projects.ts
Normal 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
16
src/admin/src/api/tags.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
370
src/admin/src/components/features/FeatureDetail.vue
Normal file
370
src/admin/src/components/features/FeatureDetail.vue
Normal 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>
|
||||
217
src/admin/src/components/features/FeatureDialog.vue
Normal file
217
src/admin/src/components/features/FeatureDialog.vue
Normal 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>
|
||||
86
src/admin/src/components/features/FeatureValueEditor.vue
Normal file
86
src/admin/src/components/features/FeatureValueEditor.vue
Normal 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>
|
||||
175
src/admin/src/components/features/TagsChipInput.vue
Normal file
175
src/admin/src/components/features/TagsChipInput.vue
Normal 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>
|
||||
96
src/admin/src/components/nav/EnvironmentTabBar.vue
Normal file
96
src/admin/src/components/nav/EnvironmentTabBar.vue
Normal 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>
|
||||
56
src/admin/src/components/nav/OrgSelector.vue
Normal file
56
src/admin/src/components/nav/OrgSelector.vue
Normal 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>
|
||||
57
src/admin/src/components/nav/ProjectSelector.vue
Normal file
57
src/admin/src/components/nav/ProjectSelector.vue
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
src/admin/src/stores/auth.ts
Normal file
94
src/admin/src/stores/auth.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
85
src/admin/src/stores/context.ts
Normal file
85
src/admin/src/stores/context.ts
Normal 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
398
src/admin/src/types/api.ts
Normal 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;
|
||||
}
|
||||
18
src/admin/src/views/AuditLogsView.vue
Normal file
18
src/admin/src/views/AuditLogsView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
src/admin/src/views/IdentitiesView.vue
Normal file
18
src/admin/src/views/IdentitiesView.vue
Normal 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>
|
||||
125
src/admin/src/views/LoginView.vue
Normal file
125
src/admin/src/views/LoginView.vue
Normal 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>
|
||||
187
src/admin/src/views/RegisterView.vue
Normal file
187
src/admin/src/views/RegisterView.vue
Normal 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>
|
||||
18
src/admin/src/views/SegmentsView.vue
Normal file
18
src/admin/src/views/SegmentsView.vue
Normal 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>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
51
src/admin/tests/unit/api/client.spec.ts
Normal file
51
src/admin/tests/unit/api/client.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
Normal file
150
src/admin/tests/unit/components/EnvironmentTabBar.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/admin/tests/unit/components/OrgSelector.spec.ts
Normal file
98
src/admin/tests/unit/components/OrgSelector.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/admin/tests/unit/components/ProjectSelector.spec.ts
Normal file
98
src/admin/tests/unit/components/ProjectSelector.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal file
238
src/admin/tests/unit/components/features/FeatureDetail.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal file
133
src/admin/tests/unit/components/features/FeatureDialog.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal file
139
src/admin/tests/unit/components/features/TagsChipInput.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/admin/tests/unit/router/router.spec.ts
Normal file
112
src/admin/tests/unit/router/router.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
172
src/admin/tests/unit/stores/auth.spec.ts
Normal file
172
src/admin/tests/unit/stores/auth.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
137
src/admin/tests/unit/stores/context.spec.ts
Normal file
137
src/admin/tests/unit/stores/context.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal file
132
src/admin/tests/unit/views/FeaturesView.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal file
13
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal 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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
|
||||
public record TokenRequest(string Username, string Password);
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
@@ -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,
|
||||
@@ -1,6 +1,6 @@
|
||||
using MicCheck.Api.Users;
|
||||
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LogoutRequest(string Token);
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record RefreshRequest(string Token);
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Auth;
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record TokenResponse(string Token, DateTime ExpiresAt);
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
895
src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.Designer.cs
generated
Normal file
895
src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.Designer.cs
generated
Normal 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
Reference in New Issue
Block a user