qdadm 0.26.3 → 0.27.0
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/package.json
CHANGED
package/src/components/index.js
CHANGED
|
@@ -55,3 +55,6 @@ export { default as EmptyState } from './display/EmptyState.vue'
|
|
|
55
55
|
export { default as IntensityBar } from './display/IntensityBar.vue'
|
|
56
56
|
export { default as BoolCell } from './BoolCell.vue'
|
|
57
57
|
export { default as SeverityTag } from './SeverityTag.vue'
|
|
58
|
+
|
|
59
|
+
// Pages
|
|
60
|
+
export { default as LoginPage } from './pages/LoginPage.vue'
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* LoginPage - Generic login page component
|
|
4
|
+
*
|
|
5
|
+
* Uses authAdapter from qdadm context for authentication.
|
|
6
|
+
* Customizable via props and slots for app branding.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <LoginPage />
|
|
10
|
+
* <LoginPage title="My App" icon="pi pi-lock" />
|
|
11
|
+
* <LoginPage :logo="LogoComponent" />
|
|
12
|
+
*
|
|
13
|
+
* With slot:
|
|
14
|
+
* <LoginPage>
|
|
15
|
+
* <template #footer>
|
|
16
|
+
* <p>Demo accounts: admin, user</p>
|
|
17
|
+
* </template>
|
|
18
|
+
* </LoginPage>
|
|
19
|
+
*/
|
|
20
|
+
import { ref, inject, computed } from 'vue'
|
|
21
|
+
import { useRouter } from 'vue-router'
|
|
22
|
+
import { useToast } from 'primevue/usetoast'
|
|
23
|
+
import Card from 'primevue/card'
|
|
24
|
+
import InputText from 'primevue/inputtext'
|
|
25
|
+
import Password from 'primevue/password'
|
|
26
|
+
import Button from 'primevue/button'
|
|
27
|
+
|
|
28
|
+
const props = defineProps({
|
|
29
|
+
/**
|
|
30
|
+
* Override app title (defaults to qdadm app.name config)
|
|
31
|
+
*/
|
|
32
|
+
title: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: null
|
|
35
|
+
},
|
|
36
|
+
/**
|
|
37
|
+
* PrimeIcons icon class (e.g., 'pi pi-lock')
|
|
38
|
+
*/
|
|
39
|
+
icon: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: 'pi pi-shield'
|
|
42
|
+
},
|
|
43
|
+
/**
|
|
44
|
+
* Custom logo component (replaces icon)
|
|
45
|
+
*/
|
|
46
|
+
logo: {
|
|
47
|
+
type: Object,
|
|
48
|
+
default: null
|
|
49
|
+
},
|
|
50
|
+
/**
|
|
51
|
+
* Username field label
|
|
52
|
+
*/
|
|
53
|
+
usernameLabel: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: 'Username'
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* Password field label
|
|
59
|
+
*/
|
|
60
|
+
passwordLabel: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: 'Password'
|
|
63
|
+
},
|
|
64
|
+
/**
|
|
65
|
+
* Submit button label
|
|
66
|
+
*/
|
|
67
|
+
submitLabel: {
|
|
68
|
+
type: String,
|
|
69
|
+
default: 'Sign In'
|
|
70
|
+
},
|
|
71
|
+
/**
|
|
72
|
+
* Route to redirect after successful login
|
|
73
|
+
*/
|
|
74
|
+
redirectTo: {
|
|
75
|
+
type: String,
|
|
76
|
+
default: '/'
|
|
77
|
+
},
|
|
78
|
+
/**
|
|
79
|
+
* Default username value
|
|
80
|
+
*/
|
|
81
|
+
defaultUsername: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: ''
|
|
84
|
+
},
|
|
85
|
+
/**
|
|
86
|
+
* Default password value
|
|
87
|
+
*/
|
|
88
|
+
defaultPassword: {
|
|
89
|
+
type: String,
|
|
90
|
+
default: ''
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Emit business signal on login (requires orchestrator)
|
|
94
|
+
*/
|
|
95
|
+
emitSignal: {
|
|
96
|
+
type: Boolean,
|
|
97
|
+
default: false
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const emit = defineEmits(['login', 'error'])
|
|
102
|
+
|
|
103
|
+
const router = useRouter()
|
|
104
|
+
const toast = useToast()
|
|
105
|
+
const authAdapter = inject('authAdapter', null)
|
|
106
|
+
const orchestrator = inject('qdadmOrchestrator', null)
|
|
107
|
+
const appConfig = inject('qdadmApp', {})
|
|
108
|
+
|
|
109
|
+
const username = ref(props.defaultUsername)
|
|
110
|
+
const password = ref(props.defaultPassword)
|
|
111
|
+
const loading = ref(false)
|
|
112
|
+
|
|
113
|
+
const displayTitle = computed(() => props.title || appConfig.name || 'Admin')
|
|
114
|
+
|
|
115
|
+
async function handleLogin() {
|
|
116
|
+
if (!authAdapter?.login) {
|
|
117
|
+
toast.add({
|
|
118
|
+
severity: 'error',
|
|
119
|
+
summary: 'Configuration Error',
|
|
120
|
+
detail: 'No auth adapter configured',
|
|
121
|
+
life: 5000
|
|
122
|
+
})
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
loading.value = true
|
|
127
|
+
try {
|
|
128
|
+
const result = await authAdapter.login({
|
|
129
|
+
username: username.value,
|
|
130
|
+
password: password.value
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Emit business signal if enabled
|
|
134
|
+
if (props.emitSignal && orchestrator?.signals) {
|
|
135
|
+
orchestrator.signals.emit('auth:login', { user: result.user })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Emit component event
|
|
139
|
+
emit('login', result)
|
|
140
|
+
|
|
141
|
+
router.push(props.redirectTo)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
toast.add({
|
|
144
|
+
severity: 'error',
|
|
145
|
+
summary: 'Login Failed',
|
|
146
|
+
detail: error.message || 'Invalid credentials',
|
|
147
|
+
life: 3000
|
|
148
|
+
})
|
|
149
|
+
emit('error', error)
|
|
150
|
+
} finally {
|
|
151
|
+
loading.value = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<div class="qdadm-login-page">
|
|
158
|
+
<Card class="qdadm-login-card">
|
|
159
|
+
<template #title>
|
|
160
|
+
<div class="qdadm-login-header">
|
|
161
|
+
<slot name="logo">
|
|
162
|
+
<component v-if="logo" :is="logo" class="qdadm-login-logo" />
|
|
163
|
+
<i v-else :class="icon" class="qdadm-login-icon"></i>
|
|
164
|
+
</slot>
|
|
165
|
+
<h1>{{ displayTitle }}</h1>
|
|
166
|
+
</div>
|
|
167
|
+
</template>
|
|
168
|
+
<template #content>
|
|
169
|
+
<form @submit.prevent="handleLogin" class="qdadm-login-form">
|
|
170
|
+
<div class="qdadm-login-field">
|
|
171
|
+
<label for="qdadm-username">{{ usernameLabel }}</label>
|
|
172
|
+
<InputText
|
|
173
|
+
v-model="username"
|
|
174
|
+
id="qdadm-username"
|
|
175
|
+
class="w-full"
|
|
176
|
+
autocomplete="username"
|
|
177
|
+
:disabled="loading"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="qdadm-login-field">
|
|
181
|
+
<label for="qdadm-password">{{ passwordLabel }}</label>
|
|
182
|
+
<Password
|
|
183
|
+
v-model="password"
|
|
184
|
+
id="qdadm-password"
|
|
185
|
+
class="w-full"
|
|
186
|
+
:feedback="false"
|
|
187
|
+
toggleMask
|
|
188
|
+
:disabled="loading"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<Button
|
|
192
|
+
type="submit"
|
|
193
|
+
:label="submitLabel"
|
|
194
|
+
icon="pi pi-sign-in"
|
|
195
|
+
class="w-full"
|
|
196
|
+
:loading="loading"
|
|
197
|
+
/>
|
|
198
|
+
<slot name="footer"></slot>
|
|
199
|
+
</form>
|
|
200
|
+
</template>
|
|
201
|
+
</Card>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
|
|
205
|
+
<style scoped>
|
|
206
|
+
.qdadm-login-page {
|
|
207
|
+
min-height: 100vh;
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: center;
|
|
211
|
+
background: var(--p-surface-100);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.qdadm-login-card {
|
|
215
|
+
width: 100%;
|
|
216
|
+
max-width: 400px;
|
|
217
|
+
margin: 1rem;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.qdadm-login-header {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 0.75rem;
|
|
224
|
+
justify-content: center;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.qdadm-login-header h1 {
|
|
228
|
+
margin: 0;
|
|
229
|
+
font-size: 1.5rem;
|
|
230
|
+
color: var(--p-text-color);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.qdadm-login-icon {
|
|
234
|
+
font-size: 2rem;
|
|
235
|
+
color: var(--p-primary-500);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.qdadm-login-logo {
|
|
239
|
+
height: 2rem;
|
|
240
|
+
width: auto;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.qdadm-login-form {
|
|
244
|
+
display: flex;
|
|
245
|
+
flex-direction: column;
|
|
246
|
+
gap: 1rem;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.qdadm-login-field {
|
|
250
|
+
display: flex;
|
|
251
|
+
flex-direction: column;
|
|
252
|
+
gap: 0.5rem;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.qdadm-login-field label {
|
|
256
|
+
font-weight: 500;
|
|
257
|
+
color: var(--p-text-color);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Responsive */
|
|
261
|
+
@media (max-width: 480px) {
|
|
262
|
+
.qdadm-login-card {
|
|
263
|
+
max-width: 100%;
|
|
264
|
+
margin: 0.5rem;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
</style>
|
package/src/composables/index.js
CHANGED
|
@@ -20,3 +20,4 @@ export { useSignals } from './useSignals'
|
|
|
20
20
|
export { useZoneRegistry } from './useZoneRegistry'
|
|
21
21
|
export { useHooks } from './useHooks'
|
|
22
22
|
export { useLayoutResolver, createLayoutComponents, layoutMeta, LAYOUT_TYPES } from './useLayoutResolver'
|
|
23
|
+
export { useSSE } from './useSSE'
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSSE - Server-Sent Events composable
|
|
3
|
+
*
|
|
4
|
+
* Manages EventSource connection with automatic reconnection and cleanup.
|
|
5
|
+
* Uses authAdapter.getToken() for authentication if available.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { connected, error, reconnect, close } = useSSE('/api/events', {
|
|
9
|
+
* eventHandlers: {
|
|
10
|
+
* 'bot:status': (data) => console.log('Bot status:', data),
|
|
11
|
+
* 'task:complete': (data) => handleTaskComplete(data)
|
|
12
|
+
* }
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* Options:
|
|
16
|
+
* - eventHandlers: Object mapping event names to handler functions
|
|
17
|
+
* - reconnectDelay: Delay in ms before reconnecting (default: 5000)
|
|
18
|
+
* - autoConnect: Start connection immediately (default: true)
|
|
19
|
+
* - withCredentials: Include credentials in request (default: false)
|
|
20
|
+
* - tokenParam: Query param name for token (default: 'token')
|
|
21
|
+
* - getToken: Custom token getter function (default: uses authAdapter)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { ref, inject, onUnmounted, onMounted } from 'vue'
|
|
25
|
+
|
|
26
|
+
export function useSSE(url, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
eventHandlers = {},
|
|
29
|
+
reconnectDelay = 5000,
|
|
30
|
+
autoConnect = true,
|
|
31
|
+
withCredentials = false,
|
|
32
|
+
tokenParam = 'token',
|
|
33
|
+
getToken: customGetToken = null
|
|
34
|
+
} = options
|
|
35
|
+
|
|
36
|
+
const authAdapter = inject('authAdapter', null)
|
|
37
|
+
|
|
38
|
+
const connected = ref(false)
|
|
39
|
+
const error = ref(null)
|
|
40
|
+
const reconnecting = ref(false)
|
|
41
|
+
|
|
42
|
+
let eventSource = null
|
|
43
|
+
let reconnectTimer = null
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get authentication token
|
|
47
|
+
*/
|
|
48
|
+
const getToken = () => {
|
|
49
|
+
// Custom getter takes precedence
|
|
50
|
+
if (customGetToken) {
|
|
51
|
+
return customGetToken()
|
|
52
|
+
}
|
|
53
|
+
// Try authAdapter
|
|
54
|
+
if (authAdapter?.getToken) {
|
|
55
|
+
return authAdapter.getToken()
|
|
56
|
+
}
|
|
57
|
+
// Fallback to localStorage
|
|
58
|
+
return localStorage.getItem('auth_token')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build SSE URL with token
|
|
63
|
+
*/
|
|
64
|
+
const buildUrl = () => {
|
|
65
|
+
const token = getToken()
|
|
66
|
+
const sseUrl = new URL(url, window.location.origin)
|
|
67
|
+
|
|
68
|
+
if (token && tokenParam) {
|
|
69
|
+
sseUrl.searchParams.set(tokenParam, token)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return sseUrl.toString()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Connect to SSE endpoint
|
|
77
|
+
*/
|
|
78
|
+
const connect = () => {
|
|
79
|
+
// Clean up existing connection
|
|
80
|
+
if (eventSource) {
|
|
81
|
+
eventSource.close()
|
|
82
|
+
eventSource = null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clear any pending reconnect
|
|
86
|
+
if (reconnectTimer) {
|
|
87
|
+
clearTimeout(reconnectTimer)
|
|
88
|
+
reconnectTimer = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const sseUrl = buildUrl()
|
|
93
|
+
|
|
94
|
+
eventSource = new EventSource(sseUrl, {
|
|
95
|
+
withCredentials
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
eventSource.onopen = () => {
|
|
99
|
+
connected.value = true
|
|
100
|
+
error.value = null
|
|
101
|
+
reconnecting.value = false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
eventSource.onerror = (err) => {
|
|
105
|
+
connected.value = false
|
|
106
|
+
error.value = 'Connection error'
|
|
107
|
+
|
|
108
|
+
// Close broken connection
|
|
109
|
+
if (eventSource) {
|
|
110
|
+
eventSource.close()
|
|
111
|
+
eventSource = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Schedule reconnect
|
|
115
|
+
if (reconnectDelay > 0) {
|
|
116
|
+
reconnecting.value = true
|
|
117
|
+
reconnectTimer = setTimeout(() => {
|
|
118
|
+
if (!connected.value) {
|
|
119
|
+
connect()
|
|
120
|
+
}
|
|
121
|
+
}, reconnectDelay)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Register custom event handlers
|
|
126
|
+
for (const [eventName, handler] of Object.entries(eventHandlers)) {
|
|
127
|
+
if (eventName === 'message') continue // Handle below
|
|
128
|
+
|
|
129
|
+
eventSource.addEventListener(eventName, (event) => {
|
|
130
|
+
try {
|
|
131
|
+
const data = JSON.parse(event.data)
|
|
132
|
+
handler(data, event)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(`[useSSE] Error parsing event "${eventName}":`, err)
|
|
135
|
+
// Call handler with raw data on parse error
|
|
136
|
+
handler(event.data, event)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle generic message events
|
|
142
|
+
eventSource.onmessage = (event) => {
|
|
143
|
+
if (!eventHandlers.message) return
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const data = JSON.parse(event.data)
|
|
147
|
+
eventHandlers.message(data, event)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('[useSSE] Error parsing message:', err)
|
|
150
|
+
eventHandlers.message(event.data, event)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
} catch (err) {
|
|
155
|
+
error.value = err.message
|
|
156
|
+
connected.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Close connection
|
|
162
|
+
*/
|
|
163
|
+
const close = () => {
|
|
164
|
+
if (reconnectTimer) {
|
|
165
|
+
clearTimeout(reconnectTimer)
|
|
166
|
+
reconnectTimer = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (eventSource) {
|
|
170
|
+
eventSource.close()
|
|
171
|
+
eventSource = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
connected.value = false
|
|
175
|
+
reconnecting.value = false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reconnect (close and connect)
|
|
180
|
+
*/
|
|
181
|
+
const reconnect = () => {
|
|
182
|
+
close()
|
|
183
|
+
connect()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Auto-connect on mount if enabled
|
|
187
|
+
onMounted(() => {
|
|
188
|
+
if (autoConnect) {
|
|
189
|
+
connect()
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Cleanup on unmount
|
|
194
|
+
onUnmounted(() => {
|
|
195
|
+
close()
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
/** Reactive connection state */
|
|
200
|
+
connected,
|
|
201
|
+
/** Reactive error message */
|
|
202
|
+
error,
|
|
203
|
+
/** Reactive reconnecting state */
|
|
204
|
+
reconnecting,
|
|
205
|
+
/** Manually connect */
|
|
206
|
+
connect,
|
|
207
|
+
/** Close connection */
|
|
208
|
+
close,
|
|
209
|
+
/** Reconnect (close + connect) */
|
|
210
|
+
reconnect
|
|
211
|
+
}
|
|
212
|
+
}
|