feat(usage): prompt existing admins once for usage reporting after an update

An admin who already had PicPeak installed before the opt-in reporting
feature existed never gets asked — the setup wizard only runs once, on
a brand-new instance. Adds a one-time modal, shown on the admin's next
dashboard visit after updating, offering the same choice the wizard
gives a new install.

- New `product_usage_state.prompt_shown` column (migration 211) and
  UsageService.markPromptShown(), set on either outcome (enable or
  decline) from both this modal and the wizard step, so an
  installation is never asked twice regardless of which path it took.
- New POST /admin/usage/prompt-seen endpoint.
- Extracted the wizard's three-point pitch (UsageReportingPitch.tsx)
  so the modal and the wizard step share identical copy instead of
  drifting apart.
- The modal never shows once participation is already active, and
  never shows a second time after either the wizard or the modal has
  been through it once.

Depends on #1360 (the setup wizard step this reuses).
This commit is contained in:
Paul Nothaft
2026-09-08 16:25:51 +02:00
parent 8d0c32902d
commit d20f80112f
15 changed files with 223 additions and 28 deletions
@@ -54,6 +54,7 @@ maybe('product usage on Postgres', () => {
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db); await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
await require('../../migrations/core/205_product_usage_consent_version').up(db); await require('../../migrations/core/205_product_usage_consent_version').up(db);
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db); await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
await require('../../migrations/core/211_product_usage_prompt_shown').up(db);
await db.schema.createTable('app_settings', (t) => { await db.schema.createTable('app_settings', (t) => {
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type'); t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
@@ -42,6 +42,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled'); t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false); t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64); t.string('installation_id', 64);
t.string('public_key', 59); t.string('public_key', 59);
t.text('private_key_encrypted'); t.text('private_key_encrypted');
@@ -32,6 +32,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled'); t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
t.boolean('notice_dismissed').notNullable().defaultTo(false); t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64); t.string('installation_id', 64);
t.string('public_key', 59); t.string('public_key', 59);
t.text('private_key_encrypted'); t.text('private_key_encrypted');
@@ -26,6 +26,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled'); t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false); t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64); t.string('installation_id', 64);
t.string('public_key', 59); t.string('public_key', 59);
t.text('private_key_encrypted'); t.text('private_key_encrypted');
@@ -25,6 +25,7 @@ async function bootDb() {
t.string('status', 30).notNullable().defaultTo('disabled'); t.string('status', 30).notNullable().defaultTo('disabled');
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
t.boolean('notice_dismissed').notNullable().defaultTo(false); t.boolean('notice_dismissed').notNullable().defaultTo(false);
t.boolean('prompt_shown').notNullable().defaultTo(false);
t.string('installation_id', 64); t.string('installation_id', 64);
t.string('public_key', 59); t.string('public_key', 59);
t.text('private_key_encrypted'); t.text('private_key_encrypted');
@@ -0,0 +1,25 @@
// Tracks whether this installation has ever been offered the one-time
// usage-reporting opt-in prompt shown to an existing admin on their first
// login after an update (see UsageService.markPromptShown()). A fresh
// install that went through the setup wizard's own opt-in step sets this
// too, so upgraded and brand-new installs share one "already asked" marker
// and neither gets asked twice. Separate from `notice_dismissed`, which
// governs the persistent, re-visitable dashboard banner instead.
exports.up = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
!(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
) {
await knex.schema.alterTable('product_usage_state', (t) => {
t.boolean('prompt_shown').notNullable().defaultTo(false);
});
}
};
exports.down = async function (knex) {
if (
(await knex.schema.hasTable('product_usage_state')) &&
(await knex.schema.hasColumn('product_usage_state', 'prompt_shown'))
) {
await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('prompt_shown'));
}
};
+7
View File
@@ -73,6 +73,13 @@ router.post(
'/dismiss', '/dismiss',
wrap(async (_req, res) => res.json(await service.dismiss())) wrap(async (_req, res) => res.json(await service.dismiss()))
); );
// Acknowledges the one-time opt-in prompt (setup wizard or the post-update
// modal) regardless of whether the admin enabled or declined — either way it
// must not ask this installation again.
router.post(
'/prompt-seen',
wrap(async (_req, res) => res.json(await service.markPromptShown()))
);
router.post( router.post(
'/enable', '/enable',
wrap(async (req, res) => wrap(async (req, res) =>
+14
View File
@@ -263,6 +263,7 @@ class UsageService {
return { return {
status: state.status, status: state.status,
notice_dismissed: Boolean(state.notice_dismissed), notice_dismissed: Boolean(state.notice_dismissed),
prompt_shown: Boolean(state.prompt_shown),
installation_id: state.installation_id, installation_id: state.installation_id,
collector_url: collectorUrl, collector_url: collectorUrl,
collector_error: collectorError, collector_error: collectorError,
@@ -343,6 +344,18 @@ class UsageService {
.update({ notice_dismissed: formatBoolean(true) }); .update({ notice_dismissed: formatBoolean(true) });
return this.status(); return this.status();
} }
// The one-time opt-in prompt (setup wizard for a new install, a modal shown
// once to an existing admin after an update) calls this on either outcome —
// enable or decline — so it never asks the same installation twice. Kept
// separate from `notice_dismissed`: that one only silences the persistent,
// re-visitable dashboard banner and is unrelated to whether this one-time
// prompt has already been shown.
async markPromptShown() {
await this.db('product_usage_state')
.where({ id: 1 })
.update({ prompt_shown: formatBoolean(true) });
return this.status();
}
async enable(consent) { async enable(consent) {
if (!Object.values(CONSENT_VERSIONS).includes(consent)) if (!Object.values(CONSENT_VERSIONS).includes(consent))
throw new ValidationError('Explicit usage consent is required'); throw new ValidationError('Explicit usage consent is required');
@@ -382,6 +395,7 @@ class UsageService {
status: 'activation_pending', status: 'activation_pending',
consent_version: consent, consent_version: consent,
notice_dismissed: formatBoolean(true), notice_dismissed: formatBoolean(true),
prompt_shown: formatBoolean(true),
installation_id: identity.installation_id, installation_id: identity.installation_id,
public_key: identity.public_key, public_key: identity.public_key,
private_key_encrypted: this.encrypt(identity.private_key), private_key_encrypted: this.encrypt(identity.private_key),
@@ -12,6 +12,7 @@ import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
const ProductUsageNotice = lazy(() => import('./ProductUsageNotice')); const ProductUsageNotice = lazy(() => import('./ProductUsageNotice'));
const UsageReportingPrompt = lazy(() => import('./UsageReportingPrompt'));
export const AdminLayout: React.FC = () => { export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
@@ -127,6 +128,7 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
docker-compose.yml. See #669. */} docker-compose.yml. See #669. */}
<MigrationBanner /> <MigrationBanner />
{!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>} {!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>}
{!mustChangePassword && <Suspense fallback={null}><UsageReportingPrompt /></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,36 @@
import type { LucideIcon } from 'lucide-react';
import { ShieldOff, Users, MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Shared between the setup wizard's opt-in step and the one-time post-update
// prompt (#1360) so the two surfaces never drift apart. Kept deliberately
// short — most people reflexively decline "send us data" prompts, so this
// leads with what makes PicPeak's reporting different from typical analytics
// rather than repeating the full disclosure the Settings → Product usage tab
// already shows in detail.
export const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [
{ key: 'oneWay', icon: ShieldOff },
{ key: 'mutual', icon: Users },
{ key: 'feedback', icon: MessageSquare },
];
export const UsageReportingPoints: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-2">
{USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => (
<div key={key} className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3">
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-primary, #5C8762)' }} />
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800">
{t(`setup.usageReporting.${key}Title`)}
</span>
<span className="block text-xs text-neutral-500">
{t(`setup.usageReporting.${key}Desc`)}
</span>
</span>
</div>
))}
</div>
);
};
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { usePermissions } from '../../contexts/PermissionsContext';
import { productUsageService } from '../../services/productUsage.service';
import { Button, Card } from '../common';
import { UsageReportingPoints } from './UsageReportingPitch';
/**
* One-time opt-in prompt for an admin who already had PicPeak installed
* before this feature existed (#1360). A brand-new install gets the same
* choice inside the setup wizard instead both paths call
* POST /admin/usage/prompt-seen on either outcome, so whichever one an
* installation went through, this never shows a second time and never shows
* once participation is already active.
*/
export default function UsageReportingPrompt() {
const { t } = useTranslation();
const { hasPermission } = usePermissions();
const queryClient = useQueryClient();
const [hidden, setHidden] = useState(false);
const [consent, setConsent] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
const { data } = useQuery({
queryKey: ['productUsage'],
queryFn: productUsageService.status,
enabled: hasPermission('settings.edit'),
});
const dismiss = async () => {
setHidden(true);
try {
queryClient.setQueryData(['productUsage'], await productUsageService.promptSeen());
} catch {
/* Worst case the query refetches stale data and this shows once more. */
}
};
const enable = async () => {
setIsEnabling(true);
try {
queryClient.setQueryData(['productUsage'], await productUsageService.enable());
toast.success(t('setup.usageReporting.enabled'));
setHidden(true);
} catch {
toast.warn(t('setup.usageReporting.enableFailed'));
} finally {
setIsEnabling(false);
}
};
if (!hasPermission('settings.edit') || !data || hidden) return null;
if (data.status !== 'disabled' || data.prompt_shown) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6 space-y-6">
<div>
<h2 className="text-lg font-semibold text-neutral-900 mb-1">
{t('productUsagePrompt.title')}
</h2>
<p className="text-sm text-neutral-600">{t('productUsagePrompt.intro')}</p>
</div>
<UsageReportingPoints />
<label className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<span className="text-xs text-neutral-600">{t('setup.usageReporting.consentCheck')}</span>
</label>
<div className="space-y-3">
<Button
type="button"
variant="primary"
size="lg"
className="w-full"
isLoading={isEnabling}
disabled={!consent}
onClick={enable}
>
{t('setup.usageReporting.enable')}
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
disabled={isEnabling}
onClick={dismiss}
>
{t('setup.usageReporting.skip')}
</Button>
</div>
</div>
</Card>
</div>
);
}
+4
View File
@@ -1,4 +1,8 @@
{ {
"productUsagePrompt": {
"title": "PicPeak mitgestalten?",
"intro": "Diese Installation wurde noch nie nach der anonymen Nutzungsstatistik gefragt. Sie ist freiwillig — und funktioniert anders als das Tracking, das Sie sonst gewohnt sind abzulehnen."
},
"productUsage": { "productUsage": {
"fields": "usage.v5-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 87 Funktionssignale (64 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.", "fields": "usage.v5-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 87 Funktionssignale (64 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.",
"catalogTitle": "Vollständiger Katalog: 87 Funktionssignale und 2 Bestandszahlen (usage.v5)", "catalogTitle": "Vollständiger Katalog: 87 Funktionssignale und 2 Bestandszahlen (usage.v5)",
+4
View File
@@ -1,4 +1,8 @@
{ {
"productUsagePrompt": {
"title": "Help shape PicPeak?",
"intro": "This installation has never been asked about anonymous usage reporting. It's optional, and works differently from the analytics you're used to declining."
},
"productUsage": { "productUsage": {
"fields": "usage.v5 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, 87 fixed capability signals (64 configured/used pairs and 23 configuration-only booleans), and two installation totals: stored galleries and photo records excluding videos. Drafts and archived galleries and their retained photo records are included. The catalog below defines every field. There are no action counts or visitor observations.", "fields": "usage.v5 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, 87 fixed capability signals (64 configured/used pairs and 23 configuration-only booleans), and two installation totals: stored galleries and photo records excluding videos. Drafts and archived galleries and their retained photo records are included. The catalog below defines every field. There are no action counts or visitor observations.",
"catalogTitle": "Full catalog: 87 capability signals and 2 inventory totals (usage.v5)", "catalogTitle": "Full catalog: 87 capability signals and 2 inventory totals (usage.v5)",
+14 -28
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom'; import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee, ShieldOff, Users, MessageSquare } from 'lucide-react'; import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ import { productUsageService } from '../services/productUsage.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep'; import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
import { UsageReportingPoints } from '../components/admin/UsageReportingPitch';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types'; import type { AdminUser } from '../types';
@@ -37,17 +38,6 @@ const COMMUNITY_LINKS: {
{ key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee }, { key: 'support', href: 'https://www.buymeacoffee.com/theluap', icon: Coffee },
]; ];
// Anonymous usage-reporting opt-in, one step before the final thank-you
// screen. Kept deliberately short — most people reflexively decline "send us
// data" prompts, so this leads with what makes PicPeak's reporting different
// from typical analytics rather than repeating the full disclosure the
// Settings → Product usage tab already shows in detail.
const USAGE_REPORTING_POINTS: { key: string; icon: LucideIcon }[] = [
{ key: 'oneWay', icon: ShieldOff },
{ key: 'mutual', icon: Users },
{ key: 'feedback', icon: MessageSquare },
];
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin // "How will you use PicPeak?" — the opt-in feature groups shown after the admin
// account is created. galleries/analytics/userManagement are always on and not // account is created. galleries/analytics/userManagement are always on and not
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys // listed. Labels/descriptions reuse the existing Settings→Features i18n keys
@@ -305,6 +295,16 @@ export const SetupPage: React.FC = () => {
} }
}; };
// Declining here still counts as having been asked (#1360): without this,
// the one-time post-update prompt would immediately re-ask the same admin
// the same question seconds later on their first dashboard visit.
const skipUsageReporting = async () => {
try {
await productUsageService.promptSeen();
} catch (_) { /* best-effort — worst case the dashboard asks once more */ }
setStep('community');
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
return ( return (
@@ -575,21 +575,7 @@ export const SetupPage: React.FC = () => {
<div className="space-y-6"> <div className="space-y-6">
<p className="text-sm text-neutral-700">{t('setup.usageReporting.intro')}</p> <p className="text-sm text-neutral-700">{t('setup.usageReporting.intro')}</p>
<div className="space-y-2"> <UsageReportingPoints />
{USAGE_REPORTING_POINTS.map(({ key, icon: Icon }) => (
<div key={key} className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3">
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-primary, #5C8762)' }} />
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800">
{t(`setup.usageReporting.${key}Title`)}
</span>
<span className="block text-xs text-neutral-500">
{t(`setup.usageReporting.${key}Desc`)}
</span>
</span>
</div>
))}
</div>
<label className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors"> <label className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors">
<input <input
@@ -619,7 +605,7 @@ export const SetupPage: React.FC = () => {
size="lg" size="lg"
className="w-full" className="w-full"
disabled={isEnablingUsageReporting} disabled={isEnablingUsageReporting}
onClick={() => setStep('community')} onClick={skipUsageReporting}
> >
{t('setup.usageReporting.skip')} {t('setup.usageReporting.skip')}
</Button> </Button>
@@ -8,6 +8,8 @@ export interface UsageStatus {
| 'deletion_pending' | 'deletion_pending'
| 'identity_conflict'; | 'identity_conflict';
notice_dismissed: boolean; notice_dismissed: boolean;
/** Whether the one-time opt-in prompt (setup wizard or post-update modal) has ever been shown. */
prompt_shown: boolean;
installation_id: string | null; installation_id: string | null;
collector_url: string | null; collector_url: string | null;
collector_error?: 'INVALID_COLLECTOR_URL' | null; collector_error?: 'INVALID_COLLECTOR_URL' | null;
@@ -46,6 +48,9 @@ export const productUsageService = {
async dismiss(): Promise<UsageStatus> { async dismiss(): Promise<UsageStatus> {
return (await api.post('/admin/usage/dismiss')).data; return (await api.post('/admin/usage/dismiss')).data;
}, },
async promptSeen(): Promise<UsageStatus> {
return (await api.post('/admin/usage/prompt-seen')).data;
},
async enable(): Promise<UsageStatus> { async enable(): Promise<UsageStatus> {
return ( return (
await api.post('/admin/usage/enable', { await api.post('/admin/usage/enable', {