push prog 1
This commit is contained in:
parent
a43308b866
commit
aa510d7e23
8 changed files with 82 additions and 46 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green.
|
All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green.
|
||||||
|
|
||||||
Status of tests and typing
|
Status of tests and typing
|
||||||
- All tests pass: 27 files, 48 tests (router tests fixed via memory history fallback in non-browser envs).
|
- All tests pass: 29 files, 49 tests (slug-only routes; memory history fallback in non-browser envs; unauthenticated and refresh-401 guard redirects covered).
|
||||||
- `tsc` and `vue-tsc` pass with no errors.
|
- `tsc` and `vue-tsc` pass with no errors.
|
||||||
|
|
||||||
# Frontend Specification: Household Multi-Tenancy (v2)
|
# Frontend Specification: Household Multi-Tenancy (v2)
|
||||||
|
|
@ -78,33 +78,31 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th
|
||||||
2. **[x] Update Router for Multi-Tenancy**:
|
2. **[x] Update Router for Multi-Tenancy**:
|
||||||
- **Modify `src/router/index.ts`**:
|
- **Modify `src/router/index.ts`**:
|
||||||
- Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
|
- Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`.
|
||||||
- Feature flag `VUE_APP_MULTITENANT_ENABLED` controls nesting:
|
- Removed legacy flag and flat routes: feature routes are always nested under `/:householdSlug/...`.
|
||||||
- When enabled: feature routes are nested under `/:householdSlug/...`.
|
|
||||||
- When disabled: legacy flat routes remain for backward compatibility.
|
|
||||||
- **Refactor the `beforeEach` guard**:
|
- **Refactor the `beforeEach` guard**:
|
||||||
- Allows public routes.
|
- Allows public routes.
|
||||||
- When multitenant flag is on, after auth fetches households via `useAuth().fetchHouseholds()`.
|
- After auth, fetches households via `useAuth().fetchHouseholds()`.
|
||||||
- If none: redirect to `/welcome`.
|
- If none: redirect to `/welcome`.
|
||||||
- If at root (`/`): redirect to first household's `/:householdSlug/mealplan`.
|
- If at root (`/`): redirect to first household's `/:householdSlug/mealplan`.
|
||||||
- Ensures `activeHousehold` is set when navigating within a household.
|
- Ensures `activeHousehold` is set when navigating within a household.
|
||||||
- **Nest existing routes**: Implemented behind feature flag.
|
- **Nest existing routes**: Always under `/:householdSlug` (legacy flat routes removed).
|
||||||
- **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests verify both flag modes.
|
- **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests assert slug-only mode.
|
||||||
|
|
||||||
3. **[~] Implement Onboarding and Invitation Flows**:
|
3. **[~] Implement Onboarding and Invitation Flows**:
|
||||||
- Build the `Welcome.vue` view for creating the first household.
|
- Build the `Welcome.vue` view for creating the first household.
|
||||||
- Build the `CreateAccount.vue` view.
|
- Build the `CreateAccount.vue` view.
|
||||||
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
|
- Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success.
|
||||||
- Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls `POST /api/v1/invitations/accept`, and redirects to the accepted household. Uses a temporary raw fetch helper until OpenAPI adds this endpoint.
|
- Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls typed `POST /api/v1/invitations/accept`, and redirects to the accepted household.
|
||||||
|
|
||||||
4. **[~] Integrate Household Context into the App**:
|
4. **[~] Integrate Household Context into the App**:
|
||||||
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
- **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client.
|
||||||
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
|
- **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`.
|
||||||
- **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` and `Authorization` using configurable providers; no path changes.
|
- **Update API Services**: No household header injection; Authorization header provided by token provider. Cookies are only sent for the refresh endpoint; all other requests avoid credentials. SDK/services use typed path params.
|
||||||
- Verified by `tests/household.header.test.ts` and auth tests.
|
- Verified by `tests/household.header.test.ts`, auth tests, and router guard tests.
|
||||||
|
|
||||||
5. **[~] Implement Invitation UI**:
|
5. **[~] Implement Invitation UI**:
|
||||||
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
|
- Build the `HouseholdSettings.vue` view for inviting members and listing current members.
|
||||||
- Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing pending backend endpoint.
|
- Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing implemented using typed endpoint `GET /api/v1/households/{householdSlug}/members`.
|
||||||
|
|
||||||
6. **[ ] Final Review & Cleanup**:
|
6. **[ ] Final Review & Cleanup**:
|
||||||
- Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity.
|
- Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity.
|
||||||
|
|
@ -122,7 +120,7 @@ What exists now
|
||||||
- `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`.
|
- `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`.
|
||||||
- `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`.
|
- `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`.
|
||||||
- Routing
|
- Routing
|
||||||
- `src/router/index.ts`: feature-flagged nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`.
|
- `src/router/index.ts`: slug-only nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`.
|
||||||
- Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser).
|
- Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser).
|
||||||
- SDK/API
|
- SDK/API
|
||||||
- `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Persons and parse use temporary raw fetch endpoints where OpenAPI lacks coverage.
|
- `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Persons and parse use temporary raw fetch endpoints where OpenAPI lacks coverage.
|
||||||
|
|
@ -133,7 +131,7 @@ What exists now
|
||||||
- MyShopping: page remains as in master with editable panel backed by legacy v1 stubs (`getMyShoppingList/saveMyShoppingList`) pending backend ad-hoc item endpoints.
|
- MyShopping: page remains as in master with editable panel backed by legacy v1 stubs (`getMyShoppingList/saveMyShoppingList`) pending backend ad-hoc item endpoints.
|
||||||
|
|
||||||
Status of tests and typing
|
Status of tests and typing
|
||||||
- All tests pass: 27 files, 48 tests.
|
- All tests pass: 29 files, 49 tests.
|
||||||
- `tsc` and `vue-tsc` pass with no errors.
|
- `tsc` and `vue-tsc` pass with no errors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -160,11 +158,11 @@ Progress Log (Nov 1, 2025)
|
||||||
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load.
|
- Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load.
|
||||||
- Router guard updated to handle public/multitenant routing and redirects.
|
- Router guard updated to handle public/multitenant routing and redirects.
|
||||||
- Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation.
|
- Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation.
|
||||||
- Implemented Invitation Accept flow: added `src/api/invitations.ts` with `acceptInvitation` (temporary raw fetch), `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`.
|
- Implemented Invitation Accept flow: added `src/api/invitations.ts` with typed `acceptInvitation`, `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`.
|
||||||
- Next: Implement Household Settings (invite members form), then remove legacy Person UI.
|
- Next: Implement Household Settings (invite members form), then remove legacy Person UI.
|
||||||
- Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery.
|
- Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery.
|
||||||
- Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via a temporary `listMembers()` in `src/api/households.ts` using the raw fetch helper. When the backend exposes a typed endpoint, we will swap to the generated client.
|
- Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via the typed endpoint.
|
||||||
- Router uses memory history in tests to avoid relying on `window.location`.
|
- Router uses memory history in tests to avoid relying on `window.location`. Added unauthenticated and refresh-401 guard redirect tests.
|
||||||
- Backend updated OpenAPI and codegen has been run:
|
- Backend updated OpenAPI and codegen has been run:
|
||||||
- Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami).
|
- Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami).
|
||||||
- Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`.
|
- Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`.
|
||||||
|
|
@ -235,7 +233,7 @@ Tests
|
||||||
- Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active route’s slug when needed.
|
- Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active route’s slug when needed.
|
||||||
- Cleanup/migration tasks:
|
- Cleanup/migration tasks:
|
||||||
1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed)
|
1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed)
|
||||||
2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. (Members listing migrated) Persons and parse remain raw for now.
|
2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. Persons and parse remain raw for now.
|
||||||
3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next)
|
3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next)
|
||||||
4) Legacy identity: continue using `User` as the primary identity. Keep `Person` in meal-related UIs where required by backend, but remove Person as the login/identity concept.
|
4) Legacy identity: continue using `User` as the primary identity. Keep `Person` in meal-related UIs where required by backend, but remove Person as the login/identity concept.
|
||||||
|
|
||||||
|
|
@ -243,7 +241,7 @@ Tests
|
||||||
|
|
||||||
## Migration Plan & Feature Flag
|
## Migration Plan & Feature Flag
|
||||||
|
|
||||||
- `VUE_APP_MULTITENANT_ENABLED` flag controls nested household routes. With the migration complete, keep this flag for rollout control; default can be enabled once backend is stable across environments.
|
- Removed `VUE_APP_MULTITENANT_ENABLED` and legacy flat routes. The app now always uses slug-scoped routes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,22 @@ export function setAuthTokenProvider(provider: (() => string | null) | null) {
|
||||||
authTokenProvider = provider
|
authTokenProvider = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRefreshRequest(input: RequestInfo | URL): boolean {
|
function requestInfoToUrl(input: RequestInfo | URL): string | null {
|
||||||
try {
|
try {
|
||||||
const url = typeof input === 'string' ? input : (input as URL).toString()
|
if (typeof input === 'string') return input
|
||||||
return url.includes('/api/v1/auth/refresh')
|
if (typeof URL !== 'undefined' && input instanceof URL) return input.toString()
|
||||||
|
if (typeof Request !== 'undefined' && input instanceof Request) return input.url
|
||||||
|
return String(input)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRefreshRequest(input: RequestInfo | URL): boolean {
|
||||||
|
const url = requestInfoToUrl(input)
|
||||||
|
return !!url && url.includes('/api/v1/auth/refresh')
|
||||||
|
}
|
||||||
|
|
||||||
export const api = createClient<paths>({
|
export const api = createClient<paths>({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,26 @@ import { api } from '@/api/client'
|
||||||
|
|
||||||
export type Household = { id: number; name: string; slug: string }
|
export type Household = { id: number; name: string; slug: string }
|
||||||
|
|
||||||
|
function hasKey<T extends object, K extends PropertyKey>(obj: T, key: K): obj is T & Record<K, unknown> {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key)
|
||||||
|
}
|
||||||
|
|
||||||
function isHousehold(value: unknown): value is Household {
|
function isHousehold(value: unknown): value is Household {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const v = value as Record<string, unknown>
|
||||||
return (
|
return (
|
||||||
typeof value === 'object' && value !== null &&
|
typeof v.id === 'number' &&
|
||||||
typeof (value as { id: unknown }).id === 'number' &&
|
typeof v.name === 'string' &&
|
||||||
typeof (value as { name: unknown }).name === 'string' &&
|
typeof v.slug === 'string'
|
||||||
typeof (value as { slug: unknown }).slug === 'string'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeJson<T>(resp: Response): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
const d = await resp.json()
|
|
||||||
return d as T
|
|
||||||
} catch (_e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function acceptInvitation(token: string): Promise<Household> {
|
export async function acceptInvitation(token: string): Promise<Household> {
|
||||||
const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } as any })
|
const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } })
|
||||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`)
|
||||||
const dataObj = data as any
|
const obj = data && typeof data === 'object' ? data as Record<string, unknown> : null
|
||||||
const dataWrapped: { household?: unknown } | null = (dataObj && typeof dataObj === 'object') ? dataObj : null
|
const h = obj && hasKey(obj, 'household') ? obj.household : undefined
|
||||||
let h: unknown = dataWrapped && 'household' in dataWrapped ? dataWrapped.household : undefined
|
if (!isHousehold(h)) throw new Error('Invalid invitation accept response')
|
||||||
if (!isHousehold(h)) {
|
|
||||||
throw new Error('Invalid invitation accept response')
|
|
||||||
}
|
|
||||||
return { id: h.id, name: h.name, slug: h.slug }
|
return { id: h.id, name: h.name, slug: h.slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
v-for="(chef, index) in meal.chefs"
|
v-for="(chef, index) in meal.chefs"
|
||||||
:key="chef.id"
|
:key="chef.id"
|
||||||
>
|
>
|
||||||
{{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }}
|
{{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.chefs.length">somebody?</span>
|
<span v-if="!meal.chefs.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
v-for="(consumer, index) in meal.consumers"
|
v-for="(consumer, index) in meal.consumers"
|
||||||
:key="consumer.id"
|
:key="consumer.id"
|
||||||
>
|
>
|
||||||
{{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }}
|
{{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.consumers.length">somebody?</span>
|
<span v-if="!meal.consumers.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ export function createAppRouter(getCurrentUser: () => Promise<unknown> | unknown
|
||||||
|
|
||||||
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
|
// Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable
|
||||||
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
|
// Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global.
|
||||||
const hasLocation = typeof globalThis !== 'undefined' && typeof (globalThis as any).location !== 'undefined'
|
// Avoid 'as' assertions; relying on typeof global 'location' is safe and non-throwing in Node
|
||||||
|
const hasLocation = typeof location !== 'undefined'
|
||||||
const history = hasLocation ? createWebHashHistory() : createMemoryHistory()
|
const history = hasLocation ? createWebHashHistory() : createMemoryHistory()
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ describe('invitations api (send invite)', () => {
|
||||||
await loginWithPassword('x@y', 'pw')
|
await loginWithPassword('x@y', 'pw')
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('*/api/v1/households/the-smiths/invitations', async ({ request, requestId, cookies, params }) => {
|
http.post('*/api/v1/households/the-smiths/invitations', async ({ request }) => {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
expect(body).toEqual({ email: 'invite@example.com' })
|
expect(body).toEqual({ email: 'invite@example.com' })
|
||||||
const auth = request.headers.get('authorization')
|
const auth = request.headers.get('authorization')
|
||||||
|
|
|
||||||
18
tests/router.guard.refresh401.test.ts
Normal file
18
tests/router.guard.refresh401.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('router guard refresh 401 handling', () => {
|
||||||
|
it('redirects to /login with redirect when getCurrentUser throws (e.g., refresh 401)', async () => {
|
||||||
|
const { createAppRouter } = await import('@/router/index')
|
||||||
|
const router = createAppRouter(async () => { throw new Error('401 Unauthorized') })
|
||||||
|
|
||||||
|
// Stub login route with inline component to avoid loading .vue files
|
||||||
|
try { router.removeRoute('login') } catch (_) { /* ignore */ }
|
||||||
|
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
|
||||||
|
|
||||||
|
await router.push('/')
|
||||||
|
|
||||||
|
const current = router.currentRoute.value
|
||||||
|
expect(current.name).toBe('login')
|
||||||
|
expect(current.query.redirect).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
19
tests/router.guard.unauth.test.ts
Normal file
19
tests/router.guard.unauth.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('router guard unauthenticated redirect', () => {
|
||||||
|
it('redirects to /login with redirect query when not authenticated', async () => {
|
||||||
|
const { createAppRouter } = await import('@/router/index')
|
||||||
|
const router = createAppRouter(async () => null)
|
||||||
|
|
||||||
|
// Replace the lazy .vue login route with an inline component to avoid plugin-vue in tests
|
||||||
|
try { router.removeRoute('login') } catch (_) { /* ignore */ }
|
||||||
|
router.addRoute({ path: '/login', name: 'login', component: { template: '<div />' } })
|
||||||
|
|
||||||
|
// Navigate to a protected route that uses an inline component to avoid lazy .vue imports
|
||||||
|
await router.push('/')
|
||||||
|
|
||||||
|
const current = router.currentRoute.value
|
||||||
|
expect(current.name).toBe('login')
|
||||||
|
expect(current.query.redirect).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue