@kinetis/vector-ui 0.0.0-stage → 0.9.1
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/CHANGELOG.md +34 -0
- package/LICENSE +18 -0
- package/README.md +72 -2
- package/app/app.config.ts +63 -0
- package/app/app.vue +11 -0
- package/app/components/Vx/Action/Dialog.vue +107 -0
- package/app/components/Vx/Cell/Boolean.vue +10 -0
- package/app/components/Vx/Cell/Color.vue +12 -0
- package/app/components/Vx/Cell/Count.vue +12 -0
- package/app/components/Vx/Cell/Date.vue +10 -0
- package/app/components/Vx/Cell/DateTime.vue +10 -0
- package/app/components/Vx/Cell/Email.vue +9 -0
- package/app/components/Vx/Cell/Enum.vue +14 -0
- package/app/components/Vx/Cell/File.vue +30 -0
- package/app/components/Vx/Cell/Image.vue +38 -0
- package/app/components/Vx/Cell/Json.vue +18 -0
- package/app/components/Vx/Cell/KeyValue.vue +25 -0
- package/app/components/Vx/Cell/Markdown.vue +18 -0
- package/app/components/Vx/Cell/Money.vue +16 -0
- package/app/components/Vx/Cell/Number.vue +16 -0
- package/app/components/Vx/Cell/Relation.vue +16 -0
- package/app/components/Vx/Cell/RichText.vue +41 -0
- package/app/components/Vx/Cell/Text.vue +13 -0
- package/app/components/Vx/Cell/Url.vue +11 -0
- package/app/components/Vx/Cell.vue +25 -0
- package/app/components/Vx/CommandPalette.vue +118 -0
- package/app/components/Vx/Confirm.vue +19 -0
- package/app/components/Vx/Dashboard.vue +27 -0
- package/app/components/Vx/FieldsForm.vue +46 -0
- package/app/components/Vx/Import/Dialog.vue +131 -0
- package/app/components/Vx/Inbox.vue +49 -0
- package/app/components/Vx/Input/Boolean.vue +10 -0
- package/app/components/Vx/Input/Currency.vue +25 -0
- package/app/components/Vx/Input/DateTime.vue +38 -0
- package/app/components/Vx/Input/Enum.vue +21 -0
- package/app/components/Vx/Input/KeyValue.vue +78 -0
- package/app/components/Vx/Input/Markdown.vue +120 -0
- package/app/components/Vx/Input/Money.vue +47 -0
- package/app/components/Vx/Input/Number.vue +24 -0
- package/app/components/Vx/Input/Relation.vue +127 -0
- package/app/components/Vx/Input/Repeater.vue +134 -0
- package/app/components/Vx/Input/RichText.vue +182 -0
- package/app/components/Vx/Input/Text.vue +21 -0
- package/app/components/Vx/Input/Textarea.vue +20 -0
- package/app/components/Vx/Input/Upload.vue +89 -0
- package/app/components/Vx/Layout.vue +111 -0
- package/app/components/Vx/List/BulkBar.vue +44 -0
- package/app/components/Vx/List/BulkEdit.vue +91 -0
- package/app/components/Vx/List/ColumnManager.vue +74 -0
- package/app/components/Vx/List/FilterBar.vue +224 -0
- package/app/components/Vx/List/ViewMenu.vue +115 -0
- package/app/components/Vx/Notifications.vue +9 -0
- package/app/components/Vx/Resource/Audit.vue +82 -0
- package/app/components/Vx/Resource/Form.vue +351 -0
- package/app/components/Vx/Resource/RelationPanel.vue +217 -0
- package/app/components/Vx/Resource/Show.vue +35 -0
- package/app/components/Vx/Resource/Table.vue +513 -0
- package/app/components/Vx/Widget/Card.vue +80 -0
- package/app/components/Vx/Widget/Partition.vue +21 -0
- package/app/components/Vx/Widget/Progress.vue +22 -0
- package/app/components/Vx/Widget/Stat.vue +22 -0
- package/app/components/Vx/Widget/Table.vue +31 -0
- package/app/components/Vx/Widget/Trend.vue +37 -0
- package/app/composables/useConfirm.ts +34 -0
- package/app/composables/useLeaveGuard.ts +21 -0
- package/app/composables/useVectorApi.ts +74 -0
- package/app/composables/useVectorAuthStore.ts +252 -0
- package/app/composables/useVectorBranding.ts +45 -0
- package/app/composables/useVectorDashboards.ts +15 -0
- package/app/composables/useVectorExtensions.ts +44 -0
- package/app/composables/useVectorFields.ts +68 -0
- package/app/composables/useVectorForm.ts +36 -0
- package/app/composables/useVectorIdle.ts +39 -0
- package/app/composables/useVectorImpersonation.ts +78 -0
- package/app/composables/useVectorInputs.ts +61 -0
- package/app/composables/useVectorJobs.ts +62 -0
- package/app/composables/useVectorLocale.ts +70 -0
- package/app/composables/useVectorNotifications.ts +24 -0
- package/app/composables/useVectorNotificationsInbox.ts +26 -0
- package/app/composables/useVectorPreferences.ts +30 -0
- package/app/composables/useVectorResource.ts +305 -0
- package/app/composables/useVectorRuntime.ts +42 -0
- package/app/composables/useVectorSchema.ts +56 -0
- package/app/composables/useVectorT.ts +9 -0
- package/app/composables/useVectorViews.ts +25 -0
- package/app/i18n/ar/actions.ts +11 -0
- package/app/i18n/ar/common.ts +23 -0
- package/app/i18n/ar/dashboard.ts +28 -0
- package/app/i18n/ar/fields.ts +77 -0
- package/app/i18n/ar/index.ts +12 -0
- package/app/i18n/ar/jobs.ts +36 -0
- package/app/i18n/ar/list.ts +160 -0
- package/app/i18n/ar/record.ts +62 -0
- package/app/i18n/ar/settings.ts +10 -0
- package/app/i18n/ar/shell.ts +54 -0
- package/app/i18n/de/actions.ts +11 -0
- package/app/i18n/de/common.ts +23 -0
- package/app/i18n/de/dashboard.ts +28 -0
- package/app/i18n/de/fields.ts +77 -0
- package/app/i18n/de/index.ts +12 -0
- package/app/i18n/de/jobs.ts +36 -0
- package/app/i18n/de/list.ts +160 -0
- package/app/i18n/de/record.ts +62 -0
- package/app/i18n/de/settings.ts +10 -0
- package/app/i18n/de/shell.ts +54 -0
- package/app/i18n/en/actions.ts +11 -0
- package/app/i18n/en/common.ts +23 -0
- package/app/i18n/en/dashboard.ts +28 -0
- package/app/i18n/en/fields.ts +77 -0
- package/app/i18n/en/index.ts +12 -0
- package/app/i18n/en/jobs.ts +36 -0
- package/app/i18n/en/list.ts +160 -0
- package/app/i18n/en/record.ts +62 -0
- package/app/i18n/en/settings.ts +10 -0
- package/app/i18n/en/shell.ts +54 -0
- package/app/i18n/es/actions.ts +11 -0
- package/app/i18n/es/common.ts +23 -0
- package/app/i18n/es/dashboard.ts +28 -0
- package/app/i18n/es/fields.ts +77 -0
- package/app/i18n/es/index.ts +12 -0
- package/app/i18n/es/jobs.ts +36 -0
- package/app/i18n/es/list.ts +160 -0
- package/app/i18n/es/record.ts +62 -0
- package/app/i18n/es/settings.ts +10 -0
- package/app/i18n/es/shell.ts +54 -0
- package/app/i18n/fr/actions.ts +11 -0
- package/app/i18n/fr/common.ts +23 -0
- package/app/i18n/fr/dashboard.ts +28 -0
- package/app/i18n/fr/fields.ts +77 -0
- package/app/i18n/fr/index.ts +12 -0
- package/app/i18n/fr/jobs.ts +36 -0
- package/app/i18n/fr/list.ts +160 -0
- package/app/i18n/fr/record.ts +62 -0
- package/app/i18n/fr/settings.ts +10 -0
- package/app/i18n/fr/shell.ts +54 -0
- package/app/i18n/he/actions.ts +11 -0
- package/app/i18n/he/common.ts +23 -0
- package/app/i18n/he/dashboard.ts +28 -0
- package/app/i18n/he/fields.ts +77 -0
- package/app/i18n/he/index.ts +12 -0
- package/app/i18n/he/jobs.ts +36 -0
- package/app/i18n/he/list.ts +160 -0
- package/app/i18n/he/record.ts +62 -0
- package/app/i18n/he/settings.ts +10 -0
- package/app/i18n/he/shell.ts +54 -0
- package/app/i18n/index.ts +111 -0
- package/app/i18n/it/actions.ts +11 -0
- package/app/i18n/it/common.ts +23 -0
- package/app/i18n/it/dashboard.ts +28 -0
- package/app/i18n/it/fields.ts +77 -0
- package/app/i18n/it/index.ts +12 -0
- package/app/i18n/it/jobs.ts +36 -0
- package/app/i18n/it/list.ts +160 -0
- package/app/i18n/it/record.ts +62 -0
- package/app/i18n/it/settings.ts +10 -0
- package/app/i18n/it/shell.ts +54 -0
- package/app/i18n/ja/actions.ts +11 -0
- package/app/i18n/ja/common.ts +23 -0
- package/app/i18n/ja/dashboard.ts +28 -0
- package/app/i18n/ja/fields.ts +77 -0
- package/app/i18n/ja/index.ts +12 -0
- package/app/i18n/ja/jobs.ts +36 -0
- package/app/i18n/ja/list.ts +160 -0
- package/app/i18n/ja/record.ts +62 -0
- package/app/i18n/ja/settings.ts +10 -0
- package/app/i18n/ja/shell.ts +54 -0
- package/app/i18n/ko/actions.ts +11 -0
- package/app/i18n/ko/common.ts +23 -0
- package/app/i18n/ko/dashboard.ts +28 -0
- package/app/i18n/ko/fields.ts +77 -0
- package/app/i18n/ko/index.ts +12 -0
- package/app/i18n/ko/jobs.ts +36 -0
- package/app/i18n/ko/list.ts +160 -0
- package/app/i18n/ko/record.ts +62 -0
- package/app/i18n/ko/settings.ts +10 -0
- package/app/i18n/ko/shell.ts +54 -0
- package/app/i18n/nl/actions.ts +11 -0
- package/app/i18n/nl/common.ts +23 -0
- package/app/i18n/nl/dashboard.ts +28 -0
- package/app/i18n/nl/fields.ts +77 -0
- package/app/i18n/nl/index.ts +12 -0
- package/app/i18n/nl/jobs.ts +36 -0
- package/app/i18n/nl/list.ts +160 -0
- package/app/i18n/nl/record.ts +62 -0
- package/app/i18n/nl/settings.ts +10 -0
- package/app/i18n/nl/shell.ts +54 -0
- package/app/i18n/pl/actions.ts +11 -0
- package/app/i18n/pl/common.ts +23 -0
- package/app/i18n/pl/dashboard.ts +28 -0
- package/app/i18n/pl/fields.ts +77 -0
- package/app/i18n/pl/index.ts +12 -0
- package/app/i18n/pl/jobs.ts +36 -0
- package/app/i18n/pl/list.ts +160 -0
- package/app/i18n/pl/record.ts +62 -0
- package/app/i18n/pl/settings.ts +10 -0
- package/app/i18n/pl/shell.ts +54 -0
- package/app/i18n/pt/actions.ts +11 -0
- package/app/i18n/pt/common.ts +23 -0
- package/app/i18n/pt/dashboard.ts +28 -0
- package/app/i18n/pt/fields.ts +77 -0
- package/app/i18n/pt/index.ts +12 -0
- package/app/i18n/pt/jobs.ts +36 -0
- package/app/i18n/pt/list.ts +160 -0
- package/app/i18n/pt/record.ts +62 -0
- package/app/i18n/pt/settings.ts +10 -0
- package/app/i18n/pt/shell.ts +54 -0
- package/app/i18n/pt-BR/actions.ts +11 -0
- package/app/i18n/pt-BR/common.ts +23 -0
- package/app/i18n/pt-BR/dashboard.ts +28 -0
- package/app/i18n/pt-BR/fields.ts +77 -0
- package/app/i18n/pt-BR/index.ts +12 -0
- package/app/i18n/pt-BR/jobs.ts +36 -0
- package/app/i18n/pt-BR/list.ts +160 -0
- package/app/i18n/pt-BR/record.ts +62 -0
- package/app/i18n/pt-BR/settings.ts +10 -0
- package/app/i18n/pt-BR/shell.ts +54 -0
- package/app/i18n/ru/actions.ts +11 -0
- package/app/i18n/ru/common.ts +23 -0
- package/app/i18n/ru/dashboard.ts +28 -0
- package/app/i18n/ru/fields.ts +77 -0
- package/app/i18n/ru/index.ts +12 -0
- package/app/i18n/ru/jobs.ts +36 -0
- package/app/i18n/ru/list.ts +160 -0
- package/app/i18n/ru/record.ts +62 -0
- package/app/i18n/ru/settings.ts +10 -0
- package/app/i18n/ru/shell.ts +54 -0
- package/app/i18n/tr/actions.ts +11 -0
- package/app/i18n/tr/common.ts +23 -0
- package/app/i18n/tr/dashboard.ts +28 -0
- package/app/i18n/tr/fields.ts +77 -0
- package/app/i18n/tr/index.ts +12 -0
- package/app/i18n/tr/jobs.ts +36 -0
- package/app/i18n/tr/list.ts +160 -0
- package/app/i18n/tr/record.ts +62 -0
- package/app/i18n/tr/settings.ts +10 -0
- package/app/i18n/tr/shell.ts +54 -0
- package/app/i18n/uk/actions.ts +11 -0
- package/app/i18n/uk/common.ts +23 -0
- package/app/i18n/uk/dashboard.ts +28 -0
- package/app/i18n/uk/fields.ts +77 -0
- package/app/i18n/uk/index.ts +12 -0
- package/app/i18n/uk/jobs.ts +36 -0
- package/app/i18n/uk/list.ts +160 -0
- package/app/i18n/uk/record.ts +62 -0
- package/app/i18n/uk/settings.ts +10 -0
- package/app/i18n/uk/shell.ts +54 -0
- package/app/i18n/zh-Hans/actions.ts +11 -0
- package/app/i18n/zh-Hans/common.ts +23 -0
- package/app/i18n/zh-Hans/dashboard.ts +28 -0
- package/app/i18n/zh-Hans/fields.ts +77 -0
- package/app/i18n/zh-Hans/index.ts +12 -0
- package/app/i18n/zh-Hans/jobs.ts +36 -0
- package/app/i18n/zh-Hans/list.ts +160 -0
- package/app/i18n/zh-Hans/record.ts +62 -0
- package/app/i18n/zh-Hans/settings.ts +10 -0
- package/app/i18n/zh-Hans/shell.ts +54 -0
- package/app/layouts/default.vue +8 -0
- package/app/layouts/vector.vue +238 -0
- package/app/middleware/vector-auth.ts +16 -0
- package/app/pages/d/[name].vue +12 -0
- package/app/pages/index.vue +22 -0
- package/app/pages/jobs/[id].vue +52 -0
- package/app/pages/jobs/index.vue +42 -0
- package/app/pages/login.vue +109 -0
- package/app/pages/p/[name].vue +37 -0
- package/app/pages/r/[resource]/[id]/edit.vue +54 -0
- package/app/pages/r/[resource]/[id]/index.vue +143 -0
- package/app/pages/r/[resource]/create.vue +53 -0
- package/app/pages/r/[resource]/index.vue +29 -0
- package/app/pages/settings.vue +104 -0
- package/app/plugins/vector-csp.client.ts +14 -0
- package/app/plugins/vector-extensions.client.ts +84 -0
- package/app/plugins/vector-i18n.ts +76 -0
- package/app/types/vector.ts +438 -0
- package/app/utils/files.ts +14 -0
- package/app/utils/format.ts +100 -0
- package/app/utils/richtext.ts +305 -0
- package/app/utils/rules.ts +113 -0
- package/app/utils/translate.ts +45 -0
- package/app/utils/widgets.ts +11 -0
- package/nuxt.config.ts +83 -0
- package/package.json +83 -5
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
2
|
+
|
|
3
|
+
/** The value a fresh input holds: the field's default, or the type's empty value. */
|
|
4
|
+
export function initialValue(field: VectorField): unknown {
|
|
5
|
+
if (field.hasDefault) return field.default
|
|
6
|
+
if (field.type === 'boolean') return false
|
|
7
|
+
if (field.type === 'manyToMany') return []
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maps the framework's 422 document onto field names; returns the
|
|
13
|
+
* message that names no field, if any.
|
|
14
|
+
*/
|
|
15
|
+
export function applyViolations(error: unknown, values: Record<string, unknown>, serverErrors: Record<string, string[]>): string | null {
|
|
16
|
+
const failure = error as { status?: number, statusCode?: number, data?: { errors?: unknown } } | null
|
|
17
|
+
const violations = failure?.data?.errors
|
|
18
|
+
|
|
19
|
+
if ((failure?.status ?? failure?.statusCode) !== 422 || !Array.isArray(violations)) {
|
|
20
|
+
return vectorErrorMessage(error)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let general: string | null = null
|
|
24
|
+
|
|
25
|
+
for (const violation of violations as { path: (string | number)[], message: string }[]) {
|
|
26
|
+
const key = String(violation.path[0] ?? '')
|
|
27
|
+
|
|
28
|
+
if (key && key in values) {
|
|
29
|
+
(serverErrors[key] ??= []).push(`${violation.message.charAt(0).toUpperCase()}${violation.message.slice(1)}`)
|
|
30
|
+
} else {
|
|
31
|
+
general = violation.message
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return general ?? (Object.keys(serverErrors).length === 0 ? vt('common.serverRejected') : null)
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEventListener, useIdle } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ends the session when nobody has used the admin for a while. The
|
|
5
|
+
* session otherwise never ends on its own: the store renews the token
|
|
6
|
+
* before it runs out and the shell polls badges and the inbox, so an open
|
|
7
|
+
* tab stays signed in for as long as it is open. This watches for a real
|
|
8
|
+
* person — a pointer, a key, a touch, a scroll — and, once app config
|
|
9
|
+
* `vector.idleMinutes` have passed without one, calls `onIdle` exactly
|
|
10
|
+
* once. A tab that comes back into view after sleeping is checked at
|
|
11
|
+
* that moment too, since a timer may not have fired meanwhile.
|
|
12
|
+
* `idleMinutes: 0` turns it off. Call it from the signed-in layout.
|
|
13
|
+
*/
|
|
14
|
+
export function useVectorIdle(onIdle: () => void | Promise<void>): void {
|
|
15
|
+
const minutes = useAppConfig().vector.idleMinutes
|
|
16
|
+
|
|
17
|
+
if (!import.meta.client || !(minutes > 0)) return
|
|
18
|
+
|
|
19
|
+
const timeout = minutes * 60_000
|
|
20
|
+
const { idle, lastActive, reset } = useIdle(timeout, { listenForVisibilityChange: false })
|
|
21
|
+
let fired = false
|
|
22
|
+
|
|
23
|
+
const fire = () => {
|
|
24
|
+
if (fired) return
|
|
25
|
+
fired = true
|
|
26
|
+
void onIdle()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// synchronous: the session ends the moment the wait is over, not on a render tick
|
|
30
|
+
watch(idle, (now) => {
|
|
31
|
+
if (now) fire()
|
|
32
|
+
}, { flush: 'sync' })
|
|
33
|
+
|
|
34
|
+
useEventListener(document, 'visibilitychange', () => {
|
|
35
|
+
if (document.visibilityState !== 'visible') return
|
|
36
|
+
if (Date.now() - lastActive.value >= timeout) fire()
|
|
37
|
+
else reset()
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impersonation from the UI. Bearer mode receives a new token and swaps
|
|
3
|
+
* it in the auth store; cookie mode is told to reload, the session
|
|
4
|
+
* having been swapped by the application.
|
|
5
|
+
*/
|
|
6
|
+
export function useVectorImpersonation() {
|
|
7
|
+
const api = useVectorApi()
|
|
8
|
+
const auth = useVectorAuthStore()
|
|
9
|
+
const runtime = useVectorRuntime()
|
|
10
|
+
const { load } = useVectorSchema()
|
|
11
|
+
const impersonator = useState<{ id: number | string, name: string | null } | null>('vector.impersonator', () => null)
|
|
12
|
+
|
|
13
|
+
type Swap = { accessToken?: string, expiresIn?: number, user?: { id: number | string, name: string }, target?: { id: number | string, name: string }, reload?: boolean }
|
|
14
|
+
|
|
15
|
+
async function apply(result: Swap) {
|
|
16
|
+
if (result.reload && import.meta.client) {
|
|
17
|
+
window.location.reload()
|
|
18
|
+
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!result.accessToken) {
|
|
23
|
+
throw new Error(vt('shell.impersonation.noToken'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// who the token is for: the application's `user`, the controller's `target`, or the token's own subject
|
|
27
|
+
const subject = result.user ?? result.target ?? { id: subjectOf(result.accessToken) ?? '', name: auth.user?.name ?? '' }
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
const previous = { token: auth.token, expiresAt: auth.expiresAt, user: auth.user }
|
|
31
|
+
auth.adopt(result.accessToken, result.expiresIn ?? 3600, { id: subject.id, name: subject.name })
|
|
32
|
+
useVectorPreferences().clear()
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await load(true)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// the target cannot use the admin at all: undo the swap on both sides and say so
|
|
38
|
+
const status = (error as { status?: number, statusCode?: number } | null)?.status ?? (error as { statusCode?: number } | null)?.statusCode
|
|
39
|
+
|
|
40
|
+
if (status === 403) {
|
|
41
|
+
await api('/vector/impersonate', { method: 'DELETE' }).catch(() => undefined)
|
|
42
|
+
if (previous.token && previous.user) auth.adopt(previous.token, Math.max(1, Math.round((previous.expiresAt - Date.now()) / 1000)), previous.user)
|
|
43
|
+
await load(true)
|
|
44
|
+
throw new Error(vt('shell.impersonation.noAccess', { name: subject.name }), { cause: error })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `sub` of a JWT, read without verifying it: the server already did, and this only labels the session. */
|
|
53
|
+
function subjectOf(token: string): string | null {
|
|
54
|
+
try {
|
|
55
|
+
const payload = JSON.parse(atob(token.split('.')[1]?.replace(/-/g, '+').replace(/_/g, '/') ?? '')) as { sub?: unknown }
|
|
56
|
+
|
|
57
|
+
return typeof payload.sub === 'string' || typeof payload.sub === 'number' ? String(payload.sub) : null
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function start(userId: number | string) {
|
|
64
|
+
await apply(await api<Swap>(`/vector/impersonate/${encodeURIComponent(String(userId))}`, { method: 'POST' }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function stop() {
|
|
68
|
+
await apply(await api<Swap>('/vector/impersonate', { method: 'DELETE' }))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Reads who is impersonating from /vector/me; null when nobody. */
|
|
72
|
+
async function refresh() {
|
|
73
|
+
const me = await api<{ impersonator: number | string | null }>('/vector/me')
|
|
74
|
+
impersonator.value = me.impersonator === null ? null : { id: me.impersonator, name: runtime.user && 'impersonatorName' in runtime.user ? String((runtime.user as { impersonatorName?: string }).impersonatorName ?? '') : null }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { impersonator, start, stop, refresh }
|
|
78
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The input side of the field registry: the component that edits a
|
|
6
|
+
* field in a form. Built-ins are imported explicitly, like the cells;
|
|
7
|
+
* `app.config.vector.inputs` names globally registered overrides.
|
|
8
|
+
*/
|
|
9
|
+
const inputs: Record<string, Component> = {
|
|
10
|
+
string: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
11
|
+
email: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
12
|
+
url: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
13
|
+
slug: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
14
|
+
password: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
15
|
+
color: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Text.vue')),
|
|
16
|
+
file: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Upload.vue')),
|
|
17
|
+
image: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Upload.vue')),
|
|
18
|
+
text: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Textarea.vue')),
|
|
19
|
+
markdown: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Markdown.vue')),
|
|
20
|
+
richtext: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/RichText.vue')),
|
|
21
|
+
json: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Textarea.vue')),
|
|
22
|
+
keyValue: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/KeyValue.vue')),
|
|
23
|
+
number: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Number.vue')),
|
|
24
|
+
money: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Money.vue')),
|
|
25
|
+
boolean: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Boolean.vue')),
|
|
26
|
+
enum: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Enum.vue')),
|
|
27
|
+
currency: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Currency.vue')),
|
|
28
|
+
date: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/DateTime.vue')),
|
|
29
|
+
datetime: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/DateTime.vue')),
|
|
30
|
+
belongsTo: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Relation.vue')),
|
|
31
|
+
manyToMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Relation.vue')),
|
|
32
|
+
hasMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Input/Repeater.vue')),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useVectorInputs() {
|
|
36
|
+
const appConfig = useAppConfig()
|
|
37
|
+
|
|
38
|
+
function inputFor(field: VectorField): Component | string {
|
|
39
|
+
const overrides = appConfig.vector.inputs
|
|
40
|
+
|
|
41
|
+
if (field.component && overrides[field.component]) {
|
|
42
|
+
return overrides[field.component]!
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runtime = useVectorExtensions().inputs
|
|
46
|
+
|
|
47
|
+
if (field.component && runtime.has(field.component)) {
|
|
48
|
+
return runtime.get(field.component)!
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return overrides[field.type] ?? inputs[field.type] ?? inputs.string!
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a field is edited in this operation at all. */
|
|
55
|
+
function editable(field: VectorField, operation: 'create' | 'update'): boolean {
|
|
56
|
+
if (field.type === 'hasMany') return !!field.repeater && field.views.includes(operation)
|
|
57
|
+
return !field.readonly && field.views.includes(operation) && !['id', 'hasOne'].includes(field.type)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { inputFor, editable }
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { VectorJob } from '#vector/app/types/vector'
|
|
2
|
+
|
|
3
|
+
/** Background jobs: list, poll one until it settles, download its file. */
|
|
4
|
+
export function useVectorJobs() {
|
|
5
|
+
const api = useVectorApi()
|
|
6
|
+
|
|
7
|
+
async function list(): Promise<VectorJob[]> {
|
|
8
|
+
return (await api<{ data: VectorJob[] }>('/vector/jobs')).data
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function show(id: number): Promise<VectorJob> {
|
|
12
|
+
return (await api<{ data: VectorJob }>(`/vector/jobs/${id}`)).data
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Polls every second (backing off to five) until the job is done or
|
|
17
|
+
* failed, reporting each state; an aborted signal ends the poll with
|
|
18
|
+
* the last state seen, so a page that goes away stops asking.
|
|
19
|
+
*/
|
|
20
|
+
async function watch(id: number, onUpdate?: (job: VectorJob) => void, signal?: AbortSignal): Promise<VectorJob> {
|
|
21
|
+
let delay = 1000
|
|
22
|
+
|
|
23
|
+
for (;;) {
|
|
24
|
+
const job = await show(id)
|
|
25
|
+
onUpdate?.(job)
|
|
26
|
+
|
|
27
|
+
if (job.status === 'done' || job.status === 'failed' || job.status === 'stalled' || signal?.aborted) {
|
|
28
|
+
return job
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await new Promise<void>((resolve) => {
|
|
32
|
+
const timer = setTimeout(resolve, delay)
|
|
33
|
+
signal?.addEventListener('abort', () => {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
resolve()
|
|
36
|
+
}, { once: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (signal?.aborted) {
|
|
40
|
+
return job
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
delay = Math.min(5000, delay + 500)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function download(job: VectorJob): Promise<void> {
|
|
48
|
+
if (!import.meta.client) return
|
|
49
|
+
|
|
50
|
+
const response = await asFile(() => api.raw(`/vector/jobs/${job.id}/download`, { responseType: 'blob' }))
|
|
51
|
+
const url = URL.createObjectURL(response._data as Blob)
|
|
52
|
+
const link = document.createElement('a')
|
|
53
|
+
link.href = url
|
|
54
|
+
link.download = job.fileName ?? `job-${job.id}`
|
|
55
|
+
document.body.appendChild(link)
|
|
56
|
+
link.click()
|
|
57
|
+
link.remove()
|
|
58
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { list, show, watch, download }
|
|
62
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { VectorLocale, VectorSchemaI18n } from '#vector/app/types/vector'
|
|
2
|
+
import { isRtl, vuetifyLocale } from '#vector/app/i18n'
|
|
3
|
+
|
|
4
|
+
// The latest language the user picked: an earlier pick still in flight
|
|
5
|
+
// must not apply its answer over it. The UI runs in the browser only, one
|
|
6
|
+
// app per page, so one counter per module is one per session.
|
|
7
|
+
let latestChoice = 0
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The admin's language, per user. The descriptor says which language this
|
|
11
|
+
* user is shown and which ones the administrator lets them choose from;
|
|
12
|
+
* `choose()` stores the choice as the user's `locale` preference and
|
|
13
|
+
* reloads the descriptor, whose labels the server translates. Applying a
|
|
14
|
+
* language switches the UI's strings, Vuetify's, the formatting of dates,
|
|
15
|
+
* numbers and money, and the page's direction (right to left for Hebrew).
|
|
16
|
+
*/
|
|
17
|
+
export function useVectorLocale() {
|
|
18
|
+
const nuxtApp = useNuxtApp()
|
|
19
|
+
const locales = useState<VectorLocale[]>('vector.locales', () => [])
|
|
20
|
+
const current = computed(() => String(nuxtApp.$vectorI18n.global.locale.value))
|
|
21
|
+
|
|
22
|
+
function apply(code: string): void {
|
|
23
|
+
nuxtApp.$vectorI18n.global.locale.value = code
|
|
24
|
+
setFormatLocale(code)
|
|
25
|
+
|
|
26
|
+
const vuetify = nuxtApp.$vuetify as { locale?: { current: { value: string }, messages: { value: Record<string, unknown> } } } | undefined
|
|
27
|
+
if (vuetify?.locale) {
|
|
28
|
+
const tag = vuetifyLocale(code)
|
|
29
|
+
const known = vuetify.locale.messages.value
|
|
30
|
+
vuetify.locale.current.value = known[tag] ? tag : (known[code.split('-')[0]!] ? code.split('-')[0]! : 'en')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (import.meta.client) {
|
|
34
|
+
const known = locales.value.find(l => l.code === code)
|
|
35
|
+
document.documentElement.lang = code
|
|
36
|
+
document.documentElement.dir = known ? known.dir : (isRtl(code) ? 'rtl' : 'ltr')
|
|
37
|
+
try {
|
|
38
|
+
window.localStorage.setItem('vector.locale', code)
|
|
39
|
+
} catch {
|
|
40
|
+
// remembered for the next visit only when storage allows it
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Takes what the descriptor says: the language to show and the ones on offer. */
|
|
46
|
+
function adopt(i18n: VectorSchemaI18n | undefined): void {
|
|
47
|
+
if (!i18n) return
|
|
48
|
+
locales.value = i18n.locales
|
|
49
|
+
if (i18n.locale !== current.value) apply(i18n.locale)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The user picks a language: remembered as their preference, then applied
|
|
54
|
+
* — in that order, since applying it makes every page fetch its labels
|
|
55
|
+
* again, and the server answers in the preference it has — and the
|
|
56
|
+
* descriptor reloaded in it.
|
|
57
|
+
*/
|
|
58
|
+
async function choose(code: string): Promise<void> {
|
|
59
|
+
const choice = ++latestChoice
|
|
60
|
+
await useVectorPreferences().put({ locale: code })
|
|
61
|
+
if (choice !== latestChoice) return
|
|
62
|
+
apply(code)
|
|
63
|
+
const schema = await useVectorSchema().load(true)
|
|
64
|
+
if (choice === latestChoice) adopt(schema.i18n)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const direction = computed<'ltr' | 'rtl'>(() => locales.value.find(l => l.code === current.value)?.dir ?? (isRtl(current.value) ? 'rtl' : 'ltr'))
|
|
68
|
+
|
|
69
|
+
return { current, locales, direction, apply, adopt, choose }
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface VectorNotification {
|
|
2
|
+
text: string
|
|
3
|
+
color?: 'success' | 'error' | 'warning' | 'info'
|
|
4
|
+
timeout?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The queue VxNotifications renders. Shared app state, so a page, a
|
|
9
|
+
* store or a fetch hook can notify from anywhere.
|
|
10
|
+
*/
|
|
11
|
+
export function useVectorNotifications() {
|
|
12
|
+
const queue = useState<VectorNotification[]>('vector.notifications', () => [])
|
|
13
|
+
|
|
14
|
+
function notify(text: string, color: VectorNotification['color'] = 'info', timeout?: number) {
|
|
15
|
+
queue.value.push({ text, color, timeout })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
queue,
|
|
20
|
+
notify,
|
|
21
|
+
success: (text: string) => notify(text, 'success'),
|
|
22
|
+
error: (text: string) => notify(text, 'error', 6000),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { VectorNotification } from '#vector/app/types/vector'
|
|
2
|
+
|
|
3
|
+
/** The inbox and its unread count, polled while the admin is open. */
|
|
4
|
+
export function useVectorNotificationsInbox() {
|
|
5
|
+
const api = useVectorApi()
|
|
6
|
+
const unread = useState<number>('vector.inbox.unread', () => 0)
|
|
7
|
+
const items = useState<VectorNotification[]>('vector.inbox.items', () => [])
|
|
8
|
+
|
|
9
|
+
async function refresh(): Promise<void> {
|
|
10
|
+
const inbox = await api<{ data: VectorNotification[], unread: number }>('/vector/notifications')
|
|
11
|
+
items.value = inbox.data
|
|
12
|
+
unread.value = inbox.unread
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function markRead(id: number): Promise<void> {
|
|
16
|
+
await api(`/vector/notifications/${id}/read`, { method: 'POST' })
|
|
17
|
+
await refresh()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function markAllRead(): Promise<void> {
|
|
21
|
+
await api('/vector/notifications/read-all', { method: 'POST' })
|
|
22
|
+
await refresh()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { unread, items, refresh, markRead, markAllRead }
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user UI preferences, loaded once per session and written through
|
|
3
|
+
* as they change. Column state lives under `columns.<resource>`.
|
|
4
|
+
*/
|
|
5
|
+
export function useVectorPreferences() {
|
|
6
|
+
const api = useVectorApi()
|
|
7
|
+
const preferences = useState<Record<string, unknown> | null>('vector.preferences', () => null)
|
|
8
|
+
|
|
9
|
+
async function load(): Promise<Record<string, unknown>> {
|
|
10
|
+
if (preferences.value === null) {
|
|
11
|
+
preferences.value = (await api<{ data: Record<string, unknown> }>('/vector/preferences')).data
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return preferences.value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function get<T>(key: string): T | undefined {
|
|
18
|
+
return preferences.value?.[key] as T | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function put(values: Record<string, unknown>): Promise<void> {
|
|
22
|
+
preferences.value = (await api<{ data: Record<string, unknown> }>('/vector/preferences', { method: 'PUT', body: values })).data
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clear() {
|
|
26
|
+
preferences.value = null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { preferences, load, get, put, clear }
|
|
30
|
+
}
|