mgv-backoffice 1.3.0 → 1.6.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.
@@ -0,0 +1,142 @@
1
+ <template>
2
+ <!--
3
+ Compact ghost action button — the colour-coded "Edit / Logs / Stub /
4
+ Delete" actions that sit on a card footer or in an action row. No
5
+ border or fill at rest; a tinted hover background keyed to the
6
+ semantic colour. Pairs an optional leading icon with a label.
7
+
8
+ Slots:
9
+ • icon — leading Heroicon. Receives an `iconClass` slot prop
10
+ ('w-4 h-4') so callers can spread it onto their icon.
11
+
12
+ Props:
13
+ • label — button text. Optional (some actions are label-only,
14
+ e.g. "click to create").
15
+ • color — semantic colour token. Determines text + hover tint.
16
+ • disabled — greys via opacity + blocks the click; the hover tint
17
+ is suppressed so a disabled button doesn't light up.
18
+ • fullWidth — stretch to fill its flex row (adds `flex-1`). Used
19
+ on card footers where actions share the width evenly.
20
+ • title / ariaLabel — native tooltip / accessibility text.
21
+ • type — native button type. Defaults to 'button'.
22
+ -->
23
+ <button
24
+ :type="type"
25
+ :disabled="disabled"
26
+ :title="title"
27
+ :aria-label="ariaLabel"
28
+ class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
29
+ :class="[fullWidth ? 'flex-1' : '', stateClass]"
30
+ @click="$emit('click', $event)"
31
+ >
32
+ <slot name="icon" :icon-class="'w-4 h-4'" />
33
+ <span v-if="label">{{ label }}</span>
34
+ </button>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { computed } from 'vue'
39
+ import { useTheme } from '../composables/useTheme'
40
+
41
+ type ActionColor =
42
+ | 'emerald'
43
+ | 'sky'
44
+ | 'indigo'
45
+ | 'teal'
46
+ | 'purple'
47
+ | 'red'
48
+ | 'amber'
49
+ | 'amberStrong'
50
+
51
+ const props = withDefaults(
52
+ defineProps<{
53
+ label?: string
54
+ color?: ActionColor
55
+ disabled?: boolean
56
+ fullWidth?: boolean
57
+ title?: string
58
+ ariaLabel?: string
59
+ type?: 'button' | 'submit' | 'reset'
60
+ }>(),
61
+ {
62
+ label: '',
63
+ color: 'emerald',
64
+ disabled: false,
65
+ fullWidth: false,
66
+ title: undefined,
67
+ ariaLabel: undefined,
68
+ type: 'button',
69
+ },
70
+ )
71
+
72
+ defineEmits<{
73
+ click: [event: MouseEvent]
74
+ }>()
75
+
76
+ const { isDark } = useTheme()
77
+
78
+ // Per-colour text + hover-tint classes for each theme. `amberStrong`
79
+ // is the higher-contrast amber used for the "click to create" call to
80
+ // action; `amber` is the softer shade used for Clone-style actions.
81
+ const colorClass = computed(() => {
82
+ const palette: Record<ActionColor, { dark: string; light: string }> = {
83
+ emerald: {
84
+ dark: 'text-emerald-400 hover:bg-emerald-500/10',
85
+ light: 'text-emerald-600 hover:bg-emerald-50',
86
+ },
87
+ sky: {
88
+ dark: 'text-sky-400 hover:bg-sky-500/10',
89
+ light: 'text-sky-600 hover:bg-sky-50',
90
+ },
91
+ indigo: {
92
+ dark: 'text-indigo-400 hover:bg-indigo-500/10',
93
+ light: 'text-indigo-600 hover:bg-indigo-50',
94
+ },
95
+ teal: {
96
+ dark: 'text-teal-400 hover:bg-teal-500/10',
97
+ light: 'text-teal-600 hover:bg-teal-50',
98
+ },
99
+ purple: {
100
+ dark: 'text-purple-400 hover:bg-purple-500/10',
101
+ light: 'text-purple-600 hover:bg-purple-50',
102
+ },
103
+ red: {
104
+ dark: 'text-red-400 hover:bg-red-500/10',
105
+ light: 'text-red-500 hover:bg-red-50',
106
+ },
107
+ amber: {
108
+ dark: 'text-amber-400 hover:bg-amber-500/10',
109
+ light: 'text-amber-600 hover:bg-amber-50',
110
+ },
111
+ amberStrong: {
112
+ dark: 'text-amber-300 hover:bg-amber-500/10',
113
+ light: 'text-amber-700 hover:bg-amber-50',
114
+ },
115
+ }
116
+ const entry = palette[props.color]
117
+ return isDark.value ? entry.dark : entry.light
118
+ })
119
+
120
+ // Text colour without the hover tint, for the disabled state. Keeps the
121
+ // semantic colour legible but stops the button reacting to the pointer.
122
+ const disabledColorClass = computed(() => {
123
+ const palette: Record<ActionColor, { dark: string; light: string }> = {
124
+ emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
125
+ sky: { dark: 'text-sky-400', light: 'text-sky-600' },
126
+ indigo: { dark: 'text-indigo-400', light: 'text-indigo-600' },
127
+ teal: { dark: 'text-teal-400', light: 'text-teal-600' },
128
+ purple: { dark: 'text-purple-400', light: 'text-purple-600' },
129
+ red: { dark: 'text-red-400', light: 'text-red-500' },
130
+ amber: { dark: 'text-amber-400', light: 'text-amber-600' },
131
+ amberStrong: { dark: 'text-amber-300', light: 'text-amber-700' },
132
+ }
133
+ const entry = palette[props.color]
134
+ return isDark.value ? entry.dark : entry.light
135
+ })
136
+
137
+ const stateClass = computed(() =>
138
+ props.disabled
139
+ ? `${disabledColorClass.value} opacity-50 cursor-not-allowed`
140
+ : `${colorClass.value} cursor-pointer`,
141
+ )
142
+ </script>
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <!--
3
+ Page-level header. Standardises the "icon badge + title/subtitle on
4
+ the left, action buttons on the right" pattern for top-level views.
5
+
6
+ Slots:
7
+ • icon — the page-specific Heroicon. Receives an `iconClass`
8
+ slot prop carrying the theme-aware text colour, so callers can
9
+ bind `:class="iconClass"` on their icon component.
10
+ • actions — refresh / destructive buttons rendered on the right.
11
+
12
+ Props:
13
+ • title — the H1 text.
14
+ • subtitle — optional muted line below the title.
15
+ • iconColor — badge background + icon text colour
16
+ ('emerald' | 'sky' | 'red' | 'amber'). Defaults to 'emerald'.
17
+ • maxWidthClass — Tailwind max-w utility constraining the header
18
+ width so sibling views can share a footprint. Defaults to
19
+ 'max-w-4xl'.
20
+ -->
21
+ <div :class="['mx-auto px-4 sm:px-6', maxWidthClass]">
22
+ <div class="flex items-center justify-between mb-8">
23
+ <div class="flex items-center gap-3">
24
+ <div
25
+ class="w-10 h-10 rounded-lg flex items-center justify-center"
26
+ :class="badgeBgClass"
27
+ >
28
+ <slot name="icon" :icon-class="iconTextClass" />
29
+ </div>
30
+ <div>
31
+ <h1 class="text-2xl font-bold" :class="t.primaryTextSoft">
32
+ {{ title }}
33
+ </h1>
34
+ <p v-if="subtitle" class="text-sm" :class="t.dimTextAlt">
35
+ {{ subtitle }}
36
+ </p>
37
+ </div>
38
+ </div>
39
+ <div class="flex items-center gap-2">
40
+ <slot name="actions" />
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { computed } from 'vue'
48
+ import { useTheme } from '../composables/useTheme'
49
+ import { useThemeClasses } from '../composables/useThemeClasses'
50
+
51
+ type IconColor = 'emerald' | 'sky' | 'red' | 'amber'
52
+
53
+ const props = withDefaults(
54
+ defineProps<{
55
+ title: string
56
+ subtitle?: string
57
+ iconColor?: IconColor
58
+ maxWidthClass?: string
59
+ }>(),
60
+ {
61
+ subtitle: '',
62
+ iconColor: 'emerald',
63
+ maxWidthClass: 'max-w-4xl',
64
+ },
65
+ )
66
+
67
+ const { isDark } = useTheme()
68
+ const t = useThemeClasses()
69
+
70
+ // Maps the named colour palette to the badge-background classes for
71
+ // dark/light themes. Callers just pass `iconColor="sky"` and the
72
+ // component resolves both modes.
73
+ const badgeBgClass = computed(() => {
74
+ const palette: Record<IconColor, { dark: string; light: string }> = {
75
+ emerald: { dark: 'bg-emerald-500/10', light: 'bg-emerald-50' },
76
+ sky: { dark: 'bg-sky-500/10', light: 'bg-sky-50' },
77
+ red: { dark: 'bg-red-500/10', light: 'bg-red-50' },
78
+ amber: { dark: 'bg-amber-500/10', light: 'bg-amber-50' },
79
+ }
80
+ const entry = palette[props.iconColor]
81
+ return isDark.value ? entry.dark : entry.light
82
+ })
83
+
84
+ // Icon text colour, routed through the same name → palette mapping so
85
+ // callers don't need to know the exact Tailwind shade per theme. Exposed
86
+ // to the `icon` slot via the `iconClass` slot prop.
87
+ const iconTextClass = computed(() => {
88
+ const palette: Record<IconColor, { dark: string; light: string }> = {
89
+ emerald: { dark: 'text-emerald-400', light: 'text-emerald-600' },
90
+ sky: { dark: 'text-sky-400', light: 'text-sky-600' },
91
+ red: { dark: 'text-red-400', light: 'text-red-500' },
92
+ amber: { dark: 'text-amber-400', light: 'text-amber-600' },
93
+ }
94
+ const entry = palette[props.iconColor]
95
+ return isDark.value ? entry.dark : entry.light
96
+ })
97
+ </script>
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <!--
3
+ Bordered toolbar button — the "Refresh / Delete All" row that sits
4
+ under a page header. Pairs an optional leading icon with a label.
5
+
6
+ Slots:
7
+ • icon — leading Heroicon. Receives an `iconClass` slot prop
8
+ ('w-4 h-4') so callers can spread it onto their icon and still
9
+ add their own state classes (e.g. `:class="['animate-spin']"`).
10
+
11
+ Props:
12
+ • label — button text (rendered in a <span>). Optional so an
13
+ icon-only toolbar button is possible.
14
+ • variant — 'neutral' (default grey) or 'danger' (solid red).
15
+ • disabled — greys out + blocks the click.
16
+ • title — native tooltip / accessibility text.
17
+ • type — native button type. Defaults to 'button' so the
18
+ button never accidentally submits a surrounding form.
19
+ -->
20
+ <button
21
+ :type="type"
22
+ :disabled="disabled"
23
+ :title="title"
24
+ class="inline-flex shrink-0 items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
25
+ :class="variantClass"
26
+ @click="$emit('click', $event)"
27
+ >
28
+ <slot name="icon" :icon-class="'w-4 h-4'" />
29
+ <span v-if="label">{{ label }}</span>
30
+ </button>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { computed } from 'vue'
35
+ import { useTheme } from '../composables/useTheme'
36
+
37
+ type ToolbarVariant = 'neutral' | 'danger'
38
+
39
+ const props = withDefaults(
40
+ defineProps<{
41
+ label?: string
42
+ variant?: ToolbarVariant
43
+ disabled?: boolean
44
+ title?: string
45
+ type?: 'button' | 'submit' | 'reset'
46
+ }>(),
47
+ {
48
+ label: '',
49
+ variant: 'neutral',
50
+ disabled: false,
51
+ title: undefined,
52
+ type: 'button',
53
+ },
54
+ )
55
+
56
+ defineEmits<{
57
+ click: [event: MouseEvent]
58
+ }>()
59
+
60
+ const { isDark } = useTheme()
61
+
62
+ // Resolves the variant + theme into the colour classes. The danger
63
+ // style is normalised here so every destructive toolbar button reads
64
+ // identically (red-500 hover in dark, red-700 hover in light).
65
+ const variantClass = computed(() => {
66
+ const palette: Record<ToolbarVariant, { dark: string; light: string }> = {
67
+ neutral: {
68
+ dark: 'bg-gray-800 text-gray-100 border-gray-700 hover:bg-gray-700',
69
+ light: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-100',
70
+ },
71
+ danger: {
72
+ dark: 'bg-red-600 text-white border-red-600 hover:bg-red-500',
73
+ light: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
74
+ },
75
+ }
76
+ const entry = palette[props.variant]
77
+ return isDark.value ? entry.dark : entry.light
78
+ })
79
+ </script>
package/src/index.ts CHANGED
@@ -26,6 +26,9 @@ export { default as BaseNotFoundPage } from './components/BaseNotFoundPage.vue'
26
26
  export { default as BaseSidebar } from './components/BaseSidebar.vue'
27
27
  export { default as BaseEntityPickerModal } from './components/BaseEntityPickerModal.vue'
28
28
  export { default as BaseAppLayout } from './components/BaseAppLayout.vue'
29
+ export { default as BasePageHeader } from './components/BasePageHeader.vue'
30
+ export { default as BaseToolbarButton } from './components/BaseToolbarButton.vue'
31
+ export { default as BaseActionButton } from './components/BaseActionButton.vue'
29
32
 
30
33
  // Composables
31
34
  export { useTheme } from './composables/useTheme'
@@ -62,3 +65,4 @@ export {
62
65
  statusBadgeSolid,
63
66
  statusBadgeTinted,
64
67
  } from './utils/httpColors'
68
+ export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Allow-list HTML sanitizer for strings bound into `v-html`.
3
+ *
4
+ * Use whenever rich HTML from any source (backend payloads, user input,
5
+ * imported content) is rendered via `v-html`. Even "trusted" sources
6
+ * should be sanitised belt-and-suspenders so a future change can't
7
+ * smuggle script tags, inline event handlers, or `javascript:` hrefs
8
+ * into the page.
9
+ *
10
+ * Implementation:
11
+ * 1. Parse with `DOMParser` into an off-document tree.
12
+ * 2. Walk the tree once, replacing any disallowed element with its
13
+ * `textContent` (the readable text survives, the wrapper is gone).
14
+ * 3. On every element, strip every attribute except the per-tag
15
+ * allow-list below.
16
+ * 4. Re-validate `a[href]` against an explicit scheme whitelist —
17
+ * `javascript:`, `data:`, `vbscript:`, and `file:` are rejected.
18
+ * 5. Force `rel="noopener noreferrer" target="_blank"` on every
19
+ * surviving anchor so external links can't reach back through
20
+ * `window.opener`.
21
+ *
22
+ * Returns the cleaned HTML string, ready for `v-html`. Browser-only —
23
+ * relies on `DOMParser`.
24
+ */
25
+
26
+ const ALLOWED_TAGS: ReadonlySet<string> = new Set([
27
+ 'A', 'B', 'STRONG', 'I', 'EM', 'CODE', 'PRE', 'BR',
28
+ 'P', 'UL', 'OL', 'LI', 'SPAN', 'DIV',
29
+ ])
30
+
31
+ /**
32
+ * Tags whose contents are also dropped (not unwrapped as text). For
33
+ * most disallowed elements we keep the inner text so the user-visible
34
+ * copy survives even when the wrapper is stripped, but for
35
+ * code-bearing elements like `<script>` and `<style>` the inner text
36
+ * is the payload itself — leaving it as a visible text node would
37
+ * splat raw JS / CSS source into the rendered output. Drop the
38
+ * element entirely instead.
39
+ */
40
+ const STRIP_WITH_CONTENT: ReadonlySet<string> = new Set([
41
+ 'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'NOSCRIPT', 'TEMPLATE',
42
+ ])
43
+
44
+ const ALLOWED_ATTRS_BY_TAG: Readonly<Record<string, ReadonlySet<string>>> = {
45
+ A: new Set(['href', 'title']),
46
+ }
47
+
48
+ /**
49
+ * Returns true when the supplied href is safe to render as-is. Blocks
50
+ * every scheme that has historically been a vector for XSS or local-
51
+ * file leakage.
52
+ */
53
+ export function isSafeHref(value: string): boolean {
54
+ const trimmed = value.trim().toLowerCase()
55
+ return (
56
+ trimmed.startsWith('http://')
57
+ || trimmed.startsWith('https://')
58
+ || trimmed.startsWith('mailto:')
59
+ || trimmed.startsWith('tel:')
60
+ || trimmed.startsWith('/')
61
+ || trimmed.startsWith('#')
62
+ || trimmed === ''
63
+ )
64
+ }
65
+
66
+ function sanitizeNode(node: Node): void {
67
+ if (node.nodeType !== Node.ELEMENT_NODE) return
68
+ const el = node as Element
69
+ const tag = el.tagName.toUpperCase()
70
+ // Disallowed element: replace with its textContent so the text the
71
+ // user is meant to read survives, but the wrapper is gone. For
72
+ // code-bearing elements (`<script>`, `<style>`, etc.) we drop the
73
+ // contents too so the JS/CSS source itself doesn't render as
74
+ // visible text.
75
+ if (!ALLOWED_TAGS.has(tag)) {
76
+ if (STRIP_WITH_CONTENT.has(tag)) {
77
+ el.remove()
78
+ } else {
79
+ el.replaceWith(document.createTextNode(el.textContent ?? ''))
80
+ }
81
+ return
82
+ }
83
+ // Strip every attribute that isn't in the per-tag allow-list, and
84
+ // re-validate `a[href]` against `isSafeHref`.
85
+ const allowedAttrs = ALLOWED_ATTRS_BY_TAG[tag] ?? new Set<string>()
86
+ for (const attr of Array.from(el.attributes)) {
87
+ if (!allowedAttrs.has(attr.name.toLowerCase())) {
88
+ el.removeAttribute(attr.name)
89
+ continue
90
+ }
91
+ if (tag === 'A' && attr.name.toLowerCase() === 'href' && !isSafeHref(attr.value)) {
92
+ el.removeAttribute(attr.name)
93
+ }
94
+ }
95
+ // External-link hardening: every surviving anchor opens safely.
96
+ if (tag === 'A' && el.hasAttribute('href')) {
97
+ el.setAttribute('rel', 'noopener noreferrer')
98
+ el.setAttribute('target', '_blank')
99
+ }
100
+ // Recurse over children — copy the live list first so removals don't
101
+ // skip siblings.
102
+ for (const child of Array.from(el.childNodes)) {
103
+ sanitizeNode(child)
104
+ }
105
+ }
106
+
107
+ export function sanitizeHtml(raw: string | undefined | null): string {
108
+ if (!raw) return ''
109
+ const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html')
110
+ const root = doc.body.firstElementChild
111
+ if (!root) return ''
112
+ for (const child of Array.from(root.childNodes)) {
113
+ sanitizeNode(child)
114
+ }
115
+ return root.innerHTML
116
+ }