een-api-toolkit 0.3.47 → 0.3.49

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 (42) hide show
  1. package/.claude/agents/een-jobs-agent.md +676 -0
  2. package/CHANGELOG.md +7 -8
  3. package/dist/index.cjs +3 -3
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +1172 -28
  6. package/dist/index.js +796 -333
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +22 -1
  9. package/docs/ai-reference/AI-AUTH.md +1 -1
  10. package/docs/ai-reference/AI-AUTOMATIONS.md +1 -1
  11. package/docs/ai-reference/AI-DEVICES.md +1 -1
  12. package/docs/ai-reference/AI-EVENTS.md +1 -1
  13. package/docs/ai-reference/AI-GROUPING.md +1 -1
  14. package/docs/ai-reference/AI-JOBS.md +1084 -0
  15. package/docs/ai-reference/AI-MEDIA.md +1 -1
  16. package/docs/ai-reference/AI-SETUP.md +1 -1
  17. package/docs/ai-reference/AI-USERS.md +1 -1
  18. package/examples/vue-jobs/.env.example +11 -0
  19. package/examples/vue-jobs/README.md +245 -0
  20. package/examples/vue-jobs/e2e/app.spec.ts +79 -0
  21. package/examples/vue-jobs/e2e/auth.spec.ts +382 -0
  22. package/examples/vue-jobs/e2e/delete-features.spec.ts +564 -0
  23. package/examples/vue-jobs/e2e/timelapse.spec.ts +361 -0
  24. package/examples/vue-jobs/index.html +13 -0
  25. package/examples/vue-jobs/package-lock.json +1722 -0
  26. package/examples/vue-jobs/package.json +28 -0
  27. package/examples/vue-jobs/playwright.config.ts +47 -0
  28. package/examples/vue-jobs/src/App.vue +154 -0
  29. package/examples/vue-jobs/src/main.ts +25 -0
  30. package/examples/vue-jobs/src/router/index.ts +82 -0
  31. package/examples/vue-jobs/src/views/Callback.vue +76 -0
  32. package/examples/vue-jobs/src/views/CreateExport.vue +284 -0
  33. package/examples/vue-jobs/src/views/Files.vue +424 -0
  34. package/examples/vue-jobs/src/views/Home.vue +195 -0
  35. package/examples/vue-jobs/src/views/JobDetail.vue +392 -0
  36. package/examples/vue-jobs/src/views/Jobs.vue +297 -0
  37. package/examples/vue-jobs/src/views/Login.vue +33 -0
  38. package/examples/vue-jobs/src/views/Logout.vue +59 -0
  39. package/examples/vue-jobs/src/vite-env.d.ts +1 -0
  40. package/examples/vue-jobs/tsconfig.json +25 -0
  41. package/examples/vue-jobs/vite.config.ts +12 -0
  42. package/package.json +1 -1
@@ -0,0 +1,392 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted, computed } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import { getJob, downloadFile, type Job, type EenError } from 'een-api-toolkit'
5
+
6
+ const route = useRoute()
7
+ const router = useRouter()
8
+
9
+ const job = ref<Job | null>(null)
10
+ const loading = ref(false)
11
+ const error = ref<EenError | null>(null)
12
+ const downloading = ref(false)
13
+ const videoUrl = ref<string | null>(null)
14
+ const loadingVideo = ref(false)
15
+
16
+ // Polling state
17
+ const isPolling = ref(false)
18
+ let pollInterval: ReturnType<typeof setInterval> | null = null
19
+
20
+ const jobId = computed(() => route.params.id as string)
21
+
22
+ // Extract name from nested structure
23
+ const jobName = computed(() => {
24
+ return job.value?.arguments?.originalRequest?.name || '-'
25
+ })
26
+
27
+ // Extract file URL from result (for download)
28
+ const fileUrl = computed(() => {
29
+ return job.value?.result?.intervals?.[0]?.files?.[0]?.url
30
+ })
31
+
32
+ // Extract request timestamps from arguments
33
+ const requestStartTimestamp = computed(() => {
34
+ return job.value?.arguments?.originalRequest?.startTimestamp
35
+ })
36
+
37
+ const requestEndTimestamp = computed(() => {
38
+ return job.value?.arguments?.originalRequest?.endTimestamp
39
+ })
40
+
41
+ const canDownload = computed(() => {
42
+ return job.value?.state === 'success' && fileUrl.value
43
+ })
44
+
45
+ const canPlay = computed(() => {
46
+ // Can play video exports
47
+ return canDownload.value && (job.value?.type === 'video' || job.value?.type === 'export')
48
+ })
49
+
50
+ async function fetchJob() {
51
+ loading.value = true
52
+ error.value = null
53
+
54
+ const result = await getJob(jobId.value)
55
+
56
+ if (result.error) {
57
+ error.value = result.error
58
+ job.value = null
59
+ stopPolling()
60
+ } else {
61
+ job.value = result.data
62
+
63
+ // Auto-start polling if job is pending or started
64
+ if (['pending', 'started'].includes(result.data.state) && !isPolling.value) {
65
+ startPolling()
66
+ }
67
+
68
+ // Stop polling if job is complete
69
+ if (['success', 'failure', 'revoked'].includes(result.data.state)) {
70
+ stopPolling()
71
+ }
72
+ }
73
+
74
+ loading.value = false
75
+ }
76
+
77
+ function startPolling() {
78
+ if (isPolling.value) return
79
+ isPolling.value = true
80
+ pollInterval = setInterval(fetchJob, 3000) // Poll every 3 seconds
81
+ }
82
+
83
+ function stopPolling() {
84
+ if (pollInterval) {
85
+ clearInterval(pollInterval)
86
+ pollInterval = null
87
+ }
88
+ isPolling.value = false
89
+ }
90
+
91
+ async function handleDownload() {
92
+ const url = fileUrl.value
93
+ if (!url) return
94
+
95
+ // Extract file ID from URL (last path segment)
96
+ const urlParts = url.split('/')
97
+ const fileId = urlParts[urlParts.length - 1]
98
+
99
+ downloading.value = true
100
+ const result = await downloadFile(fileId)
101
+
102
+ if (result.error) {
103
+ error.value = result.error
104
+ } else {
105
+ // Create download link
106
+ const blobUrl = URL.createObjectURL(result.data.blob)
107
+ const a = document.createElement('a')
108
+ a.href = blobUrl
109
+ a.download = result.data.filename
110
+ a.click()
111
+ URL.revokeObjectURL(blobUrl)
112
+ }
113
+
114
+ downloading.value = false
115
+ }
116
+
117
+ async function handlePlay() {
118
+ const url = fileUrl.value
119
+ if (!url) return
120
+
121
+ // Extract file ID from URL (last path segment)
122
+ const urlParts = url.split('/')
123
+ const fileId = urlParts[urlParts.length - 1]
124
+
125
+ loadingVideo.value = true
126
+ error.value = null
127
+
128
+ const result = await downloadFile(fileId)
129
+
130
+ if (result.error) {
131
+ error.value = result.error
132
+ } else {
133
+ // Revoke previous URL if exists
134
+ if (videoUrl.value) {
135
+ URL.revokeObjectURL(videoUrl.value)
136
+ }
137
+ // Create blob URL for video playback
138
+ videoUrl.value = URL.createObjectURL(result.data.blob)
139
+ }
140
+
141
+ loadingVideo.value = false
142
+ }
143
+
144
+ function closePlayer() {
145
+ if (videoUrl.value) {
146
+ URL.revokeObjectURL(videoUrl.value)
147
+ videoUrl.value = null
148
+ }
149
+ }
150
+
151
+ function goBack() {
152
+ router.push('/jobs')
153
+ }
154
+
155
+ function formatDate(timestamp: string | undefined) {
156
+ if (!timestamp) return '-'
157
+ return new Date(timestamp).toLocaleString()
158
+ }
159
+
160
+ function getStateBadgeClass(state: string) {
161
+ return `badge badge-${state}`
162
+ }
163
+
164
+ onMounted(() => {
165
+ fetchJob()
166
+ })
167
+
168
+ onUnmounted(() => {
169
+ stopPolling()
170
+ if (videoUrl.value) {
171
+ URL.revokeObjectURL(videoUrl.value)
172
+ }
173
+ })
174
+ </script>
175
+
176
+ <template>
177
+ <div class="job-detail">
178
+ <div class="header">
179
+ <h2>Job Details</h2>
180
+ <button @click="goBack">Back to Jobs</button>
181
+ </div>
182
+
183
+ <div v-if="loading && !job" class="loading">
184
+ Loading job...
185
+ </div>
186
+
187
+ <div v-else-if="error" class="error">
188
+ Error: {{ error.message }}
189
+ </div>
190
+
191
+ <div v-else-if="job" class="job-info">
192
+ <div class="info-card">
193
+ <div class="info-row">
194
+ <span class="label">ID:</span>
195
+ <span class="value">{{ job.id }}</span>
196
+ </div>
197
+ <div class="info-row">
198
+ <span class="label">Name:</span>
199
+ <span class="value">{{ jobName }}</span>
200
+ </div>
201
+ <div class="info-row">
202
+ <span class="label">Type:</span>
203
+ <span class="value">{{ job.type }}</span>
204
+ </div>
205
+ <div class="info-row">
206
+ <span class="label">State:</span>
207
+ <span :class="getStateBadgeClass(job.state)">{{ job.state }}</span>
208
+ </div>
209
+ <div v-if="job.state === 'started'" class="info-row">
210
+ <span class="label">Progress:</span>
211
+ <span class="value">
212
+ <div class="progress-bar">
213
+ <div class="progress-fill" :style="{ width: `${(job.progress || 0) * 100}%` }"></div>
214
+ </div>
215
+ <span class="progress-text">{{ Math.round((job.progress || 0) * 100) }}%</span>
216
+ </span>
217
+ </div>
218
+ <div v-if="job.state === 'failure' && job.error" class="info-row">
219
+ <span class="label">Error:</span>
220
+ <span class="value error-text">{{ job.error }}</span>
221
+ </div>
222
+ <div v-if="fileUrl" class="info-row">
223
+ <span class="label">File:</span>
224
+ <span class="value file-url">{{ job.result?.intervals?.[0]?.files?.[0]?.name || 'Available' }}</span>
225
+ </div>
226
+ <div class="info-row">
227
+ <span class="label">Created:</span>
228
+ <span class="value">{{ formatDate(job.createTimestamp) }}</span>
229
+ </div>
230
+ <div v-if="requestStartTimestamp" class="info-row">
231
+ <span class="label">Period Start:</span>
232
+ <span class="value">{{ formatDate(requestStartTimestamp) }}</span>
233
+ </div>
234
+ <div v-if="requestEndTimestamp" class="info-row">
235
+ <span class="label">Period End:</span>
236
+ <span class="value">{{ formatDate(requestEndTimestamp) }}</span>
237
+ </div>
238
+ <div v-if="job.updateTimestamp" class="info-row">
239
+ <span class="label">Last Updated:</span>
240
+ <span class="value">{{ formatDate(job.updateTimestamp) }}</span>
241
+ </div>
242
+ </div>
243
+
244
+ <div class="actions">
245
+ <button v-if="canPlay" @click="handlePlay" :disabled="loadingVideo || downloading">
246
+ {{ loadingVideo ? 'Loading...' : 'Play Video' }}
247
+ </button>
248
+ <button v-if="canDownload" @click="handleDownload" :disabled="downloading || loadingVideo">
249
+ {{ downloading ? 'Downloading...' : 'Download File' }}
250
+ </button>
251
+ <button @click="fetchJob" :disabled="loading">
252
+ {{ loading ? 'Refreshing...' : 'Refresh' }}
253
+ </button>
254
+ </div>
255
+
256
+ <!-- Video Player -->
257
+ <div v-if="videoUrl" class="video-player">
258
+ <div class="video-header">
259
+ <h3>Video Player</h3>
260
+ <button class="close-player" @click="closePlayer">&times;</button>
261
+ </div>
262
+ <video controls autoplay :src="videoUrl" class="video-element">
263
+ Your browser does not support the video tag.
264
+ </video>
265
+ </div>
266
+
267
+ <div v-if="isPolling" class="polling-notice">
268
+ Auto-refreshing every 3 seconds...
269
+ </div>
270
+ </div>
271
+ </div>
272
+ </template>
273
+
274
+ <style scoped>
275
+ .job-detail {
276
+ width: 80vw;
277
+ min-width: 600px;
278
+ margin: 0 auto;
279
+ }
280
+
281
+ .header {
282
+ display: flex;
283
+ justify-content: space-between;
284
+ align-items: center;
285
+ margin-bottom: 20px;
286
+ }
287
+
288
+ .info-card {
289
+ background: #f5f5f5;
290
+ border-radius: 8px;
291
+ padding: 20px;
292
+ }
293
+
294
+ .info-row {
295
+ display: flex;
296
+ margin-bottom: 12px;
297
+ }
298
+
299
+ .info-row:last-child {
300
+ margin-bottom: 0;
301
+ }
302
+
303
+ .label {
304
+ font-weight: 600;
305
+ width: 30%;
306
+ flex-shrink: 0;
307
+ }
308
+
309
+ .value {
310
+ color: #666;
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 10px;
314
+ }
315
+
316
+ .progress-bar {
317
+ width: 150px;
318
+ height: 10px;
319
+ background: #e0e0e0;
320
+ border-radius: 5px;
321
+ overflow: hidden;
322
+ }
323
+
324
+ .progress-fill {
325
+ height: 100%;
326
+ background: #42b883;
327
+ transition: width 0.3s ease;
328
+ }
329
+
330
+ .progress-text {
331
+ font-weight: 500;
332
+ }
333
+
334
+ .error-text {
335
+ color: #e74c3c;
336
+ }
337
+
338
+ .actions {
339
+ margin-top: 20px;
340
+ display: flex;
341
+ gap: 10px;
342
+ }
343
+
344
+ .polling-notice {
345
+ margin-top: 15px;
346
+ padding: 10px;
347
+ background: #e3f2fd;
348
+ border-radius: 4px;
349
+ color: #1976d2;
350
+ font-size: 0.9rem;
351
+ }
352
+
353
+ .video-player {
354
+ margin-top: 20px;
355
+ background: #000;
356
+ border-radius: 8px;
357
+ overflow: hidden;
358
+ }
359
+
360
+ .video-header {
361
+ display: flex;
362
+ justify-content: space-between;
363
+ align-items: center;
364
+ padding: 10px 15px;
365
+ background: #222;
366
+ }
367
+
368
+ .video-header h3 {
369
+ margin: 0;
370
+ color: white;
371
+ font-size: 1rem;
372
+ }
373
+
374
+ .close-player {
375
+ background: none;
376
+ border: none;
377
+ color: white;
378
+ font-size: 24px;
379
+ cursor: pointer;
380
+ padding: 0;
381
+ line-height: 1;
382
+ }
383
+
384
+ .close-player:hover {
385
+ color: #e74c3c;
386
+ }
387
+
388
+ .video-element {
389
+ width: 100%;
390
+ display: block;
391
+ }
392
+ </style>
@@ -0,0 +1,297 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { listJobs, getCurrentUser, deleteJob, type Job, type EenError, type ListJobsParams, type JobState } from 'een-api-toolkit'
4
+ import { useRouter } from 'vue-router'
5
+
6
+ const router = useRouter()
7
+
8
+ // Reactive state
9
+ const jobs = ref<Job[]>([])
10
+ const loading = ref(false)
11
+ const error = ref<EenError | null>(null)
12
+ const nextPageToken = ref<string | undefined>(undefined)
13
+ const currentUserId = ref<string | null>(null)
14
+ const deletingId = ref<string | null>(null)
15
+
16
+ const hasNextPage = computed(() => !!nextPageToken.value)
17
+
18
+ // Filter state
19
+ const selectedStates = ref<JobState[]>([])
20
+ const stateOptions: { value: JobState; label: string }[] = [
21
+ { value: 'pending', label: 'Pending' },
22
+ { value: 'started', label: 'Started' },
23
+ { value: 'success', label: 'Success' },
24
+ { value: 'failure', label: 'Failure' },
25
+ { value: 'revoked', label: 'Revoked' }
26
+ ]
27
+
28
+ const params = ref<ListJobsParams>({ pageSize: 20 })
29
+
30
+ async function fetchJobs(fetchParams?: ListJobsParams, append = false) {
31
+ loading.value = true
32
+ error.value = null
33
+
34
+ // Get current user ID if not already fetched
35
+ if (!currentUserId.value) {
36
+ const userResult = await getCurrentUser()
37
+ if (userResult.error) {
38
+ error.value = userResult.error
39
+ loading.value = false
40
+ return { error: userResult.error, data: null }
41
+ }
42
+ currentUserId.value = userResult.data.id
43
+ }
44
+
45
+ const mergedParams: ListJobsParams = { ...params.value, ...fetchParams, userId: currentUserId.value }
46
+
47
+ // Add state filter if selected
48
+ if (selectedStates.value.length > 0) {
49
+ mergedParams.state__in = selectedStates.value
50
+ }
51
+
52
+ const result = await listJobs(mergedParams)
53
+
54
+ if (result.error) {
55
+ error.value = result.error
56
+ if (!append) {
57
+ jobs.value = []
58
+ }
59
+ nextPageToken.value = undefined
60
+ } else {
61
+ if (append) {
62
+ jobs.value = [...jobs.value, ...result.data.results]
63
+ } else {
64
+ jobs.value = result.data.results
65
+ }
66
+ nextPageToken.value = result.data.nextPageToken
67
+ }
68
+
69
+ loading.value = false
70
+ return result
71
+ }
72
+
73
+ function refresh() {
74
+ return fetchJobs()
75
+ }
76
+
77
+ async function fetchNextPage() {
78
+ if (!nextPageToken.value) return
79
+ return fetchJobs({ ...params.value, pageToken: nextPageToken.value }, true)
80
+ }
81
+
82
+ function viewJob(jobId: string) {
83
+ router.push(`/jobs/${jobId}`)
84
+ }
85
+
86
+ async function handleDelete(job: Job) {
87
+ const jobName = getJobName(job)
88
+ if (!confirm(`Are you sure you want to delete job "${jobName}"?`)) {
89
+ return
90
+ }
91
+
92
+ deletingId.value = job.id
93
+ const result = await deleteJob(job.id)
94
+
95
+ if (result.error) {
96
+ error.value = result.error
97
+ } else {
98
+ // Remove the job from the list
99
+ jobs.value = jobs.value.filter(j => j.id !== job.id)
100
+ }
101
+
102
+ deletingId.value = null
103
+ }
104
+
105
+ function formatDate(timestamp: string) {
106
+ return new Date(timestamp).toLocaleString()
107
+ }
108
+
109
+ function getStateBadgeClass(state: JobState) {
110
+ return `badge badge-${state}`
111
+ }
112
+
113
+ function getJobName(job: Job) {
114
+ return job.arguments?.originalRequest?.name || job.id
115
+ }
116
+
117
+ onMounted(() => {
118
+ fetchJobs()
119
+ })
120
+ </script>
121
+
122
+ <template>
123
+ <div class="jobs">
124
+ <div class="header">
125
+ <h2>Jobs</h2>
126
+ <button @click="refresh" :disabled="loading">
127
+ {{ loading ? 'Loading...' : 'Refresh' }}
128
+ </button>
129
+ </div>
130
+
131
+ <div class="filters">
132
+ <label>Filter by state:</label>
133
+ <div class="state-checkboxes">
134
+ <label v-for="option in stateOptions" :key="option.value" class="checkbox-label">
135
+ <input
136
+ type="checkbox"
137
+ :value="option.value"
138
+ v-model="selectedStates"
139
+ @change="refresh"
140
+ />
141
+ {{ option.label }}
142
+ </label>
143
+ </div>
144
+ </div>
145
+
146
+ <div v-if="loading && jobs.length === 0" class="loading">
147
+ Loading jobs...
148
+ </div>
149
+
150
+ <div v-else-if="error" class="error">
151
+ Error: {{ error.message }}
152
+ </div>
153
+
154
+ <div v-else>
155
+ <table v-if="jobs.length > 0">
156
+ <thead>
157
+ <tr>
158
+ <th>Name</th>
159
+ <th>Type</th>
160
+ <th>State</th>
161
+ <th>Progress</th>
162
+ <th>Created</th>
163
+ <th>Actions</th>
164
+ </tr>
165
+ </thead>
166
+ <tbody>
167
+ <tr v-for="job in jobs" :key="job.id">
168
+ <td>{{ getJobName(job) }}</td>
169
+ <td>{{ job.type }}</td>
170
+ <td>
171
+ <span :class="getStateBadgeClass(job.state)">
172
+ {{ job.state }}
173
+ </span>
174
+ </td>
175
+ <td>
176
+ <span v-if="job.state === 'started'">{{ Math.round((job.progress || 0) * 100) }}%</span>
177
+ <span v-else-if="job.state === 'success'">Complete</span>
178
+ <span v-else-if="job.state === 'failure'" class="error-text">Failed</span>
179
+ <span v-else>-</span>
180
+ </td>
181
+ <td>{{ formatDate(job.createTimestamp) }}</td>
182
+ <td class="actions">
183
+ <button class="btn-small" @click="viewJob(job.id)" :disabled="deletingId === job.id">View</button>
184
+ <button
185
+ class="btn-small btn-danger"
186
+ @click="handleDelete(job)"
187
+ :disabled="deletingId === job.id"
188
+ >
189
+ {{ deletingId === job.id ? 'Deleting...' : 'Delete' }}
190
+ </button>
191
+ </td>
192
+ </tr>
193
+ </tbody>
194
+ </table>
195
+
196
+ <p v-else>No jobs found.</p>
197
+
198
+ <div v-if="hasNextPage" class="pagination">
199
+ <button @click="fetchNextPage" :disabled="loading">
200
+ {{ loading ? 'Loading...' : 'Load More' }}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </template>
206
+
207
+ <style scoped>
208
+ .jobs {
209
+ width: 80vw;
210
+ min-width: 900px;
211
+ margin: 0 auto;
212
+ }
213
+
214
+ .header {
215
+ display: flex;
216
+ justify-content: space-between;
217
+ align-items: center;
218
+ margin-bottom: 20px;
219
+ }
220
+
221
+ .filters {
222
+ margin-bottom: 20px;
223
+ padding: 15px;
224
+ background: #f5f5f5;
225
+ border-radius: 8px;
226
+ }
227
+
228
+ .filters label {
229
+ font-weight: 500;
230
+ margin-right: 10px;
231
+ }
232
+
233
+ .state-checkboxes {
234
+ display: flex;
235
+ flex-wrap: wrap;
236
+ gap: 15px;
237
+ margin-top: 10px;
238
+ }
239
+
240
+ .checkbox-label {
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 5px;
244
+ font-weight: normal;
245
+ }
246
+
247
+ table {
248
+ width: 100%;
249
+ border-collapse: collapse;
250
+ }
251
+
252
+ th,
253
+ td {
254
+ padding: 12px;
255
+ text-align: left;
256
+ border-bottom: 1px solid #eee;
257
+ }
258
+
259
+ th {
260
+ background: #f5f5f5;
261
+ font-weight: 600;
262
+ }
263
+
264
+ .actions {
265
+ display: flex;
266
+ gap: 8px;
267
+ }
268
+
269
+ .btn-small {
270
+ padding: 5px 10px;
271
+ font-size: 0.85rem;
272
+ }
273
+
274
+ .btn-danger {
275
+ background-color: #e74c3c;
276
+ color: white;
277
+ border: none;
278
+ }
279
+
280
+ .btn-danger:hover:not(:disabled) {
281
+ background-color: #c0392b;
282
+ }
283
+
284
+ .btn-danger:disabled {
285
+ background-color: #f5b7b1;
286
+ cursor: not-allowed;
287
+ }
288
+
289
+ .error-text {
290
+ color: #e74c3c;
291
+ }
292
+
293
+ .pagination {
294
+ margin-top: 20px;
295
+ text-align: center;
296
+ }
297
+ </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>