een-api-toolkit 0.3.30 → 0.3.35

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.
Files changed (68) hide show
  1. package/.claude/agents/docs-accuracy-reviewer.md +15 -3
  2. package/.claude/agents/een-auth-agent.md +131 -0
  3. package/.claude/agents/een-devices-agent.md +10 -7
  4. package/.claude/agents/een-events-agent.md +98 -0
  5. package/.claude/agents/een-grouping-agent.md +394 -0
  6. package/.claude/agents/een-media-agent.md +25 -5
  7. package/CHANGELOG.md +101 -6
  8. package/README.md +5 -3
  9. package/dist/index.cjs +3 -3
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.ts +561 -0
  12. package/dist/index.js +388 -218
  13. package/dist/index.js.map +1 -1
  14. package/docs/AI-CONTEXT.md +13 -1
  15. package/docs/ai-reference/AI-AUTH.md +1 -1
  16. package/docs/ai-reference/AI-DEVICES.md +1 -1
  17. package/docs/ai-reference/AI-EVENTS.md +1 -1
  18. package/docs/ai-reference/AI-GROUPING.md +411 -0
  19. package/docs/ai-reference/AI-MEDIA.md +1 -1
  20. package/docs/ai-reference/AI-SETUP.md +1 -1
  21. package/docs/ai-reference/AI-USERS.md +1 -1
  22. package/examples/vue-alerts-metrics/README.md +2 -0
  23. package/examples/vue-alerts-metrics/alert-metrics-screenshot.png +0 -0
  24. package/examples/vue-alerts-metrics/e2e/auth.spec.ts +1 -1
  25. package/examples/vue-alerts-metrics/package-lock.json +17 -14
  26. package/examples/vue-alerts-metrics/package.json +1 -1
  27. package/examples/vue-bridges/package-lock.json +21 -15
  28. package/examples/vue-bridges/package.json +1 -1
  29. package/examples/vue-cameras/package-lock.json +21 -15
  30. package/examples/vue-cameras/package.json +1 -1
  31. package/examples/vue-event-subscriptions/README.md +2 -0
  32. package/examples/vue-event-subscriptions/event-subscriptions-screenshot.png +0 -0
  33. package/examples/vue-event-subscriptions/package-lock.json +17 -14
  34. package/examples/vue-event-subscriptions/package.json +1 -1
  35. package/examples/vue-events/events-screenshot.png +0 -0
  36. package/examples/vue-events/package-lock.json +17 -14
  37. package/examples/vue-events/package.json +1 -1
  38. package/examples/vue-feeds/package-lock.json +21 -15
  39. package/examples/vue-feeds/package.json +1 -1
  40. package/examples/vue-layouts/.env.example +12 -0
  41. package/examples/vue-layouts/README.md +320 -0
  42. package/examples/vue-layouts/e2e/app.spec.ts +76 -0
  43. package/examples/vue-layouts/e2e/auth.spec.ts +264 -0
  44. package/examples/vue-layouts/index.html +13 -0
  45. package/examples/vue-layouts/layouts-screenshot.png +0 -0
  46. package/examples/vue-layouts/package-lock.json +1722 -0
  47. package/examples/vue-layouts/package.json +28 -0
  48. package/examples/vue-layouts/playwright.config.ts +47 -0
  49. package/examples/vue-layouts/src/App.vue +124 -0
  50. package/examples/vue-layouts/src/components/LayoutModal.vue +456 -0
  51. package/examples/vue-layouts/src/main.ts +25 -0
  52. package/examples/vue-layouts/src/router/index.ts +62 -0
  53. package/examples/vue-layouts/src/views/Callback.vue +76 -0
  54. package/examples/vue-layouts/src/views/Home.vue +188 -0
  55. package/examples/vue-layouts/src/views/Layouts.vue +355 -0
  56. package/examples/vue-layouts/src/views/Login.vue +33 -0
  57. package/examples/vue-layouts/src/views/Logout.vue +59 -0
  58. package/examples/vue-layouts/src/vite-env.d.ts +12 -0
  59. package/examples/vue-layouts/tsconfig.json +21 -0
  60. package/examples/vue-layouts/tsconfig.node.json +10 -0
  61. package/examples/vue-layouts/vite.config.ts +12 -0
  62. package/examples/vue-media/media-screenshot.png +0 -0
  63. package/examples/vue-media/package-lock.json +19 -14
  64. package/examples/vue-media/package.json +1 -1
  65. package/examples/vue-users/package-lock.json +21 -16
  66. package/examples/vue-users/package.json +2 -2
  67. package/package.json +2 -2
  68. package/scripts/setup-agents.ts +0 -0
@@ -0,0 +1,62 @@
1
+ import { createRouter, createWebHistory } from 'vue-router'
2
+ import { useAuthStore } from 'een-api-toolkit'
3
+ import Home from '../views/Home.vue'
4
+ import Login from '../views/Login.vue'
5
+ import Callback from '../views/Callback.vue'
6
+ import Layouts from '../views/Layouts.vue'
7
+ import Logout from '../views/Logout.vue'
8
+
9
+ const router = createRouter({
10
+ history: createWebHistory(),
11
+ routes: [
12
+ {
13
+ path: '/',
14
+ name: 'home',
15
+ component: Home
16
+ },
17
+ {
18
+ path: '/login',
19
+ name: 'login',
20
+ component: Login
21
+ },
22
+ {
23
+ path: '/callback',
24
+ name: 'callback',
25
+ component: Callback
26
+ },
27
+ {
28
+ path: '/layouts',
29
+ name: 'layouts',
30
+ component: Layouts,
31
+ meta: { requiresAuth: true }
32
+ },
33
+ {
34
+ path: '/logout',
35
+ name: 'logout',
36
+ component: Logout
37
+ }
38
+ ]
39
+ })
40
+
41
+ // Navigation guard for OAuth callback and protected routes
42
+ // IMPORTANT: OAuth callback check MUST come FIRST, before auth check
43
+ // This ensures the callback is processed even if the root route becomes protected
44
+ router.beforeEach((to, _from, next) => {
45
+ // Handle OAuth callback on root path (EEN IDP redirects to http://127.0.0.1:3333)
46
+ // Check for code and state params which indicate an OAuth callback
47
+ if (to.path === '/' && to.query.code && to.query.state) {
48
+ next({ name: 'callback', query: to.query })
49
+ return
50
+ }
51
+
52
+ // Check authentication for protected routes
53
+ const authStore = useAuthStore()
54
+
55
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
56
+ next({ name: 'login' })
57
+ } else {
58
+ next()
59
+ }
60
+ })
61
+
62
+ export default router
@@ -0,0 +1,76 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { handleAuthCallback } from 'een-api-toolkit'
5
+
6
+ const router = useRouter()
7
+ const error = ref<string | null>(null)
8
+ const processing = ref(true)
9
+
10
+ onMounted(async () => {
11
+ const url = new URL(window.location.href)
12
+ const code = url.searchParams.get('code')
13
+ const state = url.searchParams.get('state')
14
+ const errorParam = url.searchParams.get('error')
15
+
16
+ if (errorParam) {
17
+ error.value = `OAuth error: ${errorParam}`
18
+ processing.value = false
19
+ return
20
+ }
21
+
22
+ if (!code || !state) {
23
+ error.value = 'Missing authorization code or state parameter'
24
+ processing.value = false
25
+ return
26
+ }
27
+
28
+ const result = await handleAuthCallback(code, state)
29
+
30
+ if (result.error) {
31
+ error.value = result.error.message
32
+ processing.value = false
33
+ return
34
+ }
35
+
36
+ // Success - redirect to home
37
+ router.push('/')
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <div class="callback">
43
+ <div v-if="processing" class="loading">
44
+ <h2>Authenticating...</h2>
45
+ <p>Please wait while we complete the login process.</p>
46
+ </div>
47
+
48
+ <div v-else-if="error" class="error-state">
49
+ <h2>Authentication Failed</h2>
50
+ <p class="error">{{ error }}</p>
51
+ <router-link to="/login">
52
+ <button>Try Again</button>
53
+ </router-link>
54
+ </div>
55
+ </div>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .callback {
60
+ text-align: center;
61
+ max-width: 400px;
62
+ margin: 0 auto;
63
+ }
64
+
65
+ h2 {
66
+ margin-bottom: 20px;
67
+ }
68
+
69
+ .loading p {
70
+ color: #666;
71
+ }
72
+
73
+ .error-state .error {
74
+ margin-bottom: 20px;
75
+ }
76
+ </style>
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { useAuthStore, getCurrentUser, getAuthUrl, getStorageStrategy, STORAGE_STRATEGY_DESCRIPTIONS, type UserProfile, type EenError } from 'een-api-toolkit'
3
+ import { computed, ref, watch } from 'vue'
4
+
5
+ const authStore = useAuthStore()
6
+ const isAuthenticated = computed(() => authStore.isAuthenticated)
7
+ const loginError = ref<string | null>(null)
8
+
9
+ const storageStrategy = getStorageStrategy()
10
+ const storageDescription = STORAGE_STRATEGY_DESCRIPTIONS[storageStrategy]
11
+
12
+ function login() {
13
+ try {
14
+ loginError.value = null
15
+ const authUrl = getAuthUrl()
16
+ window.location.href = authUrl
17
+ } catch (err) {
18
+ loginError.value = err instanceof Error ? err.message : 'Failed to initiate login'
19
+ console.error('Login error:', err)
20
+ }
21
+ }
22
+
23
+ // Reactive state for current user
24
+ const user = ref<UserProfile | null>(null)
25
+ const loading = ref(false)
26
+ const error = ref<EenError | null>(null)
27
+
28
+ // Guard to prevent concurrent fetch calls
29
+ let fetchInProgress = false
30
+
31
+ async function fetchUser() {
32
+ if (fetchInProgress) return
33
+ fetchInProgress = true
34
+ loading.value = true
35
+ error.value = null
36
+
37
+ try {
38
+ const result = await getCurrentUser()
39
+ if (result.error) {
40
+ error.value = result.error
41
+ user.value = null
42
+ } else {
43
+ user.value = result.data
44
+ }
45
+ } finally {
46
+ loading.value = false
47
+ fetchInProgress = false
48
+ }
49
+ }
50
+
51
+ // Fetch user when authentication state changes
52
+ watch(
53
+ isAuthenticated,
54
+ async (isAuth) => {
55
+ if (isAuth && !user.value && !fetchInProgress) {
56
+ await fetchUser()
57
+ }
58
+ },
59
+ { immediate: true }
60
+ )
61
+ </script>
62
+
63
+ <template>
64
+ <div class="home">
65
+ <h2>Welcome to the EEN Layouts Example</h2>
66
+
67
+ <div v-if="!isAuthenticated" class="not-authenticated" data-testid="not-authenticated">
68
+ <p data-testid="not-authenticated-message">You are not logged in.</p>
69
+ <p v-if="loginError" class="error" data-testid="login-error">{{ loginError }}</p>
70
+ <button data-testid="login-button" @click="login">Login with Eagle Eye Networks</button>
71
+ </div>
72
+
73
+ <div v-else class="authenticated">
74
+ <div v-if="loading" class="loading">Loading user profile...</div>
75
+ <div v-else-if="error" class="error">Error: {{ error.message }}</div>
76
+ <div v-else-if="user" class="user-info">
77
+ <h3>Hello, {{ user.firstName }} {{ user.lastName }}!</h3>
78
+ <p>Email: {{ user.email }}</p>
79
+ <p>Account ID: {{ user.accountId }}</p>
80
+ </div>
81
+
82
+ <div class="actions">
83
+ <router-link to="/layouts">
84
+ <button>View Layouts</button>
85
+ </router-link>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="description">
90
+ <h3>About This Example</h3>
91
+ <p>
92
+ This example demonstrates how to use the <code>getLayouts</code>,
93
+ <code>getLayout</code>, <code>createLayout</code>, <code>updateLayout</code>,
94
+ and <code>deleteLayout</code> functions from the EEN API Toolkit to manage
95
+ camera layouts from the Eagle Eye Networks platform.
96
+ </p>
97
+ <h4>Features</h4>
98
+ <ul>
99
+ <li>List layouts with pagination</li>
100
+ <li>Create new layouts with settings</li>
101
+ <li>Edit layout name and settings</li>
102
+ <li>Add/remove camera panes</li>
103
+ <li>Delete layouts</li>
104
+ <li>OAuth authentication flow</li>
105
+ </ul>
106
+ <p class="storage-note" data-testid="storage-strategy">
107
+ Storage strategy: <strong>{{ storageStrategy }}</strong> ({{ storageDescription }})
108
+ </p>
109
+ </div>
110
+ </div>
111
+ </template>
112
+
113
+ <style scoped>
114
+ .home {
115
+ text-align: center;
116
+ }
117
+
118
+ h2 {
119
+ margin-bottom: 30px;
120
+ }
121
+
122
+ .not-authenticated,
123
+ .authenticated {
124
+ margin-top: 20px;
125
+ }
126
+
127
+ .user-info {
128
+ background: #f5f5f5;
129
+ padding: 20px;
130
+ border-radius: 8px;
131
+ margin-bottom: 20px;
132
+ }
133
+
134
+ .user-info h3 {
135
+ margin-bottom: 10px;
136
+ }
137
+
138
+ .user-info p {
139
+ color: #666;
140
+ }
141
+
142
+ .actions {
143
+ margin-top: 20px;
144
+ }
145
+
146
+ .description {
147
+ margin-top: 40px;
148
+ padding: 20px;
149
+ background: #f8f9fa;
150
+ border-radius: 8px;
151
+ text-align: left;
152
+ }
153
+
154
+ .description h3 {
155
+ margin-bottom: 10px;
156
+ }
157
+
158
+ .description h4 {
159
+ margin-top: 15px;
160
+ margin-bottom: 10px;
161
+ }
162
+
163
+ .description p {
164
+ color: #666;
165
+ margin-bottom: 15px;
166
+ }
167
+
168
+ .description ul {
169
+ list-style: disc;
170
+ padding-left: 20px;
171
+ color: #666;
172
+ }
173
+
174
+ .description code {
175
+ background: #e9ecef;
176
+ padding: 2px 6px;
177
+ border-radius: 3px;
178
+ font-size: 0.9em;
179
+ }
180
+
181
+ .storage-note {
182
+ margin-top: 20px;
183
+ padding-top: 15px;
184
+ border-top: 1px solid #ddd;
185
+ font-size: 0.85em;
186
+ color: #888;
187
+ }
188
+ </style>
@@ -0,0 +1,355 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import {
4
+ getLayouts,
5
+ getCameras,
6
+ createLayout,
7
+ updateLayout,
8
+ deleteLayout,
9
+ type Layout,
10
+ type Camera,
11
+ type EenError,
12
+ type ListLayoutsParams,
13
+ type CreateLayoutParams,
14
+ type UpdateLayoutParams,
15
+ type LayoutSettings
16
+ } from 'een-api-toolkit'
17
+ import LayoutModal from '../components/LayoutModal.vue'
18
+
19
+ // Reactive state
20
+ const layouts = ref<Layout[]>([])
21
+ const cameras = ref<Camera[]>([])
22
+ const loading = ref(false)
23
+ const error = ref<EenError | null>(null)
24
+ const nextPageToken = ref<string | undefined>(undefined)
25
+
26
+ const hasNextPage = computed(() => !!nextPageToken.value)
27
+
28
+ const params = ref<ListLayoutsParams>({
29
+ pageSize: 20,
30
+ include: ['resourceCounts', 'effectivePermissions']
31
+ })
32
+
33
+ // Modal state
34
+ const showModal = ref(false)
35
+ const selectedLayout = ref<Layout | null>(null)
36
+ const modalLoading = ref(false)
37
+ const modalError = ref<string | null>(null)
38
+
39
+ // Default settings for new layouts
40
+ const defaultSettings: LayoutSettings = {
41
+ showCameraBorder: true,
42
+ showCameraName: true,
43
+ cameraAspectRatio: '16x9',
44
+ paneColumns: 3
45
+ }
46
+
47
+ async function fetchLayouts(fetchParams?: ListLayoutsParams, append = false) {
48
+ loading.value = true
49
+ error.value = null
50
+
51
+ const mergedParams = { ...params.value, ...fetchParams }
52
+ const result = await getLayouts(mergedParams)
53
+
54
+ if (result.error) {
55
+ error.value = result.error
56
+ if (!append) {
57
+ layouts.value = []
58
+ }
59
+ nextPageToken.value = undefined
60
+ } else {
61
+ if (append) {
62
+ layouts.value = [...layouts.value, ...result.data.results]
63
+ } else {
64
+ layouts.value = result.data.results
65
+ }
66
+ nextPageToken.value = result.data.nextPageToken
67
+ }
68
+
69
+ loading.value = false
70
+ return result
71
+ }
72
+
73
+ async function fetchCameras() {
74
+ const result = await getCameras({ pageSize: 100, include: ['status'] })
75
+ if (result.data) {
76
+ cameras.value = result.data.results
77
+ }
78
+ }
79
+
80
+ function refresh() {
81
+ return fetchLayouts()
82
+ }
83
+
84
+ async function fetchNextPage() {
85
+ if (!nextPageToken.value) return
86
+ // Destructure to explicitly exclude any existing pageToken from params
87
+ const { pageToken: _existingToken, ...restParams } = params.value
88
+ return fetchLayouts({ ...restParams, pageToken: nextPageToken.value }, true)
89
+ }
90
+
91
+ function openCreateModal() {
92
+ selectedLayout.value = null
93
+ modalError.value = null
94
+ showModal.value = true
95
+ }
96
+
97
+ function openEditModal(layout: Layout) {
98
+ selectedLayout.value = layout
99
+ modalError.value = null
100
+ showModal.value = true
101
+ }
102
+
103
+ function closeModal() {
104
+ showModal.value = false
105
+ selectedLayout.value = null
106
+ modalError.value = null
107
+ }
108
+
109
+ async function handleSave(data: { name: string; settings: LayoutSettings; panes: Layout['panes'] }) {
110
+ modalLoading.value = true
111
+ modalError.value = null
112
+
113
+ try {
114
+ if (selectedLayout.value) {
115
+ // Update existing layout
116
+ const updateParams: UpdateLayoutParams = {
117
+ name: data.name,
118
+ settings: data.settings,
119
+ panes: data.panes
120
+ }
121
+
122
+ const result = await updateLayout(selectedLayout.value.id, updateParams)
123
+ if (result.error) {
124
+ modalError.value = result.error.message
125
+ return
126
+ }
127
+ } else {
128
+ // Create new layout
129
+ const createParams: CreateLayoutParams = {
130
+ name: data.name,
131
+ settings: data.settings,
132
+ panes: data.panes
133
+ }
134
+
135
+ const result = await createLayout(createParams)
136
+ if (result.error) {
137
+ modalError.value = result.error.message
138
+ return
139
+ }
140
+ }
141
+
142
+ closeModal()
143
+ await fetchLayouts()
144
+ } catch (err) {
145
+ // Handle unexpected errors (network failures, state mutations, etc.)
146
+ modalError.value = err instanceof Error ? err.message : 'An unexpected error occurred'
147
+ console.error('handleSave error:', err)
148
+ } finally {
149
+ modalLoading.value = false
150
+ }
151
+ }
152
+
153
+ async function handleDelete(layoutId: string) {
154
+ if (!confirm('Are you sure you want to delete this layout?')) {
155
+ return
156
+ }
157
+
158
+ modalLoading.value = true
159
+ modalError.value = null
160
+
161
+ const result = await deleteLayout(layoutId)
162
+
163
+ if (result.error) {
164
+ modalError.value = result.error.message
165
+ modalLoading.value = false
166
+ return
167
+ }
168
+
169
+ closeModal()
170
+ await fetchLayouts()
171
+ modalLoading.value = false
172
+ }
173
+
174
+ onMounted(async () => {
175
+ await Promise.all([fetchLayouts(), fetchCameras()])
176
+ })
177
+ </script>
178
+
179
+ <template>
180
+ <div class="layouts">
181
+ <div class="header">
182
+ <h2>Layouts</h2>
183
+ <div class="controls">
184
+ <button @click="openCreateModal">Create Layout</button>
185
+ <button class="secondary" @click="refresh" :disabled="loading">
186
+ {{ loading ? 'Loading...' : 'Refresh' }}
187
+ </button>
188
+ </div>
189
+ </div>
190
+
191
+ <div v-if="loading && layouts.length === 0" class="loading">
192
+ Loading layouts...
193
+ </div>
194
+
195
+ <div v-else-if="error" class="error">
196
+ Error: {{ error.message }}
197
+ </div>
198
+
199
+ <div v-else>
200
+ <div v-if="layouts.length > 0" class="layout-grid">
201
+ <div
202
+ v-for="layout in layouts"
203
+ :key="layout.id"
204
+ class="layout-card"
205
+ @click="openEditModal(layout)"
206
+ >
207
+ <div class="layout-header">
208
+ <h3>{{ layout.name }}</h3>
209
+ <span class="pane-count">{{ layout.panes?.length || 0 }} panes</span>
210
+ </div>
211
+ <div class="layout-details">
212
+ <p>
213
+ <strong>Columns:</strong> {{ layout.settings?.paneColumns || 'N/A' }}
214
+ </p>
215
+ <p>
216
+ <strong>Aspect Ratio:</strong> {{ layout.settings?.cameraAspectRatio || 'N/A' }}
217
+ </p>
218
+ <p v-if="layout.resourceCounts?.cameras !== undefined">
219
+ <strong>Cameras:</strong> {{ layout.resourceCounts.cameras }}
220
+ </p>
221
+ <div v-if="layout.effectivePermissions" class="permissions">
222
+ <span v-if="layout.effectivePermissions.edit" class="badge edit">Can Edit</span>
223
+ <span v-if="layout.effectivePermissions.delete" class="badge delete">Can Delete</span>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <p v-else class="no-layouts">
230
+ No layouts found. Click "Create Layout" to add one.
231
+ </p>
232
+
233
+ <div v-if="hasNextPage" class="pagination">
234
+ <button @click="fetchNextPage" :disabled="loading">
235
+ {{ loading ? 'Loading...' : 'Load More' }}
236
+ </button>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- Layout Modal -->
241
+ <LayoutModal
242
+ v-if="showModal"
243
+ :layout="selectedLayout"
244
+ :cameras="cameras"
245
+ :loading="modalLoading"
246
+ :error="modalError"
247
+ :default-settings="defaultSettings"
248
+ @save="handleSave"
249
+ @delete="handleDelete"
250
+ @close="closeModal"
251
+ />
252
+ </div>
253
+ </template>
254
+
255
+ <style scoped>
256
+ .layouts {
257
+ max-width: 1000px;
258
+ margin: 0 auto;
259
+ }
260
+
261
+ .header {
262
+ display: flex;
263
+ justify-content: space-between;
264
+ align-items: center;
265
+ margin-bottom: 20px;
266
+ }
267
+
268
+ .controls {
269
+ display: flex;
270
+ gap: 10px;
271
+ }
272
+
273
+ .layout-grid {
274
+ display: grid;
275
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
276
+ gap: 20px;
277
+ }
278
+
279
+ .layout-card {
280
+ background: #fff;
281
+ border: 1px solid #e0e0e0;
282
+ border-radius: 8px;
283
+ padding: 16px;
284
+ cursor: pointer;
285
+ transition: box-shadow 0.2s, transform 0.2s;
286
+ }
287
+
288
+ .layout-card:hover {
289
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
290
+ transform: translateY(-2px);
291
+ }
292
+
293
+ .layout-header {
294
+ display: flex;
295
+ justify-content: space-between;
296
+ align-items: flex-start;
297
+ margin-bottom: 12px;
298
+ }
299
+
300
+ .layout-header h3 {
301
+ margin: 0;
302
+ font-size: 1.1rem;
303
+ color: #333;
304
+ }
305
+
306
+ .pane-count {
307
+ background: #e9ecef;
308
+ padding: 4px 8px;
309
+ border-radius: 4px;
310
+ font-size: 0.85rem;
311
+ color: #666;
312
+ }
313
+
314
+ .layout-details p {
315
+ margin: 4px 0;
316
+ font-size: 0.9rem;
317
+ color: #666;
318
+ }
319
+
320
+ .permissions {
321
+ margin-top: 8px;
322
+ display: flex;
323
+ gap: 6px;
324
+ }
325
+
326
+ .badge {
327
+ padding: 2px 8px;
328
+ border-radius: 4px;
329
+ font-size: 0.75rem;
330
+ font-weight: 500;
331
+ }
332
+
333
+ .badge.edit {
334
+ background: #d4edda;
335
+ color: #155724;
336
+ }
337
+
338
+ .badge.delete {
339
+ background: #f8d7da;
340
+ color: #721c24;
341
+ }
342
+
343
+ .no-layouts {
344
+ text-align: center;
345
+ color: #666;
346
+ padding: 40px 20px;
347
+ background: #f8f9fa;
348
+ border-radius: 8px;
349
+ }
350
+
351
+ .pagination {
352
+ margin-top: 20px;
353
+ text-align: center;
354
+ }
355
+ </style>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import { getAuthUrl } from 'een-api-toolkit'
3
+
4
+ function login() {
5
+ // Redirect to EEN OAuth login
6
+ window.location.href = getAuthUrl()
7
+ }
8
+ </script>
9
+
10
+ <template>
11
+ <div class="login">
12
+ <h2 data-testid="login-title">Login</h2>
13
+ <p>Click the button below to login with your Eagle Eye Networks account.</p>
14
+ <button data-testid="login-button" @click="login">Login with Eagle Eye Networks</button>
15
+ </div>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .login {
20
+ text-align: center;
21
+ max-width: 400px;
22
+ margin: 0 auto;
23
+ }
24
+
25
+ h2 {
26
+ margin-bottom: 20px;
27
+ }
28
+
29
+ p {
30
+ margin-bottom: 20px;
31
+ color: #666;
32
+ }
33
+ </style>