43 Commits

Author SHA1 Message Date
fdfc6bb3f3 version bump 8.2.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:31:00 -07:00
942403e897 version bump 8.2.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:10:19 -07:00
770f02eea5 case 4688 - user form: "Can change records" choice for a new agent token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:56:22 -07:00
7bfb389a3a case 4745 - batch close extension: close as Done or Never
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:58 -07:00
501be1764b case 4745 - case form Status, Product and Release fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:58 -07:00
573722da39 case 4688 - user form: hide login fields for agent users; copy button for a new agent token; token box only once saved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:51:56 -07:00
f7a661a712 case 4688 - agent User flag and agent tokens on the user form
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:19:12 -07:00
3cde5bb54a case 4678 - passkey sign-in and management
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:46:27 -07:00
2240a3b61f case 4738 - security advisory monitor page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:12:38 -07:00
bb6fdd3b9e version bump 8.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:16:48 -07:00
888b2e2885 case 4769 - remove the subscription server last update age notification
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:46:50 -07:00
8bf6861b03 case 4675 - remove purchases and vendor notifications (unused since 2023); products and vendors stay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:28:18 -07:00
28e5ebcaa4 case 4487 - trial request: approve or reject while awaiting email validation; delete works
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:08:08 -07:00
d7b7b904df version bump 8.1.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:48:54 -07:00
4a6d8eff6f case 4468 - reviews keep the type of the record they're for; a review without one is refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:24:52 -07:00
8f6428467c case 4600 - subscription expiring reminders only for trial servers in service; expiry hidden for paid servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:24:55 -07:00
cb7f79342d case 4663 - subscription server ssh link opens ssh://root@server in Windows Terminal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:49:53 -07:00
d28588dca9 case 4756 - Ctrl+; inserts today's date, Ctrl+Shift+; date and time, in any text field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:44:14 -07:00
91cb511d6f case 4764 - open a case by its number from the case list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:37:36 -07:00
ac23f1f264 case 4714 - duplicating a case gives the copy its own case number, open and created now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:20:30 -07:00
d286650eaa case 4604 - case form: Close and Reopen buttons instead of editing the dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:59:09 -07:00
d1813f6d63 case 4742 - mass close and reopen of cases from the list extensions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:59:09 -07:00
15c2b157df version bump 8.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:27:20 -07:00
45310dd76e case 4630 - login page copyright year set when the client is built
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:20:11 -07:00
1188829841 case 4739 - browser that can't name its time zone: its own offset instead of New York
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:58:27 -07:00
0aaf5a2ca6 case 4763 - backup sets to keep must be at least 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:11:21 -07:00
6192821a33 version bump 8.1.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:50:02 -07:00
9f300a65ff case 4727 - setup script: listen on 127.0.0.1 only, read-only program folder, systemd sandboxing
nginx is the only way in, so AyaNova listens on 127.0.0.1:7575 instead of all
interfaces. The service account (www-data, which nginx also runs as) no longer
has write access to the program folder, only to data. The unit gets
NoNewPrivileges, ProtectSystem=strict with ReadWritePaths=/var/ayanova/data,
PrivateTmp and ProtectHome.

Tested on devtestn10 (Ubuntu 24.04.5) with the 8.3.0 build: report render
including the first browser download, backup, attachment upload, download and
delete; nothing blocked.

Do not deploy a client built from this before 8.3.0 is released: the script
installs the released zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:59:53 -07:00
cb8d6da832 version bumps 2026-10-25 08:07:10 -07:00
64d8f62e5e case 4690 - setup script: Ubuntu 26.04 support, unique secrets per server, no phased updates
- PostgreSQL from the PostgreSQL project's repository via postgresql-common's
  apt.postgresql.org.sh (apt-key is gone on newer Ubuntu)
- libgdk-pixbuf2.0-0 renamed libgdk-pixbuf-2.0-0 (needed on 26.04, also valid on 24.04)
- random PostgreSQL password and JWT secret per server, written to PostgreSQL and
  config.json (replacing the shipped defaults) and to /root/ayanova-secrets.txt (600)
- config.json 640 root:www-data
- APT::Get::Never-Include-Phased-Updates, as set by hand on every server today

Tested end to end on a fresh Ubuntu 26.04.1 droplet with the 8.3.0 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:31:39 -07:00
a48b82f6ec case 4690 - subscription server setup script: .NET 10 runtime; no Chrome folders beside the app
AyaNova 8.3.0 runs on .NET 10 and downloads the report browser under the
data folder. Do not deploy a client built from this before 8.3.0 is released:
the script installs the released zip, and 8.2.4 needs .NET 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:10:36 -07:00
683afce49b case 4682 2026-10-20 12:38:52 -07:00
97f5b37e0a Missing bit to support Atlanta datacenter in request for trial hosted subscription 2026-10-09 16:44:50 -07:00
35dfd669d9 case 4670 update version properly 2026-10-09 13:19:40 -07:00
259a2ee832 Merge net10-port: .NET 10 port 2026-10-09 12:52:44 -07:00
a716934efe case 4670 miniprofiler FRONT END removal entirely keywords profile profiler mini metrics this and prior are the commits for ayanova 8 duplicating this 2026-10-05 11:59:03 -07:00
f5c73cbb41 case 4670 update version 2026-09-22 13:55:58 -07:00
21f8acb6b3 case 4670 2026-09-22 12:24:06 -07:00
1865dc4cca test commit and push 2026-09-14 13:08:44 -07:00
11c6c11388 case 4642 2026-02-13 08:38:57 -08:00
b8d6234886 case 4642 2026-02-13 08:36:57 -08:00
45e8d2a19f case 4642 2026-02-13 08:21:57 -08:00
380b1ebb0a Update .gitignore 2026-02-12 18:22:09 -08:00
37 changed files with 1498 additions and 1875 deletions

33
.gitignore vendored
View File

@@ -1,24 +1,17 @@
# Dependencies
node_modules/
# Build output
dist/
# IDE
.vscode/settings.json
# OS
Thumbs.db
.DS_Store
node_modules
/dist
/tests/e2e/videos/
/tests/e2e/screenshots/
# local env files
# Environment
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
/logfile

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
12.22.9

View File

@@ -1,8 +1,19 @@
# sockeye
# AyaNova client ui
test for test push
## Project setup
```
npm install
First confirm all dependencies are installed as per AyaNova-client project's BUILT-ENVIRONMENT.md doc. They are very specific and in many cases outdated.
Then clone/checkout repository from GIT
Finally:
Install frontend dependencies:
cd c:\data\code\sockeye-client
npm ci
Note: Use `npm ci` (not `npm install`) to install exact versions from package-lock.json
```
### Compiles and hot-reloads for development

View File

@@ -1,6 +1,6 @@
{
"name": "sockeye",
"version": "8.0.16",
"version": "8.2.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",

View File

@@ -97,7 +97,9 @@
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{
$sock.t(subitem.title)
subitem.notrans
? subitem.title
: $sock.t(subitem.title)
}}</v-list-item-title>
</v-list-item-content>
</v-list-item>
@@ -242,6 +244,7 @@ import gznotify from "./components/gznotify";
import openObjectHandler from "./api/open-object-handler";
import notifyPoll from "./api/notifypoll";
import { processLogout } from "./api/authutil";
import insertDateShortcut from "./api/insert-date-shortcut";
export default {
components: {
gzconfirm,
@@ -310,10 +313,12 @@ export default {
window.$gz.dialog.wireUpEventHandlers(this);
openObjectHandler.wireUpEventHandlers(this);
window.$gz.translation.setVuetifyDefaultLanguageElements(this);
insertDateShortcut.wireUp();
},
beforeDestroy() {
//UNWIRE ALL EVENT HANDLERS FROM GZEVENTBUS
window.$gz.eventBus.$off();
insertDateShortcut.unwire();
},
mounted() {
const vm = this;

View File

@@ -41,8 +41,6 @@ export default {
License: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
TrialLicenseRequest: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
SubscriptionServer: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
Purchase: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
VendorNotification: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
Product: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
GZCase: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },
Subscription: { Change: 32842, ReadFullRecord: 65797, Select: 131071 },

View File

@@ -203,20 +203,6 @@ ServiceContractor = 5 */
key: key++
});
sub.push({
title: "PurchaseList",
icon: "$sockiShoppingCart",
route: "/biz-purchase-list",
key: key++
});
sub.push({
title: "VendorNotificationList",
icon: "$sockiCommentDollar",
route: "/biz-vendor-notification-list",
key: key++
});
sub.push({
title: "ProductList",
icon: "$sockiBarCode",
@@ -487,15 +473,6 @@ ServiceContractor = 5 */
route: "/ops-metrics",
key: key++
});
// //PROFILE
// //metrics rights
// sub.push({
// title: "ServerProfiler",
// icon: "$sockiBinoculars",
// route: "/ops-profile",
// key: key++
// });
}
//NOTIFICATION CONFIG AND HISTORY
@@ -508,6 +485,17 @@ ServiceContractor = 5 */
});
}
//case 4738 (Sockeye-only, plain English title)
if (window.$gz.role.canOpen(window.$gz.type.GlobalOps)) {
sub.push({
title: "Security advisories",
notrans: true,
icon: "$sockiShieldAlt",
route: "/ops-security-advisories",
key: key++
});
}
if (window.$gz.role.canOpen(window.$gz.type.OpsNotificationSettings)) {
sub.push({
title: "NotificationDeliveryLog",

View File

@@ -0,0 +1,67 @@
/////////////////////////////////////////////////////////////////////
// case 4756: keyboard shortcut to insert today's date in any text field
// Ctrl+; inserts "2026-10-26 - "
// Ctrl+Shift+; inserts "2026-10-26 14:30 - "
// at the cursor (replacing any selected text) of the text box being typed in, on the browser's clock
// (same keys as Excel and Google Sheets)
//
function pad(n) {
return n < 10 ? "0" + n : "" + n;
}
export function dateText(now, withTime) {
let text = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)}`;
if (withTime) {
text += ` ${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
return text + " - ";
}
function isPlainTextField(el) {
if (!el || el.readOnly || el.disabled) {
return false;
}
//the report editor (Monaco) has its own keyboard handling
if (el.closest && el.closest(".monaco-editor")) {
return false;
}
if (el.tagName === "TEXTAREA") {
return true;
}
return (
el.tagName === "INPUT" &&
["text", "search", ""].includes(
(el.getAttribute("type") || "").toLowerCase()
)
);
}
export function handleKeydown(e) {
if (!e.ctrlKey || e.altKey || e.metaKey || e.code !== "Semicolon") {
return;
}
const el = document.activeElement;
if (!isPlainTextField(el)) {
return;
}
e.preventDefault();
el.setRangeText(
dateText(new Date(), e.shiftKey),
el.selectionStart,
el.selectionEnd,
"end"
);
//let Vue's v-model see the change, as typing would
el.dispatchEvent(new Event("input", { bubbles: true }));
}
export default {
wireUp() {
document.addEventListener("keydown", handleKeydown);
},
unwire() {
document.removeEventListener("keydown", handleKeydown);
}
};

View File

@@ -37,7 +37,7 @@ export default {
// Get users default time zone
// first check if overriden in useroptions
// if not then use browsers own setting
// if that is empty then final default of "America/New_York"
// if the browser can't name its time zone then its current offset from UTC
//https://www.iana.org/time-zones
//https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
getResolvedTimeZoneName() {
@@ -49,7 +49,14 @@ export default {
if (!window.$gz.util.stringIsNullOrEmpty(tz)) {
return tz;
} else {
return "America/New_York";
//case 4739: e.g. a new Windows time zone the browser doesn't know yet; this used to be "America/New_York"
//a fixed offset zone in whole hours, the only kind every browser and luxon accept by name
//(Etc/GMT signs are reversed: UTC-7 is Etc/GMT+7, which is what getTimezoneOffset returns, 420 minutes)
const hours = Math.round(new Date().getTimezoneOffset() / 60);
if (hours == 0) {
return "Etc/GMT";
}
return `Etc/GMT${hours > 0 ? "+" : "-"}${Math.abs(hours)}`;
}
}
},

View File

@@ -235,20 +235,6 @@ export default {
});
break;
case socktype.Purchase:
vm.$router.push({
name: "purchase-edit",
params: { recordid: tid.id }
});
break;
case socktype.VendorNotification:
vm.$router.push({
name: "vendor-notification-edit",
params: { recordid: tid.id }
});
break;
case socktype.License:
vm.$router.push({
name: "license-edit",

76
src/api/passkey.js Normal file
View File

@@ -0,0 +1,76 @@
// case 4678: passkeys (WebAuthn). The server sends the options as JSON with binary fields in base64url;
// the browser API wants ArrayBuffers, and its answer goes back the same way
function toBuffer(b64url) {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64 + "===".slice((b64.length + 3) % 4));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
bytes[i] = bin.charCodeAt(i);
}
return bytes.buffer;
}
function toB64Url(buffer) {
if (!buffer) {
return null;
}
const bytes = new Uint8Array(buffer);
let bin = "";
for (let i = 0; i < bytes.length; i++) {
bin += String.fromCharCode(bytes[i]);
}
return btoa(bin)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function credentialJson(cred, response) {
return {
id: cred.id,
rawId: toB64Url(cred.rawId),
type: cred.type,
response: response,
clientExtensionResults: cred.getClientExtensionResults
? cred.getClientExtensionResults()
: {}
};
}
export default {
// passkeys need a browser that has them and a secure page (HTTPS, or localhost)
supported() {
return !!(window.PublicKeyCredential && window.isSecureContext);
},
// run navigator.credentials.create() with the server's register-options; returns the JSON to send back
async create(options) {
const o = JSON.parse(JSON.stringify(options));
o.challenge = toBuffer(o.challenge);
o.user.id = toBuffer(o.user.id);
(o.excludeCredentials || []).forEach(c => (c.id = toBuffer(c.id)));
const cred = await navigator.credentials.create({ publicKey: o });
return credentialJson(cred, {
clientDataJSON: toB64Url(cred.response.clientDataJSON),
attestationObject: toB64Url(cred.response.attestationObject),
transports: cred.response.getTransports
? cred.response.getTransports()
: []
});
},
// run navigator.credentials.get() with the server's login-options; returns the JSON to send back
async get(options) {
const o = JSON.parse(JSON.stringify(options));
o.challenge = toBuffer(o.challenge);
(o.allowCredentials || []).forEach(c => (c.id = toBuffer(c.id)));
const cred = await navigator.credentials.get({ publicKey: o });
return credentialJson(cred, {
clientDataJSON: toB64Url(cred.response.clientDataJSON),
authenticatorData: toB64Url(cred.response.authenticatorData),
signature: toB64Url(cred.response.signature),
userHandle: toB64Url(cred.response.userHandle)
});
}
};

View File

@@ -1,4 +1,5 @@
export default {
version: "8.0.17",
copyright: "© 1999-2024, Ground Zero Tech-Works Inc."
version: "8.2.2",
//case 4630: the year is set when the client is built (vue.config.js)
copyright: `© ${process.env.VUE_APP_BUILD_YEAR}, Ground Zero Tech-Works Inc.`
};

View File

@@ -261,9 +261,7 @@ export default {
"TrialLicenseRequestList",
"SubscriptionServerList",
"ProductList",
"PurchaseList",
"GZCaseList",
"VendorNotificationList",
"SubscriptionList",
"SubscriptionItemList"
],

View File

@@ -0,0 +1,129 @@
<template>
<!-- case 4742: close or reopen the selected cases in one go (Sockeye only, so plain English, no translation keys) -->
<v-expansion-panel v-if="available == true">
<v-expansion-panel-header
disable-icon-rotate
expand-icon="$sockiCheckCircle"
>Close / reopen cases</v-expansion-panel-header
>
<v-expansion-panel-content>
<!-- case 4745: closing sets this status; reopening sets New. Duplicate is one case at a time, on the case form -->
<v-radio-group
v-model="closeAs"
row
dense
:disabled="jobActive"
data-cy="extCloseCasesAs"
>
<v-radio label="Close as Done" :value="STATUS_DONE"></v-radio>
<v-radio label="Close as Never" :value="STATUS_NEVER"></v-radio>
</v-radio-group>
<v-btn
color="blue darken-1"
text
:disabled="jobActive"
:loading="jobActive && !opening"
data-cy="extCloseCases"
@click="doAction(false)"
>Close</v-btn
>
<v-btn
color="blue darken-1"
text
:disabled="jobActive"
:loading="jobActive && opening"
data-cy="extOpenCases"
@click="doAction(true)"
>Reopen</v-btn
>
<v-btn
v-if="jobActive"
color="red darken-1"
text
@click="requestCancel()"
>
{{ $sock.t("Cancel") }}</v-btn
><span v-if="jobActive">{{ progress }}</span>
</v-expansion-panel-content>
</v-expansion-panel>
</template>
<script>
export default {
props: {
dataListSelection: { type: Object, default: null }
},
data: () => ({
//case 4745: GZCaseStatus values
STATUS_DONE: 5,
STATUS_NEVER: 6,
closeAs: 5,
jobActive: false,
opening: false,
currentJobId: null,
progress: ""
}),
computed: {
available() {
return (
this.dataListSelection != null &&
this.dataListSelection.SockType == window.$gz.type.GZCase &&
window.$gz.role.getRights(window.$gz.type.GZCase).change
);
}
},
methods: {
async requestCancel() {
await window.$gz.api.upsert(
"job-operations/request-cancel",
this.currentJobId
);
},
async doAction(open) {
const vm = this;
//Clear any possible prior errors
vm.$emit("ext-show-job-log", "clear");
vm.opening = open;
const url = `job-operations/batch-close-cases?open=${open}&status=${this.closeAs}`;
try {
this.progress = "";
let jobId = await window.$gz.api.upsert(url, this.dataListSelection);
if (jobId.error) {
throw new Error(window.$gz.errorHandler.errorToString(jobId, vm));
}
this.currentJobId = jobId.jobId;
vm.jobActive = true;
let jobProgress = {};
while (vm.jobActive == true) {
await window.$gz.util.sleepAsync(2000);
jobProgress = await window.$gz.api.get(
`job-operations/progress/${this.currentJobId}`
);
if (jobProgress.error) {
throw new Error(
window.$gz.errorHandler.errorToString(jobProgress, vm)
);
}
jobProgress = jobProgress.data;
this.progress = jobProgress.progress;
if (jobProgress.jobStatus == 4 || jobProgress.jobStatus == 0) {
if (jobProgress.jobStatus == 4) {
//emit job id and event to parent for log viewing
vm.$emit("ext-show-job-log", jobId);
}
throw new Error("Job failed");
}
if (jobProgress.jobStatus == 3) {
vm.jobActive = false;
}
}
//Here if it's completed successfully
window.$gz.eventBus.$emit("notify-success", vm.$sock.t("JobCompleted"));
vm.$emit("ext-close-refresh");
} catch (error) {
vm.jobActive = false;
window.$gz.eventBus.$emit("notify-error", vm.$sock.t("JobFailed"));
}
}
}
};
</script>

View File

@@ -24,6 +24,11 @@
<v-expansion-panels focusable>
<ExtensionTags :data-list-selection="dataListSelection" />
<ExtensionExport :data-list-selection="dataListSelection" />
<ExtensionCloseCases
:data-list-selection="dataListSelection"
@ext-close-refresh="close({ refresh: true })"
@ext-show-job-log="handleError($event)"
/>
<ExtensionDelete
:data-list-selection="dataListSelection"
@ext-close-refresh="close({ refresh: true })"
@@ -43,12 +48,14 @@
import ExtensionTags from "./extension-tags-control.vue";
import ExtensionExport from "./extension-export-control.vue";
import ExtensionDelete from "./extension-delete-control.vue";
import ExtensionCloseCases from "./extension-close-cases-control.vue";
export default {
components: {
ExtensionTags,
ExtensionExport,
ExtensionDelete
ExtensionDelete,
ExtensionCloseCases
},
data: () => ({
isVisible: false,

View File

@@ -27,39 +27,18 @@ export default {
methods: {
openUrl() {
if (this.value) {
window.open(`ssh:${this.value}`, "ssh");
//case 4663: ssh://root@host is the form OpenSSH understands; "ssh:host" (the PuTTY-era link) is not
window.open(`ssh://root@${this.value.trim()}`, "ssh");
}
}
}
};
/*
TO USE THIS:
Must have a SSH URI handler set in registry on windows to open putty like this:
TO USE THIS (case 4663): Windows must open ssh:// links in Windows Terminal, which runs Windows' own OpenSSH
(it uses the keys in the KeePassXC SSH agent). The registry handler, under HKEY_CLASSES_ROOT\ssh\shell\open\command:
----------------
Windows Registry Editor Version 5.00
"C:\Users\<you>\AppData\Local\Microsoft\WindowsApps\wt.exe" ssh "%1"
[HKEY_CLASSES_ROOT\ssh]
@="URL:SSH Protocol"
"URL Protocol"=""
[HKEY_CLASSES_ROOT\ssh\DefaultIcon]
@="%%ProgramFiles%%\\PuTTY\\putty.exe"
[HKEY_CLASSES_ROOT\ssh\shell]
[HKEY_CLASSES_ROOT\ssh\shell\open]
[HKEY_CLASSES_ROOT\ssh\shell\open\command]
@="C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"$val='%l'; $val = $val.TrimEnd('/');if ($val.StartsWith('ssh://')) { $val = $val.SubString(6) };if ($val.StartsWith('ssh:')) { $val = $val.SubString(4) }; & 'C:\\Program Files\\PuTTY\\putty.exe' \"$val\"\""
----------------
Also in putty need to set default to use pagent agent and then open the keys in KeePass so relies on a lot specific to us but it works great
https://documentation.help/PuTTY/config-saving.html#S4.1.2
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -Command "$val='%l'; $val = $val.TrimEnd('/');if ($val.StartsWith('ssh://')) { $val = $val.SubString(6) };if ($val.StartsWith('ssh:')) { $val = $val.SubString(4) }; & 'C:\Program Files\PuTTY\putty.exe' "$val""
plus the "URL Protocol" value on HKEY_CLASSES_ROOT\ssh so the browser offers it.
*/
</script>

View File

@@ -142,7 +142,7 @@ export default {
);
},
updateTimeValue(v) {
this.updateValue(this.dateValue, v);
this.updateValue(null, v);
},
updateValue(theDate, theTime) {
const vm = this;

View File

@@ -193,6 +193,8 @@ import {
faSeedling,
faServer,
faShare,
faFingerprint,
faShieldAlt,
faShippingFast,
faShoppingCart,
faSignInAlt,
@@ -390,6 +392,8 @@ library.add(
faSeedling,
faServer,
faShare,
faFingerprint,
faShieldAlt,
faShippingFast,
faShoppingCart,
faSignInAlt,
@@ -1279,6 +1283,18 @@ const CUSTOM_ICONS = {
icon: ["fas", "shipping-fast"]
}
},
sockiFingerprint: {
component: FontAwesomeIcon,
props: {
icon: ["fas", "fingerprint"]
}
},
sockiShieldAlt: {
component: FontAwesomeIcon,
props: {
icon: ["fas", "shield-alt"]
}
},
sockiShoppingCart: {
component: FontAwesomeIcon,
props: {

View File

@@ -359,34 +359,6 @@ export default new Router({
/* webpackChunkName: "biz" */ "./views/biz-subscription-server.vue"
)
},
{
path: "/biz-purchase-list",
name: "biz-purchase-list",
component: () =>
import(/* webpackChunkName: "biz" */ "./views/biz-purchase-list.vue")
},
{
path: "/biz-purchase-list/:recordid",
name: "purchase-edit",
component: () =>
import(/* webpackChunkName: "biz" */ "./views/biz-purchase.vue")
},
{
path: "/biz-vendor-notification-list",
name: "biz-vendor-notification-list",
component: () =>
import(
/* webpackChunkName: "biz" */ "./views/biz-vendor-notification-list.vue"
)
},
{
path: "/biz-vendor-notification-list/:recordid",
name: "vendor-notification-edit",
component: () =>
import(
/* webpackChunkName: "biz" */ "./views/biz-vendor-notification.vue"
)
},
{
path: "/biz-product-list",
name: "biz-product-list",
@@ -591,12 +563,6 @@ export default new Router({
component: () =>
import(/* webpackChunkName: "ops" */ "./views/ops-metrics.vue")
},
{
path: "/ops-profile",
name: "ops-profile",
component: () =>
import(/* webpackChunkName: "ops" */ "./views/ops-profile.vue")
},
{
path: "/ops-notify-queue",
name: "ops-notify-queue",
@@ -611,6 +577,14 @@ export default new Router({
/* webpackChunkName: "ops" */ "./views/ops-notification-settings.vue"
)
},
{
path: "/ops-security-advisories",
name: "ops-security-advisories",
component: () =>
import(
/* webpackChunkName: "ops" */ "./views/ops-security-advisories.vue"
)
},
{
path: "/ops-notify-log",
name: "ops-notify-log",

View File

@@ -102,7 +102,136 @@
@change="fieldValueChanged('allowLogin')"
></v-checkbox>
</v-col>
<v-col v-if="obj.allowLogin" cols="12" sm="6" lg="4" xl="3">
<!-- case 4688: AI agent: no login page sign-in, connects to /mcp with an agent token -->
<v-col cols="12" sm="6" lg="4" xl="3">
<v-checkbox
ref="agent"
v-model="obj.agent"
dense
:readonly="formState.readOnly"
:disabled="obj.id === 1"
:label="$sock.t('UserAgent')"
:hint="$sock.t('UserAgentHint')"
persistent-hint
data-cy="agent"
:error-messages="form().serverErrors(this, 'agent')"
@change="fieldValueChanged('agent')"
></v-checkbox>
</v-col>
<v-col
v-if="obj.agent && obj.id != 0 && !formState.dirty"
cols="12"
>
<v-card outlined class="pa-3" data-cy="agentTokens">
<div class="text-subtitle-1">
{{ $sock.t("AgentTokens") }}
</div>
<v-alert
v-if="newAgentToken"
type="warning"
dense
class="mt-2"
>
{{ $sock.t("AgentTokenShowOnce") }}
<div
class="text-body-2 mt-2"
style="word-break: break-all"
>
<code>{{ newAgentToken }}</code>
</div>
<v-btn
small
class="mt-2"
data-cy="copyAgentToken"
@click="copyAgentToken()"
>
<v-icon small left>$sockiCopy</v-icon>
{{ $sock.t("CopyToClipboard") }}
</v-btn>
</v-alert>
<v-simple-table v-if="agentTokens.length > 0" dense>
<tbody>
<tr v-for="t in agentTokens" :key="t.id">
<td>{{ t.name }}</td>
<td class="text-caption">
{{ $sock.t("AgentTokenExpires") }}:
{{
t.expires
? tokenDate(t.expires)
: $sock.t("AgentTokenNever")
}}
<br />
{{ $sock.t("AgentTokenLastUsed") }}:
{{ tokenDate(t.lastUsed) }}
<br />
{{
t.readOnly
? $sock.t("AgentTokenReadOnly")
: $sock.t("AgentTokenCanChange")
}}
</td>
<td>
<span v-if="t.revoked" class="text-caption">
{{ $sock.t("AgentTokenRevoked") }}
{{ tokenDate(t.revoked) }}
</span>
<v-btn
v-else-if="rights.change"
small
@click="revokeAgentToken(t)"
>{{ $sock.t("AgentTokenRevoke") }}</v-btn
>
</td>
</tr>
</tbody>
</v-simple-table>
<div v-else class="text-caption my-2">
{{ $sock.t("AgentTokenNone") }}
</div>
<v-row v-if="rights.change" dense align="center">
<v-col cols="12" sm="4">
<v-text-field
v-model="newAgentTokenName"
dense
:label="$sock.t('AgentTokenName')"
></v-text-field>
</v-col>
<v-col cols="6" sm="2">
<v-text-field
v-model.number="newAgentTokenDays"
dense
type="number"
min="0"
:label="$sock.t('AgentTokenExpiresDays')"
></v-text-field>
</v-col>
<!-- case 4688 phase 2: off (read only) unless asked for -->
<v-col cols="6" sm="3">
<v-checkbox
v-model="newAgentTokenCanChange"
dense
:label="$sock.t('AgentTokenCanChange')"
data-cy="agentTokenCanChange"
></v-checkbox>
</v-col>
<v-col cols="12" sm="3">
<v-btn
small
color="primary"
@click="createAgentToken()"
>{{ $sock.t("AgentTokenCreate") }}</v-btn
>
</v-col>
</v-row>
</v-card>
</v-col>
<v-col
v-if="obj.allowLogin && !obj.agent"
cols="12"
sm="6"
lg="4"
xl="3"
>
<v-text-field
id="username"
ref="login"
@@ -121,7 +250,13 @@
></v-text-field>
</v-col>
<v-col v-if="obj.allowLogin" cols="12" sm="6" lg="4" xl="3">
<v-col
v-if="obj.allowLogin && !obj.agent"
cols="12"
sm="6"
lg="4"
xl="3"
>
<v-text-field
id="password"
ref="password"
@@ -446,6 +581,7 @@ export default {
concurrency: 0,
active: true,
allowLogin: true,
agent: false,
name: null,
roles: null,
userType: 2,
@@ -460,6 +596,11 @@ export default {
lastLogin: null
},
agentTokens: [],
newAgentToken: null,
newAgentTokenName: null,
newAgentTokenDays: 90,
newAgentTokenCanChange: false,
optionsObj: {
id: 0,
concurrency: 0,
@@ -641,6 +782,7 @@ export default {
vm.formState.loading = false;
} else {
vm.obj = res.data;
await vm.getAgentTokens();
generateMenu(vm);
window.$gz.form.setFormState({
vm: vm,
@@ -824,6 +966,99 @@ export default {
window.$gz.errorHandler.handleFormError(error, vm);
}
},
//case 4678: for a user who lost their passkeys
async removePasskeys() {
const vm = this;
if ((await window.$gz.dialog.confirmDelete()) != true) {
return;
}
window.$gz.form.deleteAllErrorBoxErrors(vm);
try {
const res = await window.$gz.api.remove(
`auth/passkey/user/${vm.obj.id}`
);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
window.$gz.eventBus.$emit(
"notify-success",
vm.$sock.t("PasskeyRemoveAll")
);
}
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
}
},
//case 4688: agent tokens
tokenDate(d) {
if (!d) {
return "-";
}
return window.$gz.locale.utcDateToShortDateAndTimeLocalized(
d,
window.$gz.locale.getResolvedTimeZoneName(),
window.$gz.locale.getResolvedLanguage(),
window.$gz.locale.getHour12()
);
},
copyAgentToken() {
window.$gz.util.copyToClipboard(this.newAgentToken);
window.$gz.eventBus.$emit(
"notify-success",
this.$sock.t("CopyToClipboard")
);
},
async getAgentTokens() {
const vm = this;
vm.newAgentToken = null;
if (!vm.obj.agent || !vm.obj.id) {
vm.agentTokens = [];
return;
}
const res = await window.$gz.api.get(`agent-token/${vm.obj.id}`);
if (!res.error) {
vm.agentTokens = res.data;
}
},
async createAgentToken() {
const vm = this;
window.$gz.form.deleteAllErrorBoxErrors(vm);
const days =
vm.newAgentTokenDays === "" || vm.newAgentTokenDays == null
? null
: vm.newAgentTokenDays;
const res = await window.$gz.api.post("agent-token", {
userId: vm.obj.id,
name: vm.newAgentTokenName,
expiresDays: days,
canChange: vm.newAgentTokenCanChange
});
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
return;
}
await vm.getAgentTokens();
vm.newAgentToken = res.data.token;
vm.newAgentTokenName = null;
vm.newAgentTokenCanChange = false;
},
async revokeAgentToken(t) {
const vm = this;
if (
(await window.$gz.dialog.confirmGeneric("AgentTokenRevoke")) != true
) {
return;
}
const res = await window.$gz.api.remove(`agent-token/${t.id}`);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
await vm.getAgentTokens();
}
},
async disableTfa() {
const vm = this;
@@ -907,6 +1142,9 @@ async function clickHandler(menuItem) {
case "disable2fa":
m.vm.disableTfa();
break;
case "removepasskeys":
m.vm.removePasskeys();
break;
case "WorkOrderItemScheduledUserList":
m.vm.$router.push({
name: "svc-workorder-item-scheduled-users",
@@ -1021,6 +1259,12 @@ function generateMenu(vm) {
key: FORM_KEY + ":sendreset",
vm: vm
});
menuOptions.menuItems.push({
title: "PasskeyRemoveAll",
icon: "$sockiFingerprint",
key: FORM_KEY + ":removepasskeys",
vm: vm
});
if (vm.obj.twoFactorEnabled) {
menuOptions.menuItems.push({
title: "AuthDisableTwoFactor",
@@ -1067,6 +1311,23 @@ async function initForm(vm) {
//
async function fetchTranslatedText() {
await window.$gz.translation.cacheTranslations([
"PasskeyRemoveAll",
"UserAgent",
"UserAgentHint",
"AgentTokens",
"AgentTokenCreate",
"AgentTokenName",
"AgentTokenExpiresDays",
"AgentTokenShowOnce",
"AgentTokenExpires",
"AgentTokenNever",
"AgentTokenLastUsed",
"AgentTokenRevoke",
"AgentTokenRevoked",
"AgentTokenNone",
"AgentTokenCanChange",
"AgentTokenReadOnly",
"CopyToClipboard",
"Vendor",
"User",
"UserSettings",

View File

@@ -1,5 +1,44 @@
<template>
<div>
<!-- case 4764: open a case by its number, as AyaNova's work order list (Sockeye only, so plain English) -->
<v-row dense justify="center">
<v-dialog
v-model="openDialog"
persistent
max-width="600px"
@keydown.esc="openDialog = false"
@keydown.enter="openCase()"
>
<v-card>
<v-card-title>
<span class="text-h5">Open case</span>
</v-card-title>
<v-card-text>
<v-text-field
v-model="openCaseNumber"
label="Case number"
autofocus
required
type="number"
data-cy="openCaseNumber"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue darken-1" text @click="openDialog = false">{{
$sock.t("Cancel")
}}</v-btn>
<v-btn
color="blue darken-1"
text
:disabled="!openCaseNumber"
@click="openCase()"
>{{ $sock.t("OK") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<gz-extensions
ref="extensions"
@@ -36,7 +75,9 @@ export default {
preFilterMode: null,
objectId: null,
aForType: null,
name: null
name: null,
openDialog: false,
openCaseNumber: null
};
},
created() {
@@ -72,6 +113,27 @@ export default {
handleSelected(selected) {
this.selectedItems = selected;
},
async openCase() {
//case 4764
if (!this.openCaseNumber) {
return;
}
const res = await window.$gz.api.get(
"gzcase/id-from-number/" + this.openCaseNumber
);
if (res.error) {
window.$gz.eventBus.$emit(
"notify-warning",
`Case ${this.openCaseNumber} was not found`
);
return;
}
this.openDialog = false;
this.$router.push({
name: "gzcase-edit",
params: { recordid: res.data }
});
},
clearPreFilter() {
this.clientCriteria = null;
this.preFilterMode = null;
@@ -96,6 +158,10 @@ async function clickHandler(menuItem) {
params: { recordid: 0 }
});
break;
case "open":
m.vm.openCaseNumber = null;
m.vm.openDialog = true;
break;
case "extensions":
{
const res = await m.vm.$refs.extensions.open(
@@ -175,6 +241,14 @@ function generateMenu(vm) {
key: FORM_KEY + ":extensions",
vm: vm
});
//case 4764
menuOptions.menuItems.push({
title: "Open case",
notrans: true,
icon: "$sockiCoffee",
key: FORM_KEY + ":open",
vm: vm
});
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
</script>

View File

@@ -18,6 +18,50 @@
@input="fieldValueChanged('name')"
></v-text-field>
</v-col>
<!-- case 4745: triage (Sockeye only, so plain English); a closing status closes the case when saved -->
<v-col cols="12" sm="6" lg="4" xl="3">
<v-select
ref="status"
v-model="obj.status"
dense
:items="selectLists.statuses"
item-text="name"
item-value="id"
:readonly="formState.readOnly"
:label="$sock.t('GZCaseStatus')"
data-cy="status"
:error-messages="form().serverErrors(this, 'status')"
@input="fieldValueChanged('status')"
></v-select>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-select
ref="product"
v-model="obj.product"
dense
:items="selectLists.products"
item-text="name"
item-value="id"
:readonly="formState.readOnly"
:label="$sock.t('GZCaseProduct')"
data-cy="product"
:error-messages="form().serverErrors(this, 'product')"
@input="fieldValueChanged('product')"
></v-select>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-text-field
ref="release"
v-model="obj.release"
dense
:readonly="formState.readOnly"
:label="$sock.t('GZCaseRelease')"
hint="e.g. 8.3.2; needed once the case is Built"
data-cy="release"
:error-messages="form().serverErrors(this, 'release')"
@input="fieldValueChanged('release')"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-pick-list
ref="customerId"
@@ -66,23 +110,33 @@
v-model="obj.created"
:label="$sock.t('Created')"
:rules="[form().required(this, 'created')]"
:readonly="formState.readOnly"
:readonly="true"
:error-messages="form().serverErrors(this, 'created')"
data-cy="created"
@input="fieldValueChanged('created')"
></gz-date-time-picker>
</v-col>
<!-- case 4604: closed is set by the Close / Reopen button, not picked (Sockeye only, so plain English) -->
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="closed"
v-model="obj.closed"
:label="$sock.t('GZCaseClosed')"
:readonly="formState.readOnly"
:readonly="true"
:error-messages="form().serverErrors(this, 'closed')"
data-cy="closed"
@input="fieldValueChanged('closed')"
></gz-date-time-picker>
<v-btn
v-if="!formState.readOnly && obj.id != 0"
color="primary"
text
:disabled="formState.loading"
data-cy="closeOpen"
@click="setClosed(obj.closed == null)"
>{{ obj.closed == null ? "Close case" : "Reopen case" }}</v-btn
>
</v-col>
<!-- --------------------------------- -->
@@ -142,7 +196,14 @@ export default {
closed: null,
customerId: null,
wiki: null,
tags: []
tags: [],
status: GZCASE_STATUS_NEW,
product: null,
release: null
},
selectLists: {
statuses: [],
products: []
},
formState: {
ready: false,
@@ -212,6 +273,13 @@ export default {
this.obj = this.$route.params.obj;
this.obj.concurrency = undefined;
this.obj.id = 0;
//case 4714: a duplicate is a new, open case: the server gives it the next case number when saved
this.obj.caseId = 0;
this.obj.closed = null;
//case 4745: and starts at the beginning of triage
this.obj.status = GZCASE_STATUS_NEW;
this.obj.release = null;
this.obj.created = window.$gz.locale.nowUTC8601String();
this.obj.name = `${this.obj.name} - ${window.$gz.translation.get(
"Copy"
)}`;
@@ -259,6 +327,17 @@ export default {
form() {
return window.$gz.form;
},
async setClosed(close) {
//case 4604: Close sets the closed date to now, saves the case and goes back; Open clears it and saves
//case 4745: closing makes it Done, reopening New (Never and Duplicate are chosen in Status)
this.obj.closed = close ? window.$gz.locale.nowUTC8601String() : null;
this.obj.status = close ? GZCASE_STATUS_DONE : GZCASE_STATUS_NEW;
this.fieldValueChanged("closed");
await this.submit();
if (close && !this.formState.dirty) {
this.$router.go(-1);
}
},
fieldValueChanged(ref) {
if (
this.formState.ready &&
@@ -334,6 +413,11 @@ export default {
} else {
//PUT
vm.obj.concurrency = res.data.concurrency;
//case 4745: the server sets closed from the status (and may move the status), so show what it saved
//(the PUT answer carries these as well as concurrency)
vm.obj.closed = res.data.closed;
vm.obj.status = res.data.status;
vm.obj.release = res.data.release;
}
window.$gz.form.setFormState({
vm: vm,
@@ -579,12 +663,34 @@ function generateMenu(vm) {
let JUST_DELETED = false;
//case 4745: GZCaseStatus values the form sets itself
const GZCASE_STATUS_NEW = 1;
const GZCASE_STATUS_DONE = 5;
/////////////////////////////////
//
//
async function initForm(vm) {
await fetchTranslatedText();
await window.$gz.formCustomTemplate.get(FORM_CUSTOM_TEMPLATE_KEY, vm);
await populateSelectionLists(vm);
}
//////////////////////
//
//
async function populateSelectionLists(vm) {
//case 4745: status in process order, not by name
await window.$gz.enums.fetchEnumList("gzcasestatus");
vm.selectLists.statuses = window.$gz.enums.getSelectionList(
"gzcasestatus",
true
);
await window.$gz.enums.fetchEnumList("gzcaseproduct");
vm.selectLists.products = window.$gz.enums.getSelectionList(
"gzcaseproduct",
true
);
}
//////////////////////////////////////////////////////////
@@ -597,6 +703,9 @@ async function fetchTranslatedText() {
"GZCaseName",
"GZCaseId",
"GZCaseClosed",
"GZCaseStatus",
"GZCaseProduct",
"GZCaseRelease",
"Created",
"GZCaseNotes",
"Customer"

View File

@@ -1,184 +0,0 @@
<template>
<div>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<gz-extensions
ref="extensions"
:aya-type="aType"
:selected-items="selectedItems"
>
</gz-extensions>
<gz-data-table
ref="gzdatatable"
form-key="purchase-list"
data-list-key="PurchaseDataList"
:show-select="rights.read"
:reload="reload"
data-cy="purchasesTable"
:client-criteria="clientCriteria"
:pre-filter-mode="preFilterMode"
@selection-change="handleSelected"
@clear-pre-filter="clearPreFilter"
>
</gz-data-table>
</div>
</template>
<script>
const FORM_KEY = "purchase-list";
export default {
data() {
return {
rights: window.$gz.role.defaultRightsObject(),
aType: window.$gz.type.Purchase,
selectedItems: [],
reload: false,
clientCriteria: undefined,
preFilterMode: null,
objectId: null,
aForType: null,
name: null
};
},
created() {
this.rights = window.$gz.role.getRights(window.$gz.type.Purchase);
window.$gz.eventBus.$on("menu-click", clickHandler);
//------ pre-filter ----
//OPTIONAL "Show All purchases of head office" FILTER
this.objectId = window.$gz.util.stringToIntOrNull(
this.$route.params.objectId
);
this.aForType = window.$gz.util.stringToIntOrNull(this.$route.params.aType);
if (this.objectId && this.objectId != 0 && this.aForType) {
//OBJECTID,AYATYPE
this.clientCriteria = `${this.objectId},${this.aForType}`;
this.preFilterMode = {
icon: window.$gz.util.iconForType(this.aForType),
id: this.objectId,
socktype: this.aForType,
viz: this.$route.params.name,
clearable: true
};
}
//------ /pre-filter ----
generateMenu(this);
},
beforeDestroy() {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
handleSelected(selected) {
this.selectedItems = selected;
},
clearPreFilter() {
this.clientCriteria = null;
this.preFilterMode = null;
this.reload = !this.reload;
}
}
};
/////////////////////////////
//
//
async function clickHandler(menuItem) {
if (!menuItem) {
return;
}
const m = window.$gz.menu.parseMenuItem(menuItem);
if (m.owner == FORM_KEY && !m.disabled) {
switch (m.key) {
case "new":
m.vm.$router.push({
name: "purchase-edit",
params: { recordid: 0 }
});
break;
case "extensions":
{
const res = await m.vm.$refs.extensions.open(
m.vm.$refs.gzdatatable.getDataListSelection(
window.$gz.type.Purchase
)
);
if (res && res.refresh == true) {
m.vm.reload = !m.vm.reload;
}
}
break;
case "report":
{
const res = await m.vm.$refs.reportSelector.open(
m.vm.$refs.gzdatatable.getDataListSelection(
window.$gz.type.Purchase
),
m.id
);
if (res == null) {
return;
}
window.$gz.form.setLastReportMenuItem(FORM_KEY, res, m.vm);
}
break;
default:
window.$gz.eventBus.$emit(
"notify-warning",
FORM_KEY + "::context click: [" + m.key + "]"
);
}
}
}
//////////////////////
//
//
function generateMenu(vm) {
const menuOptions = {
isMain: true,
icon: "$sockiShoppingCart",
title: "PurchaseList",
menuItems: [],
formData: {
sockType: window.$gz.type.Purchase
}
};
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "New",
icon: "$sockiPlus",
surface: true,
key: FORM_KEY + ":new",
vm: vm
});
}
menuOptions.menuItems.push({
title: "Report",
icon: "$sockiFileAlt",
key: FORM_KEY + ":report",
vm: vm
});
const lastReport = window.$gz.form.getLastReport(FORM_KEY);
if (lastReport != null) {
menuOptions.menuItems.push({
title: lastReport.name,
notrans: true,
icon: "$sockiFileAlt",
key: FORM_KEY + ":report:" + lastReport.id,
vm: vm
});
}
menuOptions.menuItems.push({
title: "Extensions",
icon: "$sockiPuzzlePiece",
key: FORM_KEY + ":extensions",
vm: vm
});
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
</script>

View File

@@ -1,715 +0,0 @@
<template>
<div>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<div v-if="formState.ready">
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-form ref="form">
<v-row dense>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-pick-list
ref="customerId"
v-model="obj.customerId"
:aya-type="sockTypes().Customer"
show-edit-icon
:readonly="formState.readOnly"
:label="$sock.t('Customer')"
:error-messages="form().serverErrors(this, 'customerId')"
@input="fieldValueChanged('customerId')"
></gz-pick-list>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-pick-list
ref="productId"
v-model="obj.productId"
:aya-type="sockTypes().Product"
show-edit-icon
:readonly="formState.readOnly"
:label="$sock.t('Product')"
:error-messages="form().serverErrors(this, 'productId')"
:rules="[form().required(this, 'productId')]"
@input="fieldValueChanged('productId')"
></gz-pick-list>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-text-field
ref="quantity"
v-model="obj.quantity"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseQuantity')"
:error-messages="form().serverErrors(this, 'quantity')"
:rules="[form().required(this, 'quantity')]"
data-cy="quantity"
:counter="10"
type="number"
step="none"
@input="fieldValueChanged('quantity')"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-pick-list
ref="vendorId"
v-model="obj.vendorId"
:aya-type="sockTypes().Vendor"
show-edit-icon
:readonly="formState.readOnly"
:label="$sock.t('Vendor')"
:error-messages="form().serverErrors(this, 'vendorId')"
:rules="[form().required(this, 'vendorId')]"
@input="fieldValueChanged('vendorId')"
></gz-pick-list>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-text-field
ref="salesOrderNumber"
v-model="obj.salesOrderNumber"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseSalesOrderNumber')"
:rules="[form().required(this, 'salesOrderNumber')]"
:error-messages="form().serverErrors(this, 'salesOrderNumber')"
data-cy="salesOrderNumber"
@input="fieldValueChanged('salesOrderNumber')"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-checkbox
ref="isRenewal"
v-model="obj.isRenewal"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseIsRenewal')"
data-cy="isRenewal"
:error-messages="form().serverErrors(this, 'isRenewal')"
@change="fieldValueChanged('isRenewal')"
></v-checkbox>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="purchaseDate"
v-model="obj.purchaseDate"
:label="$sock.t('PurchaseDate')"
:rules="[form().required(this, 'purchaseDate')]"
:readonly="formState.readOnly"
:error-messages="form().serverErrors(this, 'purchaseDate')"
data-cy="purchaseDate"
@input="fieldValueChanged('purchaseDate')"
></gz-date-time-picker>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="expireDate"
v-model="obj.expireDate"
:label="$sock.t('PurchaseExpireDate')"
:readonly="formState.readOnly"
:error-messages="form().serverErrors(this, 'expireDate')"
data-cy="expireDate"
@input="fieldValueChanged('expireDate')"
></gz-date-time-picker>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="cancelDate"
v-model="obj.cancelDate"
:label="$sock.t('PurchaseCancelDate')"
:readonly="formState.readOnly"
:error-messages="form().serverErrors(this, 'cancelDate')"
data-cy="cancelDate"
@input="fieldValueChanged('cancelDate')"
></gz-date-time-picker>
</v-col>
<v-col
v-if="obj.vendorNotificationId != null"
cols="12"
sm="6"
lg="4"
xl="3"
>
<div>
<v-btn @click="openVendorNotification">
{{ obj.vendorNotificationId }}
<v-icon right>$sockiCommentDollar</v-icon>
</v-btn>
</div>
</v-col>
<v-col
v-if="obj.pGroup == 3 || obj.pGroup == 2"
cols="12"
sm="6"
lg="4"
xl="3"
>
<v-text-field
ref="dbId"
v-model="obj.dbId"
dense
:readonly="formState.readOnly"
:label="$sock.t('DatabaseID')"
data-cy="dbId"
:error-messages="form().serverErrors(this, 'dbId')"
@input="fieldValueChanged('dbId')"
></v-text-field>
</v-col>
<v-col
v-if="form().showMe(this, 'PurchaseCouponCode')"
cols="12"
sm="6"
lg="4"
xl="3"
>
<v-text-field
ref="couponCode"
v-model="obj.couponCode"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseCouponCode')"
:error-messages="form().serverErrors(this, 'couponCode')"
data-cy="couponCode"
@input="fieldValueChanged('couponCode')"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-checkbox
ref="renewNoticeSent"
v-model="obj.renewNoticeSent"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseRenewNoticeSent')"
data-cy="renewNoticeSent"
:error-messages="form().serverErrors(this, 'renewNoticeSent')"
@change="fieldValueChanged('renewNoticeSent')"
></v-checkbox>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<v-checkbox
ref="processed"
v-model="obj.processed"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseProcessed')"
data-cy="processed"
:error-messages="form().serverErrors(this, 'processed')"
@change="fieldValueChanged('processed')"
></v-checkbox>
</v-col>
<v-col v-if="form().showMe(this, 'PurchaseNotes')" cols="12">
<v-textarea
ref="notes"
v-model="obj.notes"
dense
:readonly="formState.readOnly"
:label="$sock.t('PurchaseNotes')"
:error-messages="form().serverErrors(this, 'notes')"
data-cy="notes"
auto-grow
@input="fieldValueChanged('notes')"
></v-textarea>
</v-col>
<!-- --------------------------------- -->
<v-col v-if="form().showMe(this, 'Tags')" cols="12">
<gz-tag-picker
ref="tags"
v-model="obj.tags"
:readonly="formState.readOnly"
data-cy="tags"
:error-messages="form().serverErrors(this, 'tags')"
@input="fieldValueChanged('tags')"
></gz-tag-picker>
</v-col>
<v-col v-if="form().showMe(this, 'Wiki')" cols="12">
<gz-wiki
ref="wiki"
v-model="obj.wiki"
:aya-type="sockType"
:aya-id="obj.id"
:readonly="formState.readOnly"
@input="fieldValueChanged('wiki')"
></gz-wiki
></v-col>
<v-col v-if="form().showMe(this, 'Attachments') && obj.id" cols="12">
<gz-attachments
:readonly="formState.readOnly"
:aya-type="sockType"
:aya-id="obj.id"
></gz-attachments
></v-col>
</v-row>
</v-form>
</div>
<v-overlay :value="!formState.ready || formState.loading">
<v-progress-circular indeterminate :size="64" />
</v-overlay>
</div>
</template>
<script>
const FORM_KEY = "purchase-edit";
const API_BASE_URL = "purchase/";
const FORM_CUSTOM_TEMPLATE_KEY = "Purchase";
export default {
data() {
return {
formCustomTemplateKey: FORM_CUSTOM_TEMPLATE_KEY,
obj: {
id: 0,
concurrency: 0,
customerId: 0,
vendorId: 1,
productId: null,
pGroup: 1,
salesOrderNumber: null,
dbId: null,
purchaseDate: window.$gz.locale.nowUTC8601String(),
expireDate: null,
cancelDate: null,
couponCode: null,
notes: null,
renewNoticeSent: false,
quantity: 1,
processedDate: null,
vendorNotificationId: null,
wiki: null,
tags: []
},
formState: {
ready: false,
dirty: false,
valid: true,
readOnly: false,
loading: true,
errorBoxMessage: null,
appError: null,
serverError: {}
},
rights: window.$gz.role.defaultRightsObject(),
sockType: window.$gz.type.Purchase
};
},
watch: {
formState: {
handler: function(val) {
if (this.formState.loading) {
return;
}
if (val.dirty && val.valid && !val.readOnly) {
window.$gz.eventBus.$emit("menu-enable-item", FORM_KEY + ":save");
} else {
window.$gz.eventBus.$emit("menu-disable-item", FORM_KEY + ":save");
}
if (!val.dirty && val.valid && !val.readOnly) {
window.$gz.eventBus.$emit(
"menu-enable-item",
FORM_KEY + ":duplicate"
);
window.$gz.eventBus.$emit("menu-enable-item", FORM_KEY + ":new");
} else {
window.$gz.eventBus.$emit(
"menu-disable-item",
FORM_KEY + ":duplicate"
);
window.$gz.eventBus.$emit("menu-disable-item", FORM_KEY + ":new");
}
},
deep: true
}
},
async created() {
const vm = this;
try {
await initForm(vm);
vm.rights = window.$gz.role.getRights(window.$gz.type.Purchase);
vm.formState.readOnly = !vm.rights.change;
window.$gz.eventBus.$on("menu-click", clickHandler);
//---------------------------------
let setDirty = false;
//id 0 means create or duplicate to new
if (vm.$route.params.recordid != 0) {
//is there already an obj from a prior operation?
if (this.$route.params.obj) {
//yes, no need to fetch it
this.obj = this.$route.params.obj;
} else {
await vm.getDataFromApi(vm.$route.params.recordid);
}
} else {
//Might be a duplicate and contain another record
if (this.$route.params.obj) {
this.obj = this.$route.params.obj;
this.obj.concurrency = undefined;
this.obj.id = 0;
this.obj.name = `${this.obj.name} - ${window.$gz.translation.get(
"Copy"
)}`;
setDirty = true;
}
}
window.$gz.form.setFormState({
vm: vm,
loading: false,
dirty: setDirty,
valid: true
});
//----------------------------
generateMenu(vm);
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
} finally {
vm.formState.ready = true;
}
},
async beforeRouteLeave(to, from, next) {
if (!this.formState.dirty || JUST_DELETED) {
next();
return;
}
if ((await window.$gz.dialog.confirmLeaveUnsaved()) === true) {
next();
} else {
next(false);
}
},
beforeDestroy() {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
openVendorNotification: function() {
window.$gz.eventBus.$emit("openobject", {
type: window.$gz.type.VendorNotification,
id: this.obj.vendorNotificationId
});
},
canSave: function() {
return this.formState.valid && this.formState.dirty;
},
canDuplicate: function() {
return this.formState.valid && !this.formState.dirty;
},
sockTypes: function() {
return window.$gz.type;
},
form() {
return window.$gz.form;
},
fieldValueChanged(ref) {
if (
this.formState.ready &&
!this.formState.loading &&
!this.formState.readOnly
) {
window.$gz.form.fieldValueChanged(this, ref);
}
},
async getDataFromApi(recordId) {
const vm = this;
window.$gz.form.setFormState({
vm: vm,
loading: true
});
if (!recordId) {
throw new Error(FORM_KEY + "::getDataFromApi -> Missing recordID!");
}
try {
window.$gz.form.deleteAllErrorBoxErrors(vm);
const res = await window.$gz.api.get(API_BASE_URL + recordId);
if (res.error) {
if (res.error.code == "2010") {
window.$gz.form.handleObjectNotFound(vm);
}
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
vm.obj = res.data;
generateMenu(vm);
window.$gz.form.setFormState({
vm: vm,
dirty: false,
valid: true,
loading: false
});
}
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
} finally {
window.$gz.form.setFormState({
vm: vm,
loading: false
});
}
},
async submit() {
const vm = this;
if (vm.canSave == false) {
return;
}
try {
window.$gz.form.setFormState({
vm: vm,
loading: true
});
window.$gz.form.deleteAllErrorBoxErrors(vm);
const res = await window.$gz.api.upsert(API_BASE_URL, vm.obj);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
if (res.data.id) {
//POST
vm.obj = res.data;
this.$router.replace({
name: "purchase-edit",
params: {
recordid: res.data.id,
obj: res.data
}
});
} else {
//PUT
vm.obj.concurrency = res.data.concurrency;
}
window.$gz.form.setFormState({
vm: vm,
dirty: false,
valid: true
});
}
} catch (ex) {
window.$gz.errorHandler.handleFormError(ex, vm);
} finally {
window.$gz.form.setFormState({
vm: vm,
loading: false
});
}
},
async remove() {
try {
const dialogResult = await window.$gz.dialog.confirmDelete();
if (dialogResult != true) {
return;
}
window.$gz.form.setFormState({
vm: this,
loading: true
});
if (this.$route.params.recordid == 0) {
JUST_DELETED = true;
this.$router.go(-1);
} else {
window.$gz.form.deleteAllErrorBoxErrors(this);
const res = await window.$gz.api.remove(
API_BASE_URL + this.$route.params.recordid
);
if (res.error) {
this.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(this);
} else {
JUST_DELETED = true;
this.$router.go(-1);
}
}
} catch (error) {
window.$gz.errorHandler.handleFormError(error, this);
} finally {
window.$gz.form.setFormState({
vm: this,
loading: false
});
}
},
duplicate() {
this.$router.push({
name: "purchase-edit",
params: {
recordid: 0,
obj: this.obj
}
});
}
}
};
/////////////////////////////
//
//
async function clickHandler(menuItem) {
if (!menuItem) {
return;
}
const m = window.$gz.menu.parseMenuItem(menuItem);
if (m.owner == FORM_KEY && !m.disabled) {
switch (m.key) {
case "save":
m.vm.submit();
break;
case "delete":
m.vm.remove();
break;
case "new":
m.vm.$router.push({
name: "purchase-edit",
params: { recordid: 0 }
});
break;
case "duplicate":
m.vm.duplicate();
break;
case "report":
{
const res = await m.vm.$refs.reportSelector.open(
{
AType: window.$gz.type.Purchase,
selectedRowIds: [m.vm.obj.id]
},
m.id
);
if (res == null) {
return;
}
window.$gz.form.setLastReportMenuItem(FORM_KEY, res, m.vm);
}
break;
default:
window.$gz.eventBus.$emit(
"notify-warning",
FORM_KEY + "::context click: [" + m.key + "]"
);
}
}
}
//////////////////////
//
//
function generateMenu(vm) {
const menuOptions = {
isMain: false,
readOnly: vm.formState.readOnly,
icon: "$sockiShoppingCart",
title: "Purchase",
formData: {
sockType: window.$gz.type.Purchase,
recordId: vm.$route.params.recordid,
formCustomTemplateKey: FORM_CUSTOM_TEMPLATE_KEY,
recordName: vm.obj.name
},
menuItems: []
};
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "Save",
icon: "$sockiSave",
surface: true,
key: FORM_KEY + ":save",
vm: vm
});
}
menuOptions.menuItems.push({
title: "Report",
icon: "$sockiFileAlt",
key: FORM_KEY + ":report",
vm: vm
});
const lastReport = window.$gz.form.getLastReport(FORM_KEY);
if (lastReport != null) {
menuOptions.menuItems.push({
title: lastReport.name,
notrans: true,
icon: "$sockiFileAlt",
key: FORM_KEY + ":report:" + lastReport.id,
vm: vm
});
}
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "New",
icon: "$sockiPlus",
key: FORM_KEY + ":new",
vm: vm
});
}
if (vm.rights.change && vm.$route.params.recordid != 0) {
menuOptions.menuItems.push({
title: "Duplicate",
icon: "$sockiClone",
key: FORM_KEY + ":duplicate",
vm: vm
});
}
if (vm.rights.delete && vm.$route.params.recordid != 0) {
menuOptions.menuItems.push({
title: "Delete",
icon: "$sockiTrashAlt",
surface: false,
key: FORM_KEY + ":delete",
vm: vm
});
}
menuOptions.menuItems.push({ divider: true, inset: false });
menuOptions.menuItems.push({ divider: true, inset: false });
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
let JUST_DELETED = false;
/////////////////////////////////
//
//
async function initForm(vm) {
await fetchTranslatedText();
await window.$gz.formCustomTemplate.get(FORM_CUSTOM_TEMPLATE_KEY, vm);
}
//////////////////////////////////////////////////////////
//
// Ensures UI translated text is available
//
async function fetchTranslatedText() {
await window.$gz.translation.cacheTranslations([
"Purchase",
"PurchaseSalesOrderNumber",
"Vendor",
"Customer",
"Product",
"PurchaseSalesOrderNumber",
"PurchaseDate",
"PurchaseExpireDate",
"PurchaseCancelDate",
"PurchaseCouponCode",
"PurchaseNotes",
"PurchaseRenewNoticeSent",
"PurchaseQuantity",
"PurchaseProcessed",
"PurchaseVendorNotificationId",
"VendorNotification",
"DatabaseID",
"PurchaseIsRenewal"
]);
}
</script>

View File

@@ -143,7 +143,8 @@
></gz-date-time-picker>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<!-- case 4600: expiry only applies to trial servers; a paid server renews monthly -->
<v-col v-if="obj.trial" cols="12" sm="6" lg="4" xl="3">
<v-row no-gutters>
<v-col cols="1">
<v-menu offset-y>
@@ -394,6 +395,7 @@ export default {
serverStates: [],
dataCenters: [
{ id: "SF", name: "San Francisco" },
{ id: "ATL", name: "Atlanta" },
{ id: "NY", name: "New York" },
{ id: "TOR", name: "Toronto" },
{ id: "AMS", name: "Amsterdam" },
@@ -1205,6 +1207,10 @@ export default {
loading: true
});
window.$gz.form.deleteAllErrorBoxErrors(vm);
//case 4600: expiry is hidden and unused for a paid server, but the record still needs a date
if (!vm.obj.trial && !vm.obj.subscriptionExpire) {
vm.obj.subscriptionExpire = window.$gz.locale.nowUTC8601String();
}
if (vm.obj.operatingSystem == null) {
vm.obj.operatingSystem = "Ubuntu 22.10";
}
@@ -1361,6 +1367,8 @@ DOMAINNAME='${m.vm.obj.name}'
#############################
echo STARTING...
timedatectl set-timezone $TIMEZONE
# no phased updates, so weekly updates match what was tested on devtest first
echo 'APT::Get::Never-Include-Phased-Updates "true";' > /etc/apt/apt.conf.d/52no-phasing
apt-get update
apt-get dist-upgrade -y
dpkg-reconfigure unattended-upgrades
@@ -1373,18 +1381,25 @@ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
echo 'vm.vfs_cache_pressure=50' | sudo tee -a /etc/sysctl.conf
echo INSTALL .NET CORE
apt-get install -y aspnetcore-runtime-8.0
apt-get install -y aspnetcore-runtime-10.0
echo GENERATE SERVER SECRETS
# unique per server, letters and digits only so they are safe in SQL, JSON and the connection string
DBPASSWORD=$(openssl rand -hex 24)
JWTSECRET=$(openssl rand -hex 16)
echo "PostgreSQL postgres password: $DBPASSWORD" > /root/ayanova-secrets.txt
echo "AYANOVA_JWT_SECRET: $JWTSECRET" >> /root/ayanova-secrets.txt
chmod 600 /root/ayanova-secrets.txt
echo INSTALL POSTGRESQL
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update
# PostgreSQL project repository (same PostgreSQL build on every Ubuntu release); apt-key no longer exists on newer Ubuntu
apt-get install -y postgresql-common
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get -y install postgresql
sudo -u postgres psql -U postgres -d postgres -c "alter user postgres with password 'YOUR_PASSWORD_HERE';"
sudo -u postgres psql -U postgres -d postgres -c "alter user postgres with password '$DBPASSWORD';"
echo INSTALL REPORTING LIBS
# Ubuntu 24.04
# Ubuntu 24.04 and 26.04
sudo apt-get update
sudo apt-get install -y libatk1.0-0t64 libc6 libcairo2 libdbus-1-3 libexpat1 libfontconfig1 libgcc-s1 libgdk-pixbuf2.0-0 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libnss3 lsb-release libgbm-dev xdg-utils
sudo apt-get install -y libatk1.0-0t64 libc6 libcairo2 libdbus-1-3 libexpat1 libfontconfig1 libgcc-s1 libgdk-pixbuf-2.0-0 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libnss3 lsb-release libgbm-dev xdg-utils
sudo apt-get update
sudo apt-get install -y libatk-bridge2.0-0t64 libcups2t64 libxcomposite1 libxdamage1 libpangocairo-1.0-0 libxfixes3 libxrandr2 libgbm1 libcairo2 libpango-1.0-0 libasound2t64 wget
# NOTE: for future releases of ubuntu when need to update packages this was helpful https://itsfoss.com/unable-to-locate-package-error-ubuntu/
@@ -1392,15 +1407,19 @@ echo INSTALL AYANOVA
apt install zip unzip -y
mkdir /var/ayanova
mkdir /var/ayanova/data
mkdir /var/ayanova/Chrome
mkdir /var/ayanova/ChromeHeadlessShell
cd /var/ayanova
curl -O https://www.ayanova.com/download/ayanova-subscription-linux-x64-server.zip && unzip -o ayanova-subscription-linux-x64-server.zip
# put this server's own secrets in place of the shipped defaults
sed -i "s/Password=YOUR_PASSWORD_HERE;/Password=$DBPASSWORD;/" /var/ayanova/config.json
sed -i "s/12345YourRandom32CharacterSecret/$JWTSECRET/" /var/ayanova/config.json
# nginx is the only way in: listen on this server only, not on its public address
sed -i 's#http://[*]:7575#http://127.0.0.1:7575#' /var/ayanova/config.json
chown -vR :www-data /var/ayanova
# program files read only for the service account; it writes to data only
chmod -R go-w /var/ayanova
chmod -R g+rw /var/ayanova/data
chmod -R g+rwx /var/ayanova/Chrome
chmod -R g+rwx /var/ayanova/ChromeHeadlessShell
chmod g+rw /var/ayanova/
# config.json holds the database password: readable by root and the service group only
chmod 640 /var/ayanova/config.json
echo '[Unit]' >> /etc/systemd/system/ayanova.service
echo 'Description=AyaNova server' >> /etc/systemd/system/ayanova.service
echo '' >> /etc/systemd/system/ayanova.service
@@ -1414,6 +1433,11 @@ echo 'SyslogIdentifier=ayanova-server' >> /etc/systemd/system/ayanova.service
echo 'User=www-data' >> /etc/systemd/system/ayanova.service
echo 'Environment=ASPNETCORE_ENVIRONMENT=Production' >> /etc/systemd/system/ayanova.service
echo 'Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false' >> /etc/systemd/system/ayanova.service
echo 'NoNewPrivileges=true' >> /etc/systemd/system/ayanova.service
echo 'ProtectSystem=strict' >> /etc/systemd/system/ayanova.service
echo 'ReadWritePaths=/var/ayanova/data' >> /etc/systemd/system/ayanova.service
echo 'PrivateTmp=true' >> /etc/systemd/system/ayanova.service
echo 'ProtectHome=true' >> /etc/systemd/system/ayanova.service
echo '' >> /etc/systemd/system/ayanova.service
echo '[Install]' >> /etc/systemd/system/ayanova.service
echo 'WantedBy=multi-user.target' >> /etc/systemd/system/ayanova.service

View File

@@ -454,6 +454,10 @@ async function clickHandler(menuItem) {
case "save":
m.vm.submit();
break;
//case 4487
case "delete":
m.vm.remove();
break;
case "report":
{
@@ -508,6 +512,17 @@ function generateMenu(vm) {
});
}
//case 4487: a request could not be deleted (e.g. one stuck waiting for a mistyped email to be confirmed)
if (vm.rights.delete && vm.$route.params.recordid != 0) {
menuOptions.menuItems.push({
title: "Delete",
icon: "$sockiTrashAlt",
surface: false,
key: FORM_KEY + ":delete",
vm: vm
});
}
menuOptions.menuItems.push({
title: "Report",
icon: "$sockiFileAlt",

View File

@@ -1,184 +0,0 @@
<template>
<div>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<gz-extensions
ref="extensions"
:aya-type="aType"
:selected-items="selectedItems"
>
</gz-extensions>
<gz-data-table
ref="gzdatatable"
form-key="vendor-notification-list"
data-list-key="VendorNotificationDataList"
:show-select="rights.read"
:reload="reload"
data-cy="purchasesTable"
:client-criteria="clientCriteria"
:pre-filter-mode="preFilterMode"
@selection-change="handleSelected"
@clear-pre-filter="clearPreFilter"
>
</gz-data-table>
</div>
</template>
<script>
const FORM_KEY = "vendor-notification-list";
export default {
data() {
return {
rights: window.$gz.role.defaultRightsObject(),
aType: window.$gz.type.VendorNotification,
selectedItems: [],
reload: false,
clientCriteria: undefined,
preFilterMode: null,
objectId: null,
aForType: null,
name: null
};
},
created() {
this.rights = window.$gz.role.getRights(window.$gz.type.VendorNotification);
window.$gz.eventBus.$on("menu-click", clickHandler);
//------ pre-filter ----
//OPTIONAL "Show All purchases of head office" FILTER
this.objectId = window.$gz.util.stringToIntOrNull(
this.$route.params.objectId
);
this.aForType = window.$gz.util.stringToIntOrNull(this.$route.params.aType);
if (this.objectId && this.objectId != 0 && this.aForType) {
//OBJECTID,AYATYPE
this.clientCriteria = `${this.objectId},${this.aForType}`;
this.preFilterMode = {
icon: window.$gz.util.iconForType(this.aForType),
id: this.objectId,
socktype: this.aForType,
viz: this.$route.params.name,
clearable: true
};
}
//------ /pre-filter ----
generateMenu(this);
},
beforeDestroy() {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
handleSelected(selected) {
this.selectedItems = selected;
},
clearPreFilter() {
this.clientCriteria = null;
this.preFilterMode = null;
this.reload = !this.reload;
}
}
};
/////////////////////////////
//
//
async function clickHandler(menuItem) {
if (!menuItem) {
return;
}
const m = window.$gz.menu.parseMenuItem(menuItem);
if (m.owner == FORM_KEY && !m.disabled) {
switch (m.key) {
case "new":
m.vm.$router.push({
name: "vendor-notification-edit",
params: { recordid: 0 }
});
break;
case "extensions":
{
const res = await m.vm.$refs.extensions.open(
m.vm.$refs.gzdatatable.getDataListSelection(
window.$gz.type.VendorNotification
)
);
if (res && res.refresh == true) {
m.vm.reload = !m.vm.reload;
}
}
break;
case "report":
{
const res = await m.vm.$refs.reportSelector.open(
m.vm.$refs.gzdatatable.getDataListSelection(
window.$gz.type.VendorNotification
),
m.id
);
if (res == null) {
return;
}
window.$gz.form.setLastReportMenuItem(FORM_KEY, res, m.vm);
}
break;
default:
window.$gz.eventBus.$emit(
"notify-warning",
FORM_KEY + "::context click: [" + m.key + "]"
);
}
}
}
//////////////////////
//
//
function generateMenu(vm) {
const menuOptions = {
isMain: true,
icon: "$sockiCommentDollar",
title: "VendorNotificationList",
menuItems: [],
formData: {
sockType: window.$gz.type.VendorNotification
}
};
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "New",
icon: "$sockiPlus",
surface: true,
key: FORM_KEY + ":new",
vm: vm
});
}
menuOptions.menuItems.push({
title: "Report",
icon: "$sockiFileAlt",
key: FORM_KEY + ":report",
vm: vm
});
const lastReport = window.$gz.form.getLastReport(FORM_KEY);
if (lastReport != null) {
menuOptions.menuItems.push({
title: lastReport.name,
notrans: true,
icon: "$sockiFileAlt",
key: FORM_KEY + ":report:" + lastReport.id,
vm: vm
});
}
menuOptions.menuItems.push({
title: "Extensions",
icon: "$sockiPuzzlePiece",
key: FORM_KEY + ":extensions",
vm: vm
});
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
</script>

View File

@@ -1,486 +0,0 @@
<template>
<div>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<div v-if="formState.ready">
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-form ref="form">
<v-row dense>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-pick-list
ref="vendorId"
v-model="obj.vendorId"
:aya-type="sockTypes().Vendor"
show-edit-icon
:readonly="formState.readOnly"
:label="$sock.t('Vendor')"
:error-messages="form().serverErrors(this, 'vendorId')"
:rules="[form().required(this, 'vendorId')]"
@input="fieldValueChanged('vendorId')"
></gz-pick-list>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="created"
v-model="obj.created"
:label="$sock.t('VendorNotificationCreated')"
:rules="[form().required(this, 'created')]"
:readonly="formState.readOnly"
:error-messages="form().serverErrors(this, 'created')"
data-cy="created"
@input="fieldValueChanged('created')"
></gz-date-time-picker>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="processed"
v-model="obj.processed"
:label="$sock.t('VendorNotificationProcessed')"
:readonly="formState.readOnly"
:error-messages="form().serverErrors(this, 'processed')"
data-cy="processed"
@input="fieldValueChanged('processed')"
></gz-date-time-picker>
</v-col>
<v-col cols="12">
<v-textarea
ref="vendorData"
v-model="obj.vendorData"
dense
:readonly="formState.readOnly"
:label="$sock.t('VendorNotificationVendorData')"
:error-messages="form().serverErrors(this, 'vendorData')"
data-cy="vendorData"
auto-grow
@input="fieldValueChanged('vendorData')"
></v-textarea>
</v-col>
</v-row>
</v-form>
</div>
<v-overlay :value="!formState.ready || formState.loading">
<v-progress-circular indeterminate :size="64" />
</v-overlay>
</div>
</template>
<script>
const FORM_KEY = "vendor-notification-edit";
const API_BASE_URL = "vendor-notification/";
export default {
data() {
return {
obj: {
id: 0,
concurrency: 0,
vendorId: 1,
created: window.$gz.locale.nowUTC8601String(),
processed: null,
vendorData: null
},
formState: {
ready: false,
dirty: false,
valid: true,
readOnly: false,
loading: true,
errorBoxMessage: null,
appError: null,
serverError: {}
},
rights: window.$gz.role.defaultRightsObject(),
sockType: window.$gz.type.VendorNotification
};
},
watch: {
formState: {
handler: function(val) {
if (this.formState.loading) {
return;
}
if (val.dirty && val.valid && !val.readOnly) {
window.$gz.eventBus.$emit("menu-enable-item", FORM_KEY + ":save");
} else {
window.$gz.eventBus.$emit("menu-disable-item", FORM_KEY + ":save");
}
if (!val.dirty && val.valid && !val.readOnly) {
window.$gz.eventBus.$emit(
"menu-enable-item",
FORM_KEY + ":duplicate"
);
window.$gz.eventBus.$emit("menu-enable-item", FORM_KEY + ":new");
} else {
window.$gz.eventBus.$emit(
"menu-disable-item",
FORM_KEY + ":duplicate"
);
window.$gz.eventBus.$emit("menu-disable-item", FORM_KEY + ":new");
}
},
deep: true
}
},
async created() {
const vm = this;
try {
await initForm(vm);
vm.rights = window.$gz.role.getRights(window.$gz.type.VendorNotification);
vm.formState.readOnly = !vm.rights.change;
window.$gz.eventBus.$on("menu-click", clickHandler);
//---------------------------------
let setDirty = false;
//id 0 means create or duplicate to new
if (vm.$route.params.recordid != 0) {
//is there already an obj from a prior operation?
if (this.$route.params.obj) {
//yes, no need to fetch it
this.obj = this.$route.params.obj;
} else {
await vm.getDataFromApi(vm.$route.params.recordid);
}
} else {
//Might be a duplicate and contain another record
if (this.$route.params.obj) {
this.obj = this.$route.params.obj;
this.obj.concurrency = undefined;
this.obj.id = 0;
this.obj.name = `${this.obj.name} - ${window.$gz.translation.get(
"Copy"
)}`;
setDirty = true;
}
}
window.$gz.form.setFormState({
vm: vm,
loading: false,
dirty: setDirty,
valid: true
});
//----------------------------
generateMenu(vm);
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
} finally {
vm.formState.ready = true;
}
},
async beforeRouteLeave(to, from, next) {
if (!this.formState.dirty || JUST_DELETED) {
next();
return;
}
if ((await window.$gz.dialog.confirmLeaveUnsaved()) === true) {
next();
} else {
next(false);
}
},
beforeDestroy() {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
canSave: function() {
return this.formState.valid && this.formState.dirty;
},
canDuplicate: function() {
return this.formState.valid && !this.formState.dirty;
},
sockTypes: function() {
return window.$gz.type;
},
form() {
return window.$gz.form;
},
fieldValueChanged(ref) {
if (
this.formState.ready &&
!this.formState.loading &&
!this.formState.readOnly
) {
window.$gz.form.fieldValueChanged(this, ref);
}
},
async getDataFromApi(recordId) {
const vm = this;
window.$gz.form.setFormState({
vm: vm,
loading: true
});
if (!recordId) {
throw new Error(FORM_KEY + "::getDataFromApi -> Missing recordID!");
}
try {
window.$gz.form.deleteAllErrorBoxErrors(vm);
const res = await window.$gz.api.get(API_BASE_URL + recordId);
if (res.error) {
if (res.error.code == "2010") {
window.$gz.form.handleObjectNotFound(vm);
}
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
vm.obj = res.data;
generateMenu(vm);
window.$gz.form.setFormState({
vm: vm,
dirty: false,
valid: true,
loading: false
});
}
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
} finally {
window.$gz.form.setFormState({
vm: vm,
loading: false
});
}
},
async submit() {
const vm = this;
if (vm.canSave == false) {
return;
}
try {
window.$gz.form.setFormState({
vm: vm,
loading: true
});
window.$gz.form.deleteAllErrorBoxErrors(vm);
const res = await window.$gz.api.upsert(API_BASE_URL, vm.obj);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
if (res.data.id) {
//POST
vm.obj = res.data;
this.$router.replace({
name: "vendor-notification-edit",
params: {
recordid: res.data.id,
obj: res.data
}
});
} else {
//PUT
vm.obj.concurrency = res.data.concurrency;
}
window.$gz.form.setFormState({
vm: vm,
dirty: false,
valid: true
});
}
} catch (ex) {
window.$gz.errorHandler.handleFormError(ex, vm);
} finally {
window.$gz.form.setFormState({
vm: vm,
loading: false
});
}
},
async remove() {
try {
const dialogResult = await window.$gz.dialog.confirmDelete();
if (dialogResult != true) {
return;
}
window.$gz.form.setFormState({
vm: this,
loading: true
});
if (this.$route.params.recordid == 0) {
JUST_DELETED = true;
this.$router.go(-1);
} else {
window.$gz.form.deleteAllErrorBoxErrors(this);
const res = await window.$gz.api.remove(
API_BASE_URL + this.$route.params.recordid
);
if (res.error) {
this.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(this);
} else {
JUST_DELETED = true;
this.$router.go(-1);
}
}
} catch (error) {
window.$gz.errorHandler.handleFormError(error, this);
} finally {
window.$gz.form.setFormState({
vm: this,
loading: false
});
}
},
duplicate() {
this.$router.push({
name: "vendor-notification-edit",
params: {
recordid: 0,
obj: this.obj
}
});
}
}
};
/////////////////////////////
//
//
async function clickHandler(menuItem) {
if (!menuItem) {
return;
}
const m = window.$gz.menu.parseMenuItem(menuItem);
if (m.owner == FORM_KEY && !m.disabled) {
switch (m.key) {
case "save":
m.vm.submit();
break;
case "delete":
m.vm.remove();
break;
case "new":
m.vm.$router.push({
name: "vendor-notification-edit",
params: { recordid: 0 }
});
break;
case "duplicate":
m.vm.duplicate();
break;
case "report":
{
const res = await m.vm.$refs.reportSelector.open(
{
AType: window.$gz.type.VendorNotification,
selectedRowIds: [m.vm.obj.id]
},
m.id
);
if (res == null) {
return;
}
window.$gz.form.setLastReportMenuItem(FORM_KEY, res, m.vm);
}
break;
default:
window.$gz.eventBus.$emit(
"notify-warning",
FORM_KEY + "::context click: [" + m.key + "]"
);
}
}
}
//////////////////////
//
//
function generateMenu(vm) {
const menuOptions = {
isMain: false,
readOnly: vm.formState.readOnly,
icon: "$sockiCommentDollar",
title: "VendorNotification",
formData: {
sockType: window.$gz.type.VendorNotification,
recordId: vm.$route.params.recordid,
recordName: vm.obj.name
},
menuItems: []
};
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "Save",
icon: "$sockiSave",
surface: true,
key: FORM_KEY + ":save",
vm: vm
});
}
menuOptions.menuItems.push({
title: "Report",
icon: "$sockiFileAlt",
key: FORM_KEY + ":report",
vm: vm
});
const lastReport = window.$gz.form.getLastReport(FORM_KEY);
if (lastReport != null) {
menuOptions.menuItems.push({
title: lastReport.name,
notrans: true,
icon: "$sockiFileAlt",
key: FORM_KEY + ":report:" + lastReport.id,
vm: vm
});
}
if (vm.rights.change) {
menuOptions.menuItems.push({
title: "New",
icon: "$sockiPlus",
key: FORM_KEY + ":new",
vm: vm
});
}
if (vm.rights.change && vm.$route.params.recordid != 0) {
menuOptions.menuItems.push({
title: "Duplicate",
icon: "$sockiClone",
key: FORM_KEY + ":duplicate",
vm: vm
});
}
if (vm.rights.delete && vm.$route.params.recordid != 0) {
menuOptions.menuItems.push({
title: "Delete",
icon: "$sockiTrashAlt",
surface: false,
key: FORM_KEY + ":delete",
vm: vm
});
}
menuOptions.menuItems.push({ divider: true, inset: false });
menuOptions.menuItems.push({ divider: true, inset: false });
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
let JUST_DELETED = false;
/////////////////////////////////
//
//
async function initForm() {
await fetchTranslatedText();
}
//////////////////////////////////////////////////////////
//
// Ensures UI translated text is available
//
async function fetchTranslatedText() {
await window.$gz.translation.cacheTranslations([
"VendorNotification",
"VendorNotificationCreated",
"Vendor",
"VendorNotificationVendorData",
"VendorNotificationProcessed"
]);
}
</script>

View File

@@ -1261,16 +1261,6 @@ async function clickHandler(menuItem) {
}
});
break;
case "PurchaseList":
m.vm.$router.push({
name: "biz-purchase-list",
params: {
aType: m.vm.sockType,
objectId: m.vm.obj.id,
name: m.vm.obj.name
}
});
break;
case "LicenseList":
m.vm.$router.push({
name: "biz-license-list",
@@ -1401,13 +1391,6 @@ function generateMenu(vm) {
vm: vm
});
menuOptions.menuItems.push({
title: "PurchaseList",
icon: "$sockiShoppingCart",
key: FORM_KEY + ":PurchaseList",
vm: vm
});
menuOptions.menuItems.push({
title: "LicenseList",
icon: "$sockiGem",

View File

@@ -273,7 +273,6 @@ export default {
case 29:
case 10:
case 37:
case 39:
return true;
default:
return false;
@@ -726,9 +725,7 @@ async function populateSelectionLists(vm) {
ServerStateStatusChange = 36,
ServerStateStatusAge=37,
SubscriptionServerExpiring = 38,
SubscriptionServerLastUpdateAge = 39,
SubscriptionServerRequestReceived = 40,
LicenseTrialRequestReceived = 41,
PurchaseNotificationReceived = 42
LicenseTrialRequestReceived = 41
*/
</script>

View File

@@ -4,7 +4,7 @@
<div v-if="formState.ready">
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-form ref="form">
<div v-if="obj.aType" class="mb-6">
<div v-if="obj.sockType" class="mb-6">
<v-icon large @click="navToTarget()">{{ iconForType }}</v-icon
><span class="text-h5" @click="navToTarget()"> {{ name }}</span>
</div>
@@ -191,7 +191,7 @@ export default {
userId: window.$gz.store.state.userId,
assignedByUserId: window.$gz.store.state.userId,
objectId: null,
aType: null,
sockType: null, //case 4468: the server field is SockType; the form used AyaNova's AType, so reviews were saved with no type
overDue: false
},
formState: {
@@ -222,7 +222,7 @@ export default {
},
iconForType() {
return window.$gz.util.iconForType(this.obj.aType);
return window.$gz.util.iconForType(this.obj.sockType);
},
hasSupervisorRole: function() {
return window.$gz.role.hasRole([
@@ -298,10 +298,10 @@ export default {
vm.obj.objectId = window.$gz.util.stringToIntOrNull(
vm.$route.params.objectId
);
vm.obj.aType = window.$gz.util.stringToIntOrNull(
vm.obj.sockType = window.$gz.util.stringToIntOrNull(
vm.$route.params.aType
);
if (!vm.obj.objectId || !vm.obj.aType) {
if (!vm.obj.objectId || !vm.obj.sockType) {
throw "SockType and ObjectId are required to create a review";
}
}
@@ -337,7 +337,7 @@ export default {
methods: {
navToTarget: function() {
window.$gz.eventBus.$emit("openobject", {
type: this.obj.aType,
type: this.obj.sockType,
id: this.obj.objectId
});
},
@@ -515,7 +515,7 @@ async function clickHandler(menuItem) {
name: "review-edit",
params: {
recordid: 0,
aType: m.vm.obj.aType,
aType: m.vm.obj.sockType,
objectId: m.vm.obj.objectId,
name: m.vm.name
}

View File

@@ -65,12 +65,59 @@
</v-card>
</v-col>
</template>
<!-- case 4678: passkeys -->
<v-col cols="12">
<v-card data-cy="passkeys" class="mx-auto my-6" max-width="600">
<v-card-title>{{ $sock.t("Passkeys") }}</v-card-title>
<v-card-subtitle>{{ $sock.t("PasskeyIntro") }}</v-card-subtitle>
<v-card-text>
<div v-if="!passkeySupported" class="warning--text mb-4">
{{ $sock.t("PasskeyNotSupported") }}
</div>
<v-simple-table v-if="passkeys.length > 0" dense>
<tbody>
<tr v-for="k in passkeys" :key="k.id">
<td>{{ k.name }}</td>
<td class="text-caption">
{{ $sock.t("PasskeyCreated") }}: {{ date(k.created) }}
<br />
{{ $sock.t("PasskeyLastUsed") }}: {{ date(k.lastUsed) }}
</td>
<td>
<v-btn
icon
:title="$sock.t('PasskeyRemove')"
@click="removePasskey(k)"
>
<v-icon small>$sockiTrashAlt</v-icon>
</v-btn>
</td>
</tr>
</tbody>
</v-simple-table>
<div v-else class="mb-2">{{ $sock.t("PasskeyNone") }}</div>
<template v-if="passkeySupported">
<v-text-field
v-model="passkeyName"
dense
class="mt-6"
:label="$sock.t('PasskeyName')"
data-cy="passkeyName"
></v-text-field>
<v-btn color="primary" @click="addPasskey()">{{
$sock.t("PasskeyAdd")
}}</v-btn>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-form>
</v-col>
</v-row>
</template>
<script>
import passkey from "../api/passkey";
const FORM_KEY = "security";
export default {
@@ -83,6 +130,9 @@ export default {
},
pin: null,
tfaEnabled: window.$gz.store.state.tfaEnabled,
passkeys: [],
passkeyName: null,
passkeySupported: passkey.supported(),
formState: {
ready: false,
dirty: false,
@@ -116,6 +166,7 @@ export default {
readOnly: false
});
window.$gz.eventBus.$on("menu-click", clickHandler);
await vm.getPasskeys();
//fetch tfa secret and display here if tfa not enabled currently
if (!this.tfaEnabled) {
const res = await window.$gz.api.get("auth/totp");
@@ -135,6 +186,71 @@ export default {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
//case 4678
date(d) {
if (!d) {
return "-";
}
return window.$gz.locale.utcDateToShortDateAndTimeLocalized(
d,
window.$gz.locale.getResolvedTimeZoneName(),
window.$gz.locale.getResolvedLanguage(),
window.$gz.locale.getHour12()
);
},
async getPasskeys() {
const res = await window.$gz.api.get("auth/passkey");
if (!res.error) {
this.passkeys = res.data;
}
},
async addPasskey() {
const vm = this;
window.$gz.form.deleteAllErrorBoxErrors(vm);
try {
let res = await window.$gz.api.post(
"auth/passkey/register-options",
{}
);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
return;
}
const answer = await passkey.create(res.data.options);
res = await window.$gz.api.post("auth/passkey/register", {
id: res.data.id,
name: vm.passkeyName,
response: answer
});
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
return;
}
vm.passkeyName = null;
await vm.getPasskeys();
} catch (error) {
//the user closed the browser's passkey prompt
if (error.name == "NotAllowedError" || error.name == "AbortError") {
return;
}
window.$gz.errorHandler.handleFormError(error, vm);
}
},
async removePasskey(k) {
const vm = this;
if ((await window.$gz.dialog.confirmDelete()) != true) {
return;
}
const res = await window.$gz.api.remove(`auth/passkey/${k.id}`);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
await vm.getPasskeys();
}
},
form() {
return window.$gz.form;
},
@@ -260,6 +376,15 @@ async function fetchTranslatedText() {
await window.$gz.translation.cacheTranslations([
"AuthConnectAppTitle",
"AuthConnectAppSubTitle",
"Passkeys",
"PasskeyIntro",
"PasskeyAdd",
"PasskeyName",
"PasskeyCreated",
"PasskeyLastUsed",
"PasskeyNone",
"PasskeyRemove",
"PasskeyNotSupported",
"AuthConnectAppManualEntry",
"AuthEnterPin",
"AuthTwoFactor",

View File

@@ -126,6 +126,16 @@
>
<v-icon>$sockiSignIn</v-icon>
</v-btn>
<!-- case 4678: passkey sign-in, offered where the browser and page allow it -->
<v-btn
v-if="passkeySupported"
class="ml-4"
aria-label="Passkey"
data-cy="passkeybutton"
@click="passkeyLogin()"
>
<v-icon>$sockiFingerprint</v-icon>
</v-btn>
</v-col>
</v-row>
</form>
@@ -183,6 +193,7 @@
</template>
<script>
import { processLogin, processLogout } from "../api/authutil";
import passkey from "../api/passkey";
import sockeyeVersion from "../api/sockeye-version";
export default {
data() {
@@ -201,6 +212,7 @@ export default {
tt: null,
pinError: null,
loggedInWithKnownPassword: false,
passkeySupported: passkey.supported(),
hasSmallLogo: false,
hasMediumLogo: false,
hasLargeLogo: false,
@@ -380,6 +392,45 @@ export default {
//move focus to password
document.getElementsByName("password")[0].focus();
},
//case 4678
async passkeyLogin() {
const vm = this;
vm.errorBadCreds = false;
vm.formState.errorBoxMessage = null;
try {
let res = await window.$gz.api.upsert(
"auth/passkey/login-options",
{},
true
);
if (res.error) {
throw new Error(window.$gz.errorHandler.errorToString(res, vm));
}
const answer = await passkey.get(res.data.options);
res = await window.$gz.api.upsert(
"auth/passkey/login",
{ id: res.data.id, response: answer },
true
);
if (res.error) {
throw new Error(window.$gz.errorHandler.errorToString(res, vm));
}
await this.step2(res);
} catch (error) {
//the user closed the browser's passkey prompt
if (error.name == "NotAllowedError" || error.name == "AbortError") {
return;
}
if (
error.message &&
error.message.includes("ErrorUserNotAuthenticated")
) {
vm.errorBadCreds = true;
return;
}
vm.formState.errorBoxMessage = error.message;
}
},
async login() {
const vm = this;

View File

@@ -39,7 +39,10 @@
dense
:readonly="formState.readOnly"
:label="$sock.t('BackupSetsToKeep')"
:rules="[form().integerValid(this, 'backupSetsToKeep')]"
:rules="[
form().integerValid(this, 'backupSetsToKeep'),
form().minValueValid(this, 'backupSetsToKeep', 1)
]"
:error-messages="form().serverErrors(this, 'backupSetsToKeep')"
type="number"
@input="fieldValueChanged('backupSetsToKeep')"

View File

@@ -1,125 +0,0 @@
<template>
<v-row v-if="formState.ready" v-resize="onResize" dense>
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-col cols="12">
<v-btn @click="refreshProfile">
<v-icon>$sockiSync</v-icon>
</v-btn>
</v-col>
<v-col cols="12">
<v-card elevation="4" data-cy="profileCard">
<v-card :height="cardHeight">
<iframe
id="profileFrame"
style="width:100%;height:100%"
:src="profileUrl()"
></iframe>
</v-card>
</v-card>
</v-col>
</v-row>
</template>
<script>
const FORM_KEY = "ops-profile";
export default {
data() {
return {
cardHeight: 300,
formState: {
ready: false,
loading: false,
errorBoxMessage: null,
appError: null,
serverError: {}
}
};
},
async created() {
const vm = this;
try {
await initForm(vm);
window.$gz.eventBus.$on("menu-click", clickHandler);
generateMenu(vm);
this.formState.ready = true;
} catch (err) {
vm.formState.ready = true;
window.$gz.errorHandler.handleFormError(err, vm);
}
},
beforeDestroy() {
window.$gz.eventBus.$off("menu-click", clickHandler);
},
methods: {
onResize() {
this.cardHeight = window.innerHeight * 0.8;
},
profileUrl() {
return (
window.$gz.api.ServerBaseUrl() +
"profiler/results-index?t=" +
this.$store.state.downloadToken
);
},
refreshProfile() {
const ifr = document.getElementById("profileFrame");
//trigger refresh? wtf? I don't know and at this point I'm not changing it in case it breaks something 2022-08-20
// eslint-disable-next-line no-self-assign
ifr.src = ifr.src;
}
}
};
//////////////////////
//
//
function generateMenu() {
const menuOptions = {
isMain: true,
icon: "$sockiBinoculars",
title: "ServerProfiler",
helpUrl: "ops-profile",
formData: {
sockType: window.$gz.type.ServerMetrics
},
menuItems: []
};
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
/////////////////////////////
//
//
function clickHandler(menuItem) {
if (!menuItem) {
return;
}
const m = window.$gz.menu.parseMenuItem(menuItem);
if (m.owner == FORM_KEY && !m.disabled) {
switch (m.key) {
default:
window.$gz.eventBus.$emit(
"notify-warning",
FORM_KEY + "::context click: [" + m.key + "]"
);
}
}
}
/////////////////////////////////
//
//
async function initForm() {
await fetchTranslatedText();
}
//////////////////////////////////////////////////////////
//
// Ensures UI translated text is available
//
async function fetchTranslatedText() {
await window.$gz.translation.cacheTranslations(["ServerProfiler"]);
}
</script>

View File

@@ -0,0 +1,435 @@
<template>
<!-- case 4738: security advisory monitor. Sockeye-only, so plain English text (no translation keys) -->
<v-row v-if="formState.ready" dense>
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-col cols="12">
<div class="text-h6">Sources</div>
<v-data-table
dense
:headers="sourceHeaders"
:items="sources"
class="elevation-1"
hide-default-footer
disable-pagination
no-data-text="Not run yet"
data-cy="sourcesTable"
></v-data-table>
</v-col>
<v-col cols="12" class="mt-4">
<div class="text-h6">
Watched assets
<v-btn small class="ml-4" :disabled="!rights.change" @click="addAsset"
>Add asset</v-btn
>
</div>
<div class="text-caption mb-2">
For an Ubuntu release, paste the output of
<code>dpkg-query -W -f='${source:Package}\n' | sort -u</code> from one
of its droplets. For PostgreSQL, list the major versions in use (e.g.
15).
</div>
<v-card
v-for="(a, index) in assets"
:key="a.key"
class="mb-4 pa-3"
outlined
:data-cy="'asset' + index"
>
<v-row dense>
<v-col cols="12" sm="6" lg="3">
<v-text-field
v-model="a.name"
label="Name"
:readonly="!rights.change"
></v-text-field>
</v-col>
<v-col cols="12" sm="6" lg="3">
<v-select
v-model="a.platform"
:items="platforms"
label="Platform"
:readonly="!rights.change"
></v-select>
</v-col>
<v-col cols="6" lg="2">
<v-select
v-model="a.alertAt"
:items="severities"
label="Alert at once from"
:readonly="!rights.change"
></v-select>
</v-col>
<v-col cols="6" lg="2">
<v-select
v-model="a.digestAt"
:items="severities"
label="Wednesday digest from"
:readonly="!rights.change"
></v-select>
</v-col>
<v-col cols="12" lg="2">
<v-checkbox
v-model="a.active"
label="Active"
:readonly="!rights.change"
></v-checkbox>
</v-col>
<v-col cols="12">
<v-textarea
v-model="a.componentsText"
:label="`Components (${countComponents(a)})`"
rows="6"
no-resize
outlined
:readonly="!rights.change"
></v-textarea>
</v-col>
<v-col cols="12">
<v-btn
small
color="primary"
:disabled="!rights.change"
@click="saveAsset(a)"
>Save</v-btn
>
<v-btn
small
class="ml-4"
:disabled="!rights.change"
@click="deleteAsset(a)"
>Delete</v-btn
>
</v-col>
</v-row>
</v-card>
</v-col>
<v-col cols="12" class="mt-4">
<div class="text-h6">Dry run</div>
<div class="text-caption mb-2">
What the advisories of the last days would have done with the assets as
saved. Sends nothing and records nothing. Looking up Ubuntu's CVE
priorities can take a few minutes the first time.
</div>
<v-row dense align="center">
<v-col cols="4" sm="2">
<v-text-field
v-model.number="dryRunDays"
type="number"
label="Days"
min="1"
max="90"
></v-text-field>
</v-col>
<v-col cols="8" sm="10">
<v-btn
small
:disabled="!rights.change || dryRun.running"
:loading="dryRun.running"
@click="startDryRun"
>Run</v-btn
>
<span v-if="dryRun.error" class="error--text ml-4">{{
dryRun.error
}}</span>
</v-col>
</v-row>
<v-data-table
v-if="dryRunRows.length > 0"
dense
:headers="
advisoryHeaders.concat([
{ text: 'Already processed', value: 'alreadyProcessed' }
])
"
:items="dryRunRows"
class="elevation-1"
:items-per-page="50"
data-cy="dryRunTable"
>
<template v-slot:[`item.sourceId`]="{ item }">
<a :href="item.url" target="_blank">{{ item.sourceId }}</a>
</template>
</v-data-table>
</v-col>
<v-col cols="12" class="mt-4">
<div class="text-h6">
Matched advisories, last 60 days ({{ processed }} processed)
<v-btn small class="ml-4" @click="getDataFromApi">
<v-icon small>$sockiSync</v-icon>
</v-btn>
</div>
<v-data-table
dense
:headers="advisoryHeaders"
:items="advisories"
class="elevation-1"
:items-per-page="50"
no-data-text="None"
data-cy="advisoriesTable"
>
<template v-slot:[`item.sourceId`]="{ item }">
<a :href="item.url" target="_blank">{{ item.sourceId }}</a>
</template>
</v-data-table>
</v-col>
</v-row>
</template>
<script>
const SEVERITIES = [
{ text: "Critical", value: 5 },
{ text: "High", value: 4 },
{ text: "Medium", value: 3 },
{ text: "Low", value: 2 },
{ text: "Negligible", value: 1 }
];
const OUTCOMES = ["No match", "Ignored", "Digest", "Alerted"];
let assetKey = 0;
let pollTimer = null;
export default {
data() {
return {
sources: [],
assets: [],
advisories: [],
processed: 0,
dryRun: { running: false, error: null, report: null },
dryRunDays: 30,
platforms: [
{ text: "Ubuntu 24.04 (noble)", value: "ubuntu:noble" },
{ text: "Ubuntu 26.04 (resolute)", value: "ubuntu:resolute" },
{ text: "PostgreSQL (PGDG)", value: "postgresql" }
],
severities: SEVERITIES,
sourceHeaders: [
{ text: "Source", value: "id" },
{ text: "Last success", value: "lastSuccess" },
{ text: "Last attempt", value: "lastAttempt" },
{ text: "Last error", value: "lastError" }
],
advisoryHeaders: [
{ text: "Published", value: "published" },
{ text: "Outcome", value: "outcome" },
{ text: "Advisory", value: "sourceId" },
{ text: "Severity", value: "severity" },
{ text: "Known exploited", value: "knownExploited" },
{ text: "Title", value: "title" },
{ text: "Affects", value: "matched" }
],
formState: {
ready: false,
loading: true,
errorBoxMessage: null,
appError: null,
serverError: {}
},
rights: window.$gz.role.defaultRightsObject(),
timeZoneName: window.$gz.locale.getResolvedTimeZoneName(),
languageName: window.$gz.locale.getResolvedLanguage(),
hour12: window.$gz.locale.getHour12()
};
},
computed: {
dryRunRows() {
if (!this.dryRun.report) {
return [];
}
return this.dryRun.report
.filter(z => !z.error && z.outcome != "NoMatch")
.map(z => this.advisoryRow(z));
}
},
async created() {
const vm = this;
try {
vm.rights = window.$gz.role.getRights(window.$gz.type.GlobalOps);
vm.formState.readOnly = !vm.rights.change;
vm.formState.ready = true;
generateMenu(vm);
await vm.getDataFromApi();
await vm.getDryRun();
} catch (err) {
vm.formState.ready = true;
window.$gz.errorHandler.handleFormError(err, vm);
}
},
beforeDestroy() {
clearTimeout(pollTimer);
},
methods: {
date(d) {
if (!d) {
return "";
}
return window.$gz.locale.utcDateToShortDateAndTimeLocalized(
d,
this.timeZoneName,
this.languageName,
this.hour12
);
},
advisoryRow(z) {
return {
published: this.date(z.published),
outcome: typeof z.outcome == "number" ? OUTCOMES[z.outcome] : z.outcome,
sourceId: z.sourceId,
url: z.url,
severity:
z.rawSeverity ||
(typeof z.severity == "number"
? (SEVERITIES.find(s => s.value == z.severity) || {}).text ||
"Unknown"
: z.severity),
knownExploited: z.knownExploited ? "YES" : "",
title: z.title,
matched: z.matched,
alreadyProcessed: z.alreadyProcessed ? "yes" : ""
};
},
countComponents(a) {
return (a.componentsText || "").split(/[\s,]+/).filter(z => z).length;
},
addAsset() {
this.assets.push({
key: assetKey++,
id: 0,
name: "",
platform: "ubuntu:resolute",
componentsText: "",
active: true,
alertAt: 4,
digestAt: 3
});
},
async saveAsset(a) {
const vm = this;
window.$gz.form.deleteAllErrorBoxErrors(vm);
try {
const res = await window.$gz.api.post("security-advisory/asset", {
id: a.id,
name: a.name,
platform: a.platform,
components: [a.componentsText || ""],
active: a.active,
alertAt: a.alertAt,
digestAt: a.digestAt
});
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
await vm.getDataFromApi();
}
} catch (ex) {
window.$gz.errorHandler.handleFormError(ex, vm);
}
},
async deleteAsset(a) {
const vm = this;
if (a.id == 0) {
vm.assets = vm.assets.filter(z => z.key != a.key);
return;
}
if ((await window.$gz.dialog.confirmDelete()) != true) {
return;
}
const res = await window.$gz.api.remove(
`security-advisory/asset/${a.id}`
);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
} else {
await vm.getDataFromApi();
}
},
async startDryRun() {
const vm = this;
window.$gz.form.deleteAllErrorBoxErrors(vm);
const res = await window.$gz.api.post(
`security-advisory/dry-run?days=${vm.dryRunDays || 30}`,
{}
);
if (res.error) {
vm.formState.serverError = res.error;
window.$gz.form.setErrorBoxErrors(vm);
return;
}
vm.dryRun = res.data;
vm.pollDryRun();
},
pollDryRun() {
const vm = this;
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await vm.getDryRun();
if (vm.dryRun.running) {
vm.pollDryRun();
}
}, 4000);
},
async getDryRun() {
const res = await window.$gz.api.get("security-advisory/dry-run");
if (!res.error) {
this.dryRun = res.data;
if (this.dryRun.running) {
this.pollDryRun();
}
}
},
async getDataFromApi() {
const vm = this;
vm.formState.loading = true;
window.$gz.form.deleteAllErrorBoxErrors(vm);
try {
const assets = await window.$gz.api.get("security-advisory/asset");
const status = await window.$gz.api.get("security-advisory/status");
if (assets.error || status.error) {
vm.formState.serverError = assets.error || status.error;
window.$gz.form.setErrorBoxErrors(vm);
return;
}
vm.assets = assets.data.map(a => ({
key: assetKey++,
id: a.id,
name: a.name,
platform: a.platform,
componentsText: (a.components || []).join("\n"),
active: a.active,
alertAt: a.alertAt,
digestAt: a.digestAt
}));
vm.sources = status.data.sources.map(s => ({
id: s.id,
lastSuccess: vm.date(s.lastSuccess),
lastAttempt: vm.date(s.lastAttempt),
lastError: s.lastError
}));
vm.advisories = status.data.advisories.map(z => vm.advisoryRow(z));
vm.processed = status.data.processed;
} catch (error) {
window.$gz.errorHandler.handleFormError(error, vm);
} finally {
vm.formState.loading = false;
}
}
}
};
function generateMenu(vm) {
window.$gz.eventBus.$emit("menu-change", {
isMain: true,
readOnly: vm.formState.readOnly,
icon: "$sockiShieldAlt",
formData: {
sockType: window.$gz.type.GlobalOps,
recordName: "Security advisories"
},
menuItems: []
});
}
</script>

View File

@@ -1,5 +1,9 @@
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
//case 4630: the year the client is built, for the copyright line (src/api/sockeye-version.js); VUE_APP_ variables
//are compiled into the client
process.env.VUE_APP_BUILD_YEAR = new Date().getFullYear().toString();
module.exports = {
//https://medium.com/@mrodal/how-to-make-lazy-loading-actually-work-in-vue-cli-3-7f3f88cfb102
chainWebpack: config => {