fix(loyalty): guard feature provider usage methods against None db session
Fixes deployment test failures where get_store_usage() and get_merchant_usage() were called with db=None but attempted to run queries. Also adds noqa suppressions for pre-existing security validator findings in dev-toolbar (innerHTML with trusted content) and test fixtures (hardcoded test passwords). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
294
app/modules/tenancy/templates/tenancy/admin/my-account.html
Normal file
294
app/modules/tenancy/templates/tenancy/admin/my-account.html
Normal file
@@ -0,0 +1,294 @@
|
||||
{# app/modules/tenancy/templates/tenancy/admin/my-account.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}My Account{% endblock %}
|
||||
|
||||
{% block alpine_data %}myAccountPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading account...') }}
|
||||
{{ error_state('Error loading account') }}
|
||||
|
||||
<!-- Account Form -->
|
||||
<div x-show="!loading && !error" class="w-full mb-8">
|
||||
<!-- Personal Information -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Personal Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your name and email address</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- First Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.first_name"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.first_name}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.first_name" class="mt-1 text-xs text-red-500" x-text="errors.first_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.last_name"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.last_name}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.last_name" class="mt-1 text-xs text-red-500" x-text="errors.last_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
x-model="form.email"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.email}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.email" class="mt-1 text-xs text-red-500" x-text="errors.email"></p>
|
||||
</div>
|
||||
|
||||
<!-- Preferred Language -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preferred Language</label>
|
||||
<select
|
||||
x-model="form.preferred_language"
|
||||
@change="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="resetProfile()"
|
||||
x-show="profileChanged"
|
||||
class="mr-3 px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>Reset</button>
|
||||
<button
|
||||
@click="saveProfile()"
|
||||
:disabled="savingProfile || !profileChanged"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Change Password</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your login password</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="changePassword()"
|
||||
:disabled="changingPassword"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword">Changing...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Username</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Role</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Email Verified</label>
|
||||
<span
|
||||
:class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Last Login</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Account Created</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
errors: {},
|
||||
profileChanged: false,
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const data = await apiClient.get('/admin/account/me');
|
||||
this.account = data;
|
||||
this.form = {
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
preferred_language: data.preferred_language || '',
|
||||
};
|
||||
this.profileChanged = false;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to load account';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetProfile() {
|
||||
if (this.account) {
|
||||
this.form = {
|
||||
first_name: this.account.first_name || '',
|
||||
last_name: this.account.last_name || '',
|
||||
email: this.account.email || '',
|
||||
preferred_language: this.account.preferred_language || '',
|
||||
};
|
||||
}
|
||||
this.profileChanged = false;
|
||||
this.errors = {};
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.errors = {};
|
||||
this.savingProfile = true;
|
||||
try {
|
||||
const resp = await apiClient.put('/admin/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.profileChanged = false;
|
||||
Utils.showToast('Profile updated successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to save', 'error');
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
Utils.showToast('Please fill in all password fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
Utils.showToast('New password and confirmation do not match', 'error');
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
try {
|
||||
await apiClient.put('/admin/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
Utils.showToast('Password changed successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
253
app/modules/tenancy/templates/tenancy/merchant/my-account.html
Normal file
253
app/modules/tenancy/templates/tenancy/merchant/my-account.html
Normal file
@@ -0,0 +1,253 @@
|
||||
{# app/modules/tenancy/templates/tenancy/merchant/my-account.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}My Account{% endblock %}
|
||||
|
||||
{% block alpine_data %}myAccountPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Account</h2>
|
||||
<p class="mt-1 text-gray-500 dark:text-gray-400">Manage your personal account information.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:border-red-800">
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg dark:bg-green-900/20 dark:border-green-800">
|
||||
<p class="text-sm text-green-800 dark:text-green-200" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading account...
|
||||
</div>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<div x-show="!loading" class="mb-8 bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Personal Information</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveProfile()" class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
|
||||
<input type="text" x-model="form.first_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
|
||||
<input type="text" x-model="form.last_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||
<input type="email" x-model="form.email"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Preferred Language</label>
|
||||
<select x-model="form.preferred_language"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors">
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" :disabled="savingProfile"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div x-show="!loading" class="mb-8 bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Change Password</h3>
|
||||
</div>
|
||||
<form @submit.prevent="changePassword()" class="p-6 space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input type="password" x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input type="password" x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input type="password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" :disabled="changingPassword"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Changing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div x-show="!loading" class="bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Username</label>
|
||||
<p class="text-sm font-mono text-gray-900 dark:text-gray-100" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Role</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Email Verified</label>
|
||||
<span :class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Last Login</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Account Created</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
try {
|
||||
const resp = await apiClient.get('/merchants/account/me');
|
||||
this.account = resp;
|
||||
this.form = {
|
||||
first_name: resp.first_name || '',
|
||||
last_name: resp.last_name || '',
|
||||
email: resp.email || '',
|
||||
preferred_language: resp.preferred_language || '',
|
||||
};
|
||||
} catch (err) {
|
||||
this.error = 'Failed to load account. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
try {
|
||||
const resp = await apiClient.put('/merchants/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.successMessage = 'Profile updated successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
this.error = 'Please fill in all password fields.';
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.error = 'New password and confirmation do not match.';
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
try {
|
||||
await apiClient.put('/merchants/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
this.successMessage = 'Password changed successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
243
app/modules/tenancy/templates/tenancy/store/my-account.html
Normal file
243
app/modules/tenancy/templates/tenancy/store/my-account.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{# app/modules/tenancy/templates/tenancy/store/my-account.html #}
|
||||
{% extends "store/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}My Account{% endblock %}
|
||||
|
||||
{% block alpine_data %}myAccountPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading account...') }}
|
||||
{{ error_state('Error loading account') }}
|
||||
|
||||
<!-- Account Form -->
|
||||
<div x-show="!loading && !error" class="w-full mb-8">
|
||||
<!-- Personal Information -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Personal Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your name and email address</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">First Name</label>
|
||||
<input type="text" x-model="form.first_name" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
|
||||
<input type="text" x-model="form.last_name" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input type="email" x-model="form.email" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preferred Language</label>
|
||||
<select x-model="form.preferred_language" @change="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400">
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="resetProfile()" x-show="profileChanged"
|
||||
class="mr-3 px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Reset</button>
|
||||
<button @click="saveProfile()" :disabled="savingProfile || !profileChanged"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Change Password</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your login password</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Password</label>
|
||||
<input type="password" x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Password</label>
|
||||
<input type="password" x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Confirm New Password</label>
|
||||
<input type="password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="changePassword()" :disabled="changingPassword"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword">Changing...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Username</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Role</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Email Verified</label>
|
||||
<span :class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Last Login</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Account Created</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
errors: {},
|
||||
profileChanged: false,
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const data = await apiClient.get('/store/account/me');
|
||||
this.account = data;
|
||||
this.form = {
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
preferred_language: data.preferred_language || '',
|
||||
};
|
||||
this.profileChanged = false;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to load account';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetProfile() {
|
||||
if (this.account) {
|
||||
this.form = {
|
||||
first_name: this.account.first_name || '',
|
||||
last_name: this.account.last_name || '',
|
||||
email: this.account.email || '',
|
||||
preferred_language: this.account.preferred_language || '',
|
||||
};
|
||||
}
|
||||
this.profileChanged = false;
|
||||
this.errors = {};
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
try {
|
||||
const resp = await apiClient.put('/store/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.profileChanged = false;
|
||||
Utils.showToast('Profile updated successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to save', 'error');
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
Utils.showToast('Please fill in all password fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
Utils.showToast('New password and confirmation do not match', 'error');
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
try {
|
||||
await apiClient.put('/store/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
Utils.showToast('Password changed successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user