Password Policy/App rename/PM Scheduling Improvements

This commit is contained in:
2026-06-06 02:32:47 -05:00
parent dfd999b6a9
commit f024286b5e
59 changed files with 3814 additions and 299 deletions

View File

@@ -3,13 +3,17 @@
<v-app :theme="activeTheme">
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
<div v-else-if="forcePasswordChange" class="forced-change-screen">
<ChangePasswordCard :token="token" forced standalone @changed="completeForcedChange" @cancel="logout" />
</div>
<div v-else class="app-shell">
<a class="skip-link" href="#main-content">Skip to main content</a>
<v-navigation-drawer class="rail" :width="254" :rail="navCollapsed" :rail-width="76" permanent aria-label="Primary navigation">
<div class="brand-lockup">
<template v-if="!navCollapsed">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
<span class="brand-name">DepreCore</span>
<v-spacer />
<v-btn icon="mdi-chevron-left" size="small" variant="text" title="Collapse menu" aria-label="Collapse navigation menu" @click="toggleNav" />
</template>
@@ -68,6 +72,7 @@
:theme-items="themeItems"
:title="currentTitle"
:user="user"
@change-password="changePasswordDialog = true"
@logout="logout"
@open-help="helpDialog = true"
@save-preferences="saveProfilePreferences"
@@ -148,7 +153,7 @@
<TaxRulesView
v-if="activeView === 'tax-rules'"
:token="token"
:dark="activeTheme === 'mixedAssetsDark'"
:dark="activeTheme === 'depreCoreDark'"
@updated="reloadTaxRules"
/>
<BooksView
@@ -197,6 +202,7 @@
@save-workday="saveWorkdayConnection"
@sync-workday="syncWorkday"
@update-user="updateUser"
@unlock-user="unlockUser"
/>
</v-main>
@@ -237,6 +243,7 @@
@attach-pm="attachPm"
@detach-pm="detachPm"
@complete-pm="completePm"
@log-reading="logReading"
@view-completion="viewCompletion"
@add-adjustment="addAdjustment"
@add-note="addNote"
@@ -276,6 +283,14 @@
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
<UserManual v-model="helpDialog" />
<v-dialog v-model="changePasswordDialog" max-width="480">
<ChangePasswordCard :token="token" @changed="changePasswordDialog = false" @cancel="changePasswordDialog = false" />
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="5000" location="top end">
{{ snackbar.text }}
</v-snackbar>
</div>
</v-app>
</fluent-design-system-provider>
@@ -290,6 +305,7 @@ import ContactsView from './views/ContactsView.vue';
import AssetsView from './views/AssetsView.vue';
import DashboardView from './views/DashboardView.vue';
import LoginView from './views/LoginView.vue';
import ChangePasswordCard from './components/ChangePasswordCard.vue';
import PartsView from './views/PartsView.vue';
import PmView from './views/PmView.vue';
import ReportsView from './views/ReportsView.vue';
@@ -322,6 +338,7 @@ export default {
AssetDrawer,
AssetsView,
BooksView,
ChangePasswordCard,
ContactsView,
DashboardView,
LabelSheet,
@@ -378,26 +395,29 @@ export default {
entities: [],
error: '',
helpDialog: false,
changePasswordDialog: false,
snackbar: { show: false, text: '', color: 'error' },
labelDialog: false,
labelAssetsOverride: null,
loading: false,
loginForm: { email: 'admin@mixedassets.local', password: 'ChangeMe123!' },
forcePasswordChange: false,
loginForm: { email: 'admin@deprecore.local', password: 'ChangeMe123!' },
massChanges: { status: null, location: '', department: '' },
massDialog: false,
navCollapsed: localStorage.getItem('mixedassets.navCollapsed') === 'true',
navCollapsed: localStorage.getItem('deprecore.navCollapsed') === 'true',
navItems,
preferenceSaving: false,
profilePreferences: {
theme: localStorage.getItem('mixedassets.theme') || 'mixedAssetsDark',
fontScale: localStorage.getItem('mixedassets.fontScale') || 'normal',
accent: localStorage.getItem('mixedassets.accent') || '',
contrast: localStorage.getItem('mixedassets.contrast') === 'true'
theme: localStorage.getItem('deprecore.theme') || 'depreCoreDark',
fontScale: localStorage.getItem('deprecore.fontScale') || 'normal',
accent: localStorage.getItem('deprecore.accent') || '',
contrast: localStorage.getItem('deprecore.contrast') === 'true'
},
references: [],
workLocations: [],
vendorContacts: [],
depreciationZones: [],
userCapabilities: JSON.parse(localStorage.getItem('mixedassets.capabilities') || '[]'),
userCapabilities: JSON.parse(localStorage.getItem('deprecore.capabilities') || '[]'),
capabilityCatalog: [],
roles: [],
saving: false,
@@ -418,8 +438,8 @@ export default {
},
templates: [],
themeItems,
token: localStorage.getItem('mixedassets.token') || '',
user: JSON.parse(localStorage.getItem('mixedassets.user') || 'null'),
token: localStorage.getItem('deprecore.token') || '',
user: JSON.parse(localStorage.getItem('deprecore.user') || 'null'),
userSaving: false,
users: [],
workdayConnection: {},
@@ -503,6 +523,8 @@ export default {
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
admin: 'Users, configuration, and integrations',
'admin-users': 'User accounts, teams, and role permissions',
'admin-security': 'Password rules, account lockout, and complexity policy',
'admin-audit': 'Complete audit trail of all user activity, with export',
'admin-config': 'Application settings, maintenance defaults, and tax rule sets',
'admin-integrations': 'Email, webhooks, ServiceNow, and Workday connectors'
}[this.activeView];
@@ -523,6 +545,8 @@ export default {
'tax-rules': 'Tax rules',
admin: 'Configuration',
'admin-users': 'Users & Teams',
'admin-security': 'Password & Security',
'admin-audit': 'Activity & Audit Trail',
'admin-config': 'Application Configuration',
'admin-integrations': 'Integrations'
}[this.activeView];
@@ -539,7 +563,7 @@ export default {
return this.assets;
},
activeTheme() {
return this.profilePreferences.theme || 'mixedAssetsDark';
return this.profilePreferences.theme || 'depreCoreDark';
},
fluentLuminance() {
return darkThemes.includes(this.activeTheme) ? '0.12' : '1';
@@ -581,6 +605,8 @@ export default {
books: ['finance.manage'],
'tax-rules': ['finance.manage'],
'admin-users': ['admin.users'],
'admin-security': ['admin.security'],
'admin-audit': ['admin.audit'],
'admin-config': ['admin.settings', 'config.manage'],
'admin-integrations': ['admin.integrations']
};
@@ -624,11 +650,11 @@ export default {
},
mounted() {
this.applyAppearance();
window.addEventListener('mixedassets:unauthorized', this.handleUnauthorized);
window.addEventListener('deprecore:unauthorized', this.handleUnauthorized);
if (this.token) this.loadInitial();
},
beforeUnmount() {
window.removeEventListener('mixedassets:unauthorized', this.handleUnauthorized);
window.removeEventListener('deprecore:unauthorized', this.handleUnauthorized);
},
methods: {
currency,
@@ -643,7 +669,7 @@ export default {
},
setCapabilities(capabilities) {
this.userCapabilities = Array.isArray(capabilities) ? capabilities : [];
localStorage.setItem('mixedassets.capabilities', JSON.stringify(this.userCapabilities));
localStorage.setItem('deprecore.capabilities', JSON.stringify(this.userCapabilities));
},
// Apply accessibility/appearance preferences to the live DOM: base text size (scales
// the whole UI via the root font-size), a custom accent colour, and a high-contrast boost.
@@ -677,10 +703,10 @@ export default {
return lum > 0.6 ? '0,0,0' : '255,255,255';
},
persistAppearanceLocal() {
localStorage.setItem('mixedassets.theme', this.activeTheme);
localStorage.setItem('mixedassets.fontScale', this.profilePreferences.fontScale || 'normal');
localStorage.setItem('mixedassets.accent', this.profilePreferences.accent || '');
localStorage.setItem('mixedassets.contrast', String(Boolean(this.profilePreferences.contrast)));
localStorage.setItem('deprecore.theme', this.activeTheme);
localStorage.setItem('deprecore.fontScale', this.profilePreferences.fontScale || 'normal');
localStorage.setItem('deprecore.accent', this.profilePreferences.accent || '');
localStorage.setItem('deprecore.contrast', String(Boolean(this.profilePreferences.contrast)));
},
async api(path, options = {}) {
return apiRequest(path, this.token, options);
@@ -747,15 +773,30 @@ export default {
this.teamNotice = error.message;
}
},
notify(text, color = 'error') {
this.snackbar = { show: true, text, color };
},
async createUser(user) {
this.userSaving = true;
try {
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
await this.loadAdmin();
this.notify('User created.', 'success');
} catch (error) {
this.notify(error.message);
} finally {
this.userSaving = false;
}
},
async unlockUser(account) {
try {
await this.api(`/api/users/${account.id}/unlock`, { method: 'POST' });
await this.loadAdmin();
this.notify(`${account.name} unlocked.`, 'success');
} catch (error) {
this.notify(error.message);
}
},
async editAsset(asset) {
this.drawerError = '';
this.disposalPreview = null;
@@ -926,6 +967,19 @@ export default {
await this.refreshActiveAsset(this.assetForm.id);
await this.loadDashboard();
},
async logReading(payload) {
const { apId, ...body } = payload;
try {
await this.api(`/api/assets/${this.assetForm.id}/pm/${apId}/readings`, {
method: 'POST',
body: JSON.stringify(body)
});
await this.refreshActiveAsset(this.assetForm.id);
this.notify('Reading logged.', 'success');
} catch (error) {
this.notify(error.message);
}
},
viewCompletion(id) {
this.completionId = id;
this.completionDialog = true;
@@ -955,7 +1009,7 @@ export default {
},
async exportAssets(format) {
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
saveBlob(blob, `mixedassets-assets.${format}`);
saveBlob(blob, `deprecore-assets.${format}`);
},
async loadReferences() {
const data = await (await this.api('/api/references')).json();
@@ -1068,9 +1122,9 @@ export default {
async loadProfile() {
const profile = await (await this.api('/api/profile')).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
this.setCapabilities(profile.capabilities);
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
this.persistAppearanceLocal();
},
async login(form) {
@@ -1082,22 +1136,35 @@ export default {
this.token = data.token;
this.user = data.user;
this.setCapabilities(data.capabilities);
localStorage.setItem('mixedassets.token', data.token);
localStorage.setItem('mixedassets.user', JSON.stringify(data.user));
await this.loadInitial();
localStorage.setItem('deprecore.token', data.token);
localStorage.setItem('deprecore.user', JSON.stringify(data.user));
// A forced/expired password gates the app behind the change-password step (token is held
// so the change endpoint is callable, but app data isn't loaded until the change succeeds).
if (data.mustChangePassword) {
this.forcePasswordChange = true;
} else {
await this.loadInitial();
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async completeForcedChange() {
this.forcePasswordChange = false;
this.error = '';
this.loginForm = { ...this.loginForm, password: '' };
await this.loadInitial();
},
logout() {
this.token = '';
this.user = null;
this.forcePasswordChange = false;
this.userCapabilities = [];
localStorage.removeItem('mixedassets.token');
localStorage.removeItem('mixedassets.user');
localStorage.removeItem('mixedassets.capabilities');
localStorage.removeItem('deprecore.token');
localStorage.removeItem('deprecore.user');
localStorage.removeItem('deprecore.capabilities');
},
// Triggered when any API call reports an invalid/expired token: return to the login screen.
handleUnauthorized() {
@@ -1115,7 +1182,7 @@ export default {
},
toggleNav() {
this.navCollapsed = !this.navCollapsed;
localStorage.setItem('mixedassets.navCollapsed', String(this.navCollapsed));
localStorage.setItem('deprecore.navCollapsed', String(this.navCollapsed));
},
// Group-only items (no own screen) just expand; real screens navigate.
selectNav(item) {
@@ -1204,8 +1271,8 @@ export default {
body: JSON.stringify({ preferences: this.profilePreferences })
})).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
this.profilePreferences = { theme: 'depreCoreDark', fontScale: 'normal', accent: '', contrast: false, ...(profile.preferences || {}) };
localStorage.setItem('deprecore.user', JSON.stringify(profile.user));
this.persistAppearanceLocal();
} finally {
this.preferenceSaving = false;
@@ -1267,6 +1334,9 @@ export default {
if (!body.password) delete body.password;
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
await this.loadAdmin();
this.notify('User updated.', 'success');
} catch (error) {
this.notify(error.message);
} finally {
this.userSaving = false;
}