feat: add opt-in product usage and feedback integration (#1110)
This commit is contained in:
@@ -336,3 +336,9 @@ LOGS=./logs
|
|||||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||||
# should you change VITE_API_URL at build time.
|
# should you change VITE_API_URL at build time.
|
||||||
|
|
||||||
|
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||||
|
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||||
|
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||||
|
# Keep this value stable until participation has been deleted.
|
||||||
|
# USAGE_ENCRYPTION_KEY=
|
||||||
|
|||||||
@@ -120,3 +120,9 @@ ARCHIVE_PATH=/app/storage/events/archived
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||||
|
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||||
|
# Encryption material for the backend-only signing key (32+ characters).
|
||||||
|
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||||
|
# USAGE_ENCRYPTION_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const mockDb = require('knex')({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true
|
||||||
|
});
|
||||||
|
jest.mock('../../src/database/db', () => ({
|
||||||
|
get db() {
|
||||||
|
return mockDb;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||||
|
isTokenRevoked: jest.fn().mockResolvedValue(false)
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/sessionCutoff', () => ({
|
||||||
|
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false)
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/logger', () => ({
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn()
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/services/productUsageService', () =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[
|
||||||
|
'tick',
|
||||||
|
'status',
|
||||||
|
'dismiss',
|
||||||
|
'enable',
|
||||||
|
'disable',
|
||||||
|
'preview',
|
||||||
|
'export',
|
||||||
|
'preferences',
|
||||||
|
'command',
|
||||||
|
'markUsed'
|
||||||
|
].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const service = require('../../src/services/productUsageService');
|
||||||
|
const { productUsage } = require('../../src/middleware/productUsage');
|
||||||
|
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
|
||||||
|
const token = (type, id = 1) =>
|
||||||
|
jwt.sign({ type, id }, SECRET, {
|
||||||
|
issuer: 'picpeak-auth',
|
||||||
|
algorithm: 'HS256'
|
||||||
|
});
|
||||||
|
let app;
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.JWT_SECRET = SECRET;
|
||||||
|
await mockDb.schema.createTable('roles', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('name');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('admin_users', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('username');
|
||||||
|
t.string('email');
|
||||||
|
t.integer('role_id');
|
||||||
|
t.boolean('is_active');
|
||||||
|
t.timestamp('password_changed_at');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('permissions', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('name');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('role_permissions', (t) => {
|
||||||
|
t.integer('role_id');
|
||||||
|
t.integer('permission_id');
|
||||||
|
});
|
||||||
|
await mockDb('roles').insert([
|
||||||
|
{ id: 1, name: 'super_admin' },
|
||||||
|
{ id: 2, name: 'viewer' }
|
||||||
|
]);
|
||||||
|
await mockDb('admin_users').insert([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'owner',
|
||||||
|
email: '[email protected]',
|
||||||
|
role_id: 1,
|
||||||
|
is_active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'viewer',
|
||||||
|
email: '[email protected]',
|
||||||
|
role_id: 2,
|
||||||
|
is_active: 1
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await mockDb('permissions').insert({ id: 1, name: 'settings.edit' });
|
||||||
|
await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 });
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/usage', require('../../src/routes/adminUsage'));
|
||||||
|
app.use((err, _req, res, _next) =>
|
||||||
|
res.status(err.statusCode || 500).json({ code: err.code })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterAll(() => mockDb.destroy());
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
['get', '/'],
|
||||||
|
['post', '/activity'],
|
||||||
|
['post', '/enable'],
|
||||||
|
['post', '/disable'],
|
||||||
|
['post', '/retry'],
|
||||||
|
['post', '/dismiss'],
|
||||||
|
['get', '/preview'],
|
||||||
|
['get', '/export'],
|
||||||
|
['put', '/feedback-preferences'],
|
||||||
|
['post', '/feedback'],
|
||||||
|
['post', '/vote'],
|
||||||
|
['post', '/portal-session']
|
||||||
|
];
|
||||||
|
test.each(ROUTES)(
|
||||||
|
'%s %s rejects unauthenticated and gallery tokens',
|
||||||
|
async (method, route) => {
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401);
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`)
|
||||||
|
.set('Authorization', `Bearer ${token('gallery')}`)
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
expect(service.tick).not.toHaveBeenCalled();
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test.each(ROUTES.filter(([, route]) => route !== '/activity'))(
|
||||||
|
'%s %s requires settings.edit',
|
||||||
|
async (method, route) => {
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`)
|
||||||
|
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/admin/usage/activity')
|
||||||
|
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toEqual({ ok: true });
|
||||||
|
expect(service.tick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
test('owner sees no-store status and supplies consent to the service', async () => {
|
||||||
|
await request(app)
|
||||||
|
.get('/api/admin/usage')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.expect('Cache-Control', 'no-store')
|
||||||
|
.expect(200);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/enable')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ consent_version: 'usage-consent.v1' })
|
||||||
|
.expect(200);
|
||||||
|
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
|
||||||
|
});
|
||||||
|
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const simulate = (path, admin, statusCode) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
productUsage({ path, admin }, res, () => {});
|
||||||
|
res.emit('finish');
|
||||||
|
};
|
||||||
|
simulate('/gallery/example', null, 200);
|
||||||
|
simulate('/customers', null, 200);
|
||||||
|
simulate('/quotes', { id: 1 }, 403);
|
||||||
|
expect(service.markUsed).not.toHaveBeenCalled();
|
||||||
|
simulate('/customers/42/hour-entries', { id: 1 }, 200);
|
||||||
|
expect(service.markUsed).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining(['crm', 'crm_hours'])
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) {
|
||||||
|
await knex.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet');
|
||||||
|
t.text('last_packet');
|
||||||
|
t.text('last_receipt');
|
||||||
|
t.string('last_report_date', 10);
|
||||||
|
t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences');
|
||||||
|
t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await knex('product_usage_state').where({ id: 1 }).first()))
|
||||||
|
await knex('product_usage_state').insert({ id: 1 });
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_markers'))) {
|
||||||
|
await knex.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
// A marker is only a capability name, never a timestamp or user/event ID.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('product_usage_markers');
|
||||||
|
await knex.schema.dropTableIfExists('product_usage_state');
|
||||||
|
};
|
||||||
Generated
+86
-14
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.122.5-beta.0",
|
"version": "3.123.0-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.122.5-beta.0",
|
"version": "3.123.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.18.1",
|
"axios": "1.18.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -1611,6 +1612,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
"json-schema-traverse": "^0.4.1",
|
||||||
|
"uri-js": "^4.2.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||||
@@ -4006,16 +4031,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-uri": "^3.0.1",
|
||||||
"json-schema-traverse": "^0.4.1",
|
"json-schema-traverse": "^1.0.0",
|
||||||
"uri-js": "^4.2.2"
|
"require-from-string": "^2.0.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5848,6 +5872,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/ajv": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
"json-schema-traverse": "^0.4.1",
|
||||||
|
"uri-js": "^4.2.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esm": {
|
"node_modules/esm": {
|
||||||
"version": "3.2.25",
|
"version": "3.2.25",
|
||||||
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||||
@@ -6130,6 +6178,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.9",
|
"version": "1.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
||||||
@@ -8105,10 +8169,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
@@ -10805,6 +10868,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-main-filename": {
|
"node_modules/require-main-filename": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.18.1",
|
"axios": "1.18.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
|
|||||||
@@ -824,6 +824,8 @@ app.get(
|
|||||||
// Routes
|
// Routes
|
||||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/admin', require('./src/middleware/productUsage').productUsage);
|
||||||
|
app.use('/api/admin/usage', require('./src/routes/adminUsage'));
|
||||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||||
// Gallery routes - main routes first, then feedback routes
|
// Gallery routes - main routes first, then feedback routes
|
||||||
app.use('/api/gallery', galleryRoutes);
|
app.use('/api/gallery', galleryRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Only successful authenticated ADMIN capability operations set a coarse
|
||||||
|
// marker. Never mount on gallery/customer/public routes. No request values,
|
||||||
|
// identifiers, paths, timing, or counts are retained or sent.
|
||||||
|
const service = require('../services/productUsageService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const RULES = [
|
||||||
|
[/^\/customers(?:\/|$)/, ['crm']],
|
||||||
|
[/^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']],
|
||||||
|
[/^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']],
|
||||||
|
[/^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']],
|
||||||
|
[/^\/projects(?:\/|$)/, ['crm', 'crm_projects']],
|
||||||
|
[/^\/calendar(?:\/|$)/, ['crm', 'crm_calendar']],
|
||||||
|
[/^\/customers\/(?:[^/]+\/)?hour-entries(?:\/|$)/, ['crm', 'crm_hours']],
|
||||||
|
[/^\/customers\/(?:invite|[^/]+\/send-invite)(?:\/|$)/, ['customer_portal']],
|
||||||
|
[
|
||||||
|
/^\/(?:ledger|expenses|tax-report|incoming-invoices)(?:\/|$)/,
|
||||||
|
['accounting']
|
||||||
|
],
|
||||||
|
[/^\/workflows(?:\/|$)/, ['workflows']],
|
||||||
|
[/^\/newsletters(?:\/|$)/, ['newsletters']],
|
||||||
|
[/^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']],
|
||||||
|
[/^\/whatsapp\/(?:send|test)(?:\/|$)/, ['whatsapp']],
|
||||||
|
[
|
||||||
|
/^\/(?:backup|database-backup)\/(?:run|backup|create|start|test|picpeak\/export)(?:\/|$)/,
|
||||||
|
['backup']
|
||||||
|
],
|
||||||
|
[/^\/backup\/s3\/test-upload(?:\/|$)/, ['s3_storage']],
|
||||||
|
[/^\/email\/(?:test|send)(?:\/|$)/, ['smtp']],
|
||||||
|
[/^\/external-media(?:\/|$)/, ['share_mounts']]
|
||||||
|
];
|
||||||
|
function productUsage(req, res, next) {
|
||||||
|
const pathname = req.path;
|
||||||
|
res.once('finish', () => {
|
||||||
|
if (!req.admin?.id || res.statusCode < 200 || res.statusCode >= 300) return;
|
||||||
|
const features = RULES.filter(([pattern]) =>
|
||||||
|
pattern.test(pathname)
|
||||||
|
).flatMap(([, keys]) => keys);
|
||||||
|
if (
|
||||||
|
process.env.STORAGE_BACKEND === 's3' &&
|
||||||
|
/^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname)
|
||||||
|
)
|
||||||
|
features.push('s3_storage');
|
||||||
|
if (features.length)
|
||||||
|
service
|
||||||
|
.markUsed(features)
|
||||||
|
.catch(() => logger.warn('Product usage marker could not be recorded'));
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
module.exports = { productUsage, RULES };
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { ValidationError } = require('../utils/errors');
|
||||||
|
const service = require('../services/productUsageService');
|
||||||
|
const router = express.Router();
|
||||||
|
const wrap = (fn) => (req, res, next) =>
|
||||||
|
Promise.resolve(fn(req, res)).catch((error) => {
|
||||||
|
if (error.name === 'ProtocolError')
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid usage request', code: error.code });
|
||||||
|
next(error);
|
||||||
|
});
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use((_req, res, next) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
// Any authenticated admin can trigger the daily rollup; only settings editors
|
||||||
|
// see identity/packets or control consent. The route never accepts telemetry.
|
||||||
|
router.post(
|
||||||
|
'/activity',
|
||||||
|
wrap(async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await service.tick();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'CONFLICT') throw error;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.use(requirePermission('settings.edit'));
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrap(async (_req, res) => res.json(await service.status()))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/dismiss',
|
||||||
|
wrap(async (_req, res) => res.json(await service.dismiss()))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/enable',
|
||||||
|
wrap(async (req, res) =>
|
||||||
|
res.json(await service.enable(req.body.consent_version))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/disable',
|
||||||
|
wrap(async (_req, res) => res.json(await service.disable()))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/retry',
|
||||||
|
wrap(async (_req, res) => res.json(await service.tick()))
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/preview',
|
||||||
|
wrap(async (_req, res) => res.json(await service.preview()))
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/export',
|
||||||
|
wrap(async (_req, res) =>
|
||||||
|
res.attachment('picpeak-usage-packets.json').json(await service.export())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
'/feedback-preferences',
|
||||||
|
wrap(async (req, res) => res.json(await service.preferences(req.body)))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/feedback',
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const body = req.body;
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
Object.keys(body).some(
|
||||||
|
(k) =>
|
||||||
|
![
|
||||||
|
'kind',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'name',
|
||||||
|
'allow_public',
|
||||||
|
'allow_marketing'
|
||||||
|
].includes(k)
|
||||||
|
) ||
|
||||||
|
typeof body.title !== 'string' ||
|
||||||
|
!body.title.trim() ||
|
||||||
|
typeof body.body !== 'string' ||
|
||||||
|
!body.body.trim()
|
||||||
|
)
|
||||||
|
throw new ValidationError('Invalid feedback');
|
||||||
|
res.json(
|
||||||
|
await service.command('feedback', {
|
||||||
|
...body,
|
||||||
|
feedback_id: crypto.randomUUID()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/vote',
|
||||||
|
wrap(async (req, res) => res.json(await service.command('vote', req.body)))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/portal-session',
|
||||||
|
wrap(async (_req, res) => {
|
||||||
|
const result = await service.command('session', {});
|
||||||
|
res.json({
|
||||||
|
...result,
|
||||||
|
url: result.receipt?.session_token
|
||||||
|
? `${service.collectorUrl()}/#connect=${encodeURIComponent(result.receipt.session_token)}`
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
module.exports = router;
|
||||||
@@ -1145,6 +1145,10 @@ router.get('/admin/sso/callback', async (req, res) => {
|
|||||||
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
|
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
|
||||||
type: 'admin', id: admin.id, name: admin.username,
|
type: 'admin', id: admin.id, name: admin.username,
|
||||||
});
|
});
|
||||||
|
// Only a successful ADMIN SSO callback sets this opt-in capability marker.
|
||||||
|
// No token, claim, address, or account identifier reaches product usage.
|
||||||
|
require('../services/productUsageService').markUsed(['oauth'])
|
||||||
|
.catch(() => logger.warn('Product usage marker could not be recorded'));
|
||||||
|
|
||||||
return res.redirect(`${frontendBase}/admin/dashboard`);
|
return res.redirect(`${frontendBase}/admin/dashboard`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const { UsageService } = require('../usage/UsageService');
|
||||||
|
module.exports = new UsageService(db);
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
'use strict';
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const { getStoragePath } = require('../config/storage');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const {
|
||||||
|
ConflictError,
|
||||||
|
ValidationError,
|
||||||
|
ServiceUnavailableError
|
||||||
|
} = require('../utils/errors');
|
||||||
|
const {
|
||||||
|
generateIdentity,
|
||||||
|
makePacket,
|
||||||
|
signPacket,
|
||||||
|
verifyEnvelope,
|
||||||
|
digest,
|
||||||
|
canonical,
|
||||||
|
FEATURE_KEYS,
|
||||||
|
LAYOUTS
|
||||||
|
} = require('./protocol.cjs');
|
||||||
|
|
||||||
|
const FLAG_MAP = {
|
||||||
|
crm: 'clients',
|
||||||
|
crm_quotes: 'quotes',
|
||||||
|
crm_invoices: 'bills',
|
||||||
|
crm_contracts: 'contracts',
|
||||||
|
crm_projects: 'projects',
|
||||||
|
crm_calendar: 'calendar',
|
||||||
|
crm_hours: 'hoursLogging',
|
||||||
|
customer_portal: 'customerPortal',
|
||||||
|
accounting: 'accounting',
|
||||||
|
workflows: 'workflows',
|
||||||
|
newsletters: 'newsletters',
|
||||||
|
face_recognition: 'faces',
|
||||||
|
whatsapp: 'whatsapp'
|
||||||
|
};
|
||||||
|
const SETTING_KEYS = [
|
||||||
|
'oidc_enabled',
|
||||||
|
'oidc_issuer_url',
|
||||||
|
'oidc_client_id',
|
||||||
|
'backup_enabled',
|
||||||
|
'backup_destination_type',
|
||||||
|
'backup_s3_bucket',
|
||||||
|
'theme_config',
|
||||||
|
'general_custom_css',
|
||||||
|
'general_public_site_custom_css'
|
||||||
|
];
|
||||||
|
const truth = (value) => value === true || value === 1 || value === '1';
|
||||||
|
const parse = (value) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (_) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class UsageService {
|
||||||
|
constructor(db, options = {}) {
|
||||||
|
this.db = db;
|
||||||
|
this.fetch = options.fetch || global.fetch;
|
||||||
|
this.now = options.now || (() => Date.now());
|
||||||
|
this.version = options.version || require('../../package.json').version;
|
||||||
|
this.secret =
|
||||||
|
options.secret ||
|
||||||
|
process.env.USAGE_ENCRYPTION_KEY ||
|
||||||
|
process.env.JWT_SECRET;
|
||||||
|
this.endpoint =
|
||||||
|
options.endpoint ||
|
||||||
|
process.env.USAGE_COLLECTOR_URL ||
|
||||||
|
'https://usage.picpeak.app';
|
||||||
|
this.bindingPath =
|
||||||
|
options.bindingPath || path.join(getStoragePath(), 'usage-instance.key');
|
||||||
|
this.encKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
collectorUrl() {
|
||||||
|
const url = new URL(this.endpoint);
|
||||||
|
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
||||||
|
if (
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
(url.protocol !== 'https:' &&
|
||||||
|
!(
|
||||||
|
url.protocol === 'http:' &&
|
||||||
|
loopback &&
|
||||||
|
process.env.NODE_ENV !== 'production'
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new ValidationError('Invalid usage collector URL');
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
key() {
|
||||||
|
if (!this.secret || this.secret.length < 32)
|
||||||
|
throw new ServiceUnavailableError(
|
||||||
|
'Usage signing-key encryption is not configured'
|
||||||
|
);
|
||||||
|
if (!this.encKey)
|
||||||
|
this.encKey = crypto.scryptSync(
|
||||||
|
this.secret,
|
||||||
|
'picpeak-product-usage-v1',
|
||||||
|
32
|
||||||
|
);
|
||||||
|
return this.encKey;
|
||||||
|
}
|
||||||
|
encrypt(value) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', this.key(), iv);
|
||||||
|
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||||
|
return [iv, cipher.getAuthTag(), data]
|
||||||
|
.map((v) => v.toString('base64url'))
|
||||||
|
.join('.');
|
||||||
|
}
|
||||||
|
decrypt(value) {
|
||||||
|
const [iv, tag, data] = value
|
||||||
|
.split('.')
|
||||||
|
.map((v) => Buffer.from(v, 'base64url'));
|
||||||
|
const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv);
|
||||||
|
cipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([cipher.update(data), cipher.final()]).toString(
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async binding(create = false) {
|
||||||
|
if (create) {
|
||||||
|
await fs.mkdir(path.dirname(this.bindingPath), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
this.bindingPath,
|
||||||
|
crypto.randomBytes(32).toString('hex'),
|
||||||
|
{ flag: 'wx', mode: 0o600 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EEXIST') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return digest(await fs.readFile(this.bindingPath));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async state() {
|
||||||
|
return this.db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
}
|
||||||
|
async status() {
|
||||||
|
const state = await this.state();
|
||||||
|
return {
|
||||||
|
status: state.status,
|
||||||
|
notice_dismissed: Boolean(state.notice_dismissed),
|
||||||
|
installation_id: state.installation_id,
|
||||||
|
collector_url: this.collectorUrl(),
|
||||||
|
schema_version: 'usage.v1',
|
||||||
|
last_report_date: state.last_report_date,
|
||||||
|
last_error: state.last_error,
|
||||||
|
pending_action: state.pending_packet
|
||||||
|
? JSON.parse(state.pending_packet).action
|
||||||
|
: null,
|
||||||
|
last_packet: state.last_packet ? JSON.parse(state.last_packet) : null,
|
||||||
|
feedback_preferences: state.feedback_preferences
|
||||||
|
? JSON.parse(state.feedback_preferences)
|
||||||
|
: { name: '' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async locked(fn) {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const updated = await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.where('lease_until', '<=', this.now())
|
||||||
|
.update({ lease_token: token, lease_until: this.now() + 60000 });
|
||||||
|
if (!updated)
|
||||||
|
throw new ConflictError('Usage operation is already in progress');
|
||||||
|
try {
|
||||||
|
return await fn(await this.state());
|
||||||
|
} finally {
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1, lease_token: token })
|
||||||
|
.update({ lease_token: null, lease_until: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss() {
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ notice_dismissed: formatBoolean(true) });
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
async enable(consent) {
|
||||||
|
if (consent !== 'usage-consent.v1')
|
||||||
|
throw new ValidationError('Explicit usage consent is required');
|
||||||
|
await this.locked(async (state) => {
|
||||||
|
if (state.status !== 'disabled')
|
||||||
|
throw new ConflictError(
|
||||||
|
'Finish the current participation before rejoining'
|
||||||
|
);
|
||||||
|
this.collectorUrl();
|
||||||
|
const identity = generateIdentity();
|
||||||
|
const pending = makePacket(identity, 'register', 0, {
|
||||||
|
consent_version: consent
|
||||||
|
});
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({
|
||||||
|
status: 'activation_pending',
|
||||||
|
notice_dismissed: formatBoolean(true),
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: this.encrypt(identity.private_key),
|
||||||
|
instance_binding: await this.binding(true),
|
||||||
|
sequence: 0,
|
||||||
|
pending_packet: JSON.stringify(pending),
|
||||||
|
last_error: null
|
||||||
|
});
|
||||||
|
await this.deliver(await this.state());
|
||||||
|
});
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disable() {
|
||||||
|
// Stop collection before waiting for an in-flight send. The sender checks
|
||||||
|
// state again before delivery and preserves this stop after its response.
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.whereNot({ status: 'disabled' })
|
||||||
|
.update({
|
||||||
|
status: 'deletion_pending',
|
||||||
|
feedback_preferences: null,
|
||||||
|
pending_packet: null,
|
||||||
|
last_packet: null,
|
||||||
|
last_receipt: null,
|
||||||
|
last_report_date: null
|
||||||
|
});
|
||||||
|
await this.db('product_usage_markers').delete();
|
||||||
|
try {
|
||||||
|
await this.tick();
|
||||||
|
} catch (error) {
|
||||||
|
// A sender may still own the lease. Collection is already stopped and
|
||||||
|
// the next admin activity retries deletion after that sender finishes.
|
||||||
|
if (error.code !== 'CONFLICT') throw error;
|
||||||
|
}
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(pathname, body, maxResponseBytes = 65536) {
|
||||||
|
const response = await this.fetch(`${this.collectorUrl()}${pathname}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
if (Number(response.headers.get('content-length') || 0) > maxResponseBytes)
|
||||||
|
throw new ServiceUnavailableError('Invalid collector response');
|
||||||
|
let raw = '';
|
||||||
|
let bytes = 0;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for await (const chunk of response.body) {
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > maxResponseBytes)
|
||||||
|
throw new ServiceUnavailableError('Invalid collector response');
|
||||||
|
raw += decoder.decode(chunk, { stream: true });
|
||||||
|
}
|
||||||
|
raw += decoder.decode();
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error('Collector rejected operation');
|
||||||
|
error.code = value.error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async deliver(state) {
|
||||||
|
const packet = JSON.parse(state.pending_packet);
|
||||||
|
if (
|
||||||
|
packet.action !== 'delete' &&
|
||||||
|
(await this.state()).status === 'deletion_pending'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
packet.action !== 'delete' &&
|
||||||
|
state.instance_binding !== (await this.binding())
|
||||||
|
) {
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({
|
||||||
|
status: 'identity_conflict',
|
||||||
|
last_error: 'INSTANCE_COPY_DETECTED'
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const envelope = signPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
public_key: state.public_key,
|
||||||
|
private_key: this.decrypt(state.private_key_encrypted)
|
||||||
|
},
|
||||||
|
new Date(this.now())
|
||||||
|
);
|
||||||
|
const receipt = await this.post('/api/envelopes', envelope);
|
||||||
|
if (
|
||||||
|
receipt.packet_id !== packet.packet_id ||
|
||||||
|
receipt.installation_id !== packet.installation_id ||
|
||||||
|
receipt.packet_digest !== digest(canonical(packet)) ||
|
||||||
|
receipt.action !== packet.action ||
|
||||||
|
receipt.sequence !== packet.sequence ||
|
||||||
|
receipt.status !== (packet.action === 'delete' ? 'deleted' : 'accepted')
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid collector receipt');
|
||||||
|
}
|
||||||
|
if (packet.action === 'delete') {
|
||||||
|
await fs.unlink(this.bindingPath).catch((error) => {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
});
|
||||||
|
await this.db('product_usage_markers').delete();
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({
|
||||||
|
status: 'disabled',
|
||||||
|
installation_id: null,
|
||||||
|
public_key: null,
|
||||||
|
private_key_encrypted: null,
|
||||||
|
instance_binding: null,
|
||||||
|
pending_packet: null,
|
||||||
|
last_packet: null,
|
||||||
|
last_receipt: null,
|
||||||
|
last_report_date: null,
|
||||||
|
last_error: null,
|
||||||
|
sequence: 0,
|
||||||
|
feedback_preferences: null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const update = {
|
||||||
|
sequence: packet.sequence,
|
||||||
|
pending_packet: null,
|
||||||
|
last_error: null,
|
||||||
|
last_receipt: JSON.stringify(receipt)
|
||||||
|
};
|
||||||
|
if (packet.action === 'report') {
|
||||||
|
update.last_packet = JSON.stringify(envelope);
|
||||||
|
update.last_report_date = packet.payload.report_date;
|
||||||
|
}
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.whereNot({ status: 'deletion_pending' })
|
||||||
|
.update(update);
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1, status: 'deletion_pending' })
|
||||||
|
.update({ sequence: packet.sequence, pending_packet: null });
|
||||||
|
if (packet.action === 'register')
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1, status: 'activation_pending' })
|
||||||
|
.update({ status: 'active' });
|
||||||
|
}
|
||||||
|
return receipt;
|
||||||
|
} catch (error) {
|
||||||
|
const rejected = [
|
||||||
|
'INVALID_PACKET',
|
||||||
|
'INVALID_REPORT_DATE',
|
||||||
|
'INVALID_PUBLICATION_CONSENT',
|
||||||
|
'REQUEST_NOT_FOUND',
|
||||||
|
'FEEDBACK_CONFLICT'
|
||||||
|
].includes(error.code);
|
||||||
|
if (rejected && ['feedback', 'vote', 'session'].includes(packet.action)) {
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.whereNot({ status: 'deletion_pending' })
|
||||||
|
.update({ pending_packet: null, last_error: 'REQUEST_REJECTED' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const conflict = [
|
||||||
|
'SEQUENCE_CONFLICT',
|
||||||
|
'IDENTITY_CONFLICT',
|
||||||
|
'IDENTITY_REVOKED',
|
||||||
|
'NOT_REGISTERED',
|
||||||
|
'PACKET_CONFLICT'
|
||||||
|
].includes(error.code);
|
||||||
|
const code = conflict ? error.code : 'DELIVERY_FAILED';
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ last_error: code });
|
||||||
|
if (conflict && packet.action !== 'delete') {
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.whereNot({ status: 'deletion_pending' })
|
||||||
|
.update({ status: 'identity_conflict' });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick() {
|
||||||
|
await this.locked(async (state) => {
|
||||||
|
if (state.status === 'disabled') return;
|
||||||
|
if (state.status === 'deletion_pending') {
|
||||||
|
const packet = makePacket(state, 'delete', Number(state.sequence), {});
|
||||||
|
state.pending_packet = JSON.stringify(packet);
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ pending_packet: state.pending_packet });
|
||||||
|
await this.deliver(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status === 'identity_conflict') return;
|
||||||
|
if (state.pending_packet) {
|
||||||
|
await this.deliver(state);
|
||||||
|
state = await this.state();
|
||||||
|
}
|
||||||
|
if (state.status !== 'active' || state.pending_packet) return;
|
||||||
|
if (
|
||||||
|
state.last_report_date ===
|
||||||
|
new Date(this.now()).toISOString().slice(0, 10)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const payload = await this.snapshot();
|
||||||
|
const packet = makePacket(
|
||||||
|
state,
|
||||||
|
'report',
|
||||||
|
Number(state.sequence) + 1,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
state.pending_packet = JSON.stringify(packet);
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ pending_packet: state.pending_packet });
|
||||||
|
await this.deliver(state);
|
||||||
|
});
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markUsed(features) {
|
||||||
|
const allowed = [...new Set(features)].filter((f) =>
|
||||||
|
FEATURE_KEYS.includes(f)
|
||||||
|
);
|
||||||
|
if (!allowed.length) return;
|
||||||
|
// Single-transaction status check prevents opt-out racing a late marker.
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
const query = tx('product_usage_state').where({ id: 1 });
|
||||||
|
if (this.db.client.config.client === 'pg') query.forUpdate();
|
||||||
|
const state = await query.first();
|
||||||
|
if (!state || state.status !== 'active') return;
|
||||||
|
if (allowed.includes('backup')) {
|
||||||
|
const destination = await tx('app_settings')
|
||||||
|
.where({ setting_key: 'backup_destination_type' })
|
||||||
|
.first();
|
||||||
|
if (destination && parse(destination.setting_value) === 's3')
|
||||||
|
allowed.push('s3_storage');
|
||||||
|
}
|
||||||
|
await tx('product_usage_markers')
|
||||||
|
.insert(allowed.map((feature) => ({ feature })))
|
||||||
|
.onConflict('feature')
|
||||||
|
.ignore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot() {
|
||||||
|
const rows = await this.db('app_settings')
|
||||||
|
.whereIn('setting_key', SETTING_KEYS)
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
const settings = Object.fromEntries(
|
||||||
|
rows.map((r) => [r.setting_key, parse(r.setting_value)])
|
||||||
|
);
|
||||||
|
const flagRows = await this.db('feature_flags')
|
||||||
|
.whereIn('key', Object.values(FLAG_MAP))
|
||||||
|
.select('key', 'value');
|
||||||
|
const flags = Object.fromEntries(
|
||||||
|
flagRows.map((r) => [r.key, truth(r.value)])
|
||||||
|
);
|
||||||
|
const used = new Set(
|
||||||
|
await this.db('product_usage_markers').pluck('feature')
|
||||||
|
);
|
||||||
|
const features = Object.fromEntries(
|
||||||
|
FEATURE_KEYS.map((key) => [
|
||||||
|
key,
|
||||||
|
{ configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
features.oauth.configured =
|
||||||
|
truth(settings.oidc_enabled) &&
|
||||||
|
Boolean(settings.oidc_issuer_url && settings.oidc_client_id);
|
||||||
|
features.backup.configured = truth(settings.backup_enabled);
|
||||||
|
features.s3_storage.configured =
|
||||||
|
(settings.backup_destination_type === 's3' &&
|
||||||
|
Boolean(settings.backup_s3_bucket)) ||
|
||||||
|
(process.env.STORAGE_BACKEND === 's3' &&
|
||||||
|
Boolean(
|
||||||
|
process.env.STORAGE_S3_BUCKET &&
|
||||||
|
process.env.STORAGE_S3_ACCESS_KEY &&
|
||||||
|
process.env.STORAGE_S3_SECRET_KEY
|
||||||
|
));
|
||||||
|
features.share_mounts.configured = Boolean(
|
||||||
|
await this.db('events')
|
||||||
|
.whereNotNull('external_path')
|
||||||
|
.whereNot('external_path', '')
|
||||||
|
.select('id')
|
||||||
|
.first()
|
||||||
|
);
|
||||||
|
features.smtp.configured =
|
||||||
|
Boolean(
|
||||||
|
await this.db('email_configs')
|
||||||
|
.whereNotNull('smtp_host')
|
||||||
|
.whereNot('smtp_host', '')
|
||||||
|
.select('id')
|
||||||
|
.first()
|
||||||
|
) ||
|
||||||
|
Boolean(
|
||||||
|
await this.db('mail_accounts')
|
||||||
|
.whereNotNull('smtp_host')
|
||||||
|
.whereNot('smtp_host', '')
|
||||||
|
.select('id')
|
||||||
|
.first()
|
||||||
|
);
|
||||||
|
features.whatsapp.configured =
|
||||||
|
features.whatsapp.configured &&
|
||||||
|
Boolean(
|
||||||
|
await this.db('whatsapp_configs')
|
||||||
|
.where({ enabled: formatBoolean(true) })
|
||||||
|
.whereNot('phone_number_id', '')
|
||||||
|
.whereNot('access_token', '')
|
||||||
|
.select('id')
|
||||||
|
.first()
|
||||||
|
);
|
||||||
|
const theme = settings.theme_config || {};
|
||||||
|
features.custom_css.configured = Boolean(
|
||||||
|
settings.general_custom_css ||
|
||||||
|
settings.general_public_site_custom_css ||
|
||||||
|
theme.customCss
|
||||||
|
);
|
||||||
|
// Read only the theme field, never event names, IDs, sizes, counts, or photos.
|
||||||
|
const themes = await this.db('events').distinct('color_theme');
|
||||||
|
const layouts = new Set();
|
||||||
|
for (const row of themes) {
|
||||||
|
const value = parse(row.color_theme);
|
||||||
|
const layout =
|
||||||
|
value && typeof value === 'object'
|
||||||
|
? value.galleryLayout || 'grid'
|
||||||
|
: 'grid';
|
||||||
|
layouts.add(LAYOUTS.includes(layout) ? layout : 'other');
|
||||||
|
if (value && typeof value === 'object' && value.customCss)
|
||||||
|
features.custom_css.configured = true;
|
||||||
|
}
|
||||||
|
// Applied CSS is already a capability in use; no visitor observation is
|
||||||
|
// needed. Remember its presence as a coarse lifetime marker after consent.
|
||||||
|
if (features.custom_css.configured) {
|
||||||
|
await this.markUsed(['custom_css']);
|
||||||
|
features.custom_css.used = true;
|
||||||
|
}
|
||||||
|
const now = new Date(this.now()).toISOString();
|
||||||
|
return {
|
||||||
|
picpeak_version: this.version,
|
||||||
|
report_date: now.slice(0, 10),
|
||||||
|
generated_at: now,
|
||||||
|
features,
|
||||||
|
gallery_layouts: [...layouts].sort()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async preview() {
|
||||||
|
const state = await this.state();
|
||||||
|
if (state.status !== 'active')
|
||||||
|
throw new ConflictError('Usage participation is not active');
|
||||||
|
return this.snapshot();
|
||||||
|
}
|
||||||
|
async command(action, payload) {
|
||||||
|
let receipt;
|
||||||
|
await this.locked(async (state) => {
|
||||||
|
if (state.status !== 'active')
|
||||||
|
throw new ConflictError('Usage participation is not active');
|
||||||
|
if (state.pending_packet)
|
||||||
|
throw new ConflictError('Retry the pending usage operation first');
|
||||||
|
if (!['feedback', 'vote', 'session'].includes(action))
|
||||||
|
throw new ValidationError('Invalid usage action');
|
||||||
|
const packet = makePacket(
|
||||||
|
state,
|
||||||
|
action,
|
||||||
|
Number(state.sequence) + 1,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
// Validate the complete packet before storing an un-sendable operation.
|
||||||
|
verifyEnvelope(
|
||||||
|
signPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
public_key: state.public_key,
|
||||||
|
private_key: this.decrypt(state.private_key_encrypted)
|
||||||
|
},
|
||||||
|
new Date(this.now())
|
||||||
|
),
|
||||||
|
this.now()
|
||||||
|
);
|
||||||
|
state.pending_packet = JSON.stringify(packet);
|
||||||
|
await this.db('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ pending_packet: state.pending_packet });
|
||||||
|
receipt = await this.deliver(state);
|
||||||
|
});
|
||||||
|
const state = await this.status();
|
||||||
|
return {
|
||||||
|
delivered: Boolean(receipt),
|
||||||
|
queued: Boolean(state.pending_action),
|
||||||
|
receipt,
|
||||||
|
state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async preferences(value) {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
Object.keys(value).some((k) => k !== 'name') ||
|
||||||
|
typeof value.name !== 'string' ||
|
||||||
|
value.name.length > 80
|
||||||
|
)
|
||||||
|
throw new ValidationError('Invalid feedback preferences');
|
||||||
|
const updated = await this.db('product_usage_state')
|
||||||
|
.where({ id: 1, status: 'active' })
|
||||||
|
.update({
|
||||||
|
feedback_preferences: JSON.stringify({ name: value.name.trim() })
|
||||||
|
});
|
||||||
|
if (!updated) throw new ConflictError('Usage participation is not active');
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
async export() {
|
||||||
|
const state = await this.state();
|
||||||
|
if (!state.installation_id) throw new ConflictError('No usage identity');
|
||||||
|
// Own-data export includes the complete retained history, not a truncated
|
||||||
|
// packet subset. The acceptance/receipt path above stays strictly bounded.
|
||||||
|
return this.post(
|
||||||
|
'/api/participant/lookup',
|
||||||
|
{ installation_id: state.installation_id },
|
||||||
|
Infinity
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = { UsageService, FLAG_MAP };
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use strict";
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const Ajv = require("ajv");
|
||||||
|
const {
|
||||||
|
envelopeSchema,
|
||||||
|
FEATURE_KEYS,
|
||||||
|
LAYOUTS,
|
||||||
|
payloads,
|
||||||
|
} = require("./schema.cjs");
|
||||||
|
const validate = new Ajv({ allErrors: false, strict: true }).compile(
|
||||||
|
envelopeSchema,
|
||||||
|
);
|
||||||
|
const MAX_BYTES = 16384;
|
||||||
|
const MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
class ProtocolError extends Error {
|
||||||
|
constructor(code, status = 400) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function canonical(value) {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
const digest = (value) =>
|
||||||
|
crypto.createHash("sha256").update(value).digest("hex");
|
||||||
|
function publicKeyIdentity(publicKey) {
|
||||||
|
const bytes = Buffer.from(publicKey, "base64url");
|
||||||
|
if (bytes.toString("base64url") !== publicKey || bytes.length !== 44)
|
||||||
|
throw new ProtocolError("INVALID_KEY");
|
||||||
|
const key = crypto.createPublicKey({
|
||||||
|
key: bytes,
|
||||||
|
format: "der",
|
||||||
|
type: "spki",
|
||||||
|
});
|
||||||
|
if (key.asymmetricKeyType !== "ed25519")
|
||||||
|
throw new ProtocolError("INVALID_KEY");
|
||||||
|
return { key, id: digest(bytes) };
|
||||||
|
}
|
||||||
|
function generateIdentity() {
|
||||||
|
const keys = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const public_key = keys.publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.toString("base64url");
|
||||||
|
return {
|
||||||
|
installation_id: publicKeyIdentity(public_key).id,
|
||||||
|
public_key,
|
||||||
|
private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePacket(identity, action, sequence, payload) {
|
||||||
|
return {
|
||||||
|
schema_version: "usage.v1",
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
packet_id: crypto.randomUUID(),
|
||||||
|
action,
|
||||||
|
sequence,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function signPacket(packet, identity, now = new Date()) {
|
||||||
|
const signed = {
|
||||||
|
packet,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
issued_at: now.toISOString(),
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
const signature = crypto
|
||||||
|
.sign(null, Buffer.from(canonical(signed)), identity.private_key)
|
||||||
|
.toString("base64url");
|
||||||
|
const envelope = { ...signed, signature };
|
||||||
|
if (
|
||||||
|
!validate(envelope) ||
|
||||||
|
Buffer.byteLength(JSON.stringify(envelope)) > MAX_BYTES
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_PACKET");
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
function verifyEnvelope(envelope, now = Date.now()) {
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
|
||||||
|
!validate(envelope)
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_PACKET");
|
||||||
|
const issued = Date.parse(envelope.issued_at);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(issued) ||
|
||||||
|
new Date(issued).toISOString() !== envelope.issued_at ||
|
||||||
|
Math.abs(now - issued) > MAX_AGE_MS
|
||||||
|
) {
|
||||||
|
throw new ProtocolError("EXPIRED_SIGNATURE", 401);
|
||||||
|
}
|
||||||
|
let identity;
|
||||||
|
try {
|
||||||
|
identity = publicKeyIdentity(envelope.public_key);
|
||||||
|
} catch (_) {
|
||||||
|
throw new ProtocolError("INVALID_KEY", 401);
|
||||||
|
}
|
||||||
|
if (identity.id !== envelope.packet.installation_id)
|
||||||
|
throw new ProtocolError("IDENTITY_MISMATCH", 401);
|
||||||
|
const { signature, ...signed } = envelope;
|
||||||
|
const signatureBytes = Buffer.from(signature, "base64url");
|
||||||
|
if (
|
||||||
|
signatureBytes.toString("base64url") !== signature ||
|
||||||
|
!crypto.verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(canonical(signed)),
|
||||||
|
identity.key,
|
||||||
|
signatureBytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_SIGNATURE", 401);
|
||||||
|
const { action, payload } = envelope.packet;
|
||||||
|
if (action === "report") {
|
||||||
|
const generated = Date.parse(payload.generated_at);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(generated) ||
|
||||||
|
new Date(generated).toISOString() !== payload.generated_at ||
|
||||||
|
payload.report_date !== payload.generated_at.slice(0, 10) ||
|
||||||
|
generated > now + MAX_AGE_MS
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_REPORT_DATE");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
action === "feedback" &&
|
||||||
|
payload.allow_marketing &&
|
||||||
|
(payload.kind !== "testimonial" || !payload.allow_public)
|
||||||
|
) {
|
||||||
|
throw new ProtocolError("INVALID_PUBLICATION_CONSENT");
|
||||||
|
}
|
||||||
|
return envelope.packet;
|
||||||
|
}
|
||||||
|
module.exports = {
|
||||||
|
canonical,
|
||||||
|
digest,
|
||||||
|
generateIdentity,
|
||||||
|
makePacket,
|
||||||
|
signPacket,
|
||||||
|
verifyEnvelope,
|
||||||
|
ProtocolError,
|
||||||
|
MAX_BYTES,
|
||||||
|
MAX_AGE_MS,
|
||||||
|
FEATURE_KEYS,
|
||||||
|
LAYOUTS,
|
||||||
|
envelopeSchema,
|
||||||
|
payloads,
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Vendored unchanged in PicPeak. Changing the wire contract requires a new
|
||||||
|
// schema version and matching conformance tests in both repositories.
|
||||||
|
const FEATURE_KEYS = [
|
||||||
|
"crm",
|
||||||
|
"crm_quotes",
|
||||||
|
"crm_invoices",
|
||||||
|
"crm_contracts",
|
||||||
|
"crm_projects",
|
||||||
|
"crm_calendar",
|
||||||
|
"crm_hours",
|
||||||
|
"customer_portal",
|
||||||
|
"accounting",
|
||||||
|
"workflows",
|
||||||
|
"newsletters",
|
||||||
|
"face_recognition",
|
||||||
|
"custom_css",
|
||||||
|
"oauth",
|
||||||
|
"smtp",
|
||||||
|
"whatsapp",
|
||||||
|
"backup",
|
||||||
|
"s3_storage",
|
||||||
|
"share_mounts",
|
||||||
|
];
|
||||||
|
const LAYOUTS = [
|
||||||
|
"grid",
|
||||||
|
"masonry",
|
||||||
|
"carousel",
|
||||||
|
"timeline",
|
||||||
|
"mosaic",
|
||||||
|
"gallery-premium",
|
||||||
|
"gallery-story",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
const object = (properties, required = Object.keys(properties)) => ({
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
});
|
||||||
|
const uuid = {
|
||||||
|
type: "string",
|
||||||
|
pattern:
|
||||||
|
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
|
};
|
||||||
|
const hash = { type: "string", pattern: "^[0-9a-f]{64}$" };
|
||||||
|
const timestamp = {
|
||||||
|
type: "string",
|
||||||
|
pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
|
||||||
|
};
|
||||||
|
const text = (maxLength, minLength = 1) => ({
|
||||||
|
type: "string",
|
||||||
|
minLength,
|
||||||
|
maxLength,
|
||||||
|
});
|
||||||
|
const boolean = { type: "boolean" };
|
||||||
|
const features = object(
|
||||||
|
Object.fromEntries(
|
||||||
|
FEATURE_KEYS.map((key) => [
|
||||||
|
key,
|
||||||
|
object({ configured: boolean, used: boolean }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const report = object({
|
||||||
|
picpeak_version: {
|
||||||
|
type: "string",
|
||||||
|
maxLength: 48,
|
||||||
|
pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$",
|
||||||
|
},
|
||||||
|
report_date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
|
||||||
|
generated_at: timestamp,
|
||||||
|
features,
|
||||||
|
gallery_layouts: {
|
||||||
|
type: "array",
|
||||||
|
uniqueItems: true,
|
||||||
|
maxItems: LAYOUTS.length,
|
||||||
|
items: { enum: LAYOUTS },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const feedback = object({
|
||||||
|
feedback_id: uuid,
|
||||||
|
kind: { enum: ["feedback", "feature_request", "testimonial"] },
|
||||||
|
title: text(120),
|
||||||
|
body: text(4000),
|
||||||
|
name: text(80, 0),
|
||||||
|
allow_public: boolean,
|
||||||
|
allow_marketing: boolean,
|
||||||
|
});
|
||||||
|
const payloads = {
|
||||||
|
register: object({ consent_version: { const: "usage-consent.v1" } }),
|
||||||
|
report,
|
||||||
|
delete: object({}),
|
||||||
|
feedback,
|
||||||
|
vote: object({ feedback_id: uuid, voted: boolean }),
|
||||||
|
session: object({}),
|
||||||
|
};
|
||||||
|
const packetBase = {
|
||||||
|
schema_version: { const: "usage.v1" },
|
||||||
|
installation_id: hash,
|
||||||
|
packet_id: uuid,
|
||||||
|
sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||||
|
};
|
||||||
|
const packetSchema = {
|
||||||
|
oneOf: Object.entries(payloads).map(([action, payload]) =>
|
||||||
|
object({
|
||||||
|
...packetBase,
|
||||||
|
action: { const: action },
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const envelopeSchema = {
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
$id: "https://usage.picpeak.app/schema/usage.v1.json",
|
||||||
|
title: "PicPeak usage.v1 signed envelope",
|
||||||
|
description:
|
||||||
|
"Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.",
|
||||||
|
...object({
|
||||||
|
packet: packetSchema,
|
||||||
|
public_key: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 59,
|
||||||
|
maxLength: 59,
|
||||||
|
pattern: "^[A-Za-z0-9_-]+$",
|
||||||
|
},
|
||||||
|
issued_at: timestamp,
|
||||||
|
nonce: uuid,
|
||||||
|
signature: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 86,
|
||||||
|
maxLength: 86,
|
||||||
|
pattern: "^[A-Za-z0-9_-]+$",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
module.exports = { FEATURE_KEYS, LAYOUTS, envelopeSchema, payloads };
|
||||||
@@ -81,6 +81,9 @@ services:
|
|||||||
# JWT_SECRET) and the break-glass override that re-enables local
|
# JWT_SECRET) and the break-glass override that re-enables local
|
||||||
# password login when the IdP is down while SSO-only mode is active.
|
# password login when the IdP is down while SSO-only mode is active.
|
||||||
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
|
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
|
||||||
|
# Optional product usage (#1110); remains off until explicit in-app consent.
|
||||||
|
- USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-https://usage.picpeak.app}
|
||||||
|
- USAGE_ENCRYPTION_KEY=${USAGE_ENCRYPTION_KEY:-}
|
||||||
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
|
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
|
||||||
- ADMIN_URL=${ADMIN_URL:-}
|
- ADMIN_URL=${ADMIN_URL:-}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Optional product usage and feedback (#1110)
|
||||||
|
|
||||||
|
Tracking is disabled by default. After updating, settings editors see a
|
||||||
|
dismissible invitation in the admin shell. Only explicit consent in Settings →
|
||||||
|
Product usage & feedback registers an installation. Public galleries never
|
||||||
|
load the usage UI chunk or trigger product reports.
|
||||||
|
|
||||||
|
The backend builds and signs allowlisted feature packets, sending them at most
|
||||||
|
daily on authenticated admin use. It does not send gallery visitors, click
|
||||||
|
histories, photo/gallery counts, names, emails, domains, filenames, or secrets.
|
||||||
|
The settings page provides disclosure, raw preview/export, a private lookup
|
||||||
|
hash, delivery status, feedback, and a short-lived voting connection.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Run the normal core migrations; migration 201 creates dedicated state/marker
|
||||||
|
tables. No identity or key is created by the migration. No extra browser
|
||||||
|
tracker or CORS policy is required. The collector is the separate
|
||||||
|
`picpeak-usage` app from [#1110](https://github.com/PicPeak/picpeak/issues/1110).
|
||||||
|
|
||||||
|
| Backend variable | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production |
|
||||||
|
| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM |
|
||||||
|
|
||||||
|
Local development can use an HTTP loopback collector outside production. The
|
||||||
|
collector URL is never writable through generic settings or request payloads.
|
||||||
|
Keep the encryption material stable and protected; losing it makes the old
|
||||||
|
identity unable to sign deletion requests. Keys live in a dedicated database
|
||||||
|
table, not the generic readable settings. A random mode-0600 file at
|
||||||
|
`getStoragePath()/usage-instance.key` binds the database to its local storage.
|
||||||
|
|
||||||
|
## Consent and deletion lifecycle
|
||||||
|
|
||||||
|
Disabled → activation pending → active. Registration/delivery failures are
|
||||||
|
durable and retried. Multiple admin tabs/processes share a database lease;
|
||||||
|
only accepted receipts advance the sequence and report date. Re-signed retries
|
||||||
|
reuse the immutable packet ID so lost acknowledgements do not duplicate data.
|
||||||
|
|
||||||
|
Opt-out immediately stops collection, clears markers/previews/feedback
|
||||||
|
preferences, and enters deletion pending. It keeps only credentials and the
|
||||||
|
deletion operation until the collector confirms deletion. The collector removes
|
||||||
|
reports, projections, feedback/publications, votes, and sessions. PicPeak then
|
||||||
|
erases the local fingerprint, private key and binding. A later join generates
|
||||||
|
a fresh identity. Repeated deletion handles lost receipts safely.
|
||||||
|
|
||||||
|
A missing/mismatched storage binding or conflicting collector sequence stops
|
||||||
|
reporting with identity conflict. A full clone of a signing identity cannot be
|
||||||
|
distinguished cryptographically. Do not run the same participation identity in
|
||||||
|
two deployments; disable/delete the old participation and rejoin. Deletion
|
||||||
|
affects any other copy that shared the same identity.
|
||||||
|
|
||||||
|
## Feedback and permissions
|
||||||
|
|
||||||
|
Only settings.edit can inspect identity/packets or change participation and
|
||||||
|
feedback preferences. Any authenticated admin may trigger the fixed daily
|
||||||
|
report; the activity endpoint accepts no telemetry input. Every usage endpoint
|
||||||
|
uses adminAuth, including token-type checks. Gallery tokens cannot use it.
|
||||||
|
|
||||||
|
Feedback is sent only on explicit submission. Each item defaults anonymous and
|
||||||
|
private; names, publication permission, and testimonial marketing permission
|
||||||
|
are separate choices. Published requests/testimonials require maintainer review.
|
||||||
|
Public voting uses a backend-authorized 15-minute session, never the lookup hash.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
The closed schema is in `backend/src/usage/schema.cjs`, with signing in
|
||||||
|
`protocol.cjs`. Keep both byte-identical to the collector's `protocol/` copies.
|
||||||
|
The collector serves the schema, complete source archive, public projections,
|
||||||
|
and full raw exports. Feature semantics and retention are documented in its
|
||||||
|
`docs/PROTOCOL.md` and `docs/OPERATIONS.md`.
|
||||||
|
|
||||||
|
Used flags represent successful allowlisted admin capability calls since
|
||||||
|
joining, not visitor behavior or counts. OAuth marks successful admin SSO;
|
||||||
|
applied CSS is observed during report generation. Gallery layouts are controlled
|
||||||
|
enums extracted from event themes without IDs or counts. Other signals use the
|
||||||
|
explicit rules in `middleware/productUsage.js` and `usage/UsageService.js`.
|
||||||
|
|
||||||
|
Tests: `backend/__tests__/routes/adminUsage.test.js`, frontend
|
||||||
|
`features/settings/__tests__/ProductUsageTab.test.tsx`, and the collector's
|
||||||
|
cross-repository integration suite with isolated databases and real HTTP.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { lazy, Suspense, useState } from 'react';
|
||||||
import { Outlet, Navigate } from 'react-router-dom';
|
import { Outlet, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
@@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner';
|
|||||||
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
|
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
|
||||||
|
const ProductUsageNotice = lazy(() => import('./ProductUsageNotice'));
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
||||||
@@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
|
|||||||
(or remove this mount) after operators have had time to update their
|
(or remove this mount) after operators have had time to update their
|
||||||
docker-compose.yml. See #669. */}
|
docker-compose.yml. See #669. */}
|
||||||
<MigrationBanner />
|
<MigrationBanner />
|
||||||
|
{!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>}
|
||||||
|
|
||||||
{/* Page content - disabled when password change required.
|
{/* Page content - disabled when password change required.
|
||||||
overflow moved up to the column so the scrollbar gutter is
|
overflow moved up to the column so the scrollbar gutter is
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||||
|
import { productUsageService } from '../../services/productUsage.service';
|
||||||
|
|
||||||
|
// Loaded only inside the authenticated admin tree. Gallery routes never import
|
||||||
|
// this chunk, make usage requests, or record product usage markers.
|
||||||
|
export default function ProductUsageNotice() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['productUsage'],
|
||||||
|
queryFn: productUsageService.status,
|
||||||
|
enabled: hasPermission('settings.edit')
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
let running = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (document.visibilityState === 'hidden' || running) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await productUsageService.activity();
|
||||||
|
} catch {
|
||||||
|
/* Best-effort delivery; settings show persisted retry state. */
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
const timer = window.setInterval(tick, 5 * 60 * 1000);
|
||||||
|
document.addEventListener('visibilitychange', tick);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener('visibilitychange', tick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!hasPermission('settings.edit') || !data || data.status !== 'disabled' || data.notice_dismissed) return null;
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="mx-6 mt-4 rounded-lg border border-theme p-4 text-theme bg-theme-surface"
|
||||||
|
aria-label={t('productUsage.title')}
|
||||||
|
>
|
||||||
|
<p>{t('productUsage.notice')}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-4">
|
||||||
|
<Link className="underline" to="/admin/settings?tab=usage">
|
||||||
|
{t('productUsage.review')}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="underline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
['productUsage'],
|
||||||
|
await productUsageService.dismiss()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* The notice remains available. */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('productUsage.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
cleanup
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import ProductUsageTab from '../tabs/ProductUsageTab';
|
||||||
|
import {
|
||||||
|
productUsageService as service,
|
||||||
|
type UsageStatus
|
||||||
|
} from '../../../services/productUsage.service';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key })
|
||||||
|
}));
|
||||||
|
vi.mock('../../../components/common/ConfirmDialog', () => ({
|
||||||
|
useConfirm: () => async () => true
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/productUsage.service', () => ({
|
||||||
|
productUsageService: {
|
||||||
|
status: vi.fn(),
|
||||||
|
enable: vi.fn(),
|
||||||
|
disable: vi.fn(),
|
||||||
|
retry: vi.fn(),
|
||||||
|
preview: vi.fn(),
|
||||||
|
export: vi.fn(),
|
||||||
|
preferences: vi.fn(),
|
||||||
|
feedback: vi.fn(),
|
||||||
|
portalSession: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const status: UsageStatus = {
|
||||||
|
status: 'disabled',
|
||||||
|
notice_dismissed: false,
|
||||||
|
installation_id: null,
|
||||||
|
collector_url: 'https://usage.picpeak.app',
|
||||||
|
schema_version: 'usage.v1',
|
||||||
|
last_report_date: null,
|
||||||
|
last_error: null,
|
||||||
|
pending_action: null,
|
||||||
|
last_packet: null,
|
||||||
|
feedback_preferences: { name: 'Remembered private name' }
|
||||||
|
};
|
||||||
|
const mount = () =>
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={
|
||||||
|
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProductUsageTab />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(service.status).mockResolvedValue({ ...status });
|
||||||
|
HTMLDialogElement.prototype.showModal = function () {
|
||||||
|
this.setAttribute('open', '');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
describe('product usage controls', () => {
|
||||||
|
it('requires the disclosure and an unchecked-by-default consent before enabling', async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByText('productUsage.review'));
|
||||||
|
const enable = screen.getByRole('button', { name: 'productUsage.enable' });
|
||||||
|
expect(enable).toBeDisabled();
|
||||||
|
for (const key of [
|
||||||
|
'fields',
|
||||||
|
'excluded',
|
||||||
|
'transport',
|
||||||
|
'visibility',
|
||||||
|
'deletion',
|
||||||
|
'feedbackDisclosure'
|
||||||
|
])
|
||||||
|
expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument();
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
expect(service.preview).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.consentCheck'));
|
||||||
|
fireEvent.click(enable);
|
||||||
|
await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
it('sends anonymous private feedback even when a remembered name exists', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active',
|
||||||
|
installation_id: 'a'.repeat(64)
|
||||||
|
});
|
||||||
|
vi.mocked(service.feedback).mockResolvedValue({
|
||||||
|
delivered: true,
|
||||||
|
state: { ...status, status: 'active' }
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.feedbackTitle');
|
||||||
|
expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked();
|
||||||
|
fireEvent.change(screen.getByLabelText('productUsage.subject'), {
|
||||||
|
target: { value: 'Feedback title' }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('productUsage.message'), {
|
||||||
|
target: { value: 'Useful details' }
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'productUsage.sendFeedback' })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(service.feedback).toHaveBeenCalledWith({
|
||||||
|
kind: 'feedback',
|
||||||
|
title: 'Feedback title',
|
||||||
|
body: 'Useful details',
|
||||||
|
name: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('requires a separate marketing choice and clears it when publication permission is removed', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
fireEvent.change(await screen.findByLabelText('productUsage.kind'), {
|
||||||
|
target: { value: 'testimonial' }
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked();
|
||||||
|
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowMarketing'));
|
||||||
|
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText('productUsage.allowMarketing')
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'deletion_pending',
|
||||||
|
last_error: 'DELIVERY_FAILED'
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.states.deletion_pending');
|
||||||
|
expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText('productUsage.feedbackTitle')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' }));
|
||||||
|
await waitFor(() => expect(service.retry).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
productUsageService as service,
|
||||||
|
type ProductFeedback
|
||||||
|
} from '../../../services/productUsage.service';
|
||||||
|
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
||||||
|
import { Button } from '../../../components/common/Button';
|
||||||
|
|
||||||
|
function ConsentDialog({
|
||||||
|
close,
|
||||||
|
enable,
|
||||||
|
busy,
|
||||||
|
collector
|
||||||
|
}: {
|
||||||
|
close: () => void;
|
||||||
|
enable: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
collector: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.showModal();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
onCancel={close}
|
||||||
|
aria-labelledby="usage-consent-title"
|
||||||
|
className="w-full max-w-2xl rounded-xl p-6 text-theme bg-theme-surface backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<h2 id="usage-consent-title" className="text-xl font-semibold">
|
||||||
|
{t('productUsage.consentTitle')}
|
||||||
|
</h2>
|
||||||
|
<div className="my-4 max-h-[55vh] overflow-y-auto space-y-3">
|
||||||
|
{[
|
||||||
|
'purpose',
|
||||||
|
'fields',
|
||||||
|
'excluded',
|
||||||
|
'transport',
|
||||||
|
'visibility',
|
||||||
|
'deletion',
|
||||||
|
'feedbackDisclosure'
|
||||||
|
].map((key) => (
|
||||||
|
<p key={key}>{t(`productUsage.${key}`, { collector })}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-start gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => setChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('productUsage.consentCheck')}
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={close} disabled={busy}>
|
||||||
|
{t('productUsage.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={enable} disabled={!checked || busy}>
|
||||||
|
{t('productUsage.enable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductUsageTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data, isPending, isError } = useQuery({
|
||||||
|
queryKey: ['productUsage'],
|
||||||
|
queryFn: service.status,
|
||||||
|
refetchInterval: 30000
|
||||||
|
});
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [preview, setPreview] = useState<unknown>(null);
|
||||||
|
const [portalUrl, setPortalUrl] = useState<string | null>(null);
|
||||||
|
const [named, setNamed] = useState(false);
|
||||||
|
const [form, setForm] = useState<ProductFeedback>({
|
||||||
|
kind: 'feedback',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
name: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
});
|
||||||
|
const run = async (fn: () => Promise<void>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch {
|
||||||
|
setMessage(t('productUsage.failed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['productUsage'] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const download = (value: unknown) => {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = 'picpeak-usage-packets.json';
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
};
|
||||||
|
if (isPending) return <p>{t('productUsage.loading')}</p>;
|
||||||
|
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
|
||||||
|
const active = data.status === 'active';
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-theme">
|
||||||
|
<p>{t('productUsage.purpose')}</p>
|
||||||
|
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t(`productUsage.states.${data.status}`)}
|
||||||
|
</h3>
|
||||||
|
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
|
||||||
|
{data.installation_id && (
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.hash')}
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-theme bg-theme-surface p-2 font-mono text-sm"
|
||||||
|
readOnly
|
||||||
|
value={data.installation_id}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{data.last_report_date && (
|
||||||
|
<p>{t('productUsage.lastReport', { date: data.last_report_date })}</p>
|
||||||
|
)}
|
||||||
|
{data.last_error && (
|
||||||
|
<p role="status">{t('productUsage.deliveryProblem')}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.status === 'disabled' ? (
|
||||||
|
<Button disabled={busy} onClick={() => setConsent(true)}>
|
||||||
|
{t('productUsage.review')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
await service.retry();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.retry')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || data.status === 'deletion_pending'}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
title: t('productUsage.disable'),
|
||||||
|
message: t('productUsage.deletion'),
|
||||||
|
confirmLabel: t('productUsage.disable'),
|
||||||
|
variant: 'danger'
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
await run(async () => {
|
||||||
|
await service.disable();
|
||||||
|
setPreview(null);
|
||||||
|
setPortalUrl(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('productUsage.disable')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
className="underline self-center"
|
||||||
|
href={`${data.collector_url}/transparency`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.transparency')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<section className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('productUsage.inspect')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => setPreview(await service.preview()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || !data.last_packet}
|
||||||
|
onClick={() => setPreview(data.last_packet)}
|
||||||
|
>
|
||||||
|
{t('productUsage.lastPacket')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => download(await service.export()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.export')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || Boolean(data.pending_action)}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
const result = await service.portalSession();
|
||||||
|
setPortalUrl(result.url);
|
||||||
|
if (!result.delivered) setMessage(t('productUsage.queued'));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.connect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{portalUrl && (
|
||||||
|
<a
|
||||||
|
href={portalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{t('productUsage.openPortal')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{preview !== null && (
|
||||||
|
<pre
|
||||||
|
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
|
||||||
|
aria-label={t('productUsage.preview')}
|
||||||
|
>
|
||||||
|
{JSON.stringify(preview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<form
|
||||||
|
className="rounded-xl border border-theme bg-theme-surface p-5 space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void run(async () => {
|
||||||
|
const result = await service.feedback({
|
||||||
|
...form,
|
||||||
|
name: named ? form.name : ''
|
||||||
|
});
|
||||||
|
setMessage(
|
||||||
|
t(
|
||||||
|
result.delivered
|
||||||
|
? 'productUsage.feedbackSent'
|
||||||
|
: result.queued
|
||||||
|
? 'productUsage.queued'
|
||||||
|
: 'productUsage.failed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('productUsage.feedbackTitle')}
|
||||||
|
</h3>
|
||||||
|
<p>{t('productUsage.feedbackDisclosure')}</p>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.kind')}
|
||||||
|
<select
|
||||||
|
aria-label={t('productUsage.kind')}
|
||||||
|
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
kind: e.target.value as ProductFeedback['kind'],
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{['feedback', 'feature_request', 'testimonial'].map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{t(`productUsage.kinds.${kind}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.subject')}
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.message')}
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
maxLength={4000}
|
||||||
|
rows={5}
|
||||||
|
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.body}
|
||||||
|
onChange={(e) => setForm({ ...form, body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={named}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNamed(e.target.checked);
|
||||||
|
if (e.target.checked && !form.name)
|
||||||
|
setForm({ ...form, name: data.feedback_preferences.name });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('productUsage.includeName')}
|
||||||
|
</label>
|
||||||
|
{named && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.name')}
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
await service.preferences(form.name);
|
||||||
|
setMessage(t('productUsage.saved'));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.saveName')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.kind !== 'feedback' && (
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
allow_public: e.target.checked,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('productUsage.allowPublic')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.kind === 'testimonial' && (
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_marketing}
|
||||||
|
disabled={!form.allow_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, allow_marketing: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('productUsage.allowMarketing')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || Boolean(data.pending_action)}
|
||||||
|
>
|
||||||
|
{t('productUsage.sendFeedback')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{message && <p role="status">{message}</p>}
|
||||||
|
{consent && (
|
||||||
|
<ConsentDialog
|
||||||
|
collector={data.collector_url}
|
||||||
|
busy={busy}
|
||||||
|
close={() => setConsent(false)}
|
||||||
|
enable={() =>
|
||||||
|
run(async () => {
|
||||||
|
await service.enable();
|
||||||
|
setConsent(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,67 @@
|
|||||||
{
|
{
|
||||||
|
"productUsage": {
|
||||||
|
"title": "Produktnutzung & Feedback",
|
||||||
|
"notice": "Hilf mit, PicPeak weiterzuentwickeln. Freiwillige Nutzungsberichte zeigen, welche Funktionen der Community wichtig sind. Berichte bleiben aus, bis du ausdrücklich teilnimmst.",
|
||||||
|
"review": "Teilnahme prüfen",
|
||||||
|
"later": "Nicht jetzt",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"loading": "Teilnahmeeinstellungen werden geladen…",
|
||||||
|
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfe den Status und versuche es erneut.",
|
||||||
|
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
|
||||||
|
"consentTitle": "Produktnutzung freiwillig teilen",
|
||||||
|
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
|
||||||
|
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
||||||
|
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet signierte Berichte an {{collector}}, einmal pro UTC-Tag bei Admin-Nutzung. Du kannst Berichte vorab ansehen und alle angenommenen Rohpakete herunterladen.",
|
||||||
|
"visibility": "Der öffentliche Datensatz zeigt Funktionskombinationen und aggregierte Ergebnisse aller berichtenden Installationen, auch Gruppen mit nur einer Installation. Der Fingerabdruck ist pseudonym, nicht anonym. Bewahre deinen Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf deine Rohpakete.",
|
||||||
|
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung deiner Berichte, Aggregatbeiträge, Rückmeldungen, veröffentlichten Wünsche/Empfehlungen, Stimmen und Sitzungen an. Ist der Dienst nicht erreichbar, bleiben nur die für die Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht. Eine erneute Teilnahme erzeugt eine neue Identität. Der Dienst behält nur einen Einweg-Sperrwert, um wiederholte alte Registrierungen abzuweisen.",
|
||||||
|
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern du keinen Namen angibst, und nur für Betreuer sichtbar, sofern du die Veröffentlichung nicht ausdrücklich erlaubst. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
|
||||||
|
"consentCheck": "Ich habe diese Hinweise gelesen und stimme der Teilnahme ausdrücklich zu.",
|
||||||
|
"enable": "Produktnutzung aktivieren",
|
||||||
|
"disable": "Deaktivieren & Daten löschen",
|
||||||
|
"retry": "Erneut versuchen / fälligen Bericht senden",
|
||||||
|
"transparency": "Öffentliches Schema & Datenschutzhinweise",
|
||||||
|
"hash": "Dein vertraulicher Abfrage-Hash",
|
||||||
|
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
|
||||||
|
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuche es erneut oder deaktiviere die Teilnahme, um die Daten zu löschen.",
|
||||||
|
"inspect": "Genau sehen, was geteilt wird",
|
||||||
|
"preview": "Nächsten Bericht ansehen",
|
||||||
|
"lastPacket": "Zuletzt angenommenes signiertes Paket",
|
||||||
|
"export": "Alle Rohpakete herunterladen",
|
||||||
|
"connect": "Mit Wünschen & Abstimmungen verbinden",
|
||||||
|
"openPortal": "Portal öffnen (Abstimmungssitzung für 15 Minuten)",
|
||||||
|
"queued": "Der Vorgang ist zur erneuten Übertragung gespeichert. Der Empfang ist noch nicht bestätigt.",
|
||||||
|
"feedbackTitle": "Feedback & Funktionswünsche",
|
||||||
|
"kind": "Art",
|
||||||
|
"subject": "Titel",
|
||||||
|
"message": "Deine Nachricht",
|
||||||
|
"includeName": "Diesem Beitrag einen Namen hinzufügen",
|
||||||
|
"name": "Anzeigename",
|
||||||
|
"saveName": "Diesen Namen lokal merken",
|
||||||
|
"saved": "Einstellung gespeichert. Neue Beiträge sind weiterhin standardmäßig anonym.",
|
||||||
|
"allowPublic": "Ich erlaube die Veröffentlichung dieses Textes und des angegebenen Namens im Nutzungsportal nach Prüfung.",
|
||||||
|
"allowMarketing": "Ich erlaube zusätzlich die Verwendung dieser Empfehlung und des angegebenen Namens für Marketing auf der PicPeak-Homepage.",
|
||||||
|
"sendFeedback": "Feedback absenden",
|
||||||
|
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert deine Erlaubnis und die Prüfung durch Betreuer.",
|
||||||
|
"states": {
|
||||||
|
"disabled": "Teilnahme ist deaktiviert",
|
||||||
|
"activation_pending": "Aktivierung ausstehend",
|
||||||
|
"active": "Du nimmst teil",
|
||||||
|
"deletion_pending": "Löschung ausstehend",
|
||||||
|
"identity_conflict": "Konflikt der Installationsidentität"
|
||||||
|
},
|
||||||
|
"stateDetails": {
|
||||||
|
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfe die Hinweise, bevor du dich entscheidest.",
|
||||||
|
"activation_pending": "Die Zustimmung ist gespeichert. Die Registrierung wird bei Admin-Nutzung oder über „Erneut versuchen“ wiederholt.",
|
||||||
|
"active": "Nur die beschriebenen Funktionssignale werden erfasst. Tagesberichte werden bei Admin-Nutzung gesendet.",
|
||||||
|
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuche es erneut, sobald der Dienst erreichbar ist.",
|
||||||
|
"identity_conflict": "Möglicherweise wurde diese Installation wiederhergestellt oder kopiert, oder die Berichtsfolge stimmt nicht mehr mit dem Dienst überein. Berichte sind gestoppt. Deaktiviere und lösche die alte Teilnahme vor einem erneuten Beitritt mit neuer Identität. Dadurch werden auch die Daten einer weiteren Kopie derselben Identität gelöscht."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"feedback": "Privates Feedback",
|
||||||
|
"feature_request": "Funktionswunsch",
|
||||||
|
"testimonial": "Empfehlung"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "Benutzerverwaltung",
|
"title": "Benutzerverwaltung",
|
||||||
"subtitle": "Admin-Benutzer und Einladungen verwalten",
|
"subtitle": "Admin-Benutzer und Einladungen verwalten",
|
||||||
|
|||||||
@@ -1,4 +1,67 @@
|
|||||||
{
|
{
|
||||||
|
"productUsage": {
|
||||||
|
"title": "Product usage & feedback",
|
||||||
|
"notice": "Help shape PicPeak. Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
|
||||||
|
"review": "Review participation",
|
||||||
|
"later": "Not now",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading participation settings…",
|
||||||
|
"failed": "The operation could not be completed. Check the status and try again.",
|
||||||
|
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
|
||||||
|
"consentTitle": "Choose whether to share product usage",
|
||||||
|
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
|
||||||
|
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
||||||
|
"transport": "Your PicPeak backend keeps the signing key and sends signed reports to {{collector}} once per UTC day when an admin uses the app. You can preview reports and download every accepted raw packet.",
|
||||||
|
"visibility": "The public dataset shows feature combinations and aggregate results from all reporting installations, including groups containing just one installation. The fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your raw packets.",
|
||||||
|
"deletion": "Disabling immediately stops collection and requests deletion of your remote reports, aggregate contributions, feedback, published requests/testimonials, votes, and sessions. If the collector is unavailable, only the credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased. Joining again creates a new identity. The collector retains only a one-way revocation digest to prevent old registrations being replayed.",
|
||||||
|
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
|
||||||
|
"consentCheck": "I have read this disclosure and explicitly agree to participate.",
|
||||||
|
"enable": "Enable product usage",
|
||||||
|
"disable": "Disable & delete my data",
|
||||||
|
"retry": "Retry / send if due",
|
||||||
|
"transparency": "Read the public schema & privacy details",
|
||||||
|
"hash": "Your private lookup hash",
|
||||||
|
"lastReport": "Last accepted report: {{date}} (UTC)",
|
||||||
|
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
|
||||||
|
"inspect": "See exactly what is shared",
|
||||||
|
"preview": "Preview next report",
|
||||||
|
"lastPacket": "Last accepted signed packet",
|
||||||
|
"export": "Download all raw packets",
|
||||||
|
"connect": "Connect to requests & voting",
|
||||||
|
"openPortal": "Open the portal (15-minute voting session)",
|
||||||
|
"queued": "The operation is saved for retry. It has not been confirmed as delivered.",
|
||||||
|
"feedbackTitle": "Feedback & feature requests",
|
||||||
|
"kind": "Type",
|
||||||
|
"subject": "Title",
|
||||||
|
"message": "Your message",
|
||||||
|
"includeName": "Include a name with this item",
|
||||||
|
"name": "Display name",
|
||||||
|
"saveName": "Remember this name locally",
|
||||||
|
"saved": "Preference saved. Future items still default to anonymous.",
|
||||||
|
"allowPublic": "I allow this text and included name to be published on the usage portal after review.",
|
||||||
|
"allowMarketing": "I also allow PicPeak to use this testimonial and included name for homepage marketing.",
|
||||||
|
"sendFeedback": "Submit feedback",
|
||||||
|
"feedbackSent": "Feedback received. Publication requires your permission and maintainer review.",
|
||||||
|
"states": {
|
||||||
|
"disabled": "Participation is off",
|
||||||
|
"activation_pending": "Activation pending",
|
||||||
|
"active": "You are participating",
|
||||||
|
"deletion_pending": "Deletion pending",
|
||||||
|
"identity_conflict": "Installation identity conflict"
|
||||||
|
},
|
||||||
|
"stateDetails": {
|
||||||
|
"disabled": "No product usage is collected or sent. You can review the details before deciding.",
|
||||||
|
"activation_pending": "Consent is saved. Registration will be retried when you use PicPeak or choose retry.",
|
||||||
|
"active": "Only the disclosed feature signals are collected. Daily reports run when an admin uses PicPeak.",
|
||||||
|
"deletion_pending": "Collection and reporting are stopped. The signing credentials remain only to finish authenticated deletion. Retry when the collector is reachable.",
|
||||||
|
"identity_conflict": "This may be a restored or cloned installation, or its report sequence no longer matches the collector. Reporting is stopped. Disable and delete the old participation before joining with a new identity; this also deletes data shared by another copy of the same identity."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"feedback": "Private feedback",
|
||||||
|
"feature_request": "Feature request",
|
||||||
|
"testimonial": "Testimonial"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
"subtitle": "Manage admin users and invitations",
|
"subtitle": "Manage admin users and invitations",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { lazy, Suspense, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Loading } from '../../components/common';
|
import { Loading } from '../../components/common';
|
||||||
|
const ProductUsageTab = lazy(() => import('../../features/settings/tabs/ProductUsageTab'));
|
||||||
import {
|
import {
|
||||||
useSettingsState,
|
useSettingsState,
|
||||||
FeaturesTab,
|
FeaturesTab,
|
||||||
@@ -64,6 +65,7 @@ import { Briefcase, Receipt, ScrollText, Landmark, Smartphone, MonitorPlay } fro
|
|||||||
// Tab keys driving the inner-nav. Must include every key used in
|
// Tab keys driving the inner-nav. Must include every key used in
|
||||||
// `navGroups` below and in the switch at the bottom of the component.
|
// `navGroups` below and in the switch at the bottom of the component.
|
||||||
type TabType =
|
type TabType =
|
||||||
|
| 'usage'
|
||||||
| 'features'
|
| 'features'
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'events'
|
| 'events'
|
||||||
@@ -107,6 +109,7 @@ interface NavGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TAB_KEYS: TabType[] = [
|
const ALL_TAB_KEYS: TabType[] = [
|
||||||
|
'usage',
|
||||||
'features', 'general', 'events', 'eventTypes',
|
'features', 'general', 'events', 'eventTypes',
|
||||||
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
|
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
|
||||||
'email', 'moderation',
|
'email', 'moderation',
|
||||||
@@ -129,6 +132,7 @@ function isValidTab(value: string | null): value is TabType {
|
|||||||
// Settings via the broadened sidebar gate and sees only the tabs whose specific
|
// Settings via the broadened sidebar gate and sees only the tabs whose specific
|
||||||
// permission it holds. Backend routes enforce the same perms regardless of UI.
|
// permission it holds. Backend routes enforce the same perms regardless of UI.
|
||||||
const TAB_PERMISSIONS: Record<TabType, string[]> = {
|
const TAB_PERMISSIONS: Record<TabType, string[]> = {
|
||||||
|
usage: ['settings.edit'],
|
||||||
features: ['settings.view', 'settings.features'],
|
features: ['settings.view', 'settings.features'],
|
||||||
general: ['settings.view', 'settings.domains'],
|
general: ['settings.view', 'settings.domains'],
|
||||||
events: ['settings.view'],
|
events: ['settings.view'],
|
||||||
@@ -382,6 +386,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
|
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
|
||||||
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
|
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
|
||||||
|
{ key: 'usage', label: t('productUsage.title'), icon: Shield },
|
||||||
{ key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive },
|
{ key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -538,6 +543,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
||||||
{activeTab === 'accounting' && <AccountingTab />}
|
{activeTab === 'accounting' && <AccountingTab />}
|
||||||
{activeTab === 'whatsapp' && <WhatsAppTab />}
|
{activeTab === 'whatsapp' && <WhatsAppTab />}
|
||||||
|
{activeTab === 'usage' && hasAnyPermission(['settings.edit']) && <Suspense fallback={<Loading />}><ProductUsageTab /></Suspense>}
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<StatusTab
|
<StatusTab
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface UsageStatus {
|
||||||
|
status:
|
||||||
|
| 'disabled'
|
||||||
|
| 'activation_pending'
|
||||||
|
| 'active'
|
||||||
|
| 'deletion_pending'
|
||||||
|
| 'identity_conflict';
|
||||||
|
notice_dismissed: boolean;
|
||||||
|
installation_id: string | null;
|
||||||
|
collector_url: string;
|
||||||
|
schema_version: string;
|
||||||
|
last_report_date: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
pending_action: string | null;
|
||||||
|
last_packet: unknown;
|
||||||
|
feedback_preferences: { name: string };
|
||||||
|
}
|
||||||
|
export interface ProductFeedback {
|
||||||
|
kind: 'feedback' | 'feature_request' | 'testimonial';
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
name: string;
|
||||||
|
allow_public: boolean;
|
||||||
|
allow_marketing: boolean;
|
||||||
|
}
|
||||||
|
export const productUsageService = {
|
||||||
|
async status(): Promise<UsageStatus> {
|
||||||
|
return (await api.get('/admin/usage')).data;
|
||||||
|
},
|
||||||
|
async activity(): Promise<void> {
|
||||||
|
await api.post('/admin/usage/activity');
|
||||||
|
},
|
||||||
|
async dismiss(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/dismiss')).data;
|
||||||
|
},
|
||||||
|
async enable(): Promise<UsageStatus> {
|
||||||
|
return (
|
||||||
|
await api.post('/admin/usage/enable', {
|
||||||
|
consent_version: 'usage-consent.v1'
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
},
|
||||||
|
async disable(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/disable')).data;
|
||||||
|
},
|
||||||
|
async retry(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/retry')).data;
|
||||||
|
},
|
||||||
|
async preview(): Promise<unknown> {
|
||||||
|
return (await api.get('/admin/usage/preview')).data;
|
||||||
|
},
|
||||||
|
async export(): Promise<unknown> {
|
||||||
|
return (await api.get('/admin/usage/export')).data;
|
||||||
|
},
|
||||||
|
async preferences(name: string): Promise<UsageStatus> {
|
||||||
|
return (await api.put('/admin/usage/feedback-preferences', { name })).data;
|
||||||
|
},
|
||||||
|
async feedback(
|
||||||
|
value: ProductFeedback
|
||||||
|
): Promise<{ delivered: boolean; queued?: boolean; state: UsageStatus }> {
|
||||||
|
return (await api.post('/admin/usage/feedback', value)).data;
|
||||||
|
},
|
||||||
|
async portalSession(): Promise<{ delivered: boolean; url: string | null }> {
|
||||||
|
return (await api.post('/admin/usage/portal-session')).data;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user