mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 10:44:08 +00:00
Refactor install wizard and mail configuration
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center justify-center h-6 w-6 rounded-full',
|
||||
ok
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-red-100 text-red-600',
|
||||
]"
|
||||
:aria-label="ok ? 'OK' : 'Missing'"
|
||||
>
|
||||
<BaseIcon
|
||||
:name="ok ? 'CheckIcon' : 'XMarkIcon'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
ok: boolean
|
||||
}>()
|
||||
</script>
|
||||
21
resources/scripts/features/installation/install-auth.ts
Normal file
21
resources/scripts/features/installation/install-auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { LS_KEYS } from '@/scripts/config/constants'
|
||||
import * as localStore from '@/scripts/utils/local-storage'
|
||||
|
||||
export function setInstallWizardAuth(token: string, companyId?: number | string | null): void {
|
||||
localStore.set(LS_KEYS.INSTALL_AUTH_TOKEN, token)
|
||||
setInstallWizardCompany(companyId)
|
||||
}
|
||||
|
||||
export function setInstallWizardCompany(companyId?: number | string | null): void {
|
||||
if (companyId === null || companyId === undefined || companyId === '') {
|
||||
localStore.remove(LS_KEYS.INSTALL_SELECTED_COMPANY)
|
||||
return
|
||||
}
|
||||
|
||||
localStore.set(LS_KEYS.INSTALL_SELECTED_COMPANY, String(companyId))
|
||||
}
|
||||
|
||||
export function clearInstallWizardAuth(): void {
|
||||
localStore.remove(LS_KEYS.INSTALL_AUTH_TOKEN)
|
||||
localStore.remove(LS_KEYS.INSTALL_SELECTED_COMPANY)
|
||||
}
|
||||
@@ -1,93 +1,122 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import InstallationLayout from '@/scripts/layouts/InstallationLayout.vue'
|
||||
|
||||
/**
|
||||
* The installation wizard is a multi-step flow rendered inside a single
|
||||
* parent view. Individual step views are not routed independently -- they
|
||||
* are controlled by the parent Installation component via dynamic
|
||||
* components. This route simply mounts the wizard entry point.
|
||||
* The installation wizard is a multi-step flow. Every step is a child of the
|
||||
* /installation parent route, which renders InstallationLayout (logo, card
|
||||
* chrome, step progress dots) once and a <router-view /> inside the card.
|
||||
*
|
||||
* The individual step views are:
|
||||
* 1. RequirementsView
|
||||
* 2. PermissionsView
|
||||
* 3. DatabaseView
|
||||
* 4. DomainView
|
||||
* 5. MailView
|
||||
* 6. AccountView
|
||||
* 7. CompanyView
|
||||
* 8. PreferencesView
|
||||
* Step order — Language is intentionally first so the rest of the wizard
|
||||
* renders in the user's chosen locale:
|
||||
*
|
||||
* 1. LanguageView (/installation/language)
|
||||
* 2. RequirementsView (/installation/requirements)
|
||||
* 3. PermissionsView (/installation/permissions)
|
||||
* 4. DatabaseView (/installation/database)
|
||||
* 5. DomainView (/installation/domain)
|
||||
* 6. MailView (/installation/mail)
|
||||
* 7. AccountView (/installation/account)
|
||||
* 8. CompanyView (/installation/company)
|
||||
* 9. PreferencesView (/installation/preferences)
|
||||
*
|
||||
* Each child view owns its own next() function and calls router.push() to
|
||||
* the next step by route name. There is no event-based step coordination —
|
||||
* the router IS the state machine.
|
||||
*/
|
||||
|
||||
export const installationRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/installation',
|
||||
name: 'installation',
|
||||
component: () => import('./views/RequirementsView.vue'),
|
||||
component: InstallationLayout,
|
||||
meta: {
|
||||
title: 'wizard.req.system_req',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/permissions',
|
||||
name: 'installation.permissions',
|
||||
component: () => import('./views/PermissionsView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.permissions.permissions',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/database',
|
||||
name: 'installation.database',
|
||||
component: () => import('./views/DatabaseView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.database.database',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/domain',
|
||||
name: 'installation.domain',
|
||||
component: () => import('./views/DomainView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.verify_domain.title',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/mail',
|
||||
name: 'installation.mail',
|
||||
component: () => import('./views/MailView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.mail.mail_config',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/account',
|
||||
name: 'installation.account',
|
||||
component: () => import('./views/AccountView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.account_info',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/company',
|
||||
name: 'installation.company',
|
||||
component: () => import('./views/CompanyView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.company_info',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/installation/preferences',
|
||||
name: 'installation.preferences',
|
||||
component: () => import('./views/PreferencesView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.preferences',
|
||||
isInstallation: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'installation.language' },
|
||||
},
|
||||
{
|
||||
path: 'language',
|
||||
name: 'installation.language',
|
||||
component: () => import('./views/LanguageView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.install_language.title',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'requirements',
|
||||
name: 'installation.requirements',
|
||||
component: () => import('./views/RequirementsView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.req.system_req',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
name: 'installation.permissions',
|
||||
component: () => import('./views/PermissionsView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.permissions.permissions',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
name: 'installation.database',
|
||||
component: () => import('./views/DatabaseView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.database.database',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'domain',
|
||||
name: 'installation.domain',
|
||||
component: () => import('./views/DomainView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.verify_domain.title',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
name: 'installation.mail',
|
||||
component: () => import('./views/MailView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.mail.mail_config',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'installation.account',
|
||||
component: () => import('./views/AccountView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.account_info',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'company',
|
||||
name: 'installation.company',
|
||||
component: () => import('./views/CompanyView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.company_info',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'preferences',
|
||||
name: 'installation.preferences',
|
||||
component: () => import('./views/PreferencesView.vue'),
|
||||
meta: {
|
||||
title: 'wizard.preferences',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
|
||||
interface InstallationResponse {
|
||||
success?: boolean | string
|
||||
error?: string | boolean
|
||||
error_message?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function useInstallationFeedback() {
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
function isSuccessfulResponse(response: InstallationResponse | null | undefined): boolean {
|
||||
return Boolean(response?.success) && !response?.error && !response?.error_message
|
||||
}
|
||||
|
||||
function showResponseError(response: InstallationResponse | null | undefined): void {
|
||||
const candidate =
|
||||
typeof response?.error_message === 'string' && response.error_message.trim()
|
||||
? response.error_message
|
||||
: typeof response?.error === 'string' && response.error.trim()
|
||||
? response.error
|
||||
: typeof response?.message === 'string' && response.message.trim()
|
||||
? response.message
|
||||
: ''
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: resolveMessage(candidate),
|
||||
})
|
||||
}
|
||||
|
||||
function showRequestError(error: unknown): void {
|
||||
if (error instanceof Error && !('response' in error) && error.message.trim()) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: resolveMessage(error.message),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedError = handleApiError(error)
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: resolveMessage(normalizedError.message),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMessage(message: string): string {
|
||||
const normalizedMessage = message.trim()
|
||||
|
||||
if (!normalizedMessage) {
|
||||
return 'validation.something_went_wrong'
|
||||
}
|
||||
|
||||
const wizardErrorKey = `wizard.errors.${normalizedMessage}`
|
||||
|
||||
if (t(wizardErrorKey) !== wizardErrorKey) {
|
||||
return wizardErrorKey
|
||||
}
|
||||
|
||||
return getErrorTranslationKey(normalizedMessage) ?? normalizedMessage
|
||||
}
|
||||
|
||||
return {
|
||||
isSuccessfulResponse,
|
||||
showRequestError,
|
||||
showResponseError,
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
helpers,
|
||||
required,
|
||||
@@ -113,7 +114,9 @@ import {
|
||||
email,
|
||||
} from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { setInstallWizardCompany } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface UserForm {
|
||||
name: string
|
||||
@@ -122,12 +125,9 @@ interface UserForm {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isShowPassword = ref<boolean>(false)
|
||||
@@ -186,22 +186,24 @@ async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const { data: res } = await client.put('/api/v1/me', userForm)
|
||||
const { data: res } = await installClient.put('/api/v1/me', userForm)
|
||||
|
||||
if (res.data) {
|
||||
if (avatarFileBlob.value) {
|
||||
const avatarData = new FormData()
|
||||
avatarData.append('admin_avatar', avatarFileBlob.value)
|
||||
await client.post('/api/v1/me/upload-avatar', avatarData)
|
||||
await installClient.post('/api/v1/me/upload-avatar', avatarData)
|
||||
}
|
||||
|
||||
const company = res.data.companies?.[0]
|
||||
if (company) {
|
||||
localStorage.setItem('selectedCompany', String(company.id))
|
||||
setInstallWizardCompany(company.id)
|
||||
}
|
||||
|
||||
emit('next', 6)
|
||||
await router.push({ name: 'installation.company' })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.company_info')"
|
||||
:description="$t('wizard.company_info_desc')"
|
||||
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -121,11 +120,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, maxLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import type { Country } from '../../../types/domain/customer'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface CompanyAddress {
|
||||
address_street_1: string
|
||||
@@ -145,12 +146,9 @@ interface CompanyFormData {
|
||||
address: CompanyAddress
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
@@ -201,11 +199,13 @@ const v$ = useVuelidate(rules, validationState)
|
||||
onMounted(async () => {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await client.get(API.COUNTRIES)
|
||||
const { data } = await installClient.get(API.COUNTRIES)
|
||||
countries.value = data.data ?? data
|
||||
// Default to US
|
||||
const us = countries.value.find((c) => c.code === 'US')
|
||||
if (us) companyForm.address.country_id = us.id
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
@@ -232,7 +232,7 @@ async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await client.put(API.COMPANY, companyForm)
|
||||
await installClient.put(API.COMPANY, companyForm)
|
||||
|
||||
if (logoFileBlob.value) {
|
||||
const logoData = new FormData()
|
||||
@@ -243,10 +243,12 @@ async function next(): Promise<void> {
|
||||
data: logoFileBlob.value,
|
||||
}),
|
||||
)
|
||||
await client.post(API.COMPANY_UPLOAD_LOGO, logoData)
|
||||
await installClient.post(API.COMPANY_UPLOAD_LOGO, logoData)
|
||||
}
|
||||
|
||||
emit('next', 7)
|
||||
await router.push({ name: 'installation.preferences' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.database.database')"
|
||||
:description="$t('wizard.database.desc')"
|
||||
step-container="w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded md:w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -53,13 +52,24 @@
|
||||
|
||||
<!-- SQLite fields -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
||||
<BaseInputGroup :label="$t('wizard.database.db_name')">
|
||||
<BaseInput v-model="databaseData.database_name" type="text" disabled />
|
||||
<div class="mb-6">
|
||||
<BaseInputGroup
|
||||
:label="$t('wizard.database.db_path')"
|
||||
help-text="Absolute path or path relative to the project root. Defaults to storage/app/database.sqlite."
|
||||
required
|
||||
>
|
||||
<BaseInput v-model="databaseData.database_name" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-6">
|
||||
<BaseCheckbox
|
||||
v-model="databaseData.database_overwrite"
|
||||
:label="$t('wizard.database.overwrite')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
@@ -73,7 +83,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { useDialogStore } from '../../../stores/dialog.store'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface DatabaseConfig {
|
||||
database_connection: string
|
||||
@@ -87,17 +100,15 @@ interface DatabaseConfig {
|
||||
app_locale: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
interface DatabaseDriverOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
|
||||
@@ -127,22 +138,29 @@ async function getDatabaseConfig(connection?: string): Promise<void> {
|
||||
const params: Record<string, string> = {}
|
||||
if (connection) params.connection = connection
|
||||
|
||||
const { data } = await client.get('/api/v1/installation/database/config', { params })
|
||||
try {
|
||||
const { data } = await installClient.get('/api/v1/installation/database/config', { params })
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
databaseData.database_connection = data.config.database_connection
|
||||
}
|
||||
|
||||
if (data.config.database_connection === 'sqlite') {
|
||||
databaseData.database_name = data.config.database_name
|
||||
} else {
|
||||
databaseData.database_name = null
|
||||
if (data.config.database_host) {
|
||||
databaseData.database_hostname = data.config.database_host
|
||||
}
|
||||
if (data.config.database_port) {
|
||||
databaseData.database_port = data.config.database_port
|
||||
if (data.config.database_connection === 'sqlite') {
|
||||
databaseData.database_name = data.config.database_name
|
||||
} else {
|
||||
databaseData.database_name = null
|
||||
if (data.config.database_host) {
|
||||
databaseData.database_hostname = data.config.database_host
|
||||
}
|
||||
if (data.config.database_port) {
|
||||
databaseData.database_port = data.config.database_port
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,18 +169,45 @@ function onChangeDriver(connection: string): void {
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
if (databaseData.database_overwrite) {
|
||||
const confirmed = await dialogStore.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('wizard.database.overwrite_confirm_desc'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const { data: res } = await client.post(
|
||||
const { data: res } = await installClient.post(
|
||||
'/api/v1/installation/database/config',
|
||||
databaseData,
|
||||
)
|
||||
|
||||
if (res.success) {
|
||||
await client.post('/api/v1/installation/finish')
|
||||
emit('next', 3)
|
||||
if (!isSuccessfulResponse(res)) {
|
||||
showResponseError(res)
|
||||
return
|
||||
}
|
||||
|
||||
const { data: finishResponse } = await installClient.post('/api/v1/installation/finish')
|
||||
|
||||
if (!isSuccessfulResponse(finishResponse)) {
|
||||
showResponseError(finishResponse)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.domain' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -49,16 +49,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import { clearInstallWizardAuth, setInstallWizardAuth } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
interface InstallationLoginResponse {
|
||||
success: boolean
|
||||
type: string
|
||||
token: string
|
||||
company: {
|
||||
id: number | string
|
||||
} | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
const isSaving = ref<boolean>(false)
|
||||
|
||||
const formData = reactive<{ app_domain: string }>({
|
||||
@@ -87,14 +97,36 @@ async function verifyDomain(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await client.put('/api/v1/installation/set-domain', formData)
|
||||
await client.get('/sanctum/csrf-cookie')
|
||||
await client.post('/api/v1/installation/login')
|
||||
const { data } = await client.get('/api/v1/auth/check')
|
||||
clearInstallWizardAuth()
|
||||
|
||||
if (data) {
|
||||
emit('next', 4)
|
||||
const { data: domainResponse } = await installClient.put(
|
||||
API.INSTALLATION_SET_DOMAIN,
|
||||
formData,
|
||||
)
|
||||
|
||||
if (!isSuccessfulResponse(domainResponse)) {
|
||||
showResponseError(domainResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await installClient.post<InstallationLoginResponse>(
|
||||
API.INSTALLATION_LOGIN,
|
||||
)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token || !data.company?.id) {
|
||||
throw new Error('Installer login response was incomplete.')
|
||||
}
|
||||
|
||||
setInstallWizardAuth(`${data.type} ${data.token}`, data.company.id)
|
||||
|
||||
await router.push({ name: 'installation.mail' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
128
resources/scripts/features/installation/views/LanguageView.vue
Normal file
128
resources/scripts/features/installation/views/LanguageView.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.install_language.title')"
|
||||
:description="$t('wizard.install_language.description')"
|
||||
step-container-class="w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<BaseInputGroup
|
||||
:label="$t('wizard.language')"
|
||||
:error="v$.language.$error ? String(v$.language.$errors[0]?.$message) : undefined"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="formData.language"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="languages"
|
||||
label="name"
|
||||
value-prop="code"
|
||||
:placeholder="$t('settings.preferences.select_language')"
|
||||
track-by="name"
|
||||
:searchable="true"
|
||||
:invalid="v$.language.$error"
|
||||
class="w-full"
|
||||
@change="onLanguageChange"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving || isFetchingInitialData"
|
||||
class="mt-8"
|
||||
>
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</form>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface LanguageOption {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
/**
|
||||
* The Language step runs BEFORE the Database step, so we can't persist the
|
||||
* choice to the `settings` table — there is no database yet. Instead we
|
||||
* store it in localStorage. InvoiceShelf::start() reads this on app boot
|
||||
* (see resources/scripts/InvoiceShelf.ts) and pre-loads the matching locale,
|
||||
* so the language survives page reloads through the rest of the wizard.
|
||||
*
|
||||
* The user-facing language preference is persisted to the DB later by
|
||||
* PreferencesView (the final step) via the existing /api/v1/me/settings
|
||||
* and /api/v1/company/settings endpoints — no separate backend call here.
|
||||
*/
|
||||
const STORAGE_KEY = 'install_language'
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(true)
|
||||
const isSaving = ref<boolean>(false)
|
||||
const languages = ref<LanguageOption[]>([])
|
||||
|
||||
const formData = reactive<{ language: string }>({
|
||||
language: localStorage.getItem(STORAGE_KEY) ?? 'en',
|
||||
})
|
||||
|
||||
const rules = computed(() => ({
|
||||
language: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, formData)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await installClient.get('/api/v1/installation/languages')
|
||||
languages.value = data?.languages ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch the UI language as soon as the user picks one — they shouldn't have
|
||||
* to click Continue to see the wizard re-render in their preferred locale.
|
||||
* Falls back silently if the locale loader hasn't been registered yet.
|
||||
*/
|
||||
async function onLanguageChange(code: string): Promise<void> {
|
||||
if (!code) return
|
||||
if (typeof window.loadLanguage === 'function') {
|
||||
await window.loadLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
// Persist the choice client-side so a page reload mid-wizard doesn't
|
||||
// lose the language. The DB doesn't exist yet at this step.
|
||||
localStorage.setItem(STORAGE_KEY, formData.language)
|
||||
await router.push({ name: 'installation.requirements' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,135 +1,18 @@
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.mail.mail_config')"
|
||||
:description="$t('wizard.mail.mail_config_desc')"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.driver')" required>
|
||||
<BaseMultiselect
|
||||
v-model="mailDriver"
|
||||
:options="mailDriverOptions"
|
||||
label="label"
|
||||
value-prop="value"
|
||||
:can-deselect="false"
|
||||
:can-clear="false"
|
||||
@update:model-value="onChangeDriver"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Fields -->
|
||||
<template v-if="mailDriver === 'smtp'">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.host')" required>
|
||||
<BaseInput v-model="mailConfig.mail_host" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.port')" required>
|
||||
<BaseInput v-model="mailConfig.mail_port" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.username')">
|
||||
<BaseInput v-model="mailConfig.mail_username" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.password')">
|
||||
<BaseInput v-model="mailConfig.mail_password" type="password" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
<BaseInputGroup :label="$t('wizard.mail.encryption')">
|
||||
<BaseMultiselect
|
||||
v-model="mailConfig.mail_encryption"
|
||||
:options="encryptionOptions"
|
||||
:can-deselect="true"
|
||||
:placeholder="$t('wizard.mail.none')"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('wizard.mail.from_mail')">
|
||||
<BaseInput v-model="mailConfig.from_mail" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2">
|
||||
<BaseInputGroup :label="$t('wizard.mail.from_name')">
|
||||
<BaseInput v-model="mailConfig.from_name" type="text" />
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Basic driver info -->
|
||||
<template v-if="mailDriver === 'sendmail' || mailDriver === 'mail'">
|
||||
<p class="text-sm text-muted mb-6">
|
||||
{{ $t('wizard.mail.basic_mail_desc') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<BaseButton :loading="isSaving" :disabled="isSaving" class="mt-4">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('wizard.save_cont') }}
|
||||
</BaseButton>
|
||||
</form>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '@/scripts/api/install-client'
|
||||
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
|
||||
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface MailConfig {
|
||||
mail_driver: string
|
||||
mail_host: string
|
||||
mail_port: string
|
||||
mail_username: string
|
||||
mail_password: string
|
||||
mail_encryption: string
|
||||
from_mail: string
|
||||
from_name: string
|
||||
[key: string]: string
|
||||
}
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
interface DriverOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const mailDriver = ref<string>('smtp')
|
||||
|
||||
const mailDriverOptions = ref<DriverOption[]>([
|
||||
{ label: 'SMTP', value: 'smtp' },
|
||||
{ label: 'Mailgun', value: 'mailgun' },
|
||||
{ label: 'SES', value: 'ses' },
|
||||
{ label: 'Sendmail', value: 'sendmail' },
|
||||
{ label: 'Mail', value: 'mail' },
|
||||
])
|
||||
|
||||
const encryptionOptions = ref<string[]>(['tls', 'ssl'])
|
||||
|
||||
const mailConfig = reactive<MailConfig>({
|
||||
mail_driver: 'smtp',
|
||||
mail_host: '',
|
||||
mail_port: '587',
|
||||
mail_username: '',
|
||||
mail_password: '',
|
||||
mail_encryption: 'tls',
|
||||
from_mail: '',
|
||||
from_name: '',
|
||||
})
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const mailConfigData = ref<MailConfig | null>(null)
|
||||
const mailDrivers = ref<MailDriver[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
@@ -137,32 +20,61 @@ onMounted(async () => {
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
try {
|
||||
const { data: configData } = await client.get('/api/v1/mail/config')
|
||||
if (configData) {
|
||||
Object.assign(mailConfig, configData)
|
||||
mailDriver.value = configData.mail_driver ?? 'smtp'
|
||||
}
|
||||
const [{ data: driversData }, { data: configData }] = await Promise.all([
|
||||
installClient.get<MailDriver[]>('/api/v1/mail/drivers'),
|
||||
installClient.get<MailConfig>('/api/v1/mail/config'),
|
||||
])
|
||||
|
||||
mailDrivers.value = driversData
|
||||
mailConfigData.value = configData
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeDriver(value: string): void {
|
||||
mailDriver.value = value
|
||||
mailConfig.mail_driver = value
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
async function saveMailConfig(value: MailConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
mailConfig.mail_driver = mailDriver.value
|
||||
const { data } = await client.post('/api/v1/mail/config', mailConfig)
|
||||
if (data.success) {
|
||||
emit('next', 5)
|
||||
const { data } = await installClient.post('/api/v1/mail/config', value)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
mailConfigData.value = {
|
||||
...value,
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.account' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.mail.mail_config')"
|
||||
:description="$t('wizard.mail.mail_config_desc')"
|
||||
>
|
||||
<MailConfigurationForm
|
||||
v-if="mailConfigData"
|
||||
:config-data="mailConfigData"
|
||||
:is-saving="isSaving"
|
||||
:mail-drivers="mailDrivers"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
translation-scope="wizard.mail"
|
||||
submit-label="wizard.save_cont"
|
||||
submit-icon="ArrowRightIcon"
|
||||
@submit-data="saveMailConfig"
|
||||
/>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
|
||||
@@ -4,54 +4,50 @@
|
||||
:description="$t('wizard.permissions.permission_desc')"
|
||||
>
|
||||
<!-- Placeholders -->
|
||||
<BaseContentPlaceholders v-if="isFetchingInitialData">
|
||||
<div
|
||||
v-if="isFetchingInitialData"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4 border border-line-default"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<BaseContentPlaceholdersText :lines="1" class="col-span-4 p-3" />
|
||||
<BaseContentPlaceholders>
|
||||
<BaseContentPlaceholdersText :lines="1" class="w-32" />
|
||||
</BaseContentPlaceholders>
|
||||
<div class="h-6 w-6 rounded-full bg-surface-tertiary animate-pulse" />
|
||||
</div>
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="mt-10"
|
||||
style="width: 96px; height: 42px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<div
|
||||
v-else-if="permissions.length"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<div
|
||||
v-for="(permission, index) in permissions"
|
||||
:key="index"
|
||||
class="border border-line-default"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<div class="grid grid-flow-row grid-cols-3 lg:gap-24 sm:gap-4">
|
||||
<div class="col-span-2 p-3">{{ permission.folder }}</div>
|
||||
<div class="p-3 text-right">
|
||||
<span
|
||||
v-if="permission.isSet"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-green-500"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 rounded-full bg-red-500"
|
||||
/>
|
||||
<span>{{ permission.permission }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-body font-mono">{{ permission.folder }}</span>
|
||||
<span class="flex items-center gap-2 text-sm text-muted">
|
||||
<span class="font-medium">{{ permission.permission }}</span>
|
||||
<RequirementBadge :ok="permission.isSet" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<BaseButton
|
||||
v-show="!isFetchingInitialData"
|
||||
class="mt-10"
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
@click="next"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('wizard.continue') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseWizardStep>
|
||||
@@ -59,7 +55,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import RequirementBadge from '../components/RequirementBadge.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface Permission {
|
||||
folder: string
|
||||
@@ -67,11 +66,8 @@ interface Permission {
|
||||
isSet: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
@@ -84,16 +80,21 @@ onMounted(() => {
|
||||
async function getPermissions(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await client.get('/api/v1/installation/permissions')
|
||||
const { data } = await installClient.get('/api/v1/installation/permissions')
|
||||
permissions.value = data.permissions?.permissions ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
emit('next')
|
||||
isSaving.value = false
|
||||
try {
|
||||
await router.push({ name: 'installation.database' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWizardStep
|
||||
:title="$t('wizard.preferences')"
|
||||
:description="$t('wizard.preferences_desc')"
|
||||
step-container="bg-surface border border-line-default border-solid mb-8 md:w-full p-8 rounded w-full"
|
||||
>
|
||||
<form @submit.prevent="next">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4 md:grid-cols-2 md:mb-6">
|
||||
@@ -131,9 +130,11 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { client } from '../../../api/client'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import { useDialogStore } from '../../../stores/dialog.store'
|
||||
import { clearInstallWizardAuth } from '../install-auth'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface PreferencesData {
|
||||
currency: number
|
||||
@@ -163,14 +164,10 @@ interface LanguageOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next', step: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(false)
|
||||
@@ -220,17 +217,19 @@ onMounted(async () => {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [currRes, dateRes, tzRes, fyRes, langRes] = await Promise.all([
|
||||
client.get(API.CURRENCIES),
|
||||
client.get(API.DATE_FORMATS),
|
||||
client.get(API.TIMEZONES),
|
||||
client.get(`${API.CONFIG}?key=fiscal_years`),
|
||||
client.get(`${API.CONFIG}?key=languages`),
|
||||
installClient.get(API.CURRENCIES),
|
||||
installClient.get(API.DATE_FORMATS),
|
||||
installClient.get(API.TIMEZONES),
|
||||
installClient.get(`${API.CONFIG}?key=fiscal_years`),
|
||||
installClient.get(`${API.CONFIG}?key=languages`),
|
||||
])
|
||||
currencies.value = currRes.data.data ?? currRes.data
|
||||
dateFormats.value = dateRes.data.data ?? dateRes.data
|
||||
timeZones.value = tzRes.data.data ?? tzRes.data
|
||||
fiscalYears.value = fyRes.data.data ?? fyRes.data ?? []
|
||||
languages.value = langRes.data.data ?? langRes.data ?? []
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
@@ -259,21 +258,35 @@ function next(): void {
|
||||
settings: { ...currentPreferences },
|
||||
}
|
||||
|
||||
const { data: response } = await client.post(API.COMPANY_SETTINGS, settingsPayload)
|
||||
const { data: response } = await installClient.post(API.COMPANY_SETTINGS, settingsPayload)
|
||||
|
||||
if (response) {
|
||||
const userSettings = {
|
||||
settings: { language: currentPreferences.language },
|
||||
}
|
||||
await client.put(API.ME_SETTINGS, userSettings)
|
||||
await installClient.put(API.ME_SETTINGS, userSettings)
|
||||
const { data: sessionLoginResponse } = await installClient.post(
|
||||
API.INSTALLATION_SESSION_LOGIN,
|
||||
)
|
||||
|
||||
if (response.token) {
|
||||
localStorage.setItem('auth.token', response.token)
|
||||
if (!isSuccessfulResponse(sessionLoginResponse)) {
|
||||
showResponseError(sessionLoginResponse)
|
||||
return
|
||||
}
|
||||
|
||||
emit('next', 'COMPLETED')
|
||||
router.push('/admin/dashboard')
|
||||
// Mark the install as complete on the backend so the
|
||||
// InstallationMiddleware stops redirecting to /installation. The
|
||||
// OnboardingWizardController persists this to
|
||||
// Setting::profile_complete.
|
||||
await installClient.post(API.INSTALLATION_WIZARD_STEP, {
|
||||
profile_complete: 'COMPLETED',
|
||||
})
|
||||
|
||||
clearInstallWizardAuth()
|
||||
await router.push('/admin/dashboard')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
@@ -3,52 +3,39 @@
|
||||
:title="$t('wizard.req.system_req')"
|
||||
:description="$t('wizard.req.system_req_desc')"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="mb-6">
|
||||
<div
|
||||
v-if="phpSupportInfo"
|
||||
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
|
||||
>
|
||||
<div class="col-span-2 text-sm">
|
||||
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{{ phpSupportInfo.current }}
|
||||
<span
|
||||
v-if="phpSupportInfo.supported"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="requirements">
|
||||
<div
|
||||
v-for="(fulfilled, name) in requirements"
|
||||
:key="name"
|
||||
class="grid grid-flow-row grid-cols-3 p-3 border border-line-default lg:gap-24 sm:gap-4"
|
||||
>
|
||||
<div class="col-span-2 text-sm">{{ name }}</div>
|
||||
<div class="text-right">
|
||||
<span
|
||||
v-if="fulfilled"
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-green-500 rounded-full"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block w-4 h-4 ml-3 mr-2 bg-red-500 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="phpSupportInfo || requirements"
|
||||
class="w-full overflow-hidden rounded-lg border border-line-default divide-y divide-line-default"
|
||||
>
|
||||
<!-- PHP version row — first so it's visually grouped with the extension list -->
|
||||
<div
|
||||
v-if="phpSupportInfo"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<span class="text-sm text-body">
|
||||
{{ $t('wizard.req.php_req_version', { version: phpSupportInfo.minimum }) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-sm font-medium text-body">
|
||||
<span class="text-muted">{{ phpSupportInfo.current }}</span>
|
||||
<RequirementBadge :ok="phpSupportInfo.supported" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Extension rows -->
|
||||
<div
|
||||
v-for="(fulfilled, name) in requirements"
|
||||
:key="name"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-hover transition-colors"
|
||||
>
|
||||
<span class="text-sm text-body font-mono">{{ name }}</span>
|
||||
<RequirementBadge :ok="fulfilled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<BaseButton v-if="hasNext" @click="next">
|
||||
{{ $t('wizard.continue') }}
|
||||
<template #left="slotProps">
|
||||
<template #right="slotProps">
|
||||
<BaseIcon name="ArrowRightIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
@@ -67,7 +54,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '../../../api/install-client'
|
||||
import RequirementBadge from '../components/RequirementBadge.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
interface PhpSupportInfo {
|
||||
minimum: string
|
||||
@@ -75,11 +65,8 @@ interface PhpSupportInfo {
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'next'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const router = useRouter()
|
||||
const { showRequestError } = useInstallationFeedback()
|
||||
|
||||
const requirements = ref<Record<string, boolean> | null>(null)
|
||||
const phpSupportInfo = ref<PhpSupportInfo | null>(null)
|
||||
@@ -98,17 +85,22 @@ onMounted(() => {
|
||||
async function getRequirements(): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const { data } = await client.get('/api/v1/installation/requirements')
|
||||
const { data } = await installClient.get('/api/v1/installation/requirements')
|
||||
requirements.value = data?.requirements?.requirements?.php ?? null
|
||||
phpSupportInfo.value = data?.phpSupportInfo ?? null
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function next(): void {
|
||||
async function next(): Promise<void> {
|
||||
isSaving.value = true
|
||||
emit('next')
|
||||
isSaving.value = false
|
||||
try {
|
||||
await router.push({ name: 'installation.permissions' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user