@bobbykim/manguito-cms-admin 0.1.0 → 0.2.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/LICENSE.md +21 -0
- package/index.html +13 -0
- package/package.json +19 -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/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__/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 +548 -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 +281 -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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { defineStore } from 'pinia'
|
|
3
|
+
|
|
4
|
+
export type UserResponse = {
|
|
5
|
+
id: string
|
|
6
|
+
email: string
|
|
7
|
+
role: string
|
|
8
|
+
must_change_password: boolean
|
|
9
|
+
created_at: string
|
|
10
|
+
updated_at: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useUsersStore = defineStore('users', () => {
|
|
14
|
+
const users = ref<UserResponse[]>([])
|
|
15
|
+
const loading = ref(false)
|
|
16
|
+
|
|
17
|
+
function setUsers(newUsers: UserResponse[]) {
|
|
18
|
+
users.value = newUsers
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setUser(user: UserResponse) {
|
|
22
|
+
const index = users.value.findIndex(u => u.id === user.id)
|
|
23
|
+
if (index !== -1) {
|
|
24
|
+
users.value[index] = user
|
|
25
|
+
} else {
|
|
26
|
+
users.value.push(user)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function removeUser(id: string) {
|
|
31
|
+
const index = users.value.findIndex(u => u.id === id)
|
|
32
|
+
if (index !== -1) {
|
|
33
|
+
users.value.splice(index, 1)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
users,
|
|
39
|
+
loading,
|
|
40
|
+
setUsers,
|
|
41
|
+
setUser,
|
|
42
|
+
removeUser,
|
|
43
|
+
}
|
|
44
|
+
})
|
package/src/style.css
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--font-sans: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
|
|
5
|
+
--font-mono: "JetBrains Mono", monospace;
|
|
6
|
+
|
|
7
|
+
/* Accent (purple) replaces the default Tailwind indigo scale used across the app */
|
|
8
|
+
--color-indigo-50: #eeedfb;
|
|
9
|
+
--color-indigo-100: #eeedfb;
|
|
10
|
+
--color-indigo-200: #d6d5e6;
|
|
11
|
+
--color-indigo-300: #b8b6e0;
|
|
12
|
+
--color-indigo-400: #8d87e8;
|
|
13
|
+
--color-indigo-500: #6d5ef0;
|
|
14
|
+
--color-indigo-600: #5b57e8;
|
|
15
|
+
--color-indigo-700: #4b47d6;
|
|
16
|
+
--color-indigo-800: #3d3ab0;
|
|
17
|
+
--color-indigo-900: #2f2c8a;
|
|
18
|
+
|
|
19
|
+
--color-surface: #f6f6fa;
|
|
20
|
+
--color-card-border: #ecebf3;
|
|
21
|
+
--color-divider: #f0eff5;
|
|
22
|
+
--color-ink: #23233a;
|
|
23
|
+
--color-muted: #8a8a9e;
|
|
24
|
+
--color-faint: #a2a2b6;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: var(--font-sans);
|
|
29
|
+
-webkit-font-smoothing: antialiased;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Thin scrollbar — keeps a long sidebar nav list (many content/taxonomy types) from feeling heavy */
|
|
33
|
+
::-webkit-scrollbar {
|
|
34
|
+
width: 10px;
|
|
35
|
+
height: 10px;
|
|
36
|
+
}
|
|
37
|
+
::-webkit-scrollbar-thumb {
|
|
38
|
+
background: #dedde9;
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
border: 3px solid transparent;
|
|
41
|
+
background-clip: content-box;
|
|
42
|
+
}
|
|
43
|
+
::-webkit-scrollbar-thumb:hover {
|
|
44
|
+
background: #c9c8da;
|
|
45
|
+
background-clip: content-box;
|
|
46
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { setupServer } from 'msw/node'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import type { ParsedRole } from '@bobbykim/manguito-cms-core'
|
|
4
|
+
|
|
5
|
+
declare const __ADMIN_PREFIX__: string
|
|
6
|
+
|
|
7
|
+
export const testRoles: ParsedRole[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'admin',
|
|
10
|
+
label: 'Admin',
|
|
11
|
+
is_system: true,
|
|
12
|
+
hierarchy_level: 100,
|
|
13
|
+
permissions: [
|
|
14
|
+
'content:read', 'content:create', 'content:edit', 'content:delete',
|
|
15
|
+
'media:read', 'media:create', 'media:edit', 'media:delete',
|
|
16
|
+
'taxonomy:read', 'taxonomy:create', 'taxonomy:edit', 'taxonomy:delete',
|
|
17
|
+
'users:read', 'users:create', 'users:edit', 'users:delete',
|
|
18
|
+
'roles:read',
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'editor',
|
|
23
|
+
label: 'Editor',
|
|
24
|
+
is_system: false,
|
|
25
|
+
hierarchy_level: 50,
|
|
26
|
+
permissions: [
|
|
27
|
+
'content:read', 'content:create', 'content:edit',
|
|
28
|
+
'media:read', 'media:create',
|
|
29
|
+
'taxonomy:read',
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export const testUser = {
|
|
35
|
+
id: 'test-uuid',
|
|
36
|
+
email: 'editor@test.local',
|
|
37
|
+
role: 'editor',
|
|
38
|
+
must_change_password: false,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const server = setupServer(
|
|
42
|
+
http.get(`${__ADMIN_PREFIX__}/api/config`, () =>
|
|
43
|
+
HttpResponse.json({
|
|
44
|
+
ok: true,
|
|
45
|
+
data: {
|
|
46
|
+
cms_name: 'Test CMS',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
roles: testRoles,
|
|
49
|
+
user: testUser,
|
|
50
|
+
media: { max_file_size: 4194304 },
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
import { useApiClient } from '../composables/useApiClient'
|
|
5
|
+
import { useAuthStore } from '../stores/auth'
|
|
6
|
+
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
const api = useApiClient()
|
|
9
|
+
const authStore = useAuthStore()
|
|
10
|
+
|
|
11
|
+
const currentPassword = ref('')
|
|
12
|
+
const newPassword = ref('')
|
|
13
|
+
const confirmPassword = ref('')
|
|
14
|
+
const submitting = ref(false)
|
|
15
|
+
const formError = ref('')
|
|
16
|
+
|
|
17
|
+
async function onSubmit() {
|
|
18
|
+
formError.value = ''
|
|
19
|
+
|
|
20
|
+
if (newPassword.value !== confirmPassword.value) {
|
|
21
|
+
formError.value = 'Passwords do not match.'
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
submitting.value = true
|
|
26
|
+
|
|
27
|
+
const res = await api.post<{
|
|
28
|
+
id: string
|
|
29
|
+
email: string
|
|
30
|
+
role: string
|
|
31
|
+
must_change_password: boolean
|
|
32
|
+
}>('/users/change-password', {
|
|
33
|
+
current_password: currentPassword.value,
|
|
34
|
+
new_password: newPassword.value,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
submitting.value = false
|
|
38
|
+
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
if (res.error.code === 'INVALID_CREDENTIALS') {
|
|
41
|
+
formError.value = 'Current password is incorrect.'
|
|
42
|
+
} else {
|
|
43
|
+
formError.value = 'Something went wrong. Please try again.'
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Only mustChangePassword changes — id/email/role are unchanged.
|
|
49
|
+
authStore.setUser({
|
|
50
|
+
id: authStore.id!,
|
|
51
|
+
email: authStore.email!,
|
|
52
|
+
role: authStore.role!,
|
|
53
|
+
mustChangePassword: false,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
void router.push(`${__ADMIN_PREFIX__}/media`)
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<!-- Standalone layout — no AppShell/sidebar chrome -->
|
|
62
|
+
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
|
63
|
+
<div class="w-full max-w-sm">
|
|
64
|
+
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Change password</h1>
|
|
65
|
+
<p class="mb-8 text-center text-sm text-gray-500">
|
|
66
|
+
You must set a new password before continuing.
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<form class="space-y-5" novalidate @submit.prevent="onSubmit">
|
|
70
|
+
<!-- Current password -->
|
|
71
|
+
<div>
|
|
72
|
+
<label for="current-password" class="block text-[13px] font-semibold text-[#3D3D52]">
|
|
73
|
+
Current password
|
|
74
|
+
</label>
|
|
75
|
+
<input
|
|
76
|
+
id="current-password"
|
|
77
|
+
v-model="currentPassword"
|
|
78
|
+
type="password"
|
|
79
|
+
required
|
|
80
|
+
autocomplete="current-password"
|
|
81
|
+
:disabled="submitting"
|
|
82
|
+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- New password -->
|
|
87
|
+
<div>
|
|
88
|
+
<label for="new-password" class="block text-[13px] font-semibold text-[#3D3D52]">
|
|
89
|
+
New password
|
|
90
|
+
</label>
|
|
91
|
+
<input
|
|
92
|
+
id="new-password"
|
|
93
|
+
v-model="newPassword"
|
|
94
|
+
type="password"
|
|
95
|
+
required
|
|
96
|
+
autocomplete="new-password"
|
|
97
|
+
:disabled="submitting"
|
|
98
|
+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Confirm new password -->
|
|
103
|
+
<div>
|
|
104
|
+
<label for="confirm-password" class="block text-[13px] font-semibold text-[#3D3D52]">
|
|
105
|
+
Confirm new password
|
|
106
|
+
</label>
|
|
107
|
+
<input
|
|
108
|
+
id="confirm-password"
|
|
109
|
+
v-model="confirmPassword"
|
|
110
|
+
type="password"
|
|
111
|
+
required
|
|
112
|
+
autocomplete="new-password"
|
|
113
|
+
:disabled="submitting"
|
|
114
|
+
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 disabled:cursor-not-allowed disabled:opacity-60"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<!-- Inline error -->
|
|
119
|
+
<p v-if="formError" class="text-sm text-red-600" role="alert">
|
|
120
|
+
{{ formError }}
|
|
121
|
+
</p>
|
|
122
|
+
|
|
123
|
+
<!-- Submit -->
|
|
124
|
+
<button
|
|
125
|
+
type="submit"
|
|
126
|
+
:disabled="submitting"
|
|
127
|
+
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
128
|
+
>
|
|
129
|
+
{{ submitting ? 'Saving…' : 'Set new password' }}
|
|
130
|
+
</button>
|
|
131
|
+
</form>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onUnmounted } from 'vue'
|
|
3
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
4
|
+
import { useAuthStore } from '../stores/auth'
|
|
5
|
+
import { useUiStore } from '../stores/ui'
|
|
6
|
+
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const authStore = useAuthStore()
|
|
10
|
+
const uiStore = useUiStore()
|
|
11
|
+
|
|
12
|
+
const email = ref('')
|
|
13
|
+
const password = ref('')
|
|
14
|
+
const submitting = ref(false)
|
|
15
|
+
const formError = ref('')
|
|
16
|
+
const retrySeconds = ref(0)
|
|
17
|
+
|
|
18
|
+
let countdown: ReturnType<typeof setInterval> | null = null
|
|
19
|
+
|
|
20
|
+
const submitDisabled = computed(() => submitting.value || retrySeconds.value > 0)
|
|
21
|
+
|
|
22
|
+
function updateRateLimitMessage() {
|
|
23
|
+
formError.value = `Too many attempts. Try again in ${Math.ceil(retrySeconds.value / 60)} minutes.`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function startCountdown(seconds: number) {
|
|
27
|
+
retrySeconds.value = seconds
|
|
28
|
+
updateRateLimitMessage()
|
|
29
|
+
if (countdown) clearInterval(countdown)
|
|
30
|
+
countdown = setInterval(() => {
|
|
31
|
+
retrySeconds.value -= 1
|
|
32
|
+
if (retrySeconds.value <= 0) {
|
|
33
|
+
clearInterval(countdown!)
|
|
34
|
+
countdown = null
|
|
35
|
+
retrySeconds.value = 0
|
|
36
|
+
formError.value = ''
|
|
37
|
+
} else {
|
|
38
|
+
updateRateLimitMessage()
|
|
39
|
+
}
|
|
40
|
+
}, 1000)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onUnmounted(() => {
|
|
44
|
+
if (countdown) clearInterval(countdown)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
async function onSubmit() {
|
|
48
|
+
if (submitDisabled.value) return
|
|
49
|
+
submitting.value = true
|
|
50
|
+
formError.value = ''
|
|
51
|
+
|
|
52
|
+
let res: Response
|
|
53
|
+
try {
|
|
54
|
+
res = await fetch(`${__ADMIN_PREFIX__}/api/auth/login`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
credentials: 'include',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ email: email.value, password: password.value }),
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
formError.value = 'Something went wrong. Please try again.'
|
|
62
|
+
submitting.value = false
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const json = (await res.json()) as {
|
|
67
|
+
ok: boolean
|
|
68
|
+
data?: { id: string; email: string; role: string; must_change_password: boolean }
|
|
69
|
+
error?: { code: string; message: string }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!json.ok) {
|
|
73
|
+
const code = json.error?.code ?? ''
|
|
74
|
+
if (code === 'INVALID_CREDENTIALS') {
|
|
75
|
+
formError.value = 'Invalid email or password.'
|
|
76
|
+
} else if (code === 'RATE_LIMITED') {
|
|
77
|
+
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '60', 10)
|
|
78
|
+
startCountdown(isNaN(retryAfter) ? 60 : retryAfter)
|
|
79
|
+
} else {
|
|
80
|
+
formError.value = 'Something went wrong. Please try again.'
|
|
81
|
+
}
|
|
82
|
+
submitting.value = false
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const user = json.data!
|
|
87
|
+
authStore.setUser({
|
|
88
|
+
id: user.id,
|
|
89
|
+
email: user.email,
|
|
90
|
+
role: user.role,
|
|
91
|
+
mustChangePassword: user.must_change_password,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (user.must_change_password) {
|
|
95
|
+
void router.push(`${__ADMIN_PREFIX__}/change-password`)
|
|
96
|
+
} else {
|
|
97
|
+
const redirect = route.query.redirect
|
|
98
|
+
void router.push(typeof redirect === 'string' ? redirect : `${__ADMIN_PREFIX__}/media`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<div class="flex min-h-screen bg-surface">
|
|
105
|
+
<!-- Brand panel — visible on large screens -->
|
|
106
|
+
<div
|
|
107
|
+
class="hidden lg:flex lg:w-80 lg:shrink-0 lg:flex-col lg:items-start lg:justify-between bg-gradient-to-br from-[#1C1C30] to-[#121220] px-10 py-12 text-white"
|
|
108
|
+
>
|
|
109
|
+
<div>
|
|
110
|
+
<!-- Logo mark -->
|
|
111
|
+
<div class="flex h-[42px] w-[42px] items-center justify-center rounded-[13px] bg-gradient-to-br from-[#6D5EF0] to-indigo-600 shadow-[0_6px_18px_rgba(91,87,232,0.5)]">
|
|
112
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.1" stroke-linecap="round" aria-hidden="true">
|
|
113
|
+
<path d="M4 7h16" /><path d="M4 12h10" /><path d="M4 17h13" />
|
|
114
|
+
</svg>
|
|
115
|
+
</div>
|
|
116
|
+
<h1 class="mt-8 text-[28px] font-bold leading-tight tracking-tight">{{ uiStore.cmsName }}</h1>
|
|
117
|
+
<p class="mt-3.5 max-w-[300px] text-[15px] leading-relaxed text-white/60">
|
|
118
|
+
Schema-driven headless CMS — manage your content with confidence.
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
<p class="text-xs text-white/40">Self-hosted & open source</p>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Form area -->
|
|
125
|
+
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12">
|
|
126
|
+
<!-- Mobile-only brand -->
|
|
127
|
+
<div class="mb-8 flex flex-col items-center lg:hidden">
|
|
128
|
+
<div class="flex h-10 w-10 items-center justify-center rounded-[11px] bg-gradient-to-br from-[#6D5EF0] to-indigo-600 shadow-[0_4px_12px_rgba(91,87,232,0.36)]">
|
|
129
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
130
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10M4 18h6" />
|
|
131
|
+
</svg>
|
|
132
|
+
</div>
|
|
133
|
+
<h1 class="mt-4 text-lg font-bold text-ink">{{ uiStore.cmsName }}</h1>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Card -->
|
|
137
|
+
<div class="w-full max-w-sm rounded-[20px] border border-card-border bg-white px-8 py-10 shadow-[0_10px_40px_rgba(24,24,48,0.1)]">
|
|
138
|
+
<h2 class="mb-1 text-[21px] font-bold tracking-tight text-ink">Sign in</h2>
|
|
139
|
+
<p class="mb-6 text-sm text-muted">Enter your credentials to continue.</p>
|
|
140
|
+
|
|
141
|
+
<form class="space-y-[18px]" novalidate @submit.prevent="onSubmit">
|
|
142
|
+
<!-- Email -->
|
|
143
|
+
<div>
|
|
144
|
+
<label for="email" class="block text-[13px] font-semibold text-[#3D3D52]">Email</label>
|
|
145
|
+
<input
|
|
146
|
+
id="email"
|
|
147
|
+
v-model="email"
|
|
148
|
+
type="email"
|
|
149
|
+
required
|
|
150
|
+
autocomplete="email"
|
|
151
|
+
:disabled="submitting"
|
|
152
|
+
class="mt-[7px] block w-full rounded-[11px] border border-[#E4E3EE] bg-[#FBFBFD] px-3.5 py-3 text-sm text-ink placeholder-faint transition-colors focus:border-indigo-400 focus:bg-white focus:outline-none focus:ring-[3px] focus:ring-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<!-- Password -->
|
|
157
|
+
<div>
|
|
158
|
+
<label for="password" class="block text-[13px] font-semibold text-[#3D3D52]">Password</label>
|
|
159
|
+
<input
|
|
160
|
+
id="password"
|
|
161
|
+
v-model="password"
|
|
162
|
+
type="password"
|
|
163
|
+
required
|
|
164
|
+
autocomplete="current-password"
|
|
165
|
+
:disabled="submitting"
|
|
166
|
+
class="mt-[7px] block w-full rounded-[11px] border border-[#E4E3EE] bg-[#FBFBFD] px-3.5 py-3 text-sm text-ink placeholder-faint transition-colors focus:border-indigo-400 focus:bg-white focus:outline-none focus:ring-[3px] focus:ring-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<!-- Inline error -->
|
|
171
|
+
<p v-if="formError" class="rounded-[10px] bg-red-50 px-3 py-2 text-sm text-red-600" role="alert">
|
|
172
|
+
{{ formError }}
|
|
173
|
+
</p>
|
|
174
|
+
|
|
175
|
+
<!-- Submit -->
|
|
176
|
+
<button
|
|
177
|
+
type="submit"
|
|
178
|
+
:disabled="submitDisabled"
|
|
179
|
+
class="w-full rounded-[11px] bg-indigo-600 px-4 py-[13px] text-sm font-semibold text-white shadow-[0_4px_14px_rgba(91,87,232,0.34)] transition-colors hover:bg-indigo-700 hover:shadow-[0_8px_22px_rgba(91,87,232,0.44)] focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
180
|
+
>
|
|
181
|
+
{{ submitting ? 'Signing in…' : 'Sign in' }}
|
|
182
|
+
</button>
|
|
183
|
+
</form>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useSchemaStore } from '../stores/schema'
|
|
4
|
+
|
|
5
|
+
const schemaStore = useSchemaStore()
|
|
6
|
+
|
|
7
|
+
const roles = computed(() =>
|
|
8
|
+
[...schemaStore.roles].sort((a, b) => a.hierarchy_level - b.hierarchy_level)
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Permission tokens look like "content:read" — color by the category prefix, matching the design.
|
|
12
|
+
const PERM_PALETTE: Record<string, { bg: string; color: string }> = {
|
|
13
|
+
content: { bg: '#EEEDFB', color: '#5B57E8' },
|
|
14
|
+
media: { bg: '#E3F4FB', color: '#0E86AB' },
|
|
15
|
+
taxonomy: { bg: '#FBF1E2', color: '#B5780F' },
|
|
16
|
+
users: { bg: '#F2EAFB', color: '#8B3FD6' },
|
|
17
|
+
roles: { bg: '#EDEFF3', color: '#5A6478' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function permStyle(token: string): string {
|
|
21
|
+
const palette = PERM_PALETTE[token.split(':')[0]!] ?? PERM_PALETTE.roles!
|
|
22
|
+
return `background:${palette.bg};color:${palette.color}`
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div>
|
|
28
|
+
<div class="mb-[22px]">
|
|
29
|
+
<h1 class="text-[26px] font-bold tracking-tight text-ink">Roles</h1>
|
|
30
|
+
<p class="mt-1 text-sm text-muted">
|
|
31
|
+
Roles are schema-defined and read-only. Use the CLI to modify them.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div
|
|
36
|
+
v-if="roles.length === 0"
|
|
37
|
+
class="rounded-2xl border border-dashed border-gray-300 p-12 text-center text-sm text-muted"
|
|
38
|
+
>
|
|
39
|
+
No roles configured.
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div v-else class="flex flex-col gap-4">
|
|
43
|
+
<div
|
|
44
|
+
v-for="role in roles"
|
|
45
|
+
:key="role.name"
|
|
46
|
+
class="rounded-2xl border border-card-border bg-white p-5 shadow-[0_1px_2px_rgba(24,24,48,0.04),0_8px_22px_rgba(24,24,48,0.04)]"
|
|
47
|
+
>
|
|
48
|
+
<!-- Role header -->
|
|
49
|
+
<div class="mb-3.5 flex items-start justify-between gap-3">
|
|
50
|
+
<div>
|
|
51
|
+
<h2 class="text-[16.5px] font-bold tracking-tight text-ink">{{ role.label }}</h2>
|
|
52
|
+
<p class="mt-0.5 font-mono text-[12.5px] text-faint">{{ role.name }}</p>
|
|
53
|
+
</div>
|
|
54
|
+
<span class="shrink-0 whitespace-nowrap rounded-full bg-[#F1F2F6] px-[11px] py-1.5 text-[11.5px] font-semibold text-[#5A6478]">
|
|
55
|
+
{{ role.hierarchy_level === 1 ? 'Level 1' : `Level ${role.hierarchy_level}` }}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Permissions -->
|
|
60
|
+
<div>
|
|
61
|
+
<p class="mb-[9px] text-[11px] font-bold uppercase tracking-[.06em] text-[#B0B0C0]">
|
|
62
|
+
Permissions
|
|
63
|
+
</p>
|
|
64
|
+
<div
|
|
65
|
+
v-if="role.permissions.length === 0"
|
|
66
|
+
class="text-xs text-faint"
|
|
67
|
+
>
|
|
68
|
+
No permissions assigned.
|
|
69
|
+
</div>
|
|
70
|
+
<div v-else class="flex flex-wrap gap-[7px]">
|
|
71
|
+
<span
|
|
72
|
+
v-for="perm in role.permissions"
|
|
73
|
+
:key="perm"
|
|
74
|
+
class="rounded-[7px] px-2.5 py-1 font-mono text-[11.5px] font-medium"
|
|
75
|
+
:style="permStyle(perm)"
|
|
76
|
+
>
|
|
77
|
+
{{ perm }}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|