Refactor install wizard and mail configuration

This commit is contained in:
Darko Gjorgjijoski
2026-04-09 10:06:27 +02:00
parent 1d2cca5837
commit 9174254165
55 changed files with 3102 additions and 1162 deletions

View File

@@ -67,8 +67,12 @@ export default class InvoiceShelf {
/**
* Execute all registered boot callbacks, install plugins,
* and mount the app to `document.body`.
*
* Async so the install wizard's pre-DB language choice can be loaded
* before the first render — see the `install_language` localStorage key
* set by features/installation/views/LanguageView.vue.
*/
start(): void {
async start(): Promise<void> {
// Execute boot callbacks so modules can register routes / components
this.executeCallbacks()
@@ -78,6 +82,18 @@ export default class InvoiceShelf {
// i18n
this.i18n = createAppI18n(this.messages)
// If the install wizard's Language step set a locale before the DB
// existed, honor it now so the rest of the wizard renders in the right
// language. Falls through to 'en' silently on any failure.
const installLanguage = this.readInstallLanguage()
if (installLanguage && installLanguage !== 'en') {
try {
await setI18nLanguage(this.i18n, installLanguage)
} catch {
// Locale file missing or load failed — fall back to en, no-op.
}
}
// Install plugins
this.app.use(router)
this.app.use(this.i18n)
@@ -97,4 +113,16 @@ export default class InvoiceShelf {
callback(this.app, router)
}
}
/**
* Read the install-wizard language choice from localStorage. Wrapped in
* try/catch because localStorage can throw in private-browsing edge cases.
*/
private readInstallLanguage(): string | null {
try {
return localStorage.getItem('install_language')
} catch {
return null
}
}
}

View File

@@ -7,6 +7,10 @@ export const API = {
AUTH_CHECK: '/api/v1/auth/check',
CSRF_COOKIE: '/sanctum/csrf-cookie',
REGISTER_WITH_INVITATION: '/api/v1/auth/register-with-invitation',
INSTALLATION_LOGIN: '/api/v1/installation/login',
INSTALLATION_SET_DOMAIN: '/api/v1/installation/set-domain',
INSTALLATION_WIZARD_STEP: '/api/v1/installation/wizard-step',
INSTALLATION_SESSION_LOGIN: '/installation/session-login',
// Invitation Registration (public)
INVITATION_DETAILS: '/api/v1/invitations', // append /{token}/details

View File

@@ -110,11 +110,8 @@ export type {
CreateBackupPayload,
DeleteBackupParams,
MailConfig,
MailConfigResponse,
CompanyMailConfig,
MailDriver,
SmtpConfig,
MailgunConfig,
SesConfig,
TestMailPayload,
PdfConfig,
PdfConfigResponse,

View File

@@ -0,0 +1,34 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import { LS_KEYS } from '@/scripts/config/constants'
import * as localStore from '@/scripts/utils/local-storage'
export const INSTALL_WIZARD_HEADER = 'X-Install-Wizard'
const installClient: AxiosInstance = axios.create({
withCredentials: true,
headers: {
common: {
'X-Requested-With': 'XMLHttpRequest',
[INSTALL_WIZARD_HEADER]: '1',
},
},
})
installClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const authToken = localStore.get<string>(LS_KEYS.INSTALL_AUTH_TOKEN)
const companyId = localStore.get<string | number>(LS_KEYS.INSTALL_SELECTED_COMPANY)
config.headers[INSTALL_WIZARD_HEADER] = '1'
if (authToken) {
config.headers.Authorization = authToken
}
if (companyId !== null && companyId !== undefined && String(companyId) !== '') {
config.headers.company = String(companyId)
}
return config
})
export { installClient }

View File

@@ -2,6 +2,7 @@ import { client } from '../client'
import { API } from '../endpoints'
import type { Company } from '@/scripts/types/domain/company'
import type { ApiResponse } from '@/scripts/types/api'
import type { CompanyMailConfig, MailConfig, TestMailPayload } from '@/scripts/types/mail-config'
export interface UpdateCompanyPayload {
name: string
@@ -78,22 +79,22 @@ export const companyService = {
},
// Company Mail Configuration
async getMailDefaultConfig(): Promise<Record<string, unknown>> {
async getMailDefaultConfig(): Promise<Pick<MailConfig, 'from_name' | 'from_mail'>> {
const { data } = await client.get(API.COMPANY_MAIL_DEFAULT_CONFIG)
return data
},
async getMailConfig(): Promise<Record<string, unknown>> {
async getMailConfig(): Promise<CompanyMailConfig> {
const { data } = await client.get(API.COMPANY_MAIL_CONFIG)
return data
},
async saveMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
async saveMailConfig(payload: Partial<CompanyMailConfig>): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANY_MAIL_CONFIG, payload)
return data
},
async testMailConfig(payload: Record<string, unknown>): Promise<{ success: boolean }> {
async testMailConfig(payload: TestMailPayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.COMPANY_MAIL_TEST, payload)
return data
},

View File

@@ -54,7 +54,7 @@ export type { CreateNotePayload } from './note.service'
export type { CreateExchangeRateProviderPayload, BulkUpdatePayload, ExchangeRateResponse, ActiveProviderResponse } from './exchange-rate.service'
export type { Module, ModuleInstallPayload, ModuleCheckResponse } from './module.service'
export type { Backup, BackupListResponse, CreateBackupPayload, DeleteBackupParams } from './backup.service'
export type { MailConfig, MailConfigResponse, MailDriver, SmtpConfig, MailgunConfig, SesConfig, TestMailPayload } from './mail.service'
export type { MailConfig, CompanyMailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config'
export type { PdfConfig, PdfConfigResponse, PdfDriver, DomPdfConfig, GotenbergConfig } from './pdf.service'
export type { Disk, DiskDriversResponse, DiskDriverValue, CreateDiskPayload } from './disk.service'
export type { CheckUpdateResponse, UpdateRelease, UpdateDownloadResponse, UpdateStepResponse, FinishUpdatePayload } from './update.service'

View File

@@ -1,51 +1,6 @@
import { client } from '../client'
import { API } from '../endpoints'
export type MailDriver = string
export interface SmtpConfig {
mail_driver: string
mail_host: string
mail_port: number | null
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
}
export interface MailgunConfig {
mail_driver: string
mail_mailgun_domain: string
mail_mailgun_secret: string
mail_mailgun_endpoint: string
from_mail: string
from_name: string
}
export interface SesConfig {
mail_driver: string
mail_host: string
mail_port: number | null
mail_ses_key: string
mail_ses_secret: string
mail_ses_region: string
from_mail: string
from_name: string
}
export type MailConfig = SmtpConfig | MailgunConfig | SesConfig
export interface MailConfigResponse {
mail_driver: string
[key: string]: unknown
}
export interface TestMailPayload {
to: string
subject: string
message: string
}
import type { MailConfig, MailDriver, TestMailPayload } from '@/scripts/types/mail-config'
export const mailService = {
async getDrivers(): Promise<MailDriver[]> {
@@ -53,7 +8,7 @@ export const mailService = {
return data
},
async getConfig(): Promise<MailConfigResponse> {
async getConfig(): Promise<MailConfig> {
const { data } = await client.get(API.MAIL_CONFIG)
return data
},

View File

@@ -8,27 +8,32 @@ interface Props {
stepDescriptionClass?: string
}
/**
* The wizard step lives inside InstallationLayout's card chrome, so the
* container itself is just a content wrapper — no extra background, border,
* or rounding. Earlier defaults included those, which created a visible
* card-inside-a-card when used inside the layout.
*/
withDefaults(defineProps<Props>(), {
title: null,
description: null,
stepContainerClass:
'w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded',
stepContainerClass: 'w-full',
stepTitleClass: 'text-2xl not-italic font-semibold leading-7 text-heading',
stepDescriptionClass:
'w-full mt-2.5 mb-8 text-sm not-italic leading-snug text-muted lg:w-7/12 md:w-7/12 sm:w-7/12',
'mt-2 mb-6 text-sm not-italic leading-relaxed text-muted',
})
</script>
<template>
<div :class="stepContainerClass">
<div v-if="title || description">
<p v-if="title" :class="stepTitleClass">
<header v-if="title || description" class="mb-6">
<h2 v-if="title" :class="stepTitleClass">
{{ title }}
</p>
</h2>
<p v-if="description" :class="stepDescriptionClass">
{{ description }}
</p>
</div>
</header>
<slot />
</div>
</template>

View File

@@ -53,6 +53,8 @@ export type Theme = typeof THEME[keyof typeof THEME]
/** Local storage keys used throughout the app */
export const LS_KEYS = {
AUTH_TOKEN: 'auth.token',
INSTALL_AUTH_TOKEN: 'install.auth.token',
INSTALL_SELECTED_COMPANY: 'install.selectedCompany',
SELECTED_COMPANY: 'selectedCompany',
IS_ADMIN_MODE: 'isAdminMode',
SIDEBAR_COLLAPSED: 'sidebarCollapsed',

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { mailService } from '@/scripts/api/services/mail.service'
import type { MailConfig, MailDriver } from '@/scripts/api/services/mail.service'
import SmtpMailDriver from '@/scripts/features/company/settings/components/SmtpMailDriver.vue'
import MailgunMailDriver from '@/scripts/features/company/settings/components/MailgunMailDriver.vue'
import SesMailDriver from '@/scripts/features/company/settings/components/SesMailDriver.vue'
import BasicMailDriver from '@/scripts/features/company/settings/components/BasicMailDriver.vue'
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
import MailTestModal from '@/scripts/features/company/settings/components/MailTestModal.vue'
const { t } = useI18n()
@@ -17,9 +15,8 @@ const notificationStore = useNotificationStore()
const isSaving = ref(false)
const isFetchingInitialData = ref(false)
const mailConfigData = ref<Record<string, unknown> | null>(null)
const mailConfigData = ref<MailConfig | null>(null)
const mailDrivers = ref<MailDriver[]>([])
const currentMailDriver = ref('smtp')
loadData()
@@ -34,34 +31,17 @@ async function loadData(): Promise<void> {
mailDrivers.value = driversResponse
mailConfigData.value = configResponse
currentMailDriver.value = configResponse.mail_driver ?? 'smtp'
} catch (error: unknown) {
const normalizedError = handleApiError(error)
notificationStore.showNotification({
type: 'error',
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
})
} finally {
isFetchingInitialData.value = false
}
}
const mailDriver = computed(() => {
switch (currentMailDriver.value) {
case 'mailgun':
return MailgunMailDriver
case 'ses':
return SesMailDriver
case 'sendmail':
case 'mail':
return BasicMailDriver
default:
return SmtpMailDriver
}
})
function changeDriver(value: string): void {
currentMailDriver.value = value
if (mailConfigData.value) {
mailConfigData.value.mail_driver = value
}
}
async function saveEmailConfig(value: MailConfig): Promise<void> {
isSaving.value = true
@@ -71,7 +51,7 @@ async function saveEmailConfig(value: MailConfig): Promise<void> {
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: t(`settings.success.${response.success}`),
message: 'settings.mail.mail_config_updated',
})
if (mailConfigData.value) {
@@ -81,6 +61,12 @@ async function saveEmailConfig(value: MailConfig): Promise<void> {
}
}
}
} catch (error: unknown) {
const normalizedError = handleApiError(error)
notificationStore.showNotification({
type: 'error',
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
})
} finally {
isSaving.value = false
}
@@ -103,13 +89,11 @@ function openMailTestModal(): void {
:description="$t('settings.mail.mail_config_desc')"
>
<div v-if="mailConfigData" class="mt-14">
<component
:is="mailDriver"
<MailConfigurationForm
:config-data="mailConfigData"
:is-saving="isSaving"
:mail-drivers="mailDrivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="changeDriver"
@submit-data="saveEmailConfig"
>
<BaseButton
@@ -121,7 +105,7 @@ function openMailTestModal(): void {
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</MailConfigurationForm>
</div>
</BaseSettingCard>
</template>

View File

@@ -0,0 +1,697 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { email, helpers, numeric, required } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import type { MailConfig, MailDriver } from '@/scripts/types/mail-config'
interface SelectOption<TValue extends string = string> {
label: string
value: TValue
}
const props = withDefaults(
defineProps<{
configData?: Partial<MailConfig>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: MailDriver[]
translationScope?: string
submitLabel?: string
submitIcon?: string
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
translationScope: 'settings.mail',
submitLabel: 'general.save',
submitIcon: 'ArrowDownOnSquareIcon',
}
)
const emit = defineEmits<{
'submit-data': [config: MailConfig]
'on-change-driver': [driver: MailDriver]
}>()
const { t } = useI18n()
const visibleSecrets = reactive<Record<string, boolean>>({})
const showAdvancedFields = ref(false)
const mailConfig = reactive<MailConfig>(createDefaultMailConfig())
const fallbackDrivers: MailDriver[] = ['smtp', 'mail', 'sendmail']
const encryptionOptions: SelectOption[] = [
{ label: 'None', value: 'none' },
{ label: 'TLS', value: 'tls' },
{ label: 'SSL', value: 'ssl' },
]
const smtpSchemeOptions: SelectOption[] = [
{ label: 'SMTP', value: 'smtp' },
{ label: 'SMTPS', value: 'smtps' },
]
const mailgunSchemeOptions: SelectOption[] = [
{ label: 'HTTPS', value: 'https' },
{ label: 'API', value: 'api' },
]
const availableDrivers = computed<MailDriver[]>(() => {
return props.mailDrivers.length ? props.mailDrivers : fallbackDrivers
})
const driverOptions = computed<SelectOption<MailDriver>[]>(() => {
return availableDrivers.value.map((driver) => ({
label: t(`${props.translationScope}.drivers.${driver}`),
value: driver,
}))
})
const currentDriver = computed<MailDriver>({
get: () => normalizeDriver(mailConfig.mail_driver, availableDrivers.value),
set: (driver) => {
mailConfig.mail_driver = driver
},
})
const hasAdvancedFields = computed<boolean>(() => {
return getAdvancedFields(currentDriver.value).length > 0
})
const rules = computed(() => {
const commonRules = {
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}
switch (currentDriver.value) {
case 'smtp':
return {
...commonRules,
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
mail_timeout: {
numeric: helpers.withMessage(t('validation.numbers_only'), (value: string) => {
if (!value) {
return true
}
return /^\d+$/.test(value)
}),
},
}
case 'ses':
return {
...commonRules,
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_region: {
required: helpers.withMessage(t('validation.required'), required),
},
}
case 'mailgun':
return {
...commonRules,
mail_mailgun_domain: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_endpoint: {
required: helpers.withMessage(t('validation.required'), required),
},
}
case 'postmark':
return {
...commonRules,
mail_postmark_token: {
required: helpers.withMessage(t('validation.required'), required),
},
}
default:
return commonRules
}
})
const v$ = useVuelidate(rules, mailConfig)
watch(
() => [props.configData, props.mailDrivers] as const,
() => {
syncMailConfig()
},
{ immediate: true, deep: true }
)
function createDefaultMailConfig(): MailConfig {
return {
mail_driver: 'smtp',
from_mail: '',
from_name: '',
mail_host: '',
mail_port: '587',
mail_username: '',
mail_password: '',
mail_encryption: 'none',
mail_scheme: '',
mail_url: '',
mail_timeout: '',
mail_local_domain: '',
mail_sendmail_path: '/usr/sbin/sendmail -bs -i',
mail_ses_key: '',
mail_ses_secret: '',
mail_ses_region: 'us-east-1',
mail_mailgun_domain: '',
mail_mailgun_secret: '',
mail_mailgun_endpoint: 'api.mailgun.net',
mail_mailgun_scheme: 'https',
mail_postmark_token: '',
mail_postmark_message_stream_id: '',
}
}
function normalizeDriver(driver: MailConfig['mail_driver'], drivers: MailDriver[]): MailDriver {
if (driver && drivers.includes(driver as MailDriver)) {
return driver as MailDriver
}
return drivers[0] ?? 'smtp'
}
function syncMailConfig(): void {
Object.assign(mailConfig, createDefaultMailConfig(), props.configData)
mailConfig.mail_driver = normalizeDriver(mailConfig.mail_driver, availableDrivers.value)
showAdvancedFields.value = hasAdvancedValues(currentDriver.value)
v$.value.$reset()
}
function hasAdvancedValues(driver: MailDriver): boolean {
const defaultMailConfig = createDefaultMailConfig()
return getAdvancedFields(driver).some((field) => {
const value = mailConfig[field]
const defaultValue = defaultMailConfig[field]
return value !== '' && value !== null && value !== undefined && value !== defaultValue
})
}
function getAdvancedFields(driver: MailDriver): Array<keyof MailConfig> {
switch (driver) {
case 'smtp':
return ['mail_scheme', 'mail_url', 'mail_timeout', 'mail_local_domain']
case 'sendmail':
return ['mail_sendmail_path']
case 'mailgun':
return ['mail_mailgun_scheme']
case 'postmark':
return ['mail_postmark_message_stream_id']
default:
return []
}
}
function getFieldError(field: string): string | undefined {
const validationField = v$.value[field as keyof typeof v$.value]
if (!validationField || !('$error' in validationField) || !validationField.$error) {
return undefined
}
return validationField.$errors[0]?.$message as string | undefined
}
function toggleSecret(field: string): void {
visibleSecrets[field] = !visibleSecrets[field]
}
function getSecretInputType(field: string): string {
return visibleSecrets[field] ? 'text' : 'password'
}
function translationKey(key: string): string {
return `${props.translationScope}.${key}`
}
function changeDriver(value: MailDriver): void {
currentDriver.value = value
showAdvancedFields.value = false
v$.value.$reset()
emit('on-change-driver', value)
}
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
emit('submit-data', { ...mailConfig, mail_driver: currentDriver.value })
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<BaseInputGroup
:label="$t(translationKey('driver'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_driver')"
required
>
<BaseMultiselect
v-model="currentDriver"
:content-loading="isFetchingInitialData"
:options="driverOptions"
label="label"
value-prop="value"
:can-deselect="false"
:can-clear="false"
:invalid="v$.mail_driver.$error"
@update:model-value="changeDriver"
/>
</BaseInputGroup>
</div>
<div class="mt-8">
<h3 class="text-sm font-semibold text-heading">
{{ $t(translationKey('basic_settings')) }}
</h3>
<p
v-if="currentDriver === 'mail' || currentDriver === 'sendmail'"
class="mt-2 text-sm text-muted"
>
{{
currentDriver === 'mail'
? $t(translationKey('native_mail_desc'))
: $t(translationKey('sendmail_desc'))
}}
</p>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<template v-if="currentDriver === 'smtp'">
<BaseInputGroup
:label="$t(translationKey('host'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_host')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_host"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_host?.$error"
type="text"
@input="v$.mail_host?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('port'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_port')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_port"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_port?.$error"
type="text"
@input="v$.mail_port?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('username'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_username"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('password'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_password"
:content-loading="isFetchingInitialData"
:type="getSecretInputType('mail_password')"
autocomplete="off"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_password ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_password')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('encryption'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptionOptions"
label="label"
value-prop="value"
:can-clear="false"
:can-deselect="false"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'ses'">
<BaseInputGroup
:label="$t(translationKey('ses_key'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_key')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_key"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_key?.$error"
type="text"
@input="v$.mail_ses_key?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('ses_secret'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_secret')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_secret"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_secret?.$error"
:type="getSecretInputType('mail_ses_secret')"
autocomplete="off"
@input="v$.mail_ses_secret?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_ses_secret ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_ses_secret')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('ses_region'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_ses_region')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_ses_region"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_ses_region?.$error"
type="text"
@input="v$.mail_ses_region?.$touch()"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'mailgun'">
<BaseInputGroup
:label="$t(translationKey('mailgun_domain'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_domain')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_domain"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_domain?.$error"
type="text"
@input="v$.mail_mailgun_domain?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('mailgun_secret'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_secret')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_secret"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_secret?.$error"
:type="getSecretInputType('mail_mailgun_secret')"
autocomplete="off"
@input="v$.mail_mailgun_secret?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_mailgun_secret ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_mailgun_secret')"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('mailgun_endpoint'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_mailgun_endpoint')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_mailgun_endpoint"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_mailgun_endpoint?.$error"
type="text"
@input="v$.mail_mailgun_endpoint?.$touch()"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'postmark'">
<BaseInputGroup
:label="$t(translationKey('postmark_token'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_postmark_token')"
required
>
<BaseInput
v-model.trim="mailConfig.mail_postmark_token"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_postmark_token?.$error"
:type="getSecretInputType('mail_postmark_token')"
autocomplete="off"
@input="v$.mail_postmark_token?.$touch()"
>
<template #right>
<BaseIcon
:name="visibleSecrets.mail_postmark_token ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="toggleSecret('mail_postmark_token')"
/>
</template>
</BaseInput>
</BaseInputGroup>
</template>
<BaseInputGroup
:label="$t(translationKey('from_mail'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('from_mail')"
required
>
<BaseInput
v-model.trim="mailConfig.from_mail"
:content-loading="isFetchingInitialData"
:invalid="v$.from_mail.$error"
type="text"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('from_name'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('from_name')"
required
>
<BaseInput
v-model.trim="mailConfig.from_name"
:content-loading="isFetchingInitialData"
:invalid="v$.from_name.$error"
type="text"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</div>
</div>
<div v-if="hasAdvancedFields" class="mt-8">
<button
type="button"
class="inline-flex items-center gap-2 text-sm font-medium text-primary-600"
@click="showAdvancedFields = !showAdvancedFields"
>
<BaseIcon
:name="showAdvancedFields ? 'ChevronUpIcon' : 'ChevronDownIcon'"
class="h-4 w-4"
/>
{{
showAdvancedFields
? $t(translationKey('hide_advanced_settings'))
: $t(translationKey('show_advanced_settings'))
}}
</button>
<div v-if="showAdvancedFields" class="mt-4 rounded-lg border border-line-default p-4">
<h3 class="text-sm font-semibold text-heading">
{{ $t(translationKey('advanced_settings')) }}
</h3>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<template v-if="currentDriver === 'smtp'">
<BaseInputGroup
:label="$t(translationKey('scheme'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_scheme"
:content-loading="isFetchingInitialData"
:options="smtpSchemeOptions"
label="label"
value-prop="value"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('url'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_url"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('timeout'))"
:content-loading="isFetchingInitialData"
:error="getFieldError('mail_timeout')"
>
<BaseInput
v-model.trim="mailConfig.mail_timeout"
:content-loading="isFetchingInitialData"
:invalid="v$.mail_timeout?.$error"
type="text"
@input="v$.mail_timeout?.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t(translationKey('local_domain'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_local_domain"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'sendmail'">
<BaseInputGroup
:label="$t(translationKey('sendmail_path'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_sendmail_path"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'mailgun'">
<BaseInputGroup
:label="$t(translationKey('mailgun_scheme'))"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="mailConfig.mail_mailgun_scheme"
:content-loading="isFetchingInitialData"
:options="mailgunSchemeOptions"
label="label"
value-prop="value"
:can-clear="false"
:can-deselect="false"
/>
</BaseInputGroup>
</template>
<template v-if="currentDriver === 'postmark'">
<BaseInputGroup
:label="$t(translationKey('postmark_message_stream_id'))"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="mailConfig.mail_postmark_message_stream_id"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
</template>
</div>
</div>
</div>
<div class="mt-8 flex">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon v-if="!isSaving" :name="submitIcon" :class="slotProps.class" />
</template>
{{ $t(submitLabel) }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { companyService } from '../../../api/services/company.service'
import type { CompanySettingsPayload } from '../../../api/services/company.service'
import { mailService } from '../../../api/services/mail.service'
import type { MailDriver, MailConfigResponse } from '../../../api/services/mail.service'
import type { CompanyMailConfig, MailDriver } from '../../../types/mail-config'
import { useNotificationStore } from '../../../stores/notification.store'
import { handleApiError } from '../../../utils/error-handling'
@@ -15,7 +15,7 @@ import { handleApiError } from '../../../utils/error-handling'
export const useSettingsStore = defineStore('settings', () => {
// Company Mail state
const mailDrivers = ref<MailDriver[]>([])
const mailConfigData = ref<MailConfigResponse | null>(null)
const mailConfigData = ref<CompanyMailConfig | null>(null)
const currentMailDriver = ref<string>('smtp')
async function fetchMailDrivers(): Promise<MailDriver[]> {
@@ -29,9 +29,9 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
async function fetchMailConfig(): Promise<MailConfigResponse> {
async function fetchMailConfig(): Promise<CompanyMailConfig> {
try {
const response = await companyService.getMailConfig() as unknown as MailConfigResponse
const response = await companyService.getMailConfig()
mailConfigData.value = response
currentMailDriver.value = response.mail_driver ?? 'smtp'
return response
@@ -48,7 +48,7 @@ export const useSettingsStore = defineStore('settings', () => {
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: 'settings.mail.config_updated',
message: 'settings.mail.company_mail_config_updated',
})
} catch (err: unknown) {
handleApiError(err)

View File

@@ -1,79 +1,107 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import { companyService } from '../../../../api/services/company.service'
import { mailService } from '../../../../api/services/mail.service'
import type { MailDriver } from '../../../../api/services/mail.service'
import Smtp from '@/scripts/features/company/settings/components/SmtpMailDriver.vue'
import Mailgun from '@/scripts/features/company/settings/components/MailgunMailDriver.vue'
import Ses from '@/scripts/features/company/settings/components/SesMailDriver.vue'
import Basic from '@/scripts/features/company/settings/components/BasicMailDriver.vue'
import type { CompanyMailConfig, MailConfig, MailDriver } from '@/scripts/types/mail-config'
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
import MailConfigurationForm from '@/scripts/features/company/settings/components/MailConfigurationForm.vue'
import MailTestModal from '@/scripts/features/company/settings/components/MailTestModal.vue'
const { t } = useI18n()
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const useCustomMailConfig = ref<boolean>(false)
const mailConfigData = ref<Record<string, unknown> | null>(null)
const mailConfigData = ref<CompanyMailConfig | null>(null)
const mailDrivers = ref<MailDriver[]>([])
const currentMailDriver = ref<string>('smtp')
loadData()
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
const [driversResponse, configResponse] = await Promise.all([
mailService.getDrivers(),
companyService.getMailConfig(),
])
mailDrivers.value = driversResponse
mailConfigData.value = configResponse
currentMailDriver.value = (configResponse.mail_driver as string) ?? 'smtp'
useCustomMailConfig.value =
(configResponse.use_custom_mail_config as string) === 'YES'
isFetchingInitialData.value = false
}
try {
const [driversResponse, configResponse] = await Promise.all([
mailService.getDrivers(),
companyService.getMailConfig(),
])
function changeDriver(value: string): void {
currentMailDriver.value = value
if (mailConfigData.value) {
mailConfigData.value.mail_driver = value
mailDrivers.value = driversResponse
mailConfigData.value = configResponse
useCustomMailConfig.value = configResponse.use_custom_mail_config === 'YES'
} catch (error: unknown) {
const normalizedError = handleApiError(error)
notificationStore.showNotification({
type: 'error',
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
})
} finally {
isFetchingInitialData.value = false
}
}
const mailDriver = computed(() => {
if (currentMailDriver.value === 'smtp') return Smtp
if (currentMailDriver.value === 'mailgun') return Mailgun
if (currentMailDriver.value === 'sendmail') return Basic
if (currentMailDriver.value === 'ses') return Ses
if (currentMailDriver.value === 'mail') return Basic
return Smtp
})
watch(useCustomMailConfig, async (newVal, oldVal) => {
if (oldVal === undefined) return
if (!newVal) {
isSaving.value = true
await companyService.saveMailConfig({
use_custom_mail_config: 'NO',
mail_driver: '',
})
isSaving.value = false
try {
await companyService.saveMailConfig({
use_custom_mail_config: 'NO',
})
if (mailConfigData.value) {
mailConfigData.value.use_custom_mail_config = 'NO'
}
notificationStore.showNotification({
type: 'success',
message: 'settings.mail.company_mail_config_updated',
})
} catch (error: unknown) {
const normalizedError = handleApiError(error)
notificationStore.showNotification({
type: 'error',
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
})
useCustomMailConfig.value = true
} finally {
isSaving.value = false
}
}
})
async function saveEmailConfig(value: Record<string, unknown>): Promise<void> {
async function saveEmailConfig(value: MailConfig): Promise<void> {
try {
isSaving.value = true
await companyService.saveMailConfig({
...value,
use_custom_mail_config: 'YES',
})
if (mailConfigData.value) {
mailConfigData.value = {
...mailConfigData.value,
...value,
use_custom_mail_config: 'YES',
}
}
notificationStore.showNotification({
type: 'success',
message: 'settings.mail.company_mail_config_updated',
})
} catch (error: unknown) {
const normalizedError = handleApiError(error)
notificationStore.showNotification({
type: 'error',
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
})
} finally {
isSaving.value = false
}
@@ -104,13 +132,11 @@ function openMailTestModal(): void {
</div>
<div v-if="useCustomMailConfig && mailConfigData" class="mt-8">
<component
:is="mailDriver"
<MailConfigurationForm
:config-data="mailConfigData"
:is-saving="isSaving"
:mail-drivers="mailDrivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val: string) => changeDriver(val)"
@submit-data="saveEmailConfig"
>
<BaseButton
@@ -122,7 +148,7 @@ function openMailTestModal(): void {
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</MailConfigurationForm>
</div>
<div

View File

@@ -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>

View 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)
}

View File

@@ -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,
},
},
],
},
]

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -1,13 +1,121 @@
<template>
<div class="h-screen overflow-y-auto text-base">
<div class="bg-glass-gradient relative min-h-screen w-full overflow-hidden">
<NotificationRoot />
<div class="container mx-auto px-4">
<router-view />
</div>
<main
class="
relative flex min-h-screen flex-col items-center justify-center
px-4 py-12 sm:px-6
"
>
<!-- Logo above the card -->
<div class="mb-8 flex justify-center">
<MainLogo
v-if="!loginPageLogo"
class="h-12 w-auto text-primary-500"
/>
<img
v-else
:src="loginPageLogo"
alt="InvoiceShelf"
class="h-12 w-auto"
/>
</div>
<!-- Wizard card same visual language as AuthLayout / BaseCard -->
<article
class="
w-full max-w-3xl
bg-surface
rounded-xl
border border-line-default
shadow-sm
backdrop-blur-sm
px-8 py-10 sm:px-10 sm:py-12
"
>
<!-- Step progress indicator -->
<div
v-if="totalSteps > 0"
class="mb-8 flex items-center justify-center gap-2"
>
<span
v-for="step in totalSteps"
:key="step"
:class="[
'h-2 rounded-full transition-all duration-300',
step === currentStep
? 'w-8 bg-primary-500'
: step < currentStep
? 'w-2 bg-primary-500'
: 'w-2 bg-line-default',
]"
/>
</div>
<router-view />
</article>
<!-- Footer -->
<footer class="mt-8 text-center text-xs text-subtle">
<span v-if="copyrightText">{{ copyrightText }}</span>
<span v-else>
Powered by
<a
href="https://invoiceshelf.com/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-500 hover:text-primary-600 font-medium transition-colors"
>InvoiceShelf</a>
</span>
</footer>
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
declare global {
interface Window {
login_page_logo?: string
copyright_text?: string
}
}
const route = useRoute()
/**
* Step ordinal lookup. Each child route's `meta.installStep` is its 1-based
* position in the wizard. The progress dots in the layout react to this.
*/
const STEP_ORDER = [
'installation.language',
'installation.requirements',
'installation.permissions',
'installation.database',
'installation.domain',
'installation.mail',
'installation.account',
'installation.company',
'installation.preferences',
]
const totalSteps = computed<number>(() => STEP_ORDER.length)
const currentStep = computed<number>(() => {
const name = route.name?.toString() ?? ''
const idx = STEP_ORDER.indexOf(name)
return idx >= 0 ? idx + 1 : 1
})
const copyrightText = computed<string | null>(() => window.copyright_text ?? null)
const loginPageLogo = computed<string | false>(() => {
if (window.login_page_logo) return window.login_page_logo
return false
})
</script>

View File

@@ -0,0 +1,42 @@
export type MailDriver =
| 'smtp'
| 'mail'
| 'sendmail'
| 'ses'
| 'mailgun'
| 'postmark'
export interface MailConfig {
mail_driver: MailDriver | ''
from_mail: string
from_name: string
mail_host: string
mail_port: string
mail_username: string
mail_password: string
mail_encryption: string
mail_scheme: string
mail_url: string
mail_timeout: string
mail_local_domain: string
mail_sendmail_path: string
mail_ses_key: string
mail_ses_secret: string
mail_ses_region: string
mail_mailgun_domain: string
mail_mailgun_secret: string
mail_mailgun_endpoint: string
mail_mailgun_scheme: string
mail_postmark_token: string
mail_postmark_message_stream_id: string
}
export interface CompanyMailConfig extends MailConfig {
use_custom_mail_config: 'YES' | 'NO'
}
export interface TestMailPayload {
to: string
subject: string
message: string
}