Building out navigation in the admin site

This commit is contained in:
2026-04-13 15:27:09 -07:00
parent 334b6cf3e1
commit f2816b6900
67 changed files with 3426 additions and 236 deletions

40
.gitignore vendored
View File

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

View File

@@ -8,7 +8,8 @@ MicCheck is an open source project written in asp.net, c#, typescript and VueJS
## Planned Structure ## Planned Structure
- `src/admin/` — administrative components - `src/admin/` — administrative components in VueJS
- `src/api/` - api and restful endpoints in .NET
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
@@ -22,15 +23,18 @@ MicCheck is an open source project written in asp.net, c#, typescript and VueJS
# Coding # Coding
- Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper - 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 ## 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) - 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. - 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 - Do not include any Arrange / Act / Assert comments in the code
## Claude ## 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 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 - When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end

View File

@@ -1,6 +1,8 @@
services: services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db: db:
image: postgres:15-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: miccheck POSTGRES_DB: miccheck
POSTGRES_USER: miccheck POSTGRES_USER: miccheck
@@ -10,11 +12,12 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck"] test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api: api:
build: build:
context: . context: .
@@ -22,14 +25,32 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_ENVIRONMENT: Development
- ConnectionStrings__DefaultConnection=Host=db;Database=miccheck;Username=miccheck;Password=password ASPNETCORE_URLS: http://+:8080
- Jwt__SecretKey=change-this-secret-key-in-production-must-be-32-chars ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
- Jwt__Issuer=MicCheck Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
- Jwt__Audience=MicCheck Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on: depends_on:
db: db:
condition: service_healthy 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: volumes:
postgres_data: postgres_data:

View File

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

5
src/admin/.dockerignore Normal file
View File

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

19
src/admin/Dockerfile Normal file
View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"vuetify": "^3.6.0" "vuetify": "^3.6.0"
@@ -3879,6 +3881,28 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" "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": { "node_modules/@vue/reactivity": {
"version": "3.5.31", "version": "3.5.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz",
@@ -4418,8 +4442,17 @@
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"dev": true },
"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": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
@@ -4672,6 +4705,14 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -4848,7 +4889,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -5088,7 +5128,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
}, },
@@ -5217,6 +5256,20 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"dev": true "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": { "node_modules/core-js-compat": {
"version": "3.49.0", "version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
@@ -5583,7 +5636,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@@ -5753,7 +5805,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -5920,7 +5971,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -5929,7 +5979,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -5944,7 +5993,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
}, },
@@ -5956,7 +6004,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6", "get-intrinsic": "^1.2.6",
@@ -6536,7 +6583,6 @@
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6584,7 +6630,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
@@ -6638,7 +6683,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@@ -6665,7 +6709,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
@@ -6698,7 +6741,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0" "es-object-atoms": "^1.0.0"
@@ -6778,7 +6820,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -6841,7 +6882,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -6853,7 +6893,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
}, },
@@ -6874,7 +6913,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
}, },
@@ -6891,6 +6929,11 @@
"he": "bin/he" "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": { "node_modules/hpack.js": {
"version": "2.1.6", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
@@ -7414,6 +7457,17 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-wsl": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
@@ -9482,7 +9536,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@@ -9584,7 +9637,6 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"devOptional": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -9593,7 +9645,6 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"devOptional": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -9646,6 +9697,11 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mkdirp": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -10154,6 +10210,11 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"dev": true "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10171,6 +10232,34 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -10397,6 +10486,14 @@
"node": ">= 0.10" "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": { "node_modules/psl": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -10706,6 +10803,11 @@
"node": ">= 4" "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": { "node_modules/run-applescript": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -11209,6 +11311,14 @@
"wbuf": "^1.7.3" "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": { "node_modules/sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -11367,6 +11477,17 @@
"webpack": "^5.27.0" "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": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,12 @@
<template> <template>
<v-app-bar <v-app-bar color="primary" elevation="2" height="56">
color="primary"
elevation="2"
height="64"
>
<template #prepend> <template #prepend>
<v-app-bar-title class="d-flex align-center"> <v-app-bar-title class="d-flex align-center">
<img <img
:src="logoUrl" :src="logoUrl"
alt="MicCheck logo" alt="MicCheck logo"
height="40" height="32"
width="40" width="32"
class="mr-3" class="mr-3"
data-testid="app-logo" data-testid="app-logo"
/> />
@@ -18,21 +14,6 @@
</v-app-bar-title> </v-app-bar-title>
</template> </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> <template #append>
<div class="d-flex align-center ga-2 mr-2"> <div class="d-flex align-center ga-2 mr-2">
<v-btn <v-btn
@@ -43,7 +24,7 @@
data-testid="profile-link" data-testid="profile-link"
rounded="lg" rounded="lg"
> >
John Doe Account
</v-btn> </v-btn>
<v-btn <v-btn
@@ -67,13 +48,7 @@ import logoUrl from '@/assets/logo.svg';
export default defineComponent({ export default defineComponent({
name: 'AppHeader', name: 'AppHeader',
setup() { setup() {
const navLinks = [ return { logoUrl };
{ label: 'Features', route: '/features' },
{ label: 'Environments', route: '/environments' },
{ label: 'Projects', route: '/projects' },
];
return { navLinks, logoUrl };
}, },
}); });
</script> </script>

View File

@@ -1,14 +1,72 @@
<template> <template>
<v-app :theme="theme"> <div>
<AppHeader /> <!-- 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-navigation-drawer
v-model="drawer" v-model="drawer"
:rail="rail" :rail="rail"
permanent 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-list-item
v-for="link in navLinks" v-for="link in navLinks"
:key="link.route" :key="link.route"
@@ -17,48 +75,68 @@
:title="link.label" :title="link.label"
active-color="primary" active-color="primary"
rounded="lg" rounded="lg"
:data-testid="`nav-link-${link.label.toLowerCase().replace(' ', '-')}`"
/> />
</v-list> </v-list>
<!-- Settings at bottom -->
<template #append> <template #append>
<v-list density="compact" nav> <v-divider />
<v-list density="compact" nav class="mb-1">
<v-list-item <v-list-item
:prepend-icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'" to="/settings"
title="Collapse" prepend-icon="mdi-cog-outline"
@click="rail = !rail" title="Settings"
active-color="primary"
rounded="lg"
data-testid="nav-link-settings"
/> />
</v-list> </v-list>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>
<!-- Main Content -->
<v-main> <v-main>
<v-container fluid class="pa-6"> <v-container fluid class="pa-6">
<router-view /> <router-view />
</v-container> </v-container>
</v-main> </v-main>
</v-app> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, ref } from 'vue'; import { ref, computed } from 'vue';
import AppHeader from '@/components/AppHeader.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({ const route = useRoute();
name: 'AppLayout',
components: { AppHeader },
setup() {
const drawer = ref(true);
const rail = ref(false);
const theme = ref('light');
const navLinks = [ const drawer = ref(true);
{ label: 'Dashboard', route: '/', icon: 'mdi-view-dashboard' }, const rail = ref(false);
{ label: 'Features', route: '/features', icon: 'mdi-flag-outline' },
{ label: 'Environments', route: '/environments', icon: 'mdi-server-outline' },
{ label: 'Projects', route: '/projects', icon: 'mdi-folder-outline' },
];
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> </script>

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,24 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import vuetify from './plugins/vuetify'; import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.css'; import '@mdi/font/css/materialdesignicons.css';
import { useAuthStore } from './stores/auth';
import { useContextStore } from './stores/context';
const app = createApp(App); const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router); app.use(router);
app.use(vuetify); 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'); app.mount('#app');

View File

@@ -1,42 +1,88 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; 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 DashboardView from '@/views/DashboardView.vue';
import FeaturesView from '@/views/FeaturesView.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 EnvironmentsView from '@/views/EnvironmentsView.vue';
import ProjectsView from '@/views/ProjectsView.vue'; import ProjectsView from '@/views/ProjectsView.vue';
import ProfileView from '@/views/ProfileView.vue'; import ProfileView from '@/views/ProfileView.vue';
import SettingsView from '@/views/SettingsView.vue'; import SettingsView from '@/views/SettingsView.vue';
const routes: RouteRecordRaw[] = [ 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: '/', path: '/',
name: 'Dashboard', component: AppLayout,
component: DashboardView, meta: { requiresAuth: true },
}, children: [
{ {
path: '/features', path: '',
name: 'Features', name: 'Dashboard',
component: FeaturesView, component: DashboardView,
}, },
{ {
path: '/environments', path: 'features',
name: 'Environments', name: 'Features',
component: EnvironmentsView, component: FeaturesView,
}, },
{ {
path: '/projects', path: 'segments',
name: 'Projects', name: 'Segments',
component: ProjectsView, component: SegmentsView,
}, },
{ {
path: '/profile', path: 'identities',
name: 'Profile', name: 'Identities',
component: ProfileView, component: IdentitiesView,
}, },
{ {
path: '/settings', path: 'audit-logs',
name: 'Settings', name: 'AuditLogs',
component: SettingsView, 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(.*)*', path: '/:pathMatch(.*)*',
redirect: '/', redirect: '/',
@@ -48,4 +94,22 @@ const router = createRouter({
routes, 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; export default router;

View File

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

View File

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

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

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

View File

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

View File

@@ -1,18 +1,153 @@
<template> <template>
<v-row> <div>
<v-col cols="12"> <h1 class="text-h5 font-weight-bold mb-4">Dashboard</h1>
<h1 class="text-h4 mb-4">Dashboard</h1>
<v-card> <!-- Context summary -->
<v-card-text> <v-row class="mb-4" data-testid="context-summary">
Welcome to MicCheck. Use the navigation to manage your features, <v-col cols="12" sm="4">
environments, and projects. <v-card
</v-card-text> :color="contextStore.currentOrganization ? 'primary' : undefined"
</v-card> :variant="contextStore.currentOrganization ? 'tonal' : 'outlined'"
</v-col> rounded="lg"
</v-row> 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> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { useContextStore } from '@/stores/context';
export default defineComponent({ name: 'DashboardView' });
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> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -146,6 +146,13 @@ try
var app = builder.Build(); var app = builder.Build();
// Apply any pending EF Core migrations on startup (safe to run on every boot)
using (var migrationScope = app.Services.CreateScope())
{
var db = migrationScope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
await db.Database.MigrateAsync();
}
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();