Prompt file imported from soma-smart/le-coffre (
.github/prompts/default_front.prompt.md). Copyright stays with the author.
Vue.js Frontend Development Guide for Le Coffre
๐ฏ Project Overview
Le Coffre is a team password manager built with Vue 3, TypeScript, and PrimeVue. The frontend follows the same layered (hexagonal) discipline as the backend โ domain โ application โ infrastructure, with Vue sitting only at the outermost ring. Business logic is framework-agnostic and unit-tested with zero backend.
The authoritative companion doc is frontend/src/README-ARCHITECTURE.md. This guide is the working playbook; read both before writing code.
๐๏ธ Architecture & Layers
Presentation (Vue)
main.ts, pages/, components/, layouts/, router/, plugins/,
composables/, stores/ โ Vue-coupled; calls use cases via useContainer()
โ
โผ
Application
application/ports/ โ interfaces (Protocols) the infra must implement
application/<feature>/ โ use-case classes: execute(command) โ Promise<result>
โ (pure TS โ no Vue, no fetch, no SDK)
โผ
Domain
domain/<feature>/ โ entities, value objects, domain errors
(pure TS โ zero external dependencies)
โฒ
โ infrastructure IMPLEMENTS application ports
Infrastructure
infrastructure/backend/ โ Backend<Ctx>Repository / Gateway โ wraps @/client
infrastructure/in_memory/ โ InMemory<Ctx>Repository โ test-only fakes
infrastructure/local_storage/ โ browser-storage adapters
๐ฆ Dependency rule (STRICT โ enforced by ESLint and a pre-commit hook)
domain/imports nothing external โ no Vue, no Pinia, no libs, no SDK, noapplication/, noinfrastructure/.application/imports only fromdomain/.infrastructure/imports fromapplication/(ports) +domain/. Onlyinfrastructure/backend/**,composition_root.ts, andcustomClient.tsmay import@/client.- Presentation (
.vue,stores/,pages/,router/,composables/) imports use cases + domain types only. Never@/client, neverinfrastructure/. The sole exception ismain.ts, which wires the container viacomposition_root.ts. composables/may reach fordomain/,application/, the container, and other composables โ but never@/infrastructure,@/client, or individual components.localStorage/sessionStorageare reachable only frominfrastructure/adapters andutils/logout.ts. Everywhere else:useContainer().preferences.{read,write,remove}.
If lint says "Only
src/infrastructure/backend/**โฆ may import from@/client", you put network code in the wrong ring. Move it behind a port.
Swapping Vue for another UI framework means rewriting only the presentation ring; domain/, application/, and infrastructure/ port unchanged.
๐ Dependency injection
container.ts(framework-free) exports thePorts+Containertypes andbuildContainer(ports).composition_root.tswires the Backend adapters intobuildContainer()for production.plugins/container.tsis the only Vue-aware bridge:CONTAINER_KEY(InjectionKey<Container>),containerPlugin, and theuseContainer()helper.main.tsinstalls it first:app.use(containerPlugin(installProductionContainer())).- Any component, composable, or Pinia setup store resolves use cases with
useContainer().<feature>.<useCase>.execute(...). - Tests inject a fake container built from in-memory adapters via
createTestContext()(see Testing).
๐ Folder Structure
frontend/src/
โโโ main.ts # Entry point โ installs containerPlugin first, then PrimeVue/Pinia/Router
โโโ App.vue # Root component with global modals (e.g. UnlockVaultModal)
โโโ container.ts # Framework-free: Ports + Container types, buildContainer(ports)
โโโ composition_root.ts # Wires Backend* adapters into buildContainer() (prod)
โโโ customClient.ts # Global SDK interceptors (CSRF, 401 refresh, 429, 503 vault-locked)
โ
โโโ client/ # ๐ค AUTO-GENERATED โ DO NOT EDIT (types.gen, sdk.gen, client.gen)
โ
โโโ domain/<feature>/ # Entities + value objects + errors. Pure TS. No Vue/fetch/SDK.
โโโ application/
โ โโโ ports/ # Interfaces the infra must implement (PasswordRepository, โฆ)
โ โโโ <feature>/ # Use-case classes: execute(command) โ Promise<โฆ>
โ โโโ __tests__/*.spec.ts # UNIT tests against in-memory fakes
โโโ infrastructure/
โ โโโ backend/ # Backend* port impls wrapping @/client (the only @/client consumers)
โ โโโ in_memory/ # Test-only fakes (seed / failWith / useIdGenerator helpers)
โ โโโ local_storage/ # Browser-storage adapters
โ
โโโ plugins/container.ts # CONTAINER_KEY, containerPlugin, useContainer()
โโโ plugins/ # Other Vue plugins (appState, vaultStatus)
โโโ stores/ # Pinia setup stores โ call use cases, expose domain types (camelCase)
โโโ composables/ # Reusable reactive logic (useAsyncStatus, usePasswordReveal, โฆ)
โโโ components/ pages/ layouts/ # Vue-only. Receive domain entities as props. Never import @/client.
โโโ router/ # Vue Router + beforeEach guard
โโโ config/ # Pure data (colorThemes, โฆ)
โโโ utils/ # Framework-light helpers (auth, logout, slug, โฆ)
โโโ test/ # componentTestHelpers.ts (createTestContext), setup.ts
โโโ assets/ # Global styles and images
src/client/ is AUTO-GENERATED from the backend OpenAPI spec. Never edit it by hand.
๐งญ How to build a feature end-to-end (the blueprint)
The password feature is the fully-migrated pilot โ mirror its shape. TDD order, one test at a time (Red โ Green โ Refactor):
- Domain โ add the entity / value object in
domain/<feature>/<Feature>.tsand anydomain/<feature>/errors.ts(subclass<Feature>DomainError). Pure TS. Add domain unit specs for invariants. - Port โ declare the interface in
application/ports/<Name>Repository.ts(or<Name>Gateway.ts). Methods express business intent (getDecryptedValue,listEvents), not CRUD plumbing. - Use case โ
application/<feature>/<DoThing>.ts: a class with a constructor taking the port and a singleexecute(command) โ Promise<result>. It validates UX concerns (non-blank name, well-formed URL) and orchestrates โ it does not re-enforce permissions/encryption/server invariants. It throws domain errors; callers translate. - Unit-test the use case โ
application/<feature>/__tests__/<DoThing>.spec.ts, wired with anInMemory<Name>fake. Cover happy path + each failure mode. Zero Vue, zero backend. - In-memory fake โ
infrastructure/in_memory/InMemory<Name>.tsimplements the port withseed()/failWith()/useIdGenerator()helpers. - Backend adapter โ
infrastructure/backend/Backend<Name>.tsimplements the port by wrapping@/client, mapping snake_case DTO โ camelCase domain and HTTP status โ domain error (e.g.404 โ NotFoundError,503 โ VaultLockedError). - Wire DI โ add the port to
Ports, the use case toContainer, and instantiate inbuildContainer()(container.ts); add the backend adapter incomposition_root.ts; add the in-memory fake default intest/componentTestHelpers.ts. - Presentation โ a store and/or component calls
useContainer().<feature>.<useCase>.execute(...), exposes domain types, and catches domain errors โ toasts. Add component/store specs with a fake container.
Regenerate the SDK (bun run openapi-ts) whenever the backend route/model changes, and commit src/client/.
๐งฉ Layer Patterns (with real shapes)
Domain โ entities + errors
// domain/statistics/errors.ts
export class StatisticsDomainError extends Error {
constructor(message: string) { super(message); this.name = 'StatisticsDomainError' }
}
export class StatisticsUnavailableError extends StatisticsDomainError {
constructor(detail?: string) { super(detail ?? 'Failed to fetch statistics'); this.name = 'StatisticsUnavailableError' }
}
Application โ port + use case
// application/ports/StatisticsGateway.ts
import type { AdminStatistics } from '@/domain/statistics/Statistics'
export interface StatisticsGateway {
getAdminStatistics(): Promise<AdminStatistics>
}
// application/statistics/GetAdminStatistics.ts
import type { StatisticsGateway } from '@/application/ports/StatisticsGateway'
import type { AdminStatistics } from '@/domain/statistics/Statistics'
export class GetAdminStatisticsUseCase {
constructor(private readonly gateway: StatisticsGateway) {}
execute(): Promise<AdminStatistics> {
return this.gateway.getAdminStatistics()
}
}
Infrastructure โ backend adapter (the only @/client touchpoint) + fake
// infrastructure/backend/BackendCsrfGateway.ts
import { getCsrfTokenAuthCsrfTokenGet } from '@/client/sdk.gen'
import type { CsrfGateway } from '@/application/ports/CsrfGateway'
import { CsrfTokenEmptyError, CsrfTokenUnavailableError } from '@/domain/csrf/errors'
export class BackendCsrfGateway implements CsrfGateway {
async fetchToken(): Promise<string> {
const response = await getCsrfTokenAuthCsrfTokenGet()
if (response.error) throw new CsrfTokenUnavailableError(extractDetail(response.error) ?? undefined)
if (!response.data?.csrf_token) throw new CsrfTokenEmptyError()
return response.data.csrf_token // map snake_case โ camelCase here
}
}
// infrastructure/in_memory/InMemoryCsrfGateway.ts (test-only)
export class InMemoryCsrfGateway implements CsrfGateway {
private nextToken = 'in-memory-csrf-token'
private nextError: Error | null = null
seed(token: string): this { this.nextToken = token; this.nextError = null; return this }
failWith(error: Error): this { this.nextError = error; return this }
async fetchToken(): Promise<string> { if (this.nextError) throw this.nextError; return this.nextToken }
}
Presentation โ Pinia setup store
Stores are presentation orchestrators: reactive cache/loading/error state + use-case calls. They expose domain types (camelCase), never SDK DTOs. Keep the 30-second cache + single-flight dedupe convention.
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Password } from '@/domain/password/Password'
import { useContainer } from '@/plugins/container'
let globalPendingPromise: Promise<void> | null = null
export const usePasswordsStore = defineStore('passwords', () => {
// Resolve the container ONCE at setup time โ inject() has no instance inside async actions.
const { passwords: passwordUseCases } = useContainer()
const passwords = ref<Password[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const lastFetch = ref<number | null>(null)
const fetchPasswords = async (force = false) => {
const now = Date.now()
if (!force && lastFetch.value && now - lastFetch.value < 30000) return
if (!force && globalPendingPromise) return globalPendingPromise
loading.value = true
error.value = null
globalPendingPromise = (async () => {
try {
passwords.value = await passwordUseCases.list.execute()
lastFetch.value = now
} catch (e) {
console.error('Error loading passwords:', e)
error.value = 'Failed to load passwords'
} finally {
loading.value = false
globalPendingPromise = null
}
})()
return globalPendingPromise
}
return { passwords, loading, error, fetchPasswords }
})
Presentation โ component
Components receive props typed as domain entities (e.g. Password from @/domain/password/Password), not SDK DTOs. Mutation handlers call use cases and catch domain errors to map to toasts. Use cases throw; components translate.
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { type Password } from '@/domain/password/Password'
import { VaultLockedError } from '@/domain/vault/errors'
import { useContainer } from '@/plugins/container'
const props = defineProps<{ password: Password; contextGroupId?: string }>()
const emit = defineEmits<{ (e: 'deleted'): void; (e: 'edit', password: Password): void }>()
const toast = useToast()
const { passwords } = useContainer()
const remove = async () => {
try {
await passwords.delete.execute({ passwordId: props.password.id })
emit('deleted')
} catch (error) {
// 503 vault-locked is handled globally (unlock modal + toast) โ skip the duplicate.
if (error instanceof VaultLockedError) return
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete password', life: 3000 })
}
}
</script>
Presentation โ composables
Reusable reactive logic lives in src/composables/useXxx.ts (route-sync, modal state machines, reveal/copy flows, single status machine). They take their use cases as injected options so unit tests don't need a container, and follow the composables dependency rule (domain + application + container + other composables only). Prefer a single status: 'idle' | 'loading' | 'error' | 'ready' (useAsyncStatus) over a pile of booleans.
๐ Global SDK interceptors (customClient.ts)
Configured once, applies to every SDK call:
- Attaches
X-CSRF-Tokenon mutating methods. - On 401: silently refreshes the access token (coalescing concurrent refreshes), retries; redirects to
/loginon failed refresh. - On 429: rate-limit toast.
- On 503 (vault locked): triggers the global unlock modal + one "Vault Locked" toast. Components must not show a duplicate toast โ map
503 โ VaultLockedErrorin the adapter and swallow it in the catch.
๐งญ Router guards (src/router/index.ts)
A single beforeEach:
- Checks vault setup; redirects to setup when uninitialized (unless
meta.skipSetupCheck). - Silently refreshes the token if the
logged_incookie is gone; re-fetches CSRF after a page reload. - Gates
meta.requiresAdmin.
When the vault is locked, routing is still allowed (the global UnlockVaultModal takes over) โ do not add other API calls in that state.
๐งช Testing tiers (all Vitest except E2E)
| Tier | Location | What it proves | Backend? |
|---|---|---|---|
| Unit | src/application/<feat>/__tests__/, src/domain/<feat>/__tests__/ |
Use-case rules + domain invariants, wired with InMemory* fakes. |
No |
| Component | src/components/**/__tests__/, src/stores/__tests__/, src/plugins/__tests__/, src/composables/__tests__/ |
Components/stores render & react with a fake container injected. | No |
| E2E | frontend/e2e/*.spec.ts (Playwright) |
Real user flows through the real stack. | Yes |
- Build the fake container with
createTestContext(overrides)from@/test/componentTestHelpersโ it fills inInMemory*defaults for every port; override only the one under test. - Inject via
mount(Component, { global: { plugins: [pinia], provide: { [CONTAINER_KEY]: container } } }). - Stub PrimeVue
Dialogwith a pass-through component when asserting on DOM (avoids teleport-to-body). - Prefer behavioural assertions (observable state, return values, store refs, raised errors) over spying on call counts.
infrastructure/in_memory/**are test infrastructure;infrastructure/backend/**are exercised by Playwright โ neither is the target of unit coverage.
๐ Commands
bun install
bun dev # Vite dev server (hot reload)
bun run type-check # vue-tsc
bun lint # eslint --fix (enforces the dependency rule)
bun format # prettier --write src/
bun run build # type-check + vite build
bun x vitest run # unit + component tests
bun run test:e2e # Playwright
bun run openapi-ts # regenerate src/client/ from the running backend
๐ Coding Rules
TypeScript
- โ
Presentation/stores use domain types (
@/domain/<feature>/โฆ); SDK DTOs stay insideinfrastructure/backend/**. - โ
Explicit types for
ref()and reactive state. - โ NEVER
any(useunknown). โ NEVER@ts-ignorewithout justification.
Props, emits, v-model
- Type with
defineProps<Interface>()/defineEmits<{ โฆ }>(). Props down, events up. Never mutate props (computedordefineModel). - Modals use
defineModel<boolean>('visible', { required: true })rather than anupdate:visibleemit. โฅ 8props โ the component should fetch its own data or split;โฅ 5distinct emits โ a second component is hiding inside.
Reactivity
computedfirst,watchlast (side effects only). Never chain watchers; hoist into a composable or onecomputed.
PrimeVue (auto-imported)
import { useToast } from 'primevue/usetoast'
useToast().add({ severity: 'success', summary: 'Done', detail: 'โฆ', life: 3000 })
import { useConfirm } from 'primevue/useconfirm'
useConfirm().require({ message: 'Delete?', header: 'Confirm', accept: () => {/* โฆ */} })
Import organization
- Vue core โ 2. external libs (PrimeVue, Pinia) โ 3. domain types (
@/domain/โฆ) โ 4. use cases / container (@/application,@/plugins/container) โ 5. stores / composables โ 6. components, layouts, pages โ 7. utils. Use the@/alias.@/clientand@/infrastructureappear only insideinfrastructure/backend/**,composition_root.ts,customClient.ts.
Naming
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | CreatePasswordModal.vue |
| Use cases | PascalCase + UseCase |
GetPasswordUseCase |
| Ports | PascalCase + Repository/Gateway |
PasswordRepository |
| Stores / composables | camelCase + use |
usePasswordsStore, usePasswordReveal |
| Domain types | PascalCase | Password, AdminStatistics |
| Constants | UPPER_SNAKE_CASE | CACHE_DURATION |
๐ Security Rules
- NEVER log sensitive data (passwords, tokens, share values).
- NEVER prefill passwords in edit forms (
password.value = ''). - Validate permissions for UX only (disable buttons) โ the backend remains the source of truth; don't duplicate server-side checks "for safety".
- Auth is cookie-based (
access_token,refresh_tokenhttpOnly;logged_inreadable). The CSRF token lives in Pinia memory only. - Runtime config (
apiBaseUrl) is injected viapublic/config.jsat deploy time โ never bake URLs into the build.
โ Pre-Commit Checklist
-
bun run openapi-ts(if the backend changed) โ commitsrc/client/ -
bun run type-check(no TS errors) -
bun lint(passes โ including the clean-architecture dependency rule) -
bun format -
bun x vitest run(unit + component green) -
bun run build - New port wired through
container.ts+composition_root.ts+componentTestHelpers.ts - No sensitive data logged; permissions reflected in the UI
๐ฏ Key Principles
- Respect the rings:
domain โ application โ infrastructure โ presentation. The ESLint dependency rule is law. @/clientis infrastructure-only: presentation/stores/composables go throughuseContainer().<feature>.<useCase>.execute(...).- Domain types out, DTOs in: adapters translate snake_case โ camelCase and HTTP status โ domain error at the boundary.
- Use cases throw, components translate: catch domain errors โ toasts.
- Cache + dedupe in stores (30s + single-flight); expose domain types.
computedoverwatch; one status machine over boolean piles; extract reusable reactivity into composables.- Test behaviour with fakes:
createTestContext()+ in-memory adapters; Playwright for the real stack. - API client is sacred: never hand-edit
src/client/โ regenerate.
This is your reference for Vue.js frontend development in Le Coffre. The pilot (password feature) and frontend/src/README-ARCHITECTURE.md are the canonical examples โ mirror them.