@bobbykim/manguito-cms-admin 0.1.1 → 0.3.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/dist/assets/{ChangePasswordView-8UW4RWd8.js → ChangePasswordView-B0wg3O_I.js} +1 -1
- package/dist/assets/{EnumSelect-BOrBkA76.js → ComputedDisplay-C2FpwSB1.js} +2 -2
- package/dist/assets/ContentFormView-D92O0ICh.js +1 -0
- package/dist/assets/{ContentListView-BiPHUBHa.js → ContentListView-DM4xggDx.js} +1 -1
- package/dist/assets/{ContentSettingsView-DPKF5MRL.js → ContentSettingsView-B9UHNleT.js} +1 -1
- package/dist/assets/{LoginView-WELPH4Vh.js → LoginView-DPpdT3Ro.js} +1 -1
- package/dist/assets/{MediaDetailView-BT66fV9b.js → MediaDetailView-OUzzhO1B.js} +1 -1
- package/dist/assets/{MediaLibraryView-BcdiJ_7a.js → MediaLibraryView-Dx9t8r8T.js} +1 -1
- package/dist/assets/TaxonomyFormView-CZlOjxOY.js +1 -0
- package/dist/assets/{TaxonomyListView-BB7iTfjb.js → TaxonomyListView-Dkga11a3.js} +1 -1
- package/dist/assets/{UserFormView-C2p-FV0A.js → UserFormView-CaOcoKX5.js} +1 -1
- package/dist/assets/{UserListView-BnlU2Ovr.js → UserListView-B6_Hdemt.js} +1 -1
- package/dist/assets/{index-BkazhAun.js → index-s-2X8XCa.js} +2 -2
- package/dist/assets/{useNotification-DU6J5vms.js → useNotification-CEPVkHak.js} +1 -1
- package/dist/codegen/index.cjs +7 -2
- package/dist/codegen/index.js +7 -2
- package/dist/index.html +1 -1
- package/index.html +13 -0
- package/package.json +11 -7
- package/public/favicon.ico +0 -0
- package/src/App.vue +147 -0
- package/src/adapters/admin.ts +13 -0
- package/src/components/.gitkeep +0 -0
- package/src/components/fields/BooleanToggle.vue +63 -0
- package/src/components/fields/ComputedDisplay.vue +23 -0
- package/src/components/fields/DatePicker.vue +61 -0
- package/src/components/fields/EnumSelect.vue +69 -0
- package/src/components/fields/MediaUpload.vue +445 -0
- package/src/components/fields/NumberInput.vue +66 -0
- package/src/components/fields/ParagraphEmbed.vue +149 -0
- package/src/components/fields/ReferenceSelect.vue +275 -0
- package/src/components/fields/RichTextEditor.vue +293 -0
- package/src/components/fields/TextInput.vue +93 -0
- package/src/components/fields/__tests__/BooleanToggle.test.ts +37 -0
- package/src/components/fields/__tests__/ComputedDisplay.test.ts +34 -0
- package/src/components/fields/__tests__/DatePicker.test.ts +37 -0
- package/src/components/fields/__tests__/EnumSelect.test.ts +53 -0
- package/src/components/fields/__tests__/NumberInput.test.ts +64 -0
- package/src/components/fields/__tests__/ReferenceSelect.test.ts +196 -0
- package/src/components/fields/__tests__/RichTextEditor.test.ts +81 -0
- package/src/components/fields/__tests__/TextInput.test.ts +72 -0
- package/src/components/layout/AppShell.vue +33 -0
- package/src/components/layout/Footer.vue +11 -0
- package/src/components/layout/Sidebar.vue +254 -0
- package/src/components/layout/Topbar.vue +46 -0
- package/src/components/shared/ConfirmDialog.vue +38 -0
- package/src/components/shared/MediaSelectModal.vue +273 -0
- package/src/components/shared/Pagination.vue +93 -0
- package/src/components/shared/StatusBadge.vue +14 -0
- package/src/components/shared/ToastContainer.vue +44 -0
- package/src/composables/.gitkeep +0 -0
- package/src/composables/__tests__/useApiClient.test.ts +90 -0
- package/src/composables/__tests__/useFormValidation.test.ts +83 -0
- package/src/composables/__tests__/usePermission.test.ts +116 -0
- package/src/composables/useApiClient.ts +68 -0
- package/src/composables/useDirtyState.ts +28 -0
- package/src/composables/useFormValidation.ts +107 -0
- package/src/composables/useNotification.ts +26 -0
- package/src/composables/usePermission.ts +20 -0
- package/src/composables/useTabIndicator.ts +25 -0
- package/src/env.d.ts +2 -0
- package/src/main.ts +21 -0
- package/src/router/index.ts +174 -0
- package/src/stores/auth.ts +82 -0
- package/src/stores/content.ts +40 -0
- package/src/stores/media.ts +31 -0
- package/src/stores/schema.ts +130 -0
- package/src/stores/taxonomy.ts +40 -0
- package/src/stores/ui.ts +94 -0
- package/src/stores/users.ts +44 -0
- package/src/style.css +46 -0
- package/src/test-utils/server.ts +54 -0
- package/src/types/index.ts +0 -0
- package/src/views/.gitkeep +0 -0
- package/src/views/ChangePasswordView.vue +134 -0
- package/src/views/LoginView.vue +187 -0
- package/src/views/RolesView.vue +84 -0
- package/src/views/content/ContentFormView.vue +553 -0
- package/src/views/content/ContentListView.vue +236 -0
- package/src/views/content/ContentSettingsView.vue +122 -0
- package/src/views/media/MediaDetailView.vue +222 -0
- package/src/views/media/MediaLibraryView.vue +492 -0
- package/src/views/taxonomy/TaxonomyFormView.vue +286 -0
- package/src/views/taxonomy/TaxonomyListView.vue +198 -0
- package/src/views/users/UserFormView.vue +307 -0
- package/src/views/users/UserListView.vue +127 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.config.ts +17 -0
- package/dist/assets/ContentFormView-vaIInaYs.js +0 -1
- package/dist/assets/TaxonomyFormView-BdRO9L-7.js +0 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
total: number
|
|
6
|
+
page: number
|
|
7
|
+
perPage: number
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
'update:page': [page: number]
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.perPage)))
|
|
15
|
+
|
|
16
|
+
// Sliding-window range: always show first + last, ±1 around current, null = ellipsis.
|
|
17
|
+
const pageRange = computed<(number | null)[]>(() => {
|
|
18
|
+
const t = totalPages.value
|
|
19
|
+
const c = props.page
|
|
20
|
+
|
|
21
|
+
if (t <= 7) return Array.from({ length: t }, (_, i) => i + 1)
|
|
22
|
+
|
|
23
|
+
const pages: (number | null)[] = [1]
|
|
24
|
+
if (c > 3) pages.push(null)
|
|
25
|
+
|
|
26
|
+
const start = Math.max(2, c - 1)
|
|
27
|
+
const end = Math.min(t - 1, c + 1)
|
|
28
|
+
for (let i = start; i <= end; i++) pages.push(i)
|
|
29
|
+
|
|
30
|
+
if (c < t - 2) pages.push(null)
|
|
31
|
+
pages.push(t)
|
|
32
|
+
|
|
33
|
+
return pages
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function go(p: number) {
|
|
37
|
+
if (p < 1 || p > totalPages.value || p === props.page) return
|
|
38
|
+
emit('update:page', p)
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<nav
|
|
44
|
+
v-if="totalPages > 1"
|
|
45
|
+
class="flex items-center gap-1"
|
|
46
|
+
aria-label="Pagination"
|
|
47
|
+
>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
class="rounded-md px-2.5 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
51
|
+
:disabled="page === 1"
|
|
52
|
+
aria-label="Previous page"
|
|
53
|
+
@click="go(page - 1)"
|
|
54
|
+
>
|
|
55
|
+
‹
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
<template v-for="(item, i) in pageRange" :key="i">
|
|
59
|
+
<span
|
|
60
|
+
v-if="item === null"
|
|
61
|
+
class="px-1.5 py-1.5 text-sm text-gray-400"
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
>
|
|
64
|
+
…
|
|
65
|
+
</span>
|
|
66
|
+
<button
|
|
67
|
+
v-else
|
|
68
|
+
type="button"
|
|
69
|
+
:aria-label="`Page ${item}`"
|
|
70
|
+
:aria-current="item === page ? 'page' : undefined"
|
|
71
|
+
:class="[
|
|
72
|
+
'min-w-8 rounded-md px-2.5 py-1.5 text-sm font-medium',
|
|
73
|
+
item === page
|
|
74
|
+
? 'bg-indigo-600 text-white'
|
|
75
|
+
: 'text-gray-600 hover:bg-gray-100',
|
|
76
|
+
]"
|
|
77
|
+
@click="go(item)"
|
|
78
|
+
>
|
|
79
|
+
{{ item }}
|
|
80
|
+
</button>
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
class="rounded-md px-2.5 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
|
86
|
+
:disabled="page === totalPages"
|
|
87
|
+
aria-label="Next page"
|
|
88
|
+
@click="go(page + 1)"
|
|
89
|
+
>
|
|
90
|
+
›
|
|
91
|
+
</button>
|
|
92
|
+
</nav>
|
|
93
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ published: boolean }>()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<span
|
|
7
|
+
:class="[
|
|
8
|
+
'inline-flex items-center rounded-full px-[11px] py-1 text-xs font-semibold',
|
|
9
|
+
published ? 'bg-[#E7F6EC] text-[#1E8E4E]' : 'bg-[#FBF1DF] text-[#B5790E]',
|
|
10
|
+
]"
|
|
11
|
+
>
|
|
12
|
+
{{ published ? 'Published' : 'Draft' }}
|
|
13
|
+
</span>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useUiStore } from '../../stores/ui'
|
|
4
|
+
|
|
5
|
+
const ui = useUiStore()
|
|
6
|
+
|
|
7
|
+
// Newest at top — the store appends with push(), so reverse for display.
|
|
8
|
+
const visibleToasts = computed(() => [...ui.toasts].reverse())
|
|
9
|
+
|
|
10
|
+
const variantClasses: Record<string, string> = {
|
|
11
|
+
success: 'bg-green-600 text-white',
|
|
12
|
+
error: 'bg-red-600 text-white',
|
|
13
|
+
warning: 'bg-amber-500 text-white',
|
|
14
|
+
info: 'bg-blue-600 text-white',
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div
|
|
20
|
+
aria-live="polite"
|
|
21
|
+
aria-atomic="false"
|
|
22
|
+
class="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
|
23
|
+
>
|
|
24
|
+
<div
|
|
25
|
+
v-for="toast in visibleToasts"
|
|
26
|
+
:key="toast.id"
|
|
27
|
+
:class="[
|
|
28
|
+
'pointer-events-auto flex w-80 items-start gap-3 rounded-lg px-4 py-3 shadow-lg',
|
|
29
|
+
variantClasses[toast.variant] ?? 'bg-blue-600 text-white',
|
|
30
|
+
]"
|
|
31
|
+
role="status"
|
|
32
|
+
>
|
|
33
|
+
<span class="flex-1 text-sm">{{ toast.message }}</span>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
class="shrink-0 opacity-80 hover:opacity-100"
|
|
37
|
+
:aria-label="`Dismiss: ${toast.message}`"
|
|
38
|
+
@click="ui.removeToast(toast.id)"
|
|
39
|
+
>
|
|
40
|
+
×
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
+
import { http, HttpResponse } from 'msw'
|
|
4
|
+
import { server } from '../../test-utils/server'
|
|
5
|
+
import { useApiClient } from '../useApiClient'
|
|
6
|
+
import { useAuthStore } from '../../stores/auth'
|
|
7
|
+
|
|
8
|
+
const mockPush = vi.fn()
|
|
9
|
+
|
|
10
|
+
vi.mock('vue-router', () => ({
|
|
11
|
+
useRouter: () => ({ push: mockPush }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const ADMIN = '/admin'
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
setActivePinia(createPinia())
|
|
18
|
+
mockPush.mockClear()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('useApiClient', () => {
|
|
22
|
+
it('successful GET returns { ok: true, data: ... }', async () => {
|
|
23
|
+
server.use(
|
|
24
|
+
http.get(`${ADMIN}/api/items`, () =>
|
|
25
|
+
HttpResponse.json({ ok: true, data: [{ id: 1, name: 'test' }] })
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
const { get } = useApiClient()
|
|
29
|
+
const result = await get<{ id: number; name: string }[]>('/items')
|
|
30
|
+
expect(result).toEqual({ ok: true, data: [{ id: 1, name: 'test' }] })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('401 → triggers refresh → if refresh succeeds → original call retried', async () => {
|
|
34
|
+
let getCallCount = 0
|
|
35
|
+
server.use(
|
|
36
|
+
http.get(`${ADMIN}/api/protected`, () => {
|
|
37
|
+
getCallCount++
|
|
38
|
+
if (getCallCount === 1) return new HttpResponse(null, { status: 401 })
|
|
39
|
+
return HttpResponse.json({ ok: true, data: 'retry-result' })
|
|
40
|
+
}),
|
|
41
|
+
http.post(`${ADMIN}/api/auth/refresh`, () => new HttpResponse(null, { status: 200 }))
|
|
42
|
+
)
|
|
43
|
+
const { get } = useApiClient()
|
|
44
|
+
const result = await get('/protected')
|
|
45
|
+
|
|
46
|
+
// Refresh was attempted and retry succeeded
|
|
47
|
+
expect(getCallCount).toBe(2)
|
|
48
|
+
expect(result).toEqual({ ok: true, data: 'retry-result' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('401 on retry (isRetrying=true) → no further refresh attempt — only one retry maximum', async () => {
|
|
52
|
+
let refreshCount = 0
|
|
53
|
+
server.use(
|
|
54
|
+
// All calls to the resource return 401
|
|
55
|
+
http.get(`${ADMIN}/api/protected`, () =>
|
|
56
|
+
HttpResponse.json(
|
|
57
|
+
{ ok: false, error: { code: 'UNAUTHORIZED', message: 'Session expired.' } },
|
|
58
|
+
{ status: 401 }
|
|
59
|
+
)
|
|
60
|
+
),
|
|
61
|
+
http.post(`${ADMIN}/api/auth/refresh`, () => {
|
|
62
|
+
refreshCount++
|
|
63
|
+
return new HttpResponse(null, { status: 200 })
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
const { get } = useApiClient()
|
|
67
|
+
await get('/protected')
|
|
68
|
+
|
|
69
|
+
// Refresh was attempted exactly once: for the first 401.
|
|
70
|
+
// The retry also got 401 but isRetrying=true, so no second refresh.
|
|
71
|
+
expect(refreshCount).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('failed refresh → authStore.clear() called and redirect to login', async () => {
|
|
75
|
+
server.use(
|
|
76
|
+
http.get(`${ADMIN}/api/protected`, () => new HttpResponse(null, { status: 401 })),
|
|
77
|
+
// Refresh endpoint returns 401 → refresh fails
|
|
78
|
+
http.post(`${ADMIN}/api/auth/refresh`, () => new HttpResponse(null, { status: 401 }))
|
|
79
|
+
)
|
|
80
|
+
const authStore = useAuthStore()
|
|
81
|
+
authStore.setUser({ id: 'u1', email: 'test@test.local', role: 'editor' })
|
|
82
|
+
const clearSpy = vi.spyOn(authStore, 'clear')
|
|
83
|
+
|
|
84
|
+
const { get } = useApiClient()
|
|
85
|
+
await get('/protected')
|
|
86
|
+
|
|
87
|
+
expect(clearSpy).toHaveBeenCalled()
|
|
88
|
+
expect(mockPush).toHaveBeenCalledWith(`${ADMIN}/login`)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { ParsedField } from '@bobbykim/manguito-cms-core'
|
|
3
|
+
import { useFormValidation } from '../useFormValidation'
|
|
4
|
+
|
|
5
|
+
function makeField(overrides: Partial<ParsedField> = {}): ParsedField {
|
|
6
|
+
return {
|
|
7
|
+
name: 'title',
|
|
8
|
+
label: 'Title',
|
|
9
|
+
field_type: 'text/plain',
|
|
10
|
+
required: true,
|
|
11
|
+
nullable: false,
|
|
12
|
+
order: 0,
|
|
13
|
+
validation: { required: true },
|
|
14
|
+
db_column: { column_name: 'title', column_type: 'varchar', nullable: false },
|
|
15
|
+
ui_component: { component: 'text-input' },
|
|
16
|
+
...overrides,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useFormValidation', () => {
|
|
21
|
+
it('validate(): required field empty → adds error for that field', () => {
|
|
22
|
+
const { validate, touch, errors } = useFormValidation()
|
|
23
|
+
const field = makeField({ name: 'title', label: 'Title', required: true })
|
|
24
|
+
touch('title')
|
|
25
|
+
validate([field], { title: '' })
|
|
26
|
+
expect(errors.value['title']).toBe('Title is required.')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('validate(): field with value → no error', () => {
|
|
30
|
+
const { validate, touch, errors } = useFormValidation()
|
|
31
|
+
const field = makeField({ name: 'title', required: true })
|
|
32
|
+
touch('title')
|
|
33
|
+
validate([field], { title: 'Hello world' })
|
|
34
|
+
expect(errors.value['title']).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('blur behavior: error only shown after touch() is called for that field', () => {
|
|
38
|
+
const { validate, touch, errors } = useFormValidation()
|
|
39
|
+
const field = makeField({ name: 'title', required: true })
|
|
40
|
+
|
|
41
|
+
// validate without touching — validation returns false but no visible error
|
|
42
|
+
const invalid = validate([field], { title: '' })
|
|
43
|
+
expect(invalid).toBe(false)
|
|
44
|
+
expect(errors.value['title']).toBeUndefined()
|
|
45
|
+
|
|
46
|
+
// touch then validate again — error now appears
|
|
47
|
+
touch('title')
|
|
48
|
+
validate([field], { title: '' })
|
|
49
|
+
expect(errors.value['title']).toBeDefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('publish mode: all fields validated regardless of touched state', () => {
|
|
53
|
+
const { validate, errors } = useFormValidation()
|
|
54
|
+
const field = makeField({ name: 'title', required: true })
|
|
55
|
+
|
|
56
|
+
// validateAll = true (publish mode) — no touch() needed
|
|
57
|
+
const valid = validate([field], { title: '' }, true)
|
|
58
|
+
expect(valid).toBe(false)
|
|
59
|
+
expect(errors.value['title']).toBe('Title is required.')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('mergeServerErrors(): PUBLISH_VALIDATION_ERROR details merged into errors state', () => {
|
|
63
|
+
const { mergeServerErrors, errors } = useFormValidation()
|
|
64
|
+
mergeServerErrors([
|
|
65
|
+
{ field: 'title', message: 'Title is too long.' },
|
|
66
|
+
{ field: 'body', message: 'Body is required.' },
|
|
67
|
+
])
|
|
68
|
+
expect(errors.value['title']).toBe('Title is too long.')
|
|
69
|
+
expect(errors.value['body']).toBe('Body is required.')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('clearErrors(): resets all error state', () => {
|
|
73
|
+
const { validate, touch, clearErrors, errors, touched } = useFormValidation()
|
|
74
|
+
const field = makeField({ name: 'title', required: true })
|
|
75
|
+
touch('title')
|
|
76
|
+
validate([field], { title: '' }, true)
|
|
77
|
+
expect(Object.keys(errors.value).length).toBeGreaterThan(0)
|
|
78
|
+
|
|
79
|
+
clearErrors()
|
|
80
|
+
expect(Object.keys(errors.value)).toHaveLength(0)
|
|
81
|
+
expect(touched.value.size).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
+
import type { ParsedRole } from '@bobbykim/manguito-cms-core'
|
|
4
|
+
import { useAuthStore } from '../../stores/auth'
|
|
5
|
+
import { useSchemaStore } from '../../stores/schema'
|
|
6
|
+
import { usePermission } from '../usePermission'
|
|
7
|
+
|
|
8
|
+
// Five roles with clear hierarchy and distinct permission sets.
|
|
9
|
+
const roles: ParsedRole[] = [
|
|
10
|
+
{
|
|
11
|
+
name: 'admin',
|
|
12
|
+
label: 'Admin',
|
|
13
|
+
is_system: true,
|
|
14
|
+
hierarchy_level: 0,
|
|
15
|
+
permissions: [
|
|
16
|
+
'content:read', 'content:create', 'content:edit', 'content:delete',
|
|
17
|
+
'media:read', 'media:create', 'media:edit', 'media:delete',
|
|
18
|
+
'taxonomy:read', 'taxonomy:create', 'taxonomy:edit', 'taxonomy:delete',
|
|
19
|
+
'users:read', 'users:create', 'users:edit', 'users:delete',
|
|
20
|
+
'roles:read',
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'manager',
|
|
25
|
+
label: 'Manager',
|
|
26
|
+
is_system: false,
|
|
27
|
+
hierarchy_level: 1,
|
|
28
|
+
permissions: [
|
|
29
|
+
'content:read', 'content:create', 'content:edit', 'content:delete',
|
|
30
|
+
'media:read', 'media:create',
|
|
31
|
+
'taxonomy:read', 'taxonomy:create', 'taxonomy:edit',
|
|
32
|
+
'users:read',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'editor',
|
|
37
|
+
label: 'Editor',
|
|
38
|
+
is_system: false,
|
|
39
|
+
hierarchy_level: 2,
|
|
40
|
+
permissions: ['content:read', 'content:create', 'content:edit', 'media:read'],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'writer',
|
|
44
|
+
label: 'Writer',
|
|
45
|
+
is_system: false,
|
|
46
|
+
hierarchy_level: 3,
|
|
47
|
+
permissions: ['content:read', 'content:create'],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'viewer',
|
|
51
|
+
label: 'Viewer',
|
|
52
|
+
is_system: false,
|
|
53
|
+
hierarchy_level: 4,
|
|
54
|
+
permissions: [],
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
let authStore: ReturnType<typeof useAuthStore>
|
|
59
|
+
let schemaStore: ReturnType<typeof useSchemaStore>
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
setActivePinia(createPinia())
|
|
63
|
+
authStore = useAuthStore()
|
|
64
|
+
schemaStore = useSchemaStore()
|
|
65
|
+
schemaStore.setRoles(roles)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('usePermission', () => {
|
|
69
|
+
describe('can()', () => {
|
|
70
|
+
it('can("content:read") is true for editor', () => {
|
|
71
|
+
authStore.setUser({ id: '1', email: 'editor@test.local', role: 'editor' })
|
|
72
|
+
const { can } = usePermission()
|
|
73
|
+
expect(can('content:read')).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('can("content:read") is false for viewer who lacks it', () => {
|
|
77
|
+
authStore.setUser({ id: '2', email: 'viewer@test.local', role: 'viewer' })
|
|
78
|
+
const { can } = usePermission()
|
|
79
|
+
expect(can('content:read')).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('can("users:read") is false for editor', () => {
|
|
83
|
+
authStore.setUser({ id: '1', email: 'editor@test.local', role: 'editor' })
|
|
84
|
+
const { can } = usePermission()
|
|
85
|
+
expect(can('users:read')).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('rolesBelow()', () => {
|
|
90
|
+
it('manager (level 1): returns editor, writer, viewer — never admin', () => {
|
|
91
|
+
authStore.setUser({ id: '1', email: 'manager@test.local', role: 'manager' })
|
|
92
|
+
const { rolesBelow } = usePermission()
|
|
93
|
+
const below = rolesBelow()
|
|
94
|
+
const names = below.map(r => r.name)
|
|
95
|
+
|
|
96
|
+
expect(names).toContain('editor')
|
|
97
|
+
expect(names).toContain('writer')
|
|
98
|
+
expect(names).toContain('viewer')
|
|
99
|
+
expect(names).not.toContain('admin')
|
|
100
|
+
expect(names).not.toContain('manager')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('admin (level 0): returns all roles except admin itself', () => {
|
|
104
|
+
authStore.setUser({ id: '1', email: 'admin@test.local', role: 'admin' })
|
|
105
|
+
const { rolesBelow } = usePermission()
|
|
106
|
+
const below = rolesBelow()
|
|
107
|
+
const names = below.map(r => r.name)
|
|
108
|
+
|
|
109
|
+
expect(names).toContain('manager')
|
|
110
|
+
expect(names).toContain('editor')
|
|
111
|
+
expect(names).toContain('writer')
|
|
112
|
+
expect(names).toContain('viewer')
|
|
113
|
+
expect(names).not.toContain('admin')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useRouter } from 'vue-router'
|
|
2
|
+
import { useAuthStore } from '../stores/auth'
|
|
3
|
+
|
|
4
|
+
type ApiResult<T> =
|
|
5
|
+
| { ok: true; data: T }
|
|
6
|
+
| { ok: false; error: { code: string; message: string } }
|
|
7
|
+
|
|
8
|
+
export function useApiClient() {
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const authStore = useAuthStore()
|
|
11
|
+
|
|
12
|
+
async function request<T>(
|
|
13
|
+
path: string,
|
|
14
|
+
options?: RequestInit,
|
|
15
|
+
isRetrying = false
|
|
16
|
+
): Promise<ApiResult<T>> {
|
|
17
|
+
const response = await fetch(`${__ADMIN_PREFIX__}/api${path}`, {
|
|
18
|
+
...options,
|
|
19
|
+
credentials: 'include',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
...options?.headers,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (response.status === 401 && !isRetrying) {
|
|
27
|
+
const refreshed = await tryRefresh()
|
|
28
|
+
if (!refreshed) {
|
|
29
|
+
authStore.clear()
|
|
30
|
+
router.push(`${__ADMIN_PREFIX__}/login`)
|
|
31
|
+
return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Session expired.' } }
|
|
32
|
+
}
|
|
33
|
+
return request<T>(path, options, true)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return response.json() as Promise<ApiResult<T>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function tryRefresh(): Promise<boolean> {
|
|
40
|
+
const res = await fetch(`${__ADMIN_PREFIX__}/api/auth/refresh`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
credentials: 'include',
|
|
43
|
+
})
|
|
44
|
+
return res.ok
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function get<T>(path: string) {
|
|
48
|
+
return request<T>(path, { method: 'GET' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function post<T>(path: string, body: unknown) {
|
|
52
|
+
return request<T>(path, { method: 'POST', body: JSON.stringify(body) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function patch<T>(path: string, body: unknown) {
|
|
56
|
+
return request<T>(path, { method: 'PATCH', body: JSON.stringify(body) })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function put<T>(path: string, body: unknown) {
|
|
60
|
+
return request<T>(path, { method: 'PUT', body: JSON.stringify(body) })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function del(path: string) {
|
|
64
|
+
return request(path, { method: 'DELETE' })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { get, post, patch, put, del }
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useDirtyState() {
|
|
4
|
+
const currentData = ref<unknown>(null)
|
|
5
|
+
const savedSnapshot = ref<unknown>(null)
|
|
6
|
+
const hasSaved = ref(false)
|
|
7
|
+
|
|
8
|
+
// Comparing via JSON.stringify handles nested objects. Form data is expected
|
|
9
|
+
// to be serializable plain objects — no class instances or circular refs.
|
|
10
|
+
const isDirty = computed(() => {
|
|
11
|
+
if (!hasSaved.value) return false
|
|
12
|
+
return JSON.stringify(currentData.value) !== JSON.stringify(savedSnapshot.value)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function markSaved(data: unknown) {
|
|
16
|
+
const snapshot = JSON.parse(JSON.stringify(data ?? null)) as unknown
|
|
17
|
+
currentData.value = snapshot
|
|
18
|
+
savedSnapshot.value = snapshot
|
|
19
|
+
hasSaved.value = true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function confirmNavigation(): boolean {
|
|
23
|
+
if (!isDirty.value) return true
|
|
24
|
+
return window.confirm('You have unsaved changes. Are you sure you want to leave?')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { currentData, isDirty, markSaved, confirmNavigation }
|
|
28
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { ParsedField } from '@bobbykim/manguito-cms-core'
|
|
3
|
+
|
|
4
|
+
export function useFormValidation() {
|
|
5
|
+
const errors = ref<Record<string, string>>({})
|
|
6
|
+
const touched = ref<Set<string>>(new Set())
|
|
7
|
+
|
|
8
|
+
function touch(field: string) {
|
|
9
|
+
touched.value.add(field)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function setError(field: string, message: string) {
|
|
13
|
+
errors.value[field] = message
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clearErrors() {
|
|
17
|
+
errors.value = {}
|
|
18
|
+
touched.value = new Set()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mergeServerErrors(details: Array<{ field: string; message: string }>) {
|
|
22
|
+
for (const { field, message } of details) {
|
|
23
|
+
errors.value[field] = message
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validate(
|
|
28
|
+
fields: ParsedField[],
|
|
29
|
+
values: Record<string, unknown>,
|
|
30
|
+
validateAll = false
|
|
31
|
+
): boolean {
|
|
32
|
+
// When validating for publish, mark all fields touched so errors persist
|
|
33
|
+
// for subsequent interactions — no disappearing errors after a publish attempt.
|
|
34
|
+
if (validateAll) {
|
|
35
|
+
for (const field of fields) {
|
|
36
|
+
touched.value.add(field.name)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let allValid = true
|
|
41
|
+
|
|
42
|
+
for (const field of fields) {
|
|
43
|
+
const value = values[field.name]
|
|
44
|
+
const errorMessage = checkField(field, value)
|
|
45
|
+
|
|
46
|
+
if (errorMessage) {
|
|
47
|
+
allValid = false
|
|
48
|
+
if (touched.value.has(field.name)) {
|
|
49
|
+
errors.value[field.name] = errorMessage
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
delete errors.value[field.name]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return allValid
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkField(field: ParsedField, value: unknown): string | null {
|
|
60
|
+
const { required, pattern, min, max, max_items, allowed_values } = field.validation
|
|
61
|
+
|
|
62
|
+
if (required && isEmpty(value)) {
|
|
63
|
+
return `${field.label} is required.`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isEmpty(value)) return null
|
|
67
|
+
|
|
68
|
+
if (pattern && typeof value === 'string' && !new RegExp(pattern).test(value)) {
|
|
69
|
+
return `${field.label} is not in the correct format.`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
if (min !== undefined && value < min) {
|
|
74
|
+
return `${field.label} must be at least ${min}.`
|
|
75
|
+
}
|
|
76
|
+
if (max !== undefined && value > max) {
|
|
77
|
+
return `${field.label} must be at most ${max}.`
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (allowed_values && allowed_values.length > 0 && typeof value === 'string') {
|
|
82
|
+
if (!allowed_values.includes(value)) {
|
|
83
|
+
return `${field.label} must be one of: ${allowed_values.join(', ')}.`
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (max_items !== undefined && Array.isArray(value) && value.length > max_items) {
|
|
88
|
+
return `${field.label} can have at most ${max_items} items.`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isEmpty(value: unknown): boolean {
|
|
95
|
+
return value === null || value === undefined || value === ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
errors,
|
|
100
|
+
touched,
|
|
101
|
+
touch,
|
|
102
|
+
setError,
|
|
103
|
+
clearErrors,
|
|
104
|
+
mergeServerErrors,
|
|
105
|
+
validate,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useUiStore } from '../stores/ui'
|
|
2
|
+
|
|
3
|
+
// Codes where the raw API message is not suitable for end users.
|
|
4
|
+
// Unmapped codes fall back to result.error.message directly.
|
|
5
|
+
const ERROR_MESSAGES: Partial<Record<string, string>> = {
|
|
6
|
+
INTERNAL_ERROR: 'Something went wrong. Please try again.',
|
|
7
|
+
NOT_FOUND: 'The requested item could not be found.',
|
|
8
|
+
STORAGE_ERROR: 'File storage is unavailable. Please try again later.',
|
|
9
|
+
MEDIA_IN_USE: 'This file is still in use and cannot be deleted.',
|
|
10
|
+
INSUFFICIENT_PERMISSION: 'You do not have permission to do that.',
|
|
11
|
+
RATE_LIMITED: 'Too many requests. Please wait before trying again.',
|
|
12
|
+
UNAUTHORIZED: 'Your session has expired. Please log in again.',
|
|
13
|
+
TOKEN_EXPIRED: 'Your session has expired. Please log in again.',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useNotification() {
|
|
17
|
+
const ui = useUiStore()
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
success: (message: string) => ui.addToast('success', message),
|
|
21
|
+
error: (message: string) => ui.addToast('error', message),
|
|
22
|
+
warning: (message: string) => ui.addToast('warning', message),
|
|
23
|
+
apiError: (code: string, fallback: string) =>
|
|
24
|
+
ui.addToast('error', ERROR_MESSAGES[code] ?? fallback),
|
|
25
|
+
}
|
|
26
|
+
}
|