een-api-toolkit 0.3.16 → 0.3.22

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 (53) hide show
  1. package/.claude/agents/docs-accuracy-reviewer.md +146 -0
  2. package/.claude/agents/een-auth-agent.md +168 -0
  3. package/.claude/agents/een-devices-agent.md +294 -0
  4. package/.claude/agents/een-events-agent.md +375 -0
  5. package/.claude/agents/een-media-agent.md +256 -0
  6. package/.claude/agents/een-setup-agent.md +126 -0
  7. package/.claude/agents/een-users-agent.md +239 -0
  8. package/.claude/agents/test-runner.md +144 -0
  9. package/CHANGELOG.md +151 -10
  10. package/README.md +1 -0
  11. package/dist/index.cjs +3 -1
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.ts +561 -0
  14. package/dist/index.js +483 -260
  15. package/dist/index.js.map +1 -1
  16. package/docs/AI-CONTEXT.md +128 -1648
  17. package/docs/ai-reference/AI-AUTH.md +288 -0
  18. package/docs/ai-reference/AI-DEVICES.md +569 -0
  19. package/docs/ai-reference/AI-EVENTS.md +1745 -0
  20. package/docs/ai-reference/AI-MEDIA.md +974 -0
  21. package/docs/ai-reference/AI-SETUP.md +267 -0
  22. package/docs/ai-reference/AI-USERS.md +255 -0
  23. package/examples/vue-event-subscriptions/.env.example +15 -0
  24. package/examples/vue-event-subscriptions/README.md +103 -0
  25. package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
  26. package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
  27. package/examples/vue-event-subscriptions/index.html +13 -0
  28. package/examples/vue-event-subscriptions/package-lock.json +1726 -0
  29. package/examples/vue-event-subscriptions/package.json +29 -0
  30. package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
  31. package/examples/vue-event-subscriptions/src/App.vue +193 -0
  32. package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
  33. package/examples/vue-event-subscriptions/src/main.ts +25 -0
  34. package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
  35. package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
  36. package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
  37. package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
  38. package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
  39. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +901 -0
  40. package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
  41. package/examples/vue-event-subscriptions/src/views/Logout.vue +65 -0
  42. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +389 -0
  43. package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
  44. package/examples/vue-event-subscriptions/tsconfig.json +21 -0
  45. package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
  46. package/examples/vue-event-subscriptions/vite.config.ts +12 -0
  47. package/examples/vue-events/package-lock.json +8 -1
  48. package/examples/vue-events/package.json +1 -0
  49. package/examples/vue-events/src/components/EventsModal.vue +269 -47
  50. package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
  51. package/examples/vue-events/src/stores/mediaSession.ts +79 -0
  52. package/package.json +10 -2
  53. package/scripts/setup-agents.ts +116 -0
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "vue-event-subscriptions-example",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "stop": "lsof -ti :3333 2>/dev/null | xargs -r kill -9 || echo 'Port 3333 is free'",
8
+ "dev": "npm run stop && vite",
9
+ "build": "vue-tsc && vite build",
10
+ "preview": "vite preview",
11
+ "test:e2e": "playwright test",
12
+ "test:e2e:ui": "playwright test --ui"
13
+ },
14
+ "dependencies": {
15
+ "een-api-toolkit": "file:../..",
16
+ "hls.js": "^1.6.15",
17
+ "pinia": "^3.0.4",
18
+ "vue": "^3.4.0",
19
+ "vue-router": "^4.2.0"
20
+ },
21
+ "devDependencies": {
22
+ "@playwright/test": "^1.57.0",
23
+ "@vitejs/plugin-vue": "^6.0.0",
24
+ "dotenv": "^17.2.3",
25
+ "typescript": "~5.8.0",
26
+ "vite": "^7.3.0",
27
+ "vue-tsc": "^3.2.1"
28
+ }
29
+ }
@@ -0,0 +1,47 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+ import dotenv from 'dotenv'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ // Load .env files: parent first, then local with override to replace any conflicts
9
+ // In CI, env vars are passed directly via workflow
10
+ dotenv.config({ path: path.resolve(__dirname, '../../.env') })
11
+ dotenv.config({ path: path.resolve(__dirname, '.env'), override: true })
12
+
13
+ const redirectUri = process.env.VITE_REDIRECT_URI || 'http://127.0.0.1:3333'
14
+ if (!redirectUri.startsWith('http://127.0.0.1:') && !redirectUri.startsWith('http://localhost:')) {
15
+ throw new Error('VITE_REDIRECT_URI must use localhost or 127.0.0.1 for security')
16
+ }
17
+ export const baseURL = redirectUri
18
+
19
+ export default defineConfig({
20
+ testDir: './e2e',
21
+ testMatch: '**/*.spec.ts',
22
+ fullyParallel: false, // Run tests sequentially for predictable order
23
+ forbidOnly: !!process.env.CI,
24
+ retries: 0, // No retries - fail fast
25
+ maxFailures: 1, // Stop on first failure
26
+ workers: 1,
27
+ reporter: [['html', { open: 'never' }]],
28
+ timeout: 60000, // Longer timeout for SSE operations
29
+ use: {
30
+ baseURL,
31
+ trace: 'on-first-retry',
32
+ video: 'retain-on-failure'
33
+ },
34
+ outputDir: './e2e-results/',
35
+ projects: [
36
+ {
37
+ name: 'chromium',
38
+ use: { ...devices['Desktop Chrome'] }
39
+ }
40
+ ],
41
+ webServer: {
42
+ command: 'npm run dev',
43
+ url: baseURL,
44
+ reuseExistingServer: !process.env.CI,
45
+ timeout: 30000
46
+ }
47
+ })
@@ -0,0 +1,193 @@
1
+ <script setup lang="ts">
2
+ import { useAuthStore } from 'een-api-toolkit'
3
+ import { computed } from 'vue'
4
+ import { useRouter } from 'vue-router'
5
+
6
+ const authStore = useAuthStore()
7
+ const router = useRouter()
8
+
9
+ const isAuthenticated = computed(() => authStore.isAuthenticated)
10
+ const refreshFailed = computed(() => authStore.refreshFailed)
11
+ const refreshFailedMessage = computed(() => authStore.refreshFailedMessage)
12
+ const isRefreshing = computed(() => authStore.isRefreshing)
13
+
14
+ function dismissRefreshError() {
15
+ authStore.clearRefreshFailed()
16
+ }
17
+
18
+ function handleRelogin() {
19
+ authStore.logout()
20
+ router.push('/login')
21
+ }
22
+ </script>
23
+
24
+ <template>
25
+ <div class="app">
26
+ <!-- Session refresh banner -->
27
+ <div v-if="refreshFailed" class="refresh-banner error-banner">
28
+ <span>Session refresh failed: {{ refreshFailedMessage }}</span>
29
+ <div class="banner-actions">
30
+ <button class="small" @click="handleRelogin">Re-login</button>
31
+ <button class="small secondary" @click="dismissRefreshError">Dismiss</button>
32
+ </div>
33
+ </div>
34
+ <div v-else-if="isRefreshing" class="refresh-banner info-banner">
35
+ <span>Refreshing session...</span>
36
+ </div>
37
+
38
+ <header>
39
+ <h1 data-testid="app-title">EEN Event Subscriptions</h1>
40
+ <nav>
41
+ <router-link data-testid="nav-home" to="/">Home</router-link>
42
+ <router-link data-testid="nav-subscriptions" v-if="isAuthenticated" to="/subscriptions">Subscriptions</router-link>
43
+ <router-link data-testid="nav-live" v-if="isAuthenticated" to="/live">Live Events</router-link>
44
+ <router-link data-testid="nav-login" v-if="!isAuthenticated" to="/login">Login</router-link>
45
+ <router-link data-testid="nav-logout" v-if="isAuthenticated" to="/logout">Logout</router-link>
46
+ </nav>
47
+ </header>
48
+ <main>
49
+ <router-view />
50
+ </main>
51
+ </div>
52
+ </template>
53
+
54
+ <style>
55
+ * {
56
+ box-sizing: border-box;
57
+ margin: 0;
58
+ padding: 0;
59
+ }
60
+
61
+ body {
62
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
63
+ Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
64
+ line-height: 1.6;
65
+ color: #333;
66
+ }
67
+
68
+ .app {
69
+ max-width: 1200px;
70
+ margin: 0 auto;
71
+ padding: 20px;
72
+ }
73
+
74
+ header {
75
+ display: flex;
76
+ justify-content: space-between;
77
+ align-items: center;
78
+ margin-bottom: 30px;
79
+ padding-bottom: 20px;
80
+ border-bottom: 1px solid #eee;
81
+ }
82
+
83
+ header h1 {
84
+ font-size: 1.5rem;
85
+ }
86
+
87
+ nav {
88
+ display: flex;
89
+ gap: 20px;
90
+ }
91
+
92
+ nav a {
93
+ color: #42b883;
94
+ text-decoration: none;
95
+ }
96
+
97
+ nav a:hover {
98
+ text-decoration: underline;
99
+ }
100
+
101
+ nav a.router-link-active {
102
+ font-weight: bold;
103
+ }
104
+
105
+ button {
106
+ background: #42b883;
107
+ color: white;
108
+ border: none;
109
+ padding: 10px 20px;
110
+ border-radius: 4px;
111
+ cursor: pointer;
112
+ font-size: 1rem;
113
+ }
114
+
115
+ button:hover {
116
+ background: #3aa876;
117
+ }
118
+
119
+ button:disabled {
120
+ background: #ccc;
121
+ cursor: not-allowed;
122
+ }
123
+
124
+ button.danger {
125
+ background: #e74c3c;
126
+ }
127
+
128
+ button.danger:hover {
129
+ background: #c0392b;
130
+ }
131
+
132
+ button.secondary {
133
+ background: #95a5a6;
134
+ }
135
+
136
+ button.secondary:hover {
137
+ background: #7f8c8d;
138
+ }
139
+
140
+ .error {
141
+ color: #e74c3c;
142
+ padding: 10px;
143
+ background: #fdf2f2;
144
+ border-radius: 4px;
145
+ margin: 10px 0;
146
+ }
147
+
148
+ .loading {
149
+ color: #666;
150
+ font-style: italic;
151
+ }
152
+
153
+ .success {
154
+ color: #27ae60;
155
+ padding: 10px;
156
+ background: #eafaf1;
157
+ border-radius: 4px;
158
+ margin: 10px 0;
159
+ }
160
+
161
+ /* Session refresh banner */
162
+ .refresh-banner {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ padding: 12px 20px;
167
+ margin-bottom: 15px;
168
+ border-radius: 4px;
169
+ font-size: 14px;
170
+ }
171
+
172
+ .error-banner {
173
+ background: #fdf2f2;
174
+ color: #e74c3c;
175
+ border: 1px solid #f5c6cb;
176
+ }
177
+
178
+ .info-banner {
179
+ background: #e8f4fd;
180
+ color: #2980b9;
181
+ border: 1px solid #bee5eb;
182
+ }
183
+
184
+ .banner-actions {
185
+ display: flex;
186
+ gap: 8px;
187
+ }
188
+
189
+ button.small {
190
+ padding: 6px 12px;
191
+ font-size: 12px;
192
+ }
193
+ </style>
@@ -0,0 +1,272 @@
1
+ import { ref, nextTick, onUnmounted, type Ref } from 'vue'
2
+ import { listMedia, formatTimestamp, useAuthStore } from 'een-api-toolkit'
3
+ import Hls from 'hls.js'
4
+ import { useMediaSessionStore } from '../stores/mediaSession'
5
+
6
+ // Constants
7
+ const SEARCH_WINDOW_MS = 60 * 60 * 1000 // 1 hour before/after target timestamp
8
+ const MAX_MEDIA_PAGE_SIZE = 100 // Limit results for performance
9
+ const MAX_NETWORK_RETRIES = 3 // Maximum retry attempts for network errors
10
+
11
+ // Debug utility - logs only when VITE_DEBUG=true
12
+ const isDebug = import.meta.env?.VITE_DEBUG === 'true'
13
+ function debugError(...args: unknown[]): void {
14
+ if (isDebug) {
15
+ console.error('[useHlsPlayer]', ...args)
16
+ }
17
+ }
18
+
19
+ /** Return type for the useHlsPlayer composable */
20
+ export interface HlsPlayerReturn {
21
+ videoUrl: Ref<string | null>
22
+ videoError: Ref<string | null>
23
+ loadingVideo: Ref<boolean>
24
+ videoRef: Ref<HTMLVideoElement | null>
25
+ loadVideo: (deviceId: string, timestamp: string) => Promise<void>
26
+ resetVideo: () => void
27
+ destroyHls: () => void
28
+ }
29
+
30
+ /**
31
+ * Composable for HLS video playback from EEN recordings.
32
+ * Handles media session initialization, interval search, and HLS.js setup.
33
+ * Uses Pinia store for media session state to ensure consistent behavior
34
+ * across all component instances.
35
+ */
36
+ export function useHlsPlayer(): HlsPlayerReturn {
37
+ const authStore = useAuthStore()
38
+ const mediaSessionStore = useMediaSessionStore()
39
+
40
+ // State
41
+ const videoUrl = ref<string | null>(null)
42
+ const videoError = ref<string | null>(null)
43
+ const loadingVideo = ref(false)
44
+ const videoRef = ref<HTMLVideoElement | null>(null)
45
+
46
+ let hlsInstance: Hls | null = null
47
+ let networkRetryCount = 0
48
+
49
+ /**
50
+ * Initialize media session with caching via Pinia store.
51
+ * Only calls the API once per session, subsequent calls return cached result.
52
+ */
53
+ async function ensureMediaSession(): Promise<boolean> {
54
+ const success = await mediaSessionStore.ensureInitialized()
55
+ if (!success && mediaSessionStore.error) {
56
+ videoError.value = mediaSessionStore.error
57
+ }
58
+ return success
59
+ }
60
+
61
+ /**
62
+ * Destroy the HLS instance and clean up resources.
63
+ */
64
+ function destroyHls() {
65
+ if (hlsInstance) {
66
+ hlsInstance.destroy()
67
+ hlsInstance = null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Initialize HLS.js with proper authentication and error handling.
73
+ */
74
+ function initHls() {
75
+ if (!videoUrl.value || !videoRef.value) return
76
+
77
+ destroyHls()
78
+
79
+ // Always use hls.js even on Safari - native HLS cannot send Authorization headers
80
+ if (!Hls.isSupported()) {
81
+ videoError.value = 'HLS is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
82
+ return
83
+ }
84
+
85
+ // Configure hls.js to send Authorization header for authentication
86
+ hlsInstance = new Hls({
87
+ xhrSetup: function(xhr) {
88
+ xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
89
+ }
90
+ })
91
+
92
+ hlsInstance.loadSource(videoUrl.value)
93
+ hlsInstance.attachMedia(videoRef.value)
94
+
95
+ hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
96
+ // Reset retry counter on successful manifest parse
97
+ networkRetryCount = 0
98
+ videoRef.value?.play().catch(() => {
99
+ // Autoplay may be blocked, user can manually play
100
+ })
101
+ })
102
+
103
+ // Enhanced error handling for different error types
104
+ hlsInstance.on(Hls.Events.ERROR, (_, data) => {
105
+ debugError('HLS error:', data)
106
+
107
+ if (data.fatal) {
108
+ switch (data.type) {
109
+ case Hls.ErrorTypes.NETWORK_ERROR:
110
+ // Network error - could be auth issue or connectivity
111
+ if (data.response?.code === 401) {
112
+ videoError.value = 'Authentication expired. Please refresh the page and try again.'
113
+ // Don't retry on auth errors - requires user action
114
+ destroyHls()
115
+ } else if (data.response?.code === 403) {
116
+ videoError.value = 'Access denied to video stream.'
117
+ // Don't retry on permission errors
118
+ destroyHls()
119
+ } else {
120
+ // Retry other network errors with limit
121
+ networkRetryCount++
122
+ if (networkRetryCount <= MAX_NETWORK_RETRIES) {
123
+ videoError.value = `Network error loading video: ${data.details}. Retry ${networkRetryCount}/${MAX_NETWORK_RETRIES}...`
124
+ hlsInstance?.startLoad()
125
+ } else {
126
+ videoError.value = `Network error loading video: ${data.details}. Max retries (${MAX_NETWORK_RETRIES}) exceeded.`
127
+ destroyHls()
128
+ }
129
+ }
130
+ break
131
+
132
+ case Hls.ErrorTypes.MEDIA_ERROR:
133
+ // Media error - try to recover
134
+ videoError.value = `Media error: ${data.details}. Attempting recovery...`
135
+ hlsInstance?.recoverMediaError()
136
+ break
137
+
138
+ default:
139
+ // Other fatal errors
140
+ videoError.value = `HLS error: ${data.type} - ${data.details}`
141
+ destroyHls()
142
+ }
143
+ }
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Load and play HLS video for a given device and timestamp.
149
+ *
150
+ * @param deviceId - The camera device ID
151
+ * @param timestamp - ISO timestamp string (the target time to find video for)
152
+ * @returns Promise that resolves when video is ready or error occurs
153
+ */
154
+ async function loadVideo(deviceId: string, timestamp: string): Promise<void> {
155
+ loadingVideo.value = true
156
+ videoError.value = null
157
+ videoUrl.value = null
158
+ networkRetryCount = 0 // Reset retry counter for new video load
159
+
160
+ // Initialize media session (cached after first call)
161
+ const sessionOk = await ensureMediaSession()
162
+ if (!sessionOk) {
163
+ loadingVideo.value = false
164
+ return
165
+ }
166
+
167
+ // Search for recordings around the target timestamp
168
+ const targetTime = new Date(timestamp)
169
+ const searchStartTime = new Date(targetTime.getTime() - SEARCH_WINDOW_MS)
170
+ const searchEndTime = new Date(targetTime.getTime() + SEARCH_WINDOW_MS)
171
+
172
+ // Use 'main' type for video - HLS is typically only available for main feeds
173
+ const result = await listMedia({
174
+ deviceId,
175
+ type: 'main',
176
+ mediaType: 'video',
177
+ startTimestamp: formatTimestamp(searchStartTime.toISOString()),
178
+ endTimestamp: formatTimestamp(searchEndTime.toISOString()),
179
+ include: ['hlsUrl'],
180
+ pageSize: MAX_MEDIA_PAGE_SIZE
181
+ })
182
+
183
+ if (result.error) {
184
+ videoError.value = result.error.message
185
+ loadingVideo.value = false
186
+ return
187
+ }
188
+
189
+ const intervals = result.data?.results ?? []
190
+
191
+ // Validate target timestamp
192
+ const targetTimeMs = targetTime.getTime()
193
+ if (isNaN(targetTimeMs)) {
194
+ videoError.value = `Invalid timestamp format: ${timestamp}`
195
+ loadingVideo.value = false
196
+ return
197
+ }
198
+
199
+ // Find an interval that contains the target timestamp and has an HLS URL
200
+ const interval = intervals.find(i => {
201
+ if (!i.hlsUrl) return false
202
+ const intervalStart = new Date(i.startTimestamp).getTime()
203
+ const intervalEnd = new Date(i.endTimestamp).getTime()
204
+ // Skip intervals with invalid timestamps
205
+ if (isNaN(intervalStart) || isNaN(intervalEnd)) return false
206
+ return targetTimeMs >= intervalStart && targetTimeMs <= intervalEnd
207
+ })
208
+
209
+ if (!interval?.hlsUrl) {
210
+ // Provide detailed error message
211
+ if (intervals.length === 0) {
212
+ videoError.value = 'No recordings found for this time range'
213
+ } else if (!intervals.some(i => i.hlsUrl)) {
214
+ videoError.value = 'Recordings found but HLS not available'
215
+ } else {
216
+ videoError.value = `No recording contains timestamp ${timestamp}`
217
+ }
218
+ loadingVideo.value = false
219
+ return
220
+ }
221
+
222
+ // Set the HLS URL
223
+ videoUrl.value = interval.hlsUrl
224
+ loadingVideo.value = false
225
+
226
+ // Initialize HLS.js after the DOM has been updated
227
+ await nextTick()
228
+ initHls()
229
+ }
230
+
231
+ /**
232
+ * Reset all video state.
233
+ */
234
+ function resetVideo() {
235
+ destroyHls()
236
+ videoUrl.value = null
237
+ videoError.value = null
238
+ loadingVideo.value = false
239
+ }
240
+
241
+ // Cleanup on unmount
242
+ onUnmounted(() => {
243
+ destroyHls()
244
+ })
245
+
246
+ return {
247
+ // State
248
+ videoUrl,
249
+ videoError,
250
+ loadingVideo,
251
+ videoRef,
252
+
253
+ // Methods
254
+ loadVideo,
255
+ resetVideo,
256
+ destroyHls
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Reset the media session cache.
262
+ * Call this when the user logs out or the session expires to ensure
263
+ * a fresh media session is initialized on next use.
264
+ *
265
+ * @remarks
266
+ * This delegates to the Pinia media session store's reset method.
267
+ * Must be called from within a Vue component context or after Pinia is installed.
268
+ */
269
+ export function resetMediaSessionCache(): void {
270
+ const mediaSessionStore = useMediaSessionStore()
271
+ mediaSessionStore.reset()
272
+ }
@@ -0,0 +1,25 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import { initEenToolkit } from 'een-api-toolkit'
4
+ import App from './App.vue'
5
+ import router from './router'
6
+
7
+ const app = createApp(App)
8
+
9
+ // Install Pinia (required before initEenToolkit)
10
+ app.use(createPinia())
11
+
12
+ // Initialize EEN API Toolkit with sessionStorage
13
+ // Note: Using 'sessionStorage' means tokens persist within the browser tab session
14
+ initEenToolkit({
15
+ proxyUrl: import.meta.env.VITE_PROXY_URL,
16
+ clientId: import.meta.env.VITE_EEN_CLIENT_ID,
17
+ redirectUri: import.meta.env.VITE_REDIRECT_URI,
18
+ storageStrategy: 'sessionStorage',
19
+ debug: import.meta.env.VITE_DEBUG === 'true'
20
+ })
21
+
22
+ // Install router
23
+ app.use(router)
24
+
25
+ app.mount('#app')
@@ -0,0 +1,68 @@
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 Subscriptions from '../views/Subscriptions.vue'
7
+ import LiveEvents from '../views/LiveEvents.vue'
8
+ import Logout from '../views/Logout.vue'
9
+
10
+ const router = createRouter({
11
+ history: createWebHistory(),
12
+ routes: [
13
+ {
14
+ path: '/',
15
+ name: 'home',
16
+ component: Home,
17
+ // Handle OAuth callback on root path (EEN IDP redirects to http://127.0.0.1:3333)
18
+ beforeEnter: (to, _from, next) => {
19
+ // If URL has code and state params, it's an OAuth callback
20
+ if (to.query.code && to.query.state) {
21
+ next({ name: 'callback', query: to.query })
22
+ } else {
23
+ next()
24
+ }
25
+ }
26
+ },
27
+ {
28
+ path: '/login',
29
+ name: 'login',
30
+ component: Login
31
+ },
32
+ {
33
+ path: '/callback',
34
+ name: 'callback',
35
+ component: Callback
36
+ },
37
+ {
38
+ path: '/subscriptions',
39
+ name: 'subscriptions',
40
+ component: Subscriptions,
41
+ meta: { requiresAuth: true }
42
+ },
43
+ {
44
+ path: '/live',
45
+ name: 'live',
46
+ component: LiveEvents,
47
+ meta: { requiresAuth: true }
48
+ },
49
+ {
50
+ path: '/logout',
51
+ name: 'logout',
52
+ component: Logout
53
+ }
54
+ ]
55
+ })
56
+
57
+ // Navigation guard for protected routes
58
+ router.beforeEach((to, _from, next) => {
59
+ const authStore = useAuthStore()
60
+
61
+ if (to.meta.requiresAuth && !authStore.isAuthenticated) {
62
+ next({ name: 'login' })
63
+ } else {
64
+ next()
65
+ }
66
+ })
67
+
68
+ export default router