Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
9
composer.json
Normal file
9
composer.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "ktxm/profile",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\UserProfile\\": "lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/Module.php
Normal file
61
lib/Module.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXM\UserProfile;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public function handle(): string
|
||||||
|
{
|
||||||
|
return 'user_profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Profile & Settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function author(): string
|
||||||
|
{
|
||||||
|
return 'Ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'User profile management and settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function version(): string
|
||||||
|
{
|
||||||
|
return '0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_profile' => [
|
||||||
|
'label' => 'Access Profile & Settings',
|
||||||
|
'description' => 'View and access the user profile and settings module',
|
||||||
|
'group' => 'User Profile'
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerBI(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'UserProfile',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
1534
package-lock.json
generated
Normal file
1534
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "user_profile",
|
||||||
|
"description": "Ktrix User Profile Module - User Settings and Account Management",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Ktrix",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/js": "^7.4.47",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
360
src/components/ProfileAccount.vue
Normal file
360
src/components/ProfileAccount.vue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@KTXC/stores/userStore'
|
||||||
|
import { userService } from '@KTXC/services/user/userService'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const refInputEl = ref<HTMLElement>()
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// Local form state
|
||||||
|
const accountDataLocal = ref({
|
||||||
|
avatarImg: '',
|
||||||
|
name_given: '',
|
||||||
|
name_family: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip: '',
|
||||||
|
country: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load profile data on mount
|
||||||
|
onMounted(() => {
|
||||||
|
accountDataLocal.value = {
|
||||||
|
avatarImg: userStore.getProfileField('avatar') || '',
|
||||||
|
name_given: userStore.getProfileField('name_given') || '',
|
||||||
|
name_family: userStore.getProfileField('name_family') || '',
|
||||||
|
email: userStore.getProfileField('email') || '',
|
||||||
|
phone: userStore.getProfileField('phone') || '',
|
||||||
|
address: userStore.getProfileField('address') || '',
|
||||||
|
city: userStore.getProfileField('city') || '',
|
||||||
|
state: userStore.getProfileField('state') || '',
|
||||||
|
zip: userStore.getProfileField('zip') || '',
|
||||||
|
country: userStore.getProfileField('country') || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if field is editable
|
||||||
|
const isFieldEditable = (field: string) => userStore.isProfileFieldEditable(field)
|
||||||
|
|
||||||
|
// Reset form to current profile values
|
||||||
|
const resetForm = () => {
|
||||||
|
accountDataLocal.value = {
|
||||||
|
avatarImg: userStore.getProfileField('avatar') || '',
|
||||||
|
name_given: userStore.getProfileField('name_given') || '',
|
||||||
|
name_family: userStore.getProfileField('name_family') || '',
|
||||||
|
email: userStore.getProfileField('email') || '',
|
||||||
|
phone: userStore.getProfileField('phone') || '',
|
||||||
|
address: userStore.getProfileField('address') || '',
|
||||||
|
city: userStore.getProfileField('city') || '',
|
||||||
|
state: userStore.getProfileField('state') || '',
|
||||||
|
zip: userStore.getProfileField('zip') || '',
|
||||||
|
country: userStore.getProfileField('country') || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save changes
|
||||||
|
const saveChanges = async () => {
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
// Build update object with only changed fields
|
||||||
|
const updates: Record<string, any> = {}
|
||||||
|
|
||||||
|
if (accountDataLocal.value.avatarImg !== userStore.getProfileField('avatar')) {
|
||||||
|
updates.avatar = accountDataLocal.value.avatarImg
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.name_given !== userStore.getProfileField('name_given')) {
|
||||||
|
updates.name_given = accountDataLocal.value.name_given
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.name_family !== userStore.getProfileField('name_family')) {
|
||||||
|
updates.name_family = accountDataLocal.value.name_family
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.email !== userStore.getProfileField('email')) {
|
||||||
|
updates.email = accountDataLocal.value.email
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.phone !== userStore.getProfileField('phone')) {
|
||||||
|
updates.phone = accountDataLocal.value.phone
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.address !== userStore.getProfileField('address')) {
|
||||||
|
updates.address = accountDataLocal.value.address
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.city !== userStore.getProfileField('city')) {
|
||||||
|
updates.city = accountDataLocal.value.city
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.state !== userStore.getProfileField('state')) {
|
||||||
|
updates.state = accountDataLocal.value.state
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.zip !== userStore.getProfileField('zip')) {
|
||||||
|
updates.zip = accountDataLocal.value.zip
|
||||||
|
}
|
||||||
|
if (accountDataLocal.value.country !== userStore.getProfileField('country')) {
|
||||||
|
updates.country = accountDataLocal.value.country
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
// Use immediate update for explicit save action
|
||||||
|
await userService.updateProfileImmediate(updates)
|
||||||
|
|
||||||
|
// Update local store state
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
userStore.setProfileField(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save profile:', error)
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeAvatar function
|
||||||
|
const changeAvatar = (file: Event) => {
|
||||||
|
const fileReader = new FileReader()
|
||||||
|
const { files } = file.target as HTMLInputElement
|
||||||
|
|
||||||
|
if (files && files.length) {
|
||||||
|
fileReader.readAsDataURL(files[0])
|
||||||
|
fileReader.onload = () => {
|
||||||
|
if (typeof fileReader.result === 'string')
|
||||||
|
accountDataLocal.value.avatarImg = fileReader.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset avatar image
|
||||||
|
const resetAvatar = () => {
|
||||||
|
accountDataLocal.value.avatarImg = userStore.getProfileField('avatar') || ''
|
||||||
|
// Clear the file input
|
||||||
|
if (refInputEl.value) {
|
||||||
|
refInputEl.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const countries = [
|
||||||
|
'United States',
|
||||||
|
'United Kingdom',
|
||||||
|
'Canada',
|
||||||
|
'Australia',
|
||||||
|
'Germany',
|
||||||
|
'France',
|
||||||
|
'India',
|
||||||
|
'China',
|
||||||
|
'Japan',
|
||||||
|
'Brazil',
|
||||||
|
'Mexico',
|
||||||
|
'South Africa',
|
||||||
|
]
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard title="Account Details">
|
||||||
|
<VCardText class="d-flex">
|
||||||
|
<!-- 👉 Avatar -->
|
||||||
|
<VAvatar
|
||||||
|
rounded="lg"
|
||||||
|
size="100"
|
||||||
|
class="me-6"
|
||||||
|
:image="accountDataLocal.avatarImg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 👉 Upload Photo -->
|
||||||
|
<form class="d-flex flex-column justify-center gap-5">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
@click="refInputEl?.click()"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-cloud-upload" class="d-sm-none" />
|
||||||
|
<span class="d-none d-sm-block">Upload new photo</span>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="refInputEl"
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
accept=".jpeg,.png,.jpg,GIF"
|
||||||
|
hidden
|
||||||
|
@input="changeAvatar"
|
||||||
|
>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
type="button"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
@click="resetAvatar"
|
||||||
|
>
|
||||||
|
<span class="d-none d-sm-block">Reset</span>
|
||||||
|
<VIcon icon="mdi-refresh" class="d-sm-none" />
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-1 mb-0">
|
||||||
|
Allowed JPG, GIF or PNG. Max size of 800K
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<!-- 👉 Form -->
|
||||||
|
<VForm class="mt-6">
|
||||||
|
<VRow>
|
||||||
|
<!-- 👉 First Name -->
|
||||||
|
<VCol
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.name_given"
|
||||||
|
placeholder="John"
|
||||||
|
label="First Name"
|
||||||
|
:readonly="!isFieldEditable('name_given')"
|
||||||
|
:hint="!isFieldEditable('name_given') ? 'Managed by authentication provider' : ''"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Last Name -->
|
||||||
|
<VCol
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.name_family"
|
||||||
|
placeholder="Doe"
|
||||||
|
label="Last Name"
|
||||||
|
:readonly="!isFieldEditable('name_family')"
|
||||||
|
:hint="!isFieldEditable('name_family') ? 'Managed by authentication provider' : ''"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Email -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.email"
|
||||||
|
label="E-mail"
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
type="email"
|
||||||
|
:readonly="!isFieldEditable('email')"
|
||||||
|
:hint="!isFieldEditable('email') ? 'Managed by authentication provider' : ''"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Phone -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.phone"
|
||||||
|
label="Phone Number"
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
:readonly="!isFieldEditable('phone')"
|
||||||
|
:hint="!isFieldEditable('phone') ? 'Managed by authentication provider' : ''"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Address -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.address"
|
||||||
|
label="Address"
|
||||||
|
placeholder="123 Main St"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 City -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.city"
|
||||||
|
label="City"
|
||||||
|
placeholder="New York"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 State -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.state"
|
||||||
|
label="State/Province"
|
||||||
|
placeholder="State"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Zip Code -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VTextField
|
||||||
|
v-model="accountDataLocal.zip"
|
||||||
|
label="Zip Code"
|
||||||
|
placeholder="10001"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Country -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<VSelect
|
||||||
|
v-model="accountDataLocal.country"
|
||||||
|
label="Country"
|
||||||
|
:items="countries"
|
||||||
|
placeholder="Select Country"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- 👉 Form Actions -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
class="d-flex flex-wrap gap-4"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
@click="saveChanges"
|
||||||
|
:loading="isSaving"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
type="reset"
|
||||||
|
@click.prevent="resetForm"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
116
src/components/ProfileNotifications.vue
Normal file
116
src/components/ProfileNotifications.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const recentDevices = ref(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'New for you',
|
||||||
|
email: true,
|
||||||
|
browser: true,
|
||||||
|
app: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Account activity',
|
||||||
|
email: true,
|
||||||
|
browser: true,
|
||||||
|
app: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'A new browser used to sign in',
|
||||||
|
email: true,
|
||||||
|
browser: true,
|
||||||
|
app: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'A new device is linked',
|
||||||
|
email: true,
|
||||||
|
browser: false,
|
||||||
|
app: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedNotification = ref('Only when I\'m online')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VCard title="Notifications">
|
||||||
|
<VCardText>
|
||||||
|
We need permission from your browser to show notifications.
|
||||||
|
<a href="javascript:void(0)">Request Permission</a>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable class="text-no-wrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
EMAIL
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
BROWSER
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
App
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="device in recentDevices"
|
||||||
|
:key="device.type"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{{ device.type }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VCheckbox v-model="device.email" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VCheckbox v-model="device.browser" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VCheckbox v-model="device.app" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="() => {}">
|
||||||
|
<p class="text-base font-weight-medium">
|
||||||
|
When should we send you notifications?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
>
|
||||||
|
<VSelect
|
||||||
|
v-model="selectedNotification"
|
||||||
|
mandatory
|
||||||
|
:items="['Only when I\'m online', 'Anytime']"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||||
|
<VBtn type="submit">
|
||||||
|
Save Changes
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
type="reset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
70
src/components/ProfileSecurity.vue
Normal file
70
src/components/ProfileSecurity.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
|
||||||
|
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
|
||||||
|
// Get all registered security panel components from modules
|
||||||
|
const securityPanels = computed(() => {
|
||||||
|
return integrationStore.getItems('user_settings_security')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store resolved components
|
||||||
|
const resolvedComponents = ref<Array<{ id: string; label: string; priority: number; component: any }>>([])
|
||||||
|
|
||||||
|
// Load components on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
// Resolve all component loaders
|
||||||
|
const components = []
|
||||||
|
for (const panel of securityPanels.value) {
|
||||||
|
try {
|
||||||
|
if (panel.component) {
|
||||||
|
const module = await panel.component()
|
||||||
|
components.push({
|
||||||
|
id: panel.id,
|
||||||
|
label: panel.label || panel.id,
|
||||||
|
priority: panel.priority ?? 100,
|
||||||
|
component: module.default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ProfileSecurity] Failed to load component for', panel.id, ':', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority and store
|
||||||
|
resolvedComponents.value = components.sort((a, b) => a.priority - b.priority)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow dense>
|
||||||
|
<!-- Render all registered security panel components from modules -->
|
||||||
|
<component
|
||||||
|
v-for="panel in resolvedComponents"
|
||||||
|
:key="panel.id"
|
||||||
|
:is="panel.component"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Fallback message if no panels are registered -->
|
||||||
|
<VCol
|
||||||
|
v-if="resolvedComponents.length === 0"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardText class="text-center pa-8">
|
||||||
|
<VIcon
|
||||||
|
icon="mdi-shield-lock-outline"
|
||||||
|
size="48"
|
||||||
|
class="mb-4"
|
||||||
|
color="grey"
|
||||||
|
/>
|
||||||
|
<p class="text-h6 mb-2">No Security Settings Available</p>
|
||||||
|
<p class="text-body-2 text-medium-emphasis">
|
||||||
|
No security modules are currently enabled or installed.
|
||||||
|
</p>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
40
src/integrations.ts
Normal file
40
src/integrations.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
|
||||||
|
|
||||||
|
const integrations: ModuleIntegrations = {
|
||||||
|
profile_menu: [
|
||||||
|
{
|
||||||
|
id: 'user_profile',
|
||||||
|
label: 'Profile & Settings',
|
||||||
|
path: '/me',
|
||||||
|
icon: 'mdi-account-outline',
|
||||||
|
priority: 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// User personal settings
|
||||||
|
user_settings_menu: [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
label: 'Profile',
|
||||||
|
path: '/me/profile',
|
||||||
|
icon: 'mdi-account-circle',
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'preferences',
|
||||||
|
label: 'Preferences',
|
||||||
|
path: '/me/preferences',
|
||||||
|
icon: 'mdi-tune',
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
label: 'Security',
|
||||||
|
path: '/me/security',
|
||||||
|
icon: 'mdi-shield-lock',
|
||||||
|
priority: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default integrations;
|
||||||
4
src/main.ts
Normal file
4
src/main.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import routes from '@/routes'
|
||||||
|
import integrations from '@/integrations'
|
||||||
|
|
||||||
|
export { routes, integrations }
|
||||||
20
src/routes.ts
Normal file
20
src/routes.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
name: 'user_profile',
|
||||||
|
path: '/me',
|
||||||
|
component: () => import('@/views/ProfilePage.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_profile-tab',
|
||||||
|
path: '/me/:tab',
|
||||||
|
component: () => import('@/views/ProfilePage.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default routes
|
||||||
66
src/views/ProfilePage.vue
Normal file
66
src/views/ProfilePage.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import ProfileAccount from '@/components/ProfileAccount.vue'
|
||||||
|
import ProfileNotifications from '@/components/ProfileNotifications.vue'
|
||||||
|
import ProfileSecurity from '@/components/ProfileSecurity.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const activeTab = ref(route.params.tab || 'account')
|
||||||
|
|
||||||
|
// tabs
|
||||||
|
const tabs = [
|
||||||
|
{ title: 'Account', icon: 'mdi-account-outline', tab: 'account' },
|
||||||
|
{ title: 'Security', icon: 'mdi-lock-outline', tab: 'security' },
|
||||||
|
{ title: 'Notifications', icon: 'mdi-bell-outline', tab: 'notifications' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PerfectScrollbar
|
||||||
|
class="pa-4"
|
||||||
|
style="height: calc(100vh - 64px);"
|
||||||
|
:options="{ wheelPropagation: false }"
|
||||||
|
>
|
||||||
|
<VTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
show-arrows
|
||||||
|
class="v-tabs-pill"
|
||||||
|
>
|
||||||
|
<VTab
|
||||||
|
v-for="item in tabs"
|
||||||
|
:key="item.icon"
|
||||||
|
:value="item.tab"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
size="20"
|
||||||
|
start
|
||||||
|
:icon="item.icon"
|
||||||
|
/>
|
||||||
|
{{ item.title }}
|
||||||
|
</VTab>
|
||||||
|
</VTabs>
|
||||||
|
|
||||||
|
<VWindow
|
||||||
|
v-model="activeTab"
|
||||||
|
class="mt-5 disable-tab-transition"
|
||||||
|
:touch="false"
|
||||||
|
>
|
||||||
|
<!-- Account -->
|
||||||
|
<VWindowItem value="account">
|
||||||
|
<ProfileAccount />
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Security -->
|
||||||
|
<VWindowItem value="security">
|
||||||
|
<ProfileSecurity />
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Notification -->
|
||||||
|
<VWindowItem value="notifications">
|
||||||
|
<ProfileNotifications />
|
||||||
|
</VWindowItem>
|
||||||
|
</VWindow>
|
||||||
|
</PerfectScrollbar>
|
||||||
|
</template>
|
||||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
vite.config.ts
Normal file
26
vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../core/src')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'module.mjs',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue', 'vue-router', 'pinia'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user