@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
631 lines (602 loc) • 26.1 kB
JavaScript
import { UserService } from '../../services/user/user.service.js';
import { FileService } from '../../services/file/file.service.js';
import { Auth } from './Auth.js';
import { BtnIcon } from './BtnIcon.js';
import { newInstance, s4 } from './CommonJs.js';
import { renderStatus, renderWave } from './Css.js';
import { EventsUI } from './EventsUI.js';
import { fileFormDataFactory, Input } from './Input.js';
import { LogIn } from './LogIn.js';
import { Modal } from './Modal.js';
import { NotificationManager } from './NotificationManager.js';
import { ToggleSwitch } from './ToggleSwitch.js';
import { Translate } from './Translate.js';
import { Validator } from './Validator.js';
import { append, htmls, s } from './VanillaJs.js';
import { getProxyPath } from './Router.js';
import { getApiBaseUrl } from '../../services/core/core.service.js';
import { AccountEventType, accountEvents } from './ClientEvents.js';
import { loggerFactory } from './Logger.js';
const logger = loggerFactory(import.meta);
class Account {
static UpdateEvent = {};
static onUpdated(listener, options = {}) {
if (options.key) Account.UpdateEvent[options.key] = listener;
return accountEvents.on(AccountEventType.updated, listener, options);
}
static offUpdated(key) {
delete Account.UpdateEvent[key];
return accountEvents.off(key);
}
static hasUpdatedListener(key) {
return accountEvents.has(key);
}
static async instance(options = { user: {}, bottomRender: async () => '', idModal: '' }) {
// app profile page design example
// CSS animated backgrounds
let { user, idModal } = options;
const waveAnimationId = 'account-wave';
const profileFileAccept = ['image/png', 'image/jpeg'];
setTimeout(async () => {
append(
`.wave-animation-container-${waveAnimationId}`,
html` <div class="abs center account-profile-image-container">
<img
class="abs center account-profile-image"
style="opacity: 1"
${LogIn.Scope.user.main.model.user.profileImage
? `src="${LogIn.Scope.user.main.model.user.profileImage.imageSrc}"`
: `src="${getApiBaseUrl({
id: 'assets/avatar',
endpoint: 'user',
})}"`}
/>
</div>
<div class="abs center account-profile-image-loading" style="color: white"></div>`,
);
const formData = [
{
model: 'username',
id: `account-username`,
rules: [{ type: 'isEmpty' }, { type: 'isLength', options: { min: 2, max: 20 } }, { type: 'isValidUsername' }],
},
{ model: 'email', id: `account-email`, rules: [{ type: 'isEmpty' }, { type: 'isEmail' }] },
{
model: 'password',
defaultValue: '#Changethis123',
id: `account-password`,
rules: [{ type: 'isStrongPassword' }],
},
{
model: 'briefDescription',
id: `account-brief-description`,
defaultValue: 'Uploader',
rules: [{ type: 'isLength', options: { min: 0, max: 200 } }],
},
];
this.formData = formData;
this.instanceModalUiEvents = async ({ user }) => {
const accountInstance = this;
const validators = await Validator.instance(formData);
for (const inputData of formData) {
s(`.${inputData.id}`).value =
!user[inputData.model] && inputData.defaultValue ? inputData.defaultValue : user[inputData.model];
}
let lastUser;
const submit = async () => {
// Always get the current user from LogIn.Scope to avoid stale closure references
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
// Guest users cannot submit form
if (currentUser.role === 'guest') {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
lastUser = newInstance(currentUser);
const { successKeys, errorKeys, errorMessage } = await validators();
if (errorMessage) {
NotificationManager.Push({
html: `${errorKeys.map((e) => Translate.instance(e.replace('account-', '')))} ${errorMessage}`,
});
return;
}
const body = {};
for (const inputData of formData) {
const value = s(`.${inputData.id}`).value;
if (!value || value === 'undefined') continue;
if ('model' in inputData && successKeys.includes(inputData.id)) {
body[inputData.model] = value;
currentUser[inputData.model] = value;
}
}
const result = await UserService.put({ id: currentUser._id, body });
NotificationManager.Push({
html:
result.status === 'error' && result.message
? result.message
: Translate.instance(`${result.status}-update-user`),
status: result.status,
});
if (result.status === 'success') {
const updatedUser = result.data;
// Preserve profileImage from scope if it exists
const existingProfileImage = LogIn.Scope.user.main.model.user.profileImage;
LogIn.Scope.user.main.model.user = { ...updatedUser };
if (existingProfileImage && !updatedUser.profileImage) {
LogIn.Scope.user.main.model.user.profileImage = existingProfileImage;
}
accountInstance.triggerUpdateEvent({ user: updatedUser });
if (lastUser.emailConfirmed !== updatedUser.emailConfirmed) {
accountInstance.renderVerifyEmailStatus(updatedUser);
}
lastUser = newInstance(updatedUser);
}
};
EventsUI.onClick(`.btn-account`, async (e) => {
e.preventDefault();
await submit();
});
EventsUI.onClick(`.btn-account-update-username`, async (e) => {
e.preventDefault();
await submit();
});
if (s(`.btn-confirm-email`))
EventsUI.onClick(`.btn-confirm-email`, async (e) => {
e.preventDefault();
// Check if user is authenticated
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
// Guest users cannot verify email
if (currentUser.role === 'guest') {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
const result = await UserService.post({
id: 'mailer/verify-email',
body: {
email: s(`.account-email`).value,
hostname: `${location.hostname}`,
proxyPath: getProxyPath(),
},
});
NotificationManager.Push({
html: result.status === 'error' ? result.message : Translate.instance(`email send`),
status: result.status,
});
});
const currentUser = LogIn.Scope.user.main.model.user;
accountInstance.renderVerifyEmailStatus(currentUser || user);
s(`.${waveAnimationId}`).style.cursor = 'pointer';
s(`.${waveAnimationId}`).onclick = async (e) => {
e.preventDefault();
s(`.account-profile-image-input`).click();
};
EventsUI.onChange(
`.account-profile-image-input`,
async (e) => {
e.preventDefault();
s(`.account-profile-image`).style.opacity = 0;
const formFile = fileFormDataFactory(e, profileFileAccept);
// Always get the current user from LogIn.Scope
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
s(`.account-profile-image`).style.opacity = 1;
return;
}
// Guest users cannot upload profile images
if (currentUser.role === 'guest') {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
s(`.account-profile-image`).style.opacity = 1;
return;
}
const { status, data } = await UserService.put({
id: `profile-image/${currentUser._id}`,
body: formFile,
headerId: 'file',
});
if (status === 'success') {
currentUser.profileImageId = data.profileImageId;
LogIn.Scope.user.main.model.user = { ...currentUser };
delete LogIn.Scope.user.main.model.user.profileImage;
const defaultAvatarUrl = getApiBaseUrl({
id: 'assets/avatar',
endpoint: 'user',
});
// Fetch the new image immediately
let newImageSrc = defaultAvatarUrl;
try {
const resultFile = await FileService.get({ id: data.profileImageId });
if (resultFile && resultFile.status === 'success' && resultFile.data[0]) {
const imageData = resultFile.data[0];
if (!imageData.data?.data && imageData._id) {
newImageSrc = getApiBaseUrl({ id: imageData._id, endpoint: 'file/blob' });
} else if (imageData.data?.data) {
const imageBlob = new Blob([new Uint8Array(imageData.data.data)], { type: imageData.mimetype });
const imageFile = new File([imageBlob], imageData.name, { type: imageData.mimetype });
newImageSrc = URL.createObjectURL(imageFile);
}
LogIn.Scope.user.main.model.user.profileImage = {
resultFile,
imageData,
imageSrc: newImageSrc,
};
}
} catch (error) {
logger.warn('Error fetching new profile image:', error);
}
// Update both images immediately
s(`.account-profile-image`).src = newImageSrc;
const topbarImg = s(`.top-box-profile-img`);
if (topbarImg) topbarImg.src = newImageSrc;
NotificationManager.Push({
html: Translate.instance('success-update-user'),
status: 'success',
});
} else {
NotificationManager.Push({
html: data?.message || Translate.instance('file-upload-failed'),
status: 'error',
});
}
s(`.account-profile-image`).style.opacity = 1;
},
{ loadingContainer: `.account-profile-image-loading` },
);
s(`.btn-account-change-password`).onclick = (e) => {
e.preventDefault();
// s(`.btn-close-modal-account`).click();
s(`.main-btn-recover`).click();
};
EventsUI.onClick(
`.btn-account-delete-confirm`,
async (e) => {
e.preventDefault();
const confirmResult = await Modal.RenderConfirm({
html: async () => {
return html`
<div class="in section-mp" style="text-align: center">
${Translate.instance('confirm-delete-account')}
</div>
`;
},
id: 'delete-account-modal',
});
if (confirmResult.status === 'cancelled') return;
s(`.btn-account-delete-confirm`).classList.add('hide');
s(`.btn-account-delete`).classList.remove('hide');
s(`.btn-account-delete`).click();
},
{ context: 'modal' },
);
EventsUI.onClick(`.btn-brief-description-update`, async (e) => {
e.preventDefault();
const descriptionValue = s(`.account-brief-description`).value;
if (!descriptionValue || descriptionValue === 'undefined' || descriptionValue.trim() === '') {
NotificationManager.Push({
html: Translate.instance('brief-description-cannot-be-empty'),
status: 'error',
});
return;
}
// Always get the current user from LogIn.Scope
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
// Guest users cannot update brief description
if (currentUser.role === 'guest') {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
const result = await UserService.put({ id: currentUser._id, body: { briefDescription: descriptionValue } });
NotificationManager.Push({
html:
result.status === 'error' && result.message
? result.message
: Translate.instance(`${result.status}-update-user`),
status: result.status,
});
if (result.status === 'success') {
currentUser.briefDescription = descriptionValue;
// Preserve profileImage from scope
const existingProfileImage = LogIn.Scope.user.main.model.user.profileImage;
LogIn.Scope.user.main.model.user.briefDescription = descriptionValue;
if (existingProfileImage) {
LogIn.Scope.user.main.model.user.profileImage = existingProfileImage;
}
accountInstance.triggerUpdateEvent({ user: currentUser });
}
});
// Setup public profile toggle handler
setTimeout(() => {
if (ToggleSwitch.Tokens['account-public-profile']) {
const originalClick = ToggleSwitch.Tokens['account-public-profile'].click;
ToggleSwitch.Tokens['account-public-profile'].click = async function () {
originalClick.call(this);
const isChecked = s(`.account-public-profile-checkbox`).checked;
// Always get the current user from LogIn.Scope
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
// Guest users cannot toggle public profile
if (currentUser.role === 'guest') {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
const result = await UserService.put({ id: currentUser._id, body: { publicProfile: isChecked } });
NotificationManager.Push({
html:
result.status === 'error' && result.message
? result.message
: Translate.instance(`${result.status}-update-user`),
status: result.status,
});
if (result.status === 'success') {
currentUser.publicProfile = isChecked;
// Preserve profileImage from scope
const existingProfileImage = LogIn.Scope.user.main.model.user.profileImage;
LogIn.Scope.user.main.model.user.publicProfile = isChecked;
if (existingProfileImage) {
LogIn.Scope.user.main.model.user.profileImage = existingProfileImage;
}
accountInstance.triggerUpdateEvent({ user: currentUser });
}
};
// Override wrapper click handler to use our custom handler
const wrapperElement = s(`.toggle-form-container-account-public-profile`);
if (wrapperElement) {
wrapperElement.onclick = () => ToggleSwitch.Tokens['account-public-profile'].click();
}
}
});
EventsUI.onClick(`.btn-account-delete`, async (e) => {
e.preventDefault();
// Always get the current user from LogIn.Scope
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
NotificationManager.Push({
html: Translate.instance('error-user-not-authenticated'),
status: 'error',
});
return;
}
const result = await UserService.delete({ id: currentUser._id });
NotificationManager.Push({
html: result.status === 'error' ? result.message : Translate.instance(`success-delete-account`),
status: result.status,
});
s(`.btn-account-delete-confirm`).classList.remove('hide');
s(`.btn-account-delete`).classList.add('hide');
if (result.status === 'success') {
Modal.onHomeRouterEvent();
await Auth.sessionOut();
}
});
};
await this.instanceModalUiEvents({ user });
});
return html`
<label for="account-profile-image-input-label"
><span class="hide">Profile Image</span>
<input
type="file"
accept="${profileFileAccept.join(', ')}"
class="account-profile-image-input hide"
id="account-profile-image-input-label"
/></label>
${renderWave({ id: waveAnimationId })}
<form class="in">
<div class="in">
${await Input.instance({
id: `account-username`,
type: 'text',
label: html`<i class="fa-solid fa-pen-to-square"></i> ${Translate.instance('username')}`,
containerClass: 'inl section-mp width-mini-box input-container',
placeholder: true,
disabled: false,
extension: async () =>
html`${await BtnIcon.instance({
class: `in wfa btn-input-extension btn-account-update-username`,
type: 'button',
style: 'text-align: left',
label: html`${Translate.instance(`update`)}`,
})}`,
})}
</div>
<div class="in">
${await Input.instance({
id: `account-email`,
type: 'email',
label: html`<i class="fa-solid fa-envelope"></i> ${Translate.instance('email')}`,
containerClass: 'inl section-mp width-mini-box input-container',
placeholder: true,
autocomplete: 'email',
disabled: user.emailConfirmed,
extension: !(options && options.disabled && options.disabled.includes('emailConfirm'))
? async () =>
html`<div class="in verify-email-status"></div>
${await BtnIcon.instance({
class: `in wfa btn-input-extension btn-confirm-email`,
type: 'button',
style: 'text-align: left',
label: html`<div class="in">
<i class="fa-solid fa-paper-plane"></i> ${Translate.instance('send')}
${Translate.instance('verify-email')}
</div> `,
})}`
: undefined,
})}
</div>
<div class="in">
${await Input.instance({
id: `account-password`,
type: 'password',
autocomplete: 'new-password',
label: html`<i class="fa-solid fa-lock"></i> ${Translate.instance('password')}`,
containerClass: 'inl section-mp width-mini-box input-container',
placeholder: true,
disabled: true,
disabledEye: true,
extension: async () =>
html`${await BtnIcon.instance({
class: `in wfa btn-input-extension btn-account-change-password`,
type: 'button',
style: 'text-align: left',
label: html`${Translate.instance(`change-password`)}`,
})}`,
})}
</div>
<div class="in">
${await Input.instance({
id: `account-brief-description`,
label: html`<i class="fa-solid fa-pen-fancy"></i> ${Translate.instance('brief-description')}`,
containerClass: 'inl section-mp width-mini-box input-container',
placeholder: true,
rows: 4,
extension: async () =>
html`${await BtnIcon.instance({
class: `in wfa btn-input-extension btn-brief-description-update`,
type: 'button',
style: 'text-align: left',
label: html`${Translate.instance(`update`)}`,
})}`,
})}
</div>
<div class="in section-mp">
${await ToggleSwitch.instance({
wrapper: true,
wrapperLabel: html`<i class="fa-solid fa-globe"></i> ${Translate.instance('public-profile')}`,
id: 'account-public-profile',
disabledOnClick: true,
checked: user.publicProfile ? true : false,
})}
</div>
${options?.bottomRender ? await options.bottomRender() : ``}
<div class="in hide">
${await BtnIcon.instance({
class: 'in section-mp form-button btn-account',
label: Translate.instance('update'),
type: 'submit',
})}
</div>
</form>
<div class="in">
${await BtnIcon.instance({
class: 'in section-mp form-button btn-account-delete hide',
label: html` ${Translate.instance(`delete-account`)}`,
type: 'button',
style: 'color: #5f5f5f',
})}
${await BtnIcon.instance({
class: 'in section-mp form-button btn-account-delete-confirm',
label: html` ${Translate.instance(`delete-account`)}`,
type: 'button',
style: 'color: #5f5f5f',
})}
</div>
`;
}
static async triggerUpdateEvent(options = { user: {} }) {
await accountEvents.emit(AccountEventType.updated, options);
for (const updateEvent of Object.keys(this.UpdateEvent)) {
await this.UpdateEvent[updateEvent](options);
}
}
static renderVerifyEmailStatus(user) {
if (s('.verify-email-status')) {
if (s(`.btn-confirm-email`)) {
if (user.emailConfirmed) s(`.btn-confirm-email`).classList.add('hide');
else s(`.btn-confirm-email`).classList.remove('hide');
}
htmls(
'.verify-email-status',
html`${renderStatus(user.emailConfirmed ? 'success' : 'error', { class: 'inl' })} ${Translate.instance('email')}
${Translate.instance(user.emailConfirmed ? 'confirmed' : 'unconfirmed')}`,
);
if (user.emailConfirmed === true) s(`.account-email`).setAttribute('disabled', '');
}
}
static instanceModalUiEvents = async (user) => null;
static async updateForm(user) {
if (!s(`.modal-account`)) return;
// Always sync the current user data into LogIn.Scope, preserving profileImage if it exists
if (user && user._id) {
const existingProfileImage = LogIn.Scope.user.main.model.user.profileImage;
LogIn.Scope.user.main.model.user = { ...user };
// Preserve existing profileImage if new user doesn't have one
if (existingProfileImage && !user.profileImage) {
LogIn.Scope.user.main.model.user.profileImage = existingProfileImage;
}
}
// Use the current user from scope to ensure we have the latest data
const currentUser = LogIn.Scope.user.main.model.user;
if (!currentUser || !currentUser._id) {
return;
}
await this.instanceModalUiEvents({ user: currentUser });
s(`.account-profile-image`).style.opacity = 0;
for (const inputData of this.formData)
if (s(`.${inputData.id}`)) s(`.${inputData.id}`).value = currentUser[inputData.model];
// Update profile image - always show default avatar as fallback (skip for guest users)
const profileImageElement = s(`.account-profile-image`);
const defaultAvatarUrl = getApiBaseUrl({
id: 'assets/avatar',
endpoint: 'user',
});
if (currentUser.role !== 'guest' && profileImageElement) {
// Show custom image if available, otherwise default avatar
const customImageSrc = LogIn.Scope.user.main.model.user.profileImage?.imageSrc;
profileImageElement.src = customImageSrc || defaultAvatarUrl;
profileImageElement.style.opacity = 1;
} else if (profileImageElement) {
profileImageElement.style.opacity = 0;
}
// update public profile toggle
if (ToggleSwitch.Tokens['account-public-profile']) {
if (currentUser.publicProfile && !s(`.account-public-profile-checkbox`).checked) {
ToggleSwitch.Tokens['account-public-profile'].click();
}
}
}
}
export { Account };