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

5
src/admin/.dockerignore Normal file
View File

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

19
src/admin/Dockerfile Normal file
View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
});
});
});