een-api-toolkit 0.3.30 → 0.3.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/docs-accuracy-reviewer.md +15 -3
- package/.claude/agents/een-auth-agent.md +131 -0
- package/.claude/agents/een-devices-agent.md +10 -7
- package/.claude/agents/een-events-agent.md +98 -0
- package/.claude/agents/een-grouping-agent.md +394 -0
- package/.claude/agents/een-media-agent.md +25 -5
- package/CHANGELOG.md +77 -6
- package/README.md +5 -3
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +561 -0
- package/dist/index.js +388 -218
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +13 -1
- package/docs/ai-reference/AI-AUTH.md +1 -1
- package/docs/ai-reference/AI-DEVICES.md +1 -1
- package/docs/ai-reference/AI-EVENTS.md +1 -1
- package/docs/ai-reference/AI-GROUPING.md +411 -0
- package/docs/ai-reference/AI-MEDIA.md +1 -1
- package/docs/ai-reference/AI-SETUP.md +1 -1
- package/docs/ai-reference/AI-USERS.md +1 -1
- package/examples/vue-alerts-metrics/README.md +2 -0
- package/examples/vue-alerts-metrics/alert-metrics-screenshot.png +0 -0
- package/examples/vue-alerts-metrics/e2e/auth.spec.ts +1 -1
- package/examples/vue-alerts-metrics/package-lock.json +17 -14
- package/examples/vue-alerts-metrics/package.json +1 -1
- package/examples/vue-bridges/package-lock.json +21 -15
- package/examples/vue-bridges/package.json +1 -1
- package/examples/vue-cameras/package-lock.json +21 -15
- package/examples/vue-cameras/package.json +1 -1
- package/examples/vue-event-subscriptions/README.md +2 -0
- package/examples/vue-event-subscriptions/event-subscriptions-screenshot.png +0 -0
- package/examples/vue-event-subscriptions/package-lock.json +17 -14
- package/examples/vue-event-subscriptions/package.json +1 -1
- package/examples/vue-events/events-screenshot.png +0 -0
- package/examples/vue-events/package-lock.json +17 -14
- package/examples/vue-events/package.json +1 -1
- package/examples/vue-feeds/package-lock.json +21 -15
- package/examples/vue-feeds/package.json +1 -1
- package/examples/vue-layouts/.env.example +12 -0
- package/examples/vue-layouts/README.md +320 -0
- package/examples/vue-layouts/e2e/app.spec.ts +76 -0
- package/examples/vue-layouts/e2e/auth.spec.ts +264 -0
- package/examples/vue-layouts/index.html +13 -0
- package/examples/vue-layouts/layouts-screenshot.png +0 -0
- package/examples/vue-layouts/package-lock.json +1722 -0
- package/examples/vue-layouts/package.json +28 -0
- package/examples/vue-layouts/playwright.config.ts +47 -0
- package/examples/vue-layouts/src/App.vue +124 -0
- package/examples/vue-layouts/src/components/LayoutModal.vue +456 -0
- package/examples/vue-layouts/src/main.ts +25 -0
- package/examples/vue-layouts/src/router/index.ts +62 -0
- package/examples/vue-layouts/src/views/Callback.vue +76 -0
- package/examples/vue-layouts/src/views/Home.vue +188 -0
- package/examples/vue-layouts/src/views/Layouts.vue +355 -0
- package/examples/vue-layouts/src/views/Login.vue +33 -0
- package/examples/vue-layouts/src/views/Logout.vue +59 -0
- package/examples/vue-layouts/src/vite-env.d.ts +12 -0
- package/examples/vue-layouts/tsconfig.json +21 -0
- package/examples/vue-layouts/tsconfig.node.json +10 -0
- package/examples/vue-layouts/vite.config.ts +12 -0
- package/examples/vue-media/e2e/auth.spec.ts +35 -1
- package/examples/vue-media/media-screenshot.png +0 -0
- package/examples/vue-media/package-lock.json +19 -14
- package/examples/vue-media/package.json +1 -1
- package/examples/vue-users/package-lock.json +21 -16
- package/examples/vue-users/package.json +2 -2
- package/package.json +2 -2
- package/scripts/setup-agents.ts +9 -7
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# EEN API Toolkit - Vue Layouts Example
|
|
2
|
+
|
|
3
|
+
A complete example showing how to use the Layouts API with een-api-toolkit in a Vue 3 application.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Storage Strategy: Memory
|
|
8
|
+
|
|
9
|
+
This example uses the `memory` storage strategy for maximum security. This means:
|
|
10
|
+
|
|
11
|
+
- **Tokens are never written to disk** - immune to localStorage/sessionStorage XSS attacks
|
|
12
|
+
- **Page refresh requires re-authentication** - tokens exist only in memory
|
|
13
|
+
- **Each tab is independent** - opening a new tab requires separate login
|
|
14
|
+
|
|
15
|
+
This is the recommended strategy for high-security deployments where protecting against XSS token theft is critical.
|
|
16
|
+
|
|
17
|
+
## Features Demonstrated
|
|
18
|
+
|
|
19
|
+
- OAuth authentication flow (login, callback, logout)
|
|
20
|
+
- Protected routes with navigation guards
|
|
21
|
+
- `getLayouts()` function with pagination
|
|
22
|
+
- `createLayout()` function for creating new layouts
|
|
23
|
+
- `updateLayout()` function for modifying layouts
|
|
24
|
+
- `deleteLayout()` function for removing layouts
|
|
25
|
+
- Layout modal for create/edit operations
|
|
26
|
+
- Camera pane management within layouts
|
|
27
|
+
- Error handling with Result pattern
|
|
28
|
+
- Reactive authentication state
|
|
29
|
+
|
|
30
|
+
## APIs Used
|
|
31
|
+
|
|
32
|
+
- `getLayouts()` - List layouts with pagination and filtering
|
|
33
|
+
- `getLayout()` - Get a specific layout by ID
|
|
34
|
+
- `createLayout()` - Create a new layout
|
|
35
|
+
- `updateLayout()` - Update an existing layout
|
|
36
|
+
- `deleteLayout()` - Delete a layout
|
|
37
|
+
- `getCameras()` - Get cameras for pane selection
|
|
38
|
+
- `getCurrentUser()` - Get current user profile
|
|
39
|
+
- `useAuthStore()` - Authentication state management
|
|
40
|
+
- `getAuthUrl()` - Generate OAuth login URL
|
|
41
|
+
- `handleAuthCallback()` - Process OAuth callback
|
|
42
|
+
- `initEenToolkit()` - Toolkit initialization
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
### Prerequisites
|
|
47
|
+
|
|
48
|
+
1. **Start the OAuth proxy** (required for authentication):
|
|
49
|
+
|
|
50
|
+
The OAuth proxy is a separate project that handles token management securely.
|
|
51
|
+
Clone and run it from: https://github.com/klaushofrichter/een-oauth-proxy
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# In a separate terminal, from the een-oauth-proxy directory
|
|
55
|
+
npm install
|
|
56
|
+
npm run dev
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The proxy should be running at `http://localhost:8787`.
|
|
60
|
+
|
|
61
|
+
### Example Setup
|
|
62
|
+
|
|
63
|
+
All commands below should be run from this example directory (`examples/vue-layouts/`):
|
|
64
|
+
|
|
65
|
+
2. Copy the environment file:
|
|
66
|
+
```bash
|
|
67
|
+
# From examples/vue-layouts/
|
|
68
|
+
cp .env.example .env
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
3. Edit `.env` with your EEN credentials:
|
|
72
|
+
```env
|
|
73
|
+
VITE_EEN_CLIENT_ID=your-client-id
|
|
74
|
+
VITE_PROXY_URL=http://localhost:8787
|
|
75
|
+
# DO NOT change the redirect URI - EEN IDP only permits this URL
|
|
76
|
+
VITE_REDIRECT_URI=http://127.0.0.1:3333
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
4. Install dependencies and start:
|
|
80
|
+
```bash
|
|
81
|
+
# From examples/vue-layouts/
|
|
82
|
+
npm install
|
|
83
|
+
npm run dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
5. Open http://127.0.0.1:3333 in your browser.
|
|
87
|
+
|
|
88
|
+
**Important:** The EEN Identity Provider only permits `http://127.0.0.1:3333` as the OAuth redirect URI. Do not use `localhost` or other ports.
|
|
89
|
+
|
|
90
|
+
**Note:** Development and testing was done on macOS. The `npm run stop` command uses `lsof`, which is not available on Windows. Windows users should manually stop any process on port 3333 or use `npx kill-port 3333` instead.
|
|
91
|
+
|
|
92
|
+
## Project Structure
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
src/
|
|
96
|
+
├── main.ts # App entry, toolkit initialization
|
|
97
|
+
├── App.vue # Root component with navigation
|
|
98
|
+
├── router/
|
|
99
|
+
│ └── index.ts # Vue Router with auth guards
|
|
100
|
+
├── views/
|
|
101
|
+
│ ├── Home.vue # Home page with user profile
|
|
102
|
+
│ ├── Login.vue # OAuth login redirect
|
|
103
|
+
│ ├── Callback.vue # OAuth callback handler
|
|
104
|
+
│ ├── Layouts.vue # Layout list with CRUD operations
|
|
105
|
+
│ └── Logout.vue # Logout handler
|
|
106
|
+
└── components/
|
|
107
|
+
└── LayoutModal.vue # Modal for create/edit layouts
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Key Code Examples
|
|
111
|
+
|
|
112
|
+
### Initializing the Toolkit (main.ts)
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { initEenToolkit } from 'een-api-toolkit'
|
|
116
|
+
|
|
117
|
+
initEenToolkit({
|
|
118
|
+
proxyUrl: import.meta.env.VITE_PROXY_URL,
|
|
119
|
+
clientId: import.meta.env.VITE_EEN_CLIENT_ID,
|
|
120
|
+
storageStrategy: 'memory', // Maximum security - tokens lost on refresh
|
|
121
|
+
debug: true
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Fetching Layouts with Pagination (Layouts.vue)
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { ref } from 'vue'
|
|
129
|
+
import { getLayouts, type Layout } from 'een-api-toolkit'
|
|
130
|
+
|
|
131
|
+
const layouts = ref<Layout[]>([])
|
|
132
|
+
const nextPageToken = ref<string | undefined>(undefined)
|
|
133
|
+
const loading = ref(false)
|
|
134
|
+
|
|
135
|
+
async function fetchLayouts() {
|
|
136
|
+
loading.value = true
|
|
137
|
+
const result = await getLayouts({
|
|
138
|
+
pageSize: 20,
|
|
139
|
+
include: ['effectivePermissions', 'resourceCounts']
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
if (result.error) {
|
|
143
|
+
console.error('Failed to fetch layouts:', result.error.message)
|
|
144
|
+
} else {
|
|
145
|
+
layouts.value = result.data.results
|
|
146
|
+
nextPageToken.value = result.data.nextPageToken
|
|
147
|
+
}
|
|
148
|
+
loading.value = false
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Creating a Layout
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { createLayout, type CreateLayoutParams } from 'een-api-toolkit'
|
|
156
|
+
|
|
157
|
+
async function handleCreate(params: CreateLayoutParams) {
|
|
158
|
+
const result = await createLayout({
|
|
159
|
+
name: 'My New Layout',
|
|
160
|
+
settings: {
|
|
161
|
+
paneColumns: 2,
|
|
162
|
+
cameraAspectRatio: '16x9',
|
|
163
|
+
showCameraBorder: true,
|
|
164
|
+
showCameraName: true
|
|
165
|
+
},
|
|
166
|
+
panes: []
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
if (result.error) {
|
|
170
|
+
console.error('Failed to create layout:', result.error.message)
|
|
171
|
+
} else {
|
|
172
|
+
console.log('Created layout:', result.data.id)
|
|
173
|
+
await fetchLayouts() // Refresh the list
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Updating a Layout
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { updateLayout, type UpdateLayoutParams } from 'een-api-toolkit'
|
|
182
|
+
|
|
183
|
+
async function handleUpdate(layoutId: string, params: UpdateLayoutParams) {
|
|
184
|
+
const result = await updateLayout(layoutId, {
|
|
185
|
+
name: 'Updated Layout Name',
|
|
186
|
+
settings: {
|
|
187
|
+
paneColumns: 3
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
if (result.error) {
|
|
192
|
+
console.error('Failed to update layout:', result.error.message)
|
|
193
|
+
} else {
|
|
194
|
+
await fetchLayouts() // Refresh the list
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Deleting a Layout
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { deleteLayout } from 'een-api-toolkit'
|
|
203
|
+
|
|
204
|
+
async function handleDelete(layoutId: string) {
|
|
205
|
+
if (!confirm('Are you sure you want to delete this layout?')) return
|
|
206
|
+
|
|
207
|
+
const result = await deleteLayout(layoutId)
|
|
208
|
+
|
|
209
|
+
if (result.error) {
|
|
210
|
+
console.error('Failed to delete layout:', result.error.message)
|
|
211
|
+
} else {
|
|
212
|
+
await fetchLayouts() // Refresh the list
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Layout Modal Component (LayoutModal.vue)
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { ref, watch } from 'vue'
|
|
221
|
+
import { getCameras, type Layout, type Camera, type LayoutPane } from 'een-api-toolkit'
|
|
222
|
+
|
|
223
|
+
const props = defineProps<{
|
|
224
|
+
layout?: Layout | null
|
|
225
|
+
isOpen: boolean
|
|
226
|
+
}>()
|
|
227
|
+
|
|
228
|
+
const emit = defineEmits<{
|
|
229
|
+
save: [params: { name: string; settings: LayoutSettings; panes: LayoutPane[] }]
|
|
230
|
+
delete: [layoutId: string]
|
|
231
|
+
close: []
|
|
232
|
+
}>()
|
|
233
|
+
|
|
234
|
+
const cameras = ref<Camera[]>([])
|
|
235
|
+
const name = ref('')
|
|
236
|
+
const paneColumns = ref(2)
|
|
237
|
+
const panes = ref<LayoutPane[]>([])
|
|
238
|
+
|
|
239
|
+
// Fetch cameras for pane selection
|
|
240
|
+
async function fetchCameras() {
|
|
241
|
+
const result = await getCameras({ pageSize: 100, include: ['status'] })
|
|
242
|
+
if (!result.error) {
|
|
243
|
+
cameras.value = result.data.results
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Add a new pane
|
|
248
|
+
function addPane() {
|
|
249
|
+
panes.value.push({
|
|
250
|
+
id: panes.value.length,
|
|
251
|
+
name: `Pane ${panes.value.length + 1}`,
|
|
252
|
+
type: 'preview',
|
|
253
|
+
size: 1,
|
|
254
|
+
cameraId: ''
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Auth Guard (router/index.ts)
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
router.beforeEach((to, from, next) => {
|
|
263
|
+
const authStore = useAuthStore()
|
|
264
|
+
|
|
265
|
+
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
|
266
|
+
next('/login')
|
|
267
|
+
} else {
|
|
268
|
+
next()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Layout Types
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
interface Layout {
|
|
277
|
+
id: string
|
|
278
|
+
name: string
|
|
279
|
+
accountId: string
|
|
280
|
+
panes: LayoutPane[]
|
|
281
|
+
settings: LayoutSettings
|
|
282
|
+
effectivePermissions?: LayoutPermissions
|
|
283
|
+
resourceCounts?: { cameras?: number }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface LayoutPane {
|
|
287
|
+
id: number
|
|
288
|
+
name: string
|
|
289
|
+
type: 'preview' | 'compositePreview'
|
|
290
|
+
size: 1 | 2 | 3
|
|
291
|
+
cameraId: string
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface LayoutSettings {
|
|
295
|
+
showCameraBorder: boolean
|
|
296
|
+
showCameraName: boolean
|
|
297
|
+
cameraAspectRatio: '16x9' | '4x3'
|
|
298
|
+
paneColumns: number // 1-6
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Running E2E Tests
|
|
303
|
+
|
|
304
|
+
The example includes Playwright E2E tests:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
# Run all E2E tests
|
|
308
|
+
npm run test:e2e
|
|
309
|
+
|
|
310
|
+
# Run with UI for debugging
|
|
311
|
+
npm run test:e2e:ui
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Tests cover:
|
|
315
|
+
- App loads correctly
|
|
316
|
+
- Navigation between pages
|
|
317
|
+
- Authentication flow
|
|
318
|
+
- Protected route redirection
|
|
319
|
+
- OAuth login flow (when proxy is available)
|
|
320
|
+
- Layouts page after authentication
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
test.describe('Vue Layouts Example - App', () => {
|
|
4
|
+
test.beforeEach(async ({ page }) => {
|
|
5
|
+
// Capture console errors
|
|
6
|
+
page.on('console', msg => {
|
|
7
|
+
if (msg.type() === 'error') {
|
|
8
|
+
console.log('Browser console error:', msg.text())
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
page.on('pageerror', err => {
|
|
12
|
+
console.log('Page error:', err.message)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
await page.goto('/')
|
|
16
|
+
// Wait for Vue app header AND router-view content to render
|
|
17
|
+
// The header renders first, then router-view resolves the route
|
|
18
|
+
await page.waitForSelector('[data-testid="app-title"]', { timeout: 10000 })
|
|
19
|
+
// Wait for router-view content (either authenticated or not-authenticated state)
|
|
20
|
+
await page.waitForSelector('.home, .login, .layouts', { timeout: 10000 })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('app loads with correct title', async ({ page }) => {
|
|
24
|
+
await expect(page).toHaveTitle(/EEN Layouts/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('header displays app name', async ({ page }) => {
|
|
28
|
+
await expect(page.locator('[data-testid="app-title"]')).toHaveText('EEN Layouts Example')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('navigation shows Home and Login links when not authenticated', async ({ page }) => {
|
|
32
|
+
// Home link should be visible
|
|
33
|
+
await expect(page.locator('[data-testid="nav-home"]')).toBeVisible()
|
|
34
|
+
|
|
35
|
+
// Login link should be visible (not authenticated)
|
|
36
|
+
await expect(page.locator('[data-testid="nav-login"]')).toBeVisible()
|
|
37
|
+
|
|
38
|
+
// Layouts and Logout should NOT be visible (requires auth)
|
|
39
|
+
await expect(page.locator('[data-testid="nav-layouts"]')).not.toBeVisible()
|
|
40
|
+
await expect(page.locator('[data-testid="nav-logout"]')).not.toBeVisible()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('home page shows not logged in message', async ({ page }) => {
|
|
44
|
+
await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible()
|
|
45
|
+
await expect(page.locator('[data-testid="not-authenticated-message"]')).toBeVisible()
|
|
46
|
+
await expect(page.locator('[data-testid="login-button"]')).toBeVisible()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('login page displays login button', async ({ page }) => {
|
|
50
|
+
await page.goto('/login')
|
|
51
|
+
|
|
52
|
+
await expect(page.locator('[data-testid="login-title"]')).toHaveText('Login')
|
|
53
|
+
await expect(page.locator('[data-testid="login-button"]')).toBeVisible()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('protected route redirects to login', async ({ page }) => {
|
|
57
|
+
await page.goto('/layouts')
|
|
58
|
+
|
|
59
|
+
// Should be redirected to login page
|
|
60
|
+
await page.waitForURL('/login')
|
|
61
|
+
await expect(page).toHaveURL('/login')
|
|
62
|
+
await expect(page.locator('[data-testid="login-title"]')).toHaveText('Login')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('navigation between pages works', async ({ page }) => {
|
|
66
|
+
// Click Login link
|
|
67
|
+
await page.click('[data-testid="nav-login"]')
|
|
68
|
+
await page.waitForURL('/login')
|
|
69
|
+
await expect(page).toHaveURL('/login')
|
|
70
|
+
|
|
71
|
+
// Click Home link
|
|
72
|
+
await page.click('[data-testid="nav-home"]')
|
|
73
|
+
await page.waitForURL('/')
|
|
74
|
+
await expect(page).toHaveURL('/')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { test, expect, Page } from '@playwright/test'
|
|
2
|
+
import { baseURL } from '../playwright.config'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* E2E tests for the Vue Layouts Example
|
|
6
|
+
*
|
|
7
|
+
* Tests the OAuth login flow through the UI:
|
|
8
|
+
* 1. Click login button in the example app
|
|
9
|
+
* 2. Enter credentials on EEN OAuth page
|
|
10
|
+
* 3. Complete the OAuth callback
|
|
11
|
+
* 4. Verify authenticated state
|
|
12
|
+
*
|
|
13
|
+
* Required environment variables:
|
|
14
|
+
* - VITE_PROXY_URL: OAuth proxy URL (e.g., http://127.0.0.1:8787)
|
|
15
|
+
* - VITE_EEN_CLIENT_ID: EEN OAuth client ID
|
|
16
|
+
* - TEST_USER: Test user email
|
|
17
|
+
* - TEST_PASSWORD: Test user password
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Timeout constants for consistent behavior
|
|
21
|
+
// Values chosen based on OAuth flow timing requirements
|
|
22
|
+
const TIMEOUTS = {
|
|
23
|
+
OAUTH_REDIRECT: 30000, // OAuth redirects can be slow on first load
|
|
24
|
+
ELEMENT_VISIBLE: 15000, // Wait for OAuth page elements to render
|
|
25
|
+
PASSWORD_VISIBLE: 10000, // Password field appears after email validation
|
|
26
|
+
AUTH_COMPLETE: 30000, // Full OAuth flow completion
|
|
27
|
+
UI_UPDATE: 10000, // UI state updates after auth changes
|
|
28
|
+
PROXY_CHECK: 5000 // Quick check if proxy is running
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
const TEST_USER = process.env.TEST_USER
|
|
32
|
+
const TEST_PASSWORD = process.env.TEST_PASSWORD
|
|
33
|
+
const PROXY_URL = process.env.VITE_PROXY_URL
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Checks if the OAuth proxy is accessible.
|
|
37
|
+
* Returns true if proxy responds (even with 404), false if unreachable.
|
|
38
|
+
*/
|
|
39
|
+
async function isProxyAccessible(): Promise<boolean> {
|
|
40
|
+
if (!PROXY_URL) return false
|
|
41
|
+
const controller = new AbortController()
|
|
42
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.PROXY_CHECK)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(PROXY_URL, {
|
|
46
|
+
method: 'HEAD',
|
|
47
|
+
signal: controller.signal
|
|
48
|
+
})
|
|
49
|
+
// 404 is ok - means proxy is running but endpoint doesn't exist
|
|
50
|
+
return response.ok || response.status === 404
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timeoutId)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Performs OAuth login flow through the UI.
|
|
60
|
+
* Starts from home page and completes full OAuth authentication.
|
|
61
|
+
*/
|
|
62
|
+
async function performLogin(page: Page, username: string, password: string): Promise<void> {
|
|
63
|
+
// Start at home page
|
|
64
|
+
await page.goto('/')
|
|
65
|
+
|
|
66
|
+
// Click login button and wait for OAuth redirect
|
|
67
|
+
await Promise.all([
|
|
68
|
+
page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
|
|
69
|
+
page.click('[data-testid="login-button"]')
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// Fill email
|
|
73
|
+
const emailInput = page.locator('#authentication--input__email')
|
|
74
|
+
await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
75
|
+
await emailInput.fill(username)
|
|
76
|
+
|
|
77
|
+
// Click next
|
|
78
|
+
await page.getByRole('button', { name: 'Next' }).click()
|
|
79
|
+
|
|
80
|
+
// Fill password
|
|
81
|
+
const passwordInput = page.locator('#authentication--input__password')
|
|
82
|
+
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
|
|
83
|
+
await passwordInput.fill(password)
|
|
84
|
+
|
|
85
|
+
// Click sign in - use OR selector for robustness
|
|
86
|
+
await page.locator('#next, button:has-text("Sign in")').first().click()
|
|
87
|
+
|
|
88
|
+
// Wait for redirect back to the app using configured baseURL
|
|
89
|
+
const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
90
|
+
await page.waitForURL(baseURLPattern, { timeout: TIMEOUTS.AUTH_COMPLETE })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clears browser storage to reset auth state.
|
|
95
|
+
* Handles cases where localStorage isn't accessible (e.g., about:blank, cross-origin).
|
|
96
|
+
*/
|
|
97
|
+
async function clearAuthState(page: Page): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
// Only try to clear storage if we're on a page that allows it
|
|
100
|
+
const url = page.url()
|
|
101
|
+
if (url && url.startsWith('http')) {
|
|
102
|
+
await page.evaluate(() => {
|
|
103
|
+
try {
|
|
104
|
+
localStorage.clear()
|
|
105
|
+
sessionStorage.clear()
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore errors - storage may not be accessible
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore errors - page may be closed or in an inaccessible state
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test.describe('Vue Layouts Example', () => {
|
|
117
|
+
// Check proxy accessibility once before all tests
|
|
118
|
+
let proxyAccessible = false
|
|
119
|
+
|
|
120
|
+
// Helper functions to skip tests when prerequisites aren't met
|
|
121
|
+
function skipIfNoProxy() {
|
|
122
|
+
test.skip(!proxyAccessible, 'OAuth proxy not accessible')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function skipIfNoCredentials() {
|
|
126
|
+
test.skip(!TEST_USER || !TEST_PASSWORD, 'Test credentials not available')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function skipIfNoUser() {
|
|
130
|
+
test.skip(!TEST_USER, 'Test user not available')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test.beforeAll(async () => {
|
|
134
|
+
proxyAccessible = await isProxyAccessible()
|
|
135
|
+
if (!proxyAccessible) {
|
|
136
|
+
console.log('OAuth proxy not accessible - OAuth tests will be skipped')
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test.afterEach(async ({ page }) => {
|
|
141
|
+
// Clear auth state after each test to prevent state pollution
|
|
142
|
+
await clearAuthState(page)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('shows login button when not authenticated', async ({ page }) => {
|
|
146
|
+
await page.goto('/')
|
|
147
|
+
await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible()
|
|
148
|
+
await expect(page.locator('[data-testid="nav-login"]')).toBeVisible()
|
|
149
|
+
await expect(page.locator('[data-testid="nav-logout"]')).not.toBeVisible()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('layouts page shows not-authenticated state without login', async ({ page }) => {
|
|
153
|
+
await page.goto('/layouts')
|
|
154
|
+
await expect(
|
|
155
|
+
page.locator('[data-testid="not-authenticated"], [data-testid="nav-login"], .error, .auth-required').first()
|
|
156
|
+
).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('login button redirects to OAuth page', async ({ page }) => {
|
|
160
|
+
skipIfNoProxy()
|
|
161
|
+
skipIfNoCredentials()
|
|
162
|
+
|
|
163
|
+
await page.goto('/')
|
|
164
|
+
await expect(page.locator('[data-testid="login-button"]')).toBeVisible()
|
|
165
|
+
await expect(page.locator('[data-testid="login-button"]')).toBeEnabled()
|
|
166
|
+
|
|
167
|
+
// Click login and verify redirect to OAuth page
|
|
168
|
+
await Promise.all([
|
|
169
|
+
page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
|
|
170
|
+
page.click('[data-testid="login-button"]')
|
|
171
|
+
])
|
|
172
|
+
|
|
173
|
+
// Verify we're on the OAuth page
|
|
174
|
+
const emailInput = page.locator('#authentication--input__email')
|
|
175
|
+
await expect(emailInput).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('complete OAuth login flow', async ({ page }) => {
|
|
179
|
+
skipIfNoProxy()
|
|
180
|
+
skipIfNoCredentials()
|
|
181
|
+
|
|
182
|
+
// Verify initially not authenticated
|
|
183
|
+
await page.goto('/')
|
|
184
|
+
await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible()
|
|
185
|
+
|
|
186
|
+
// Perform login
|
|
187
|
+
await performLogin(page, TEST_USER!, TEST_PASSWORD!)
|
|
188
|
+
|
|
189
|
+
// Verify authenticated state
|
|
190
|
+
await expect(page.locator('[data-testid="not-authenticated"]')).not.toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
191
|
+
await expect(page.locator('[data-testid="nav-layouts"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
192
|
+
await expect(page.locator('[data-testid="nav-logout"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
193
|
+
await expect(page.locator('[data-testid="nav-login"]')).not.toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('can view layouts list after login', async ({ page }) => {
|
|
197
|
+
skipIfNoProxy()
|
|
198
|
+
skipIfNoCredentials()
|
|
199
|
+
|
|
200
|
+
await performLogin(page, TEST_USER!, TEST_PASSWORD!)
|
|
201
|
+
await expect(page.locator('[data-testid="nav-layouts"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
202
|
+
|
|
203
|
+
// Navigate to layouts page
|
|
204
|
+
await page.click('[data-testid="nav-layouts"]')
|
|
205
|
+
await page.waitForURL('/layouts')
|
|
206
|
+
|
|
207
|
+
// Should see layouts header and create button (not error state)
|
|
208
|
+
await expect(page.locator('.layouts .header h2')).toHaveText('Layouts')
|
|
209
|
+
await expect(page.getByRole('button', { name: 'Create Layout' })).toBeVisible()
|
|
210
|
+
await expect(page.locator('.error')).not.toBeVisible()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('can logout after login', async ({ page }) => {
|
|
214
|
+
skipIfNoProxy()
|
|
215
|
+
skipIfNoCredentials()
|
|
216
|
+
|
|
217
|
+
await performLogin(page, TEST_USER!, TEST_PASSWORD!)
|
|
218
|
+
await expect(page.locator('[data-testid="nav-logout"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
219
|
+
|
|
220
|
+
// Click logout
|
|
221
|
+
await page.click('[data-testid="nav-logout"]')
|
|
222
|
+
|
|
223
|
+
// Should show not authenticated - wait for redirect to app baseURL
|
|
224
|
+
const baseURLPattern = new RegExp(baseURL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
225
|
+
await page.waitForURL(baseURLPattern)
|
|
226
|
+
await expect(page.locator('[data-testid="not-authenticated"]')).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
227
|
+
await expect(page.locator('[data-testid="nav-login"]')).toBeVisible()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('invalid password shows error on OAuth page', async ({ page }) => {
|
|
231
|
+
skipIfNoProxy()
|
|
232
|
+
skipIfNoUser()
|
|
233
|
+
|
|
234
|
+
await page.goto('/')
|
|
235
|
+
|
|
236
|
+
// Click login and wait for OAuth redirect
|
|
237
|
+
await Promise.all([
|
|
238
|
+
page.waitForURL(/.*eagleeyenetworks.com.*/, { timeout: TIMEOUTS.OAUTH_REDIRECT }),
|
|
239
|
+
page.click('[data-testid="login-button"]')
|
|
240
|
+
])
|
|
241
|
+
|
|
242
|
+
// Fill valid email
|
|
243
|
+
const emailInput = page.locator('#authentication--input__email')
|
|
244
|
+
await emailInput.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
245
|
+
await emailInput.fill(TEST_USER!)
|
|
246
|
+
await page.getByRole('button', { name: 'Next' }).click()
|
|
247
|
+
|
|
248
|
+
// Fill invalid password
|
|
249
|
+
const passwordInput = page.locator('#authentication--input__password')
|
|
250
|
+
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.PASSWORD_VISIBLE })
|
|
251
|
+
await passwordInput.fill('invalid-password-12345!')
|
|
252
|
+
|
|
253
|
+
// Click sign in
|
|
254
|
+
await page.locator('#next, button:has-text("Sign in")').first().click()
|
|
255
|
+
|
|
256
|
+
// Should show error message on OAuth page
|
|
257
|
+
await expect(
|
|
258
|
+
page.locator('.error, [class*="error"], [data-testid*="error"], #error, .alert-danger').first()
|
|
259
|
+
).toBeVisible({ timeout: TIMEOUTS.UI_UPDATE })
|
|
260
|
+
|
|
261
|
+
// Should still be on OAuth page
|
|
262
|
+
await expect(page).toHaveURL(/eagleeyenetworks\.com/)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>EEN Layouts Example</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
Binary file
|