Initial commit

This commit is contained in:
root
2025-12-21 09:52:34 -05:00
committed by Sebastian Krupinski
commit c93e76313c
16 changed files with 2406 additions and 0 deletions

29
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,9 @@
{
"name": "ktxm/profile",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\UserProfile\\": "lib/"
}
}
}

61
lib/Module.php Normal file
View 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

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View 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"
}
}

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

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

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

@@ -0,0 +1,4 @@
import routes from '@/routes'
import integrations from '@/integrations'
export { routes, integrations }

20
src/routes.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

19
tsconfig.node.json Normal file
View 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
View 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'],
},
},
})