Merge pull request #470 from Luca-Timo/feat/customer-detail-section-order
feat(customers): "Manage galleries" dialog with immediate access revocation + section reorder + portal-flag revert
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Migration: Add `customer_gallery_assigned` email template.
|
||||
*
|
||||
* Sent when an admin adds new gallery assignments to an existing
|
||||
* customer via the "Manage galleries" dialog on the customer detail
|
||||
* page. Digest-style — one email per save listing every newly added
|
||||
* gallery, not one email per gallery (admins often set up new clients
|
||||
* by adding several galleries in a single sitting).
|
||||
*
|
||||
* Variables:
|
||||
* - customer_name greeting name (display name / first name / email local)
|
||||
* - gallery_count integer (string) — number of newly added galleries
|
||||
* - singular "true" when count === 1 (drives intro wording)
|
||||
* - multiple "true" when count > 1
|
||||
* - gallery_list_html pre-rendered <ul> with names + dates; passes
|
||||
* through unescaped because the service builds it
|
||||
* from trusted DB fields (event_name from
|
||||
* admin-owned rows + server-rendered dates).
|
||||
* - gallery_list_text newline-separated plain-text equivalent for
|
||||
* the text/plain body.
|
||||
* - dashboard_link URL of /customer/dashboard on the configured
|
||||
* frontend origin.
|
||||
*
|
||||
* Category + flag: 'customers' + customerPortal — categorisation
|
||||
* scaffold from migration 098. When the customer portal flag is off,
|
||||
* the Templates admin UI chips this card "Feature off" but it's still
|
||||
* editable.
|
||||
*
|
||||
* Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
|
||||
* and flagged for native review per project convention.
|
||||
*
|
||||
* Idempotent: skips if the template_key already exists.
|
||||
*/
|
||||
|
||||
const TRANSLATIONS = {
|
||||
en: {
|
||||
subject: 'New gallery access on your account',
|
||||
body_html: `<h2>You have new gallery access</h2>
|
||||
<p>Hi {{customer_name}},</p>
|
||||
{{#if singular}}<p>Your photographer just gave you access to a new gallery on your account:</p>{{/if}}{{#if multiple}}<p>Your photographer just gave you access to {{gallery_count}} new galleries on your account:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Open your dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `You have new gallery access
|
||||
|
||||
Hi {{customer_name}},
|
||||
|
||||
Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Open your dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Neue Galerie in deinem Konto verfügbar',
|
||||
body_html: `<h2>Du hast Zugriff auf neue Galerien</h2>
|
||||
<p>Hallo {{customer_name}},</p>
|
||||
{{#if singular}}<p>Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:</p>{{/if}}{{#if multiple}}<p>Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Zum Dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Du hast Zugriff auf neue Galerien
|
||||
|
||||
Hallo {{customer_name}},
|
||||
|
||||
Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerie(n) in deinem Konto gegeben:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Zum Dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
fr: {
|
||||
subject: 'Nouvel accès galerie sur votre compte',
|
||||
body_html: `<h2>Vous avez accès à de nouvelles galeries</h2>
|
||||
<p>Bonjour {{customer_name}},</p>
|
||||
{{#if singular}}<p>Votre photographe vient de vous donner accès à une nouvelle galerie sur votre compte :</p>{{/if}}{{#if multiple}}<p>Votre photographe vient de vous donner accès à {{gallery_count}} nouvelles galeries sur votre compte :</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Ouvrir mon tableau de bord</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Vous avez accès à de nouvelles galeries
|
||||
|
||||
Bonjour {{customer_name}},
|
||||
|
||||
Votre photographe vient de vous donner accès à {{gallery_count}} nouvelle(s) galerie(s) sur votre compte :
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Tableau de bord : {{dashboard_link}}`,
|
||||
},
|
||||
nl: {
|
||||
subject: 'Nieuwe galerij toegevoegd aan uw account',
|
||||
body_html: `<h2>U heeft toegang tot nieuwe galerijen</h2>
|
||||
<p>Hallo {{customer_name}},</p>
|
||||
{{#if singular}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot een nieuwe galerij in uw account:</p>{{/if}}{{#if multiple}}<p>Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerijen in uw account:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Open uw dashboard</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Werkt de knop niet? Kopieer dan deze link in uw browser:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `U heeft toegang tot nieuwe galerijen
|
||||
|
||||
Hallo {{customer_name}},
|
||||
|
||||
Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Dashboard: {{dashboard_link}}`,
|
||||
},
|
||||
pt: {
|
||||
subject: 'Nova galeria disponível em sua conta',
|
||||
body_html: `<h2>Você tem acesso a novas galerias</h2>
|
||||
<p>Olá {{customer_name}},</p>
|
||||
{{#if singular}}<p>Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:</p>{{/if}}{{#if multiple}}<p>Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Abrir meu painel</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Se o botão não funcionar, copie este link no navegador:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `Você tem acesso a novas galerias
|
||||
|
||||
Olá {{customer_name}},
|
||||
|
||||
Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Painel: {{dashboard_link}}`,
|
||||
},
|
||||
ru: {
|
||||
subject: 'Новая галерея доступна в вашем аккаунте',
|
||||
body_html: `<h2>У вас новый доступ к галереям</h2>
|
||||
<p>Здравствуйте, {{customer_name}}!</p>
|
||||
{{#if singular}}<p>Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:</p>{{/if}}{{#if multiple}}<p>Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:</p>{{/if}}
|
||||
{{gallery_list_html}}
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{dashboard_link}}" class="button">Открыть мой кабинет</a>
|
||||
</p>
|
||||
<p style="font-size: 13px; color: #666;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br>
|
||||
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
|
||||
body_text: `У вас новый доступ к галереям
|
||||
|
||||
Здравствуйте, {{customer_name}}!
|
||||
|
||||
Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
|
||||
|
||||
{{gallery_list_text}}
|
||||
|
||||
Личный кабинет: {{dashboard_link}}`,
|
||||
},
|
||||
};
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
|
||||
const existing = await knex('email_templates')
|
||||
.where({ template_key: 'customer_gallery_assigned' })
|
||||
.first();
|
||||
if (existing) {
|
||||
console.log(' customer_gallery_assigned template already exists, skipping insert');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect schema variant (legacy per-column vs normalized translations).
|
||||
// Newer installs have the email_template_translations table from
|
||||
// migration 075; older ones might still have subject_en/de/... columns
|
||||
// and NOT NULL constraints on the legacy columns. Cover both.
|
||||
const cols = await knex('email_templates').columnInfo();
|
||||
const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
|
||||
|
||||
const enContent = TRANSLATIONS.en;
|
||||
|
||||
// Build the master row. category/subcategory/feature_flag columns
|
||||
// were added in migration 098 — guard so this migration works on
|
||||
// a slightly older install too.
|
||||
const masterRow = {
|
||||
template_key: 'customer_gallery_assigned',
|
||||
variables: JSON.stringify([
|
||||
'customer_name',
|
||||
'gallery_count',
|
||||
'singular',
|
||||
'multiple',
|
||||
'gallery_list_html',
|
||||
'gallery_list_text',
|
||||
'dashboard_link',
|
||||
]),
|
||||
};
|
||||
if ('category' in cols) masterRow.category = 'customers';
|
||||
if ('subcategory' in cols) masterRow.subcategory = null;
|
||||
if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
|
||||
if ('created_at' in cols) masterRow.created_at = new Date();
|
||||
if ('updated_at' in cols) masterRow.updated_at = new Date();
|
||||
|
||||
// Populate any legacy subject_*/body_html_*/body_text_* columns the
|
||||
// schema still carries. Fallback content for non-en locales is the
|
||||
// English string — the translations table below has the real
|
||||
// per-locale copy. This only matters if the install hasn't run
|
||||
// migration 075 yet, which is rare but possible.
|
||||
for (const colName of Object.keys(cols)) {
|
||||
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.subject;
|
||||
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.body_html;
|
||||
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
|
||||
masterRow[colName] = enContent.body_text;
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await knex('email_templates').insert(masterRow).returning('id');
|
||||
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||
|
||||
if (hasTranslationsTable && templateId) {
|
||||
for (const [language, content] of Object.entries(TRANSLATIONS)) {
|
||||
await knex('email_template_translations').insert({
|
||||
template_id: templateId,
|
||||
language,
|
||||
subject: content.subject,
|
||||
body_html: content.body_html,
|
||||
body_text: content.body_text,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' customer_gallery_assigned template inserted with 6 translations');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||
await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
|
||||
};
|
||||
+27
-21
@@ -568,30 +568,36 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||
// Customer portal (#354). The customerPortal feature flag is enforced
|
||||
// in TWO places:
|
||||
// Customer portal (#354). The customerPortal feature flag is a
|
||||
// VISIBILITY toggle for the admin surface, not a kill switch for
|
||||
// customer access. Enforcement:
|
||||
//
|
||||
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
|
||||
// (handles navigation cleanly when an admin is using the app).
|
||||
// 2. Backend: the requireCustomerPortalEnabled middleware below.
|
||||
// Belt-and-braces — a stale tab, a saved bookmark, or any
|
||||
// third-party API client trying to hit /api/customer/* or
|
||||
// /api/admin/customers/* gets a 410 Gone the moment the toggle
|
||||
// is flipped off. Includes /api/customer/auth/login: flag off
|
||||
// = nobody can log in until the admin re-enables, including
|
||||
// already-issued customers (their sessions still have valid
|
||||
// JWTs but every API call returns 410 → frontend boots them
|
||||
// out). PR #458 deliberate departure from the prior design
|
||||
// that left login alive when the rest of the surface was off.
|
||||
const {
|
||||
requireCustomerPortalEnabled,
|
||||
requireCustomerPortalEnabledAdmin,
|
||||
} = require('./src/middleware/requireCustomerPortal');
|
||||
|
||||
app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
|
||||
// hide the Clients section when the flag is off. Customer-side
|
||||
// /customer/* surfaces stay reachable.
|
||||
// 2. Backend: NO route-level gate. The admin surface is gated by
|
||||
// adminAuth + permission checks (admin still has rights to
|
||||
// manage customer records even if the section is hidden in
|
||||
// their UI). The customer surface is gated by customerAuth +
|
||||
// is_active checks on customer_accounts.
|
||||
//
|
||||
// For close-to-realtime access changes use the dedicated tools:
|
||||
// - Revoke a customer's access to ONE gallery → "Manage galleries"
|
||||
// dialog removes the event_customer_assignments row, which
|
||||
// verifyGalleryAccess re-checks on every customer-minted JWT.
|
||||
// - Lock out a customer entirely → "Deactivate" sets is_active=false
|
||||
// and bumps password_changed_at, killing every outstanding JWT.
|
||||
// - Toggle per-customer feature surfaces (calendar/quotes/bills)
|
||||
// → toggles on the customer detail page.
|
||||
//
|
||||
// Putting the global flag in the kill-switch role was a mistake — a
|
||||
// stray click in Settings → Features would lock every paying
|
||||
// customer out at once. PR-revert moved the gate back to per-record.
|
||||
app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
|
||||
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
||||
// distinct token type, distinct cookie, distinct middleware.
|
||||
app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
|
||||
app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
|
||||
app.use('/api/customer/auth', require('./src/routes/customerAuth'));
|
||||
app.use('/api/customer', require('./src/routes/customer'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||
|
||||
@@ -120,7 +120,36 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
|
||||
// Customer-minted gallery JWTs (#354): when the customer obtained
|
||||
// this token via /api/customer/events/:slug/access-token, the
|
||||
// payload carries `via:'customer'` and `customerId`. The admin
|
||||
// can revoke the customer's access at any time by removing the
|
||||
// event_customer_assignments row from the "Manage galleries"
|
||||
// dialog on the customer detail page. Re-check that row here so
|
||||
// the revocation takes effect on the customer's very next
|
||||
// request — no token-blacklisting machinery required.
|
||||
if (decoded.via === 'customer' && decoded.customerId) {
|
||||
const assignment = await withRetry(async () => {
|
||||
return await db('event_customer_assignments')
|
||||
.where({
|
||||
event_id: event.id,
|
||||
customer_account_id: decoded.customerId,
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (!assignment) {
|
||||
logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', {
|
||||
customerId: decoded.customerId,
|
||||
eventId: event.id,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: 'Access to this gallery has been revoked',
|
||||
code: 'CUSTOMER_ASSIGNMENT_REVOKED',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.accessLevel = decoded.accessLevel || 'guest';
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Customer portal feature-flag gate.
|
||||
*
|
||||
* Blocks every /api/customer/* and /api/admin/customers/* endpoint
|
||||
* when the `customerPortal` flag is off. Returns 410 Gone so the
|
||||
* frontend can distinguish "feature has been disabled" from "you
|
||||
* don't have access" (which would be 403) — useful for the customer
|
||||
* dashboard's auto-redirect on a soft-kill scenario.
|
||||
*
|
||||
* Reads the flag via customerAccountsService.isCustomerPortalEnabled
|
||||
* (which itself reads from the maintainer's feature_flags table),
|
||||
* so a single source of truth.
|
||||
*/
|
||||
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function isEnabled() {
|
||||
try {
|
||||
return await customerAccountsService.isCustomerPortalEnabled();
|
||||
} catch (err) {
|
||||
// Defensive: if the lookup throws (DB unavailable, table missing
|
||||
// mid-migration), fail closed so an enabled-by-default fallback
|
||||
// can't accidentally expose customer surfaces during boot.
|
||||
logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', {
|
||||
error: err?.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing endpoints. Returns 410 with a code the frontend
|
||||
* can interpret to clear stale session storage + redirect to
|
||||
* /admin/login.
|
||||
*/
|
||||
async function requireCustomerPortalEnabled(req, res, next) {
|
||||
if (await isEnabled()) return next();
|
||||
return res.status(410).json({
|
||||
error: 'Customer portal is disabled',
|
||||
code: 'CUSTOMER_PORTAL_DISABLED',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-facing /api/admin/customers/* endpoints. Same gate, same
|
||||
* status code — keeps the contract consistent across both halves of
|
||||
* the customer-portal surface. The sidebar UI already hides the
|
||||
* entry, but a stale tab or direct API call must also be blocked.
|
||||
*/
|
||||
async function requireCustomerPortalEnabledAdmin(req, res, next) {
|
||||
if (await isEnabled()) return next();
|
||||
return res.status(410).json({
|
||||
error: 'Customer portal is disabled',
|
||||
code: 'CUSTOMER_PORTAL_DISABLED',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireCustomerPortalEnabled,
|
||||
requireCustomerPortalEnabledAdmin,
|
||||
};
|
||||
@@ -310,4 +310,35 @@ router.post('/:id/password-reset', [
|
||||
successResponse(res, { email: result.email, expiresAt: result.expiresAt });
|
||||
}));
|
||||
|
||||
/**
|
||||
* PUT /api/admin/customers/:id/events — replace the customer's full
|
||||
* event assignment list. Backs the "Manage galleries" dialog on the
|
||||
* customer detail page. Body is `{ event_ids: number[] }`. Empty
|
||||
* array clears every assignment.
|
||||
*
|
||||
* Access revocation is implicit: gallery middleware checks for a
|
||||
* live event_customer_assignments row whenever it decodes a
|
||||
* customer-minted gallery JWT, so removing an assignment here
|
||||
* immediately blocks the customer's next gallery request without
|
||||
* needing to enumerate + revoke any active tokens. Permission tier
|
||||
* is customers.create (same as invite + deactivate) — managing
|
||||
* which galleries a customer can see is a write-class operation
|
||||
* on the customer record.
|
||||
*/
|
||||
router.put('/:id/events', [
|
||||
adminAuth,
|
||||
requirePermission('customers.create'),
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('event_ids').isArray(),
|
||||
body('event_ids.*').isInt({ min: 1 }),
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const result = await customerAccountsService.setAssignmentsForCustomer(
|
||||
parseInt(req.params.id, 10),
|
||||
req.body.event_ids,
|
||||
req.admin.id,
|
||||
);
|
||||
successResponse(res, result);
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -48,15 +48,20 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
|
||||
|
||||
// ---- login -------------------------------------------------------------
|
||||
|
||||
// Flag-gate note: this route IS now gated by the customerPortal feature
|
||||
// flag via the requireCustomerPortalEnabled middleware mounted in
|
||||
// server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`).
|
||||
// When the admin flips the toggle off in Settings → Features, every
|
||||
// customer-side endpoint — including login — returns 410. The previous
|
||||
// design left login reachable while the rest of the surface was gated;
|
||||
// that was confusing and asymmetric. Single source of truth wins.
|
||||
// To lock out a specific customer without disabling the feature for
|
||||
// everyone, deactivate the account (customer_accounts.is_active = false).
|
||||
// The customerPortal feature flag deliberately does NOT gate this route.
|
||||
// Flipping the master toggle off in Settings → Features hides the
|
||||
// admin-side Clients section (sidebar entry, /admin/clients pages) but
|
||||
// must not revoke access for customers who already accepted an
|
||||
// invitation — that would mean a stray click in the Features tab
|
||||
// locks every paying customer out at once.
|
||||
//
|
||||
// To revoke access at the customer level, use the per-record tools:
|
||||
// - "Deactivate" on the customer detail page → sets
|
||||
// customer_accounts.is_active = false AND bumps password_changed_at,
|
||||
// which customerAuth rejects below + on every protected route.
|
||||
// - "Manage galleries" dialog → removes event_customer_assignments
|
||||
// rows, which verifyGalleryAccess re-checks on customer-minted
|
||||
// gallery JWTs (instant per-gallery revocation).
|
||||
router.post('/login', [
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
||||
body('password').isString().notEmpty(),
|
||||
|
||||
@@ -638,6 +638,185 @@ async function setAssignmentsForEvent(eventId, targetCustomerIds, adminId, trx =
|
||||
return { added: toAdd.length, removed: toRemove.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of setAssignmentsForEvent: replace the full set of events a
|
||||
* single customer is assigned to. Backs the "Manage galleries" dialog
|
||||
* on the customer detail page — admins pick from every available
|
||||
* event and we diff against the existing row set.
|
||||
*
|
||||
* Returns { added, removed } so the caller can surface a useful toast.
|
||||
*
|
||||
* `targetEventIds` may be empty to clear every assignment.
|
||||
*
|
||||
* Access revocation: removing a row from event_customer_assignments
|
||||
* is enough on its own — gallery middleware (galleryMiddleware.js)
|
||||
* checks for a live assignment whenever it decodes a JWT minted via
|
||||
* the customer access-token endpoint (decoded.via === 'customer').
|
||||
* No separate revoked_tokens write needed; the customer's next
|
||||
* request 401s the moment this transaction commits.
|
||||
*/
|
||||
async function setAssignmentsForCustomer(customerId, targetEventIds, adminId, trx = db) {
|
||||
const wanted = new Set((targetEventIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0));
|
||||
const existing = await trx('event_customer_assignments')
|
||||
.where('customer_account_id', customerId)
|
||||
.select('id', 'event_id');
|
||||
const existingIds = new Set(existing.map((r) => r.event_id));
|
||||
|
||||
const toAdd = [...wanted].filter((id) => !existingIds.has(id));
|
||||
const toRemove = existing.filter((r) => !wanted.has(r.event_id));
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await trx('event_customer_assignments')
|
||||
.whereIn('id', toRemove.map((r) => r.id))
|
||||
.del();
|
||||
}
|
||||
|
||||
// Collect the event IDs that actually landed in the DB (i.e. survived
|
||||
// the archived/missing filter) so the post-commit notifier knows
|
||||
// exactly which galleries to mention in the email. Empty by default.
|
||||
let addedEventIds = [];
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
// Validate the events exist + are not archived before inserting.
|
||||
// Mirrors the customer-side check in setAssignmentsForEvent so an
|
||||
// admin can't accidentally pin a customer to an archived event
|
||||
// that they couldn't actually open anyway.
|
||||
const valid = await trx('events')
|
||||
.whereIn('id', toAdd)
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.pluck('id');
|
||||
const validSet = new Set(valid);
|
||||
const ignored = toAdd.filter((id) => !validSet.has(id));
|
||||
if (ignored.length > 0) {
|
||||
logger.warn('Ignoring missing/archived event ids in customer assignment', {
|
||||
customerId, ignored,
|
||||
});
|
||||
}
|
||||
const rows = [...validSet].map((eventId) => ({
|
||||
event_id: eventId,
|
||||
customer_account_id: customerId,
|
||||
assigned_by_admin_id: adminId,
|
||||
assigned_at: new Date(),
|
||||
}));
|
||||
if (rows.length > 0) {
|
||||
await trx('event_customer_assignments').insert(rows);
|
||||
addedEventIds = rows.map((r) => r.event_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the customer about newly-accessible galleries. Best-effort
|
||||
// — a failure here must not roll back the assignment write, so we
|
||||
// fire-and-forget after the transactional work is done and swallow
|
||||
// any throw with a warn log. Skipped when no new rows were added.
|
||||
if (addedEventIds.length > 0) {
|
||||
notifyCustomerOfNewAssignments(customerId, addedEventIds).catch((err) => {
|
||||
logger.warn('Failed to queue customer_gallery_assigned email', {
|
||||
customerId, addedEventIds, error: err?.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { added: toAdd.length, removed: toRemove.length, addedEventIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a `customer_gallery_assigned` email summarising newly-granted
|
||||
* gallery access for one customer. Called by setAssignmentsForCustomer
|
||||
* after the transaction commits.
|
||||
*
|
||||
* Rules:
|
||||
* - One email per save (digest), not one per gallery.
|
||||
* - Archived + expired events are filtered out — the customer would
|
||||
* hit a "this gallery has expired" notice anyway, so naming them
|
||||
* in the email just confuses people.
|
||||
* - Deactivated customers (is_active=false) get no email — their
|
||||
* login is off, so a "you have new access" message would be
|
||||
* misleading.
|
||||
* - Customers without an email on file are skipped (silently —
|
||||
* should never happen for accepted accounts but defensive).
|
||||
* - Email failures are logged but never bubble up; the caller's
|
||||
* `.catch` handler logs again at a more specific call site.
|
||||
*/
|
||||
async function notifyCustomerOfNewAssignments(customerId, addedEventIds) {
|
||||
if (!addedEventIds || addedEventIds.length === 0) return;
|
||||
|
||||
const customer = await db('customer_accounts')
|
||||
.where({ id: customerId, is_active: formatBoolean(true) })
|
||||
.select('id', 'email', 'display_name', 'first_name', 'preferred_language')
|
||||
.first();
|
||||
if (!customer || !customer.email) {
|
||||
logger.info('Skip customer_gallery_assigned email: customer missing/inactive/no email', {
|
||||
customerId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter the added events to those the customer can actually open.
|
||||
// Archived events are hard-skipped; expired ones (expires_at in the
|
||||
// past) would render as "Expired DD MMM" in the dashboard and lead
|
||||
// to a confusing "I clicked the link in the email and got a 410"
|
||||
// experience — drop those too.
|
||||
const now = new Date();
|
||||
const events = await db('events')
|
||||
.whereIn('id', addedEventIds)
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.andWhere(function() {
|
||||
this.whereNull('expires_at').orWhere('expires_at', '>', now);
|
||||
})
|
||||
.orderBy('event_date', 'desc')
|
||||
.select('id', 'slug', 'event_name', 'event_date');
|
||||
|
||||
if (events.length === 0) {
|
||||
logger.info('Skip customer_gallery_assigned email: all added events archived/expired', {
|
||||
customerId, addedEventIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the gallery list block. HTML is whitelisted via
|
||||
// HTML_PASSTHROUGH_KEYS in emailProcessor so the <ul> survives the
|
||||
// body-html escaping pass. Names + dates come from admin-controlled
|
||||
// DB rows; the date is server-rendered.
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { escapeHtml } = require('../utils/formatters');
|
||||
const language = customer.preferred_language || 'en';
|
||||
|
||||
const formattedRows = await Promise.all(events.map(async (ev) => ({
|
||||
name: ev.event_name || ev.slug,
|
||||
date: ev.event_date ? await formatDate(ev.event_date, language) : '',
|
||||
})));
|
||||
|
||||
const galleryListHtml = `<ul>\n${
|
||||
formattedRows.map((r) => {
|
||||
const safeName = escapeHtml(r.name);
|
||||
const safeDate = r.date ? ` — ${escapeHtml(r.date)}` : '';
|
||||
return ` <li>${safeName}${safeDate}</li>`;
|
||||
}).join('\n')
|
||||
}\n</ul>`;
|
||||
|
||||
const galleryListText = formattedRows
|
||||
.map((r) => r.date ? `- ${r.name} (${r.date})` : `- ${r.name}`)
|
||||
.join('\n');
|
||||
|
||||
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||
const customerName = customer.display_name?.trim()
|
||||
|| customer.first_name?.trim()
|
||||
|| (customer.email ? customer.email.split('@')[0] : '');
|
||||
|
||||
await queueEmail(null, customer.email, 'customer_gallery_assigned', {
|
||||
customer_name: customerName,
|
||||
gallery_count: String(events.length),
|
||||
// `singular` / `multiple` drive the {{#if}} blocks in the
|
||||
// template — safeTemplateReplace treats anything non-empty +
|
||||
// non-false as truthy, so passing literal 'true' / '' works.
|
||||
singular: events.length === 1 ? 'true' : '',
|
||||
multiple: events.length > 1 ? 'true' : '',
|
||||
gallery_list_html: galleryListHtml,
|
||||
gallery_list_text: galleryListText,
|
||||
dashboard_link: `${frontendUrl}/customer/dashboard`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the customers currently assigned to an event. Returned by the
|
||||
* admin event-detail endpoint so the picker can hydrate.
|
||||
@@ -961,6 +1140,7 @@ module.exports = {
|
||||
eraseCustomer,
|
||||
searchCustomers,
|
||||
setAssignmentsForEvent,
|
||||
setAssignmentsForCustomer,
|
||||
getAssignmentsForEvent,
|
||||
listEventsForCustomer,
|
||||
customerHasAccessToEvent,
|
||||
|
||||
@@ -388,9 +388,14 @@ function htmlToText(html) {
|
||||
// else (event_name, host_name, customer_name, …) is admin-supplied free
|
||||
// text and gets escaped to prevent stored-HTML injection in customer mail.
|
||||
const HTML_PASSTHROUGH_KEYS = new Set([
|
||||
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
|
||||
'gallery_link', // server-generated URL (adminEvents.js)
|
||||
'client_link', // server-generated URL (adminEvents.js)
|
||||
'welcome_message', // already HTML (formatWelcomeMessage escapes + nl2br)
|
||||
'gallery_link', // server-generated URL (adminEvents.js)
|
||||
'client_link', // server-generated URL (adminEvents.js)
|
||||
// customer_gallery_assigned template (#354 follow-up): server-rendered
|
||||
// <ul> of newly-added galleries. Built in customerAccountsService from
|
||||
// trusted DB rows (event_name comes from admin-owned events; the date
|
||||
// is server-rendered) — escaping it here would double-escape the markup.
|
||||
'gallery_list_html',
|
||||
]);
|
||||
|
||||
const { escapeHtml } = require('../utils/formatters');
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* AssignedEventsDialog (#354 follow-up).
|
||||
*
|
||||
* Modal dialog that lets an admin replace the full set of events a
|
||||
* single customer is assigned to. Mounted from the "Assigned events"
|
||||
* card on CustomerDetailPage via the "Manage galleries" button.
|
||||
*
|
||||
* UX shape — multi-select autocomplete (mirrors CustomerAccountPicker):
|
||||
* - Search box at the top filters available events (admin-side
|
||||
* event list, debounced 200ms).
|
||||
* - Currently-selected events render as chips above the search.
|
||||
* - Click a chip to remove. Click a search result to add.
|
||||
* - Save replaces the customer's full assignment list via
|
||||
* PUT /admin/customers/:id/events.
|
||||
*
|
||||
* Access revocation: removing a chip + saving deletes the
|
||||
* event_customer_assignments row. Gallery middleware re-checks that
|
||||
* row on every customer-minted JWT, so the customer's next request
|
||||
* to a removed gallery 403s with CUSTOMER_ASSIGNMENT_REVOKED — no
|
||||
* token-blacklist step needed on the frontend.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, X, Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../common';
|
||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import type { Event as AdminEvent } from '../../services/events.service';
|
||||
|
||||
interface SelectedEvent {
|
||||
id: number;
|
||||
eventName: string;
|
||||
eventDate: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
customerId: number;
|
||||
isOpen: boolean;
|
||||
initial: SelectedEvent[];
|
||||
onClose: () => void;
|
||||
/** Called after a successful save so the parent can refetch. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export const AssignedEventsDialog: React.FC<Props> = ({ customerId, isOpen, initial, onClose, onSaved }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<SelectedEvent[]>(initial);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<AdminEvent[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Re-seed selection whenever the dialog is opened so we always start
|
||||
// from the server-current assignment list (not whatever the parent
|
||||
// last refetched before the previous close).
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelected(initial);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
// Autofocus the search input after the open animation settles.
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen, initial]);
|
||||
|
||||
// Debounced event search. Aborts in-flight responses so a fast typer
|
||||
// doesn't see a stale result win the race.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const term = query.trim();
|
||||
if (!term) {
|
||||
setResults([]);
|
||||
setIsSearching(false);
|
||||
return undefined;
|
||||
}
|
||||
setIsSearching(true);
|
||||
let cancelled = false;
|
||||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const resp = await eventsService.getEvents(1, 25, undefined, term);
|
||||
const events = Array.isArray((resp as any)?.events)
|
||||
? (resp as any).events as AdminEvent[]
|
||||
: ([] as AdminEvent[]);
|
||||
if (!cancelled) {
|
||||
// Filter out already-selected ids client-side. Cheaper than
|
||||
// round-tripping the selection state through the search API
|
||||
// and keeps the matching logic in one place.
|
||||
const selectedIds = new Set(selected.map((s) => s.id));
|
||||
setResults(events.filter((e) => !selectedIds.has(e.id)));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setResults([]);
|
||||
} finally {
|
||||
if (!cancelled) setIsSearching(false);
|
||||
}
|
||||
}, 200);
|
||||
return () => { cancelled = true; window.clearTimeout(handle); };
|
||||
}, [query, selected, isOpen]);
|
||||
|
||||
const add = (ev: AdminEvent) => {
|
||||
setSelected((prev) => [
|
||||
...prev,
|
||||
{ id: ev.id, eventName: ev.event_name, eventDate: ev.event_date || null },
|
||||
]);
|
||||
// Keep the typed query around so the admin can continue picking
|
||||
// additional matches from the same search (e.g. "Smith Wedding"
|
||||
// returns both the engagement + the wedding event; adding one
|
||||
// shouldn't force a re-type to add the other). The just-added
|
||||
// event drops out of the results automatically — the search
|
||||
// effect re-filters against the new `selected` set.
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const clearQuery = () => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const remove = (id: number) => {
|
||||
setSelected((prev) => prev.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const initialIds = useMemo(() => new Set(initial.map((s) => s.id)), [initial]);
|
||||
const selectedIds = useMemo(() => new Set(selected.map((s) => s.id)), [selected]);
|
||||
const isDirty = useMemo(() => {
|
||||
if (selectedIds.size !== initialIds.size) return true;
|
||||
for (const id of selectedIds) {
|
||||
if (!initialIds.has(id)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [selectedIds, initialIds]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.setEvents(customerId, [...selectedIds]),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
// Surface the diff so it's obvious revocations took effect.
|
||||
const parts: string[] = [];
|
||||
if (result.added) parts.push(t('customers.assignedEvents.addedN', '{{count}} added', { count: result.added }));
|
||||
if (result.removed) parts.push(t('customers.assignedEvents.removedN', '{{count}} removed', { count: result.removed }));
|
||||
toast.success(parts.length
|
||||
? t('customers.assignedEvents.savedDiff', 'Assignments updated: {{parts}}', { parts: parts.join(', ') })
|
||||
: t('customers.assignedEvents.saved', 'Assignments updated'));
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('customers.assignedEvents.error', 'Could not update assignments'));
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => {
|
||||
// Click-outside to close — only when the click was actually on
|
||||
// the backdrop, not on a child element that bubbled up.
|
||||
if (e.target === e.currentTarget && !saveMutation.isPending) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('customers.assignedEvents.title', 'Manage assigned galleries')}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{t(
|
||||
'customers.assignedEvents.subtitle',
|
||||
'Pick every gallery this customer should be able to access from their dashboard. Removing a gallery here revokes access immediately on the customer\'s next request.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saveMutation.isPending}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 flex-shrink-0"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Selected chips */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('customers.assignedEvents.currentLabel', 'Assigned galleries')}
|
||||
<span className="ml-1.5 normal-case text-neutral-400">({selected.length})</span>
|
||||
</label>
|
||||
{selected.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('customers.assignedEvents.empty', 'No galleries assigned yet. Search below to add one.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{selected.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="inline-flex items-center gap-2 pl-2 pr-1 py-1 rounded-full text-sm bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5 text-neutral-500" />
|
||||
<span className="truncate max-w-[220px]">{s.eventName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(s.id)}
|
||||
disabled={saveMutation.isPending}
|
||||
aria-label={t('customers.assignedEvents.removeAria', 'Remove {{name}}', { name: s.eventName })}
|
||||
className="p-0.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-neutral-500" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('customers.assignedEvents.searchLabel', 'Add a gallery')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('customers.assignedEvents.searchPlaceholder', 'Search by event name')}
|
||||
disabled={saveMutation.isPending}
|
||||
className="w-full pl-9 pr-9 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
{/* Inline clear button — visible only while the query has
|
||||
content. We keep the query through add() now so the
|
||||
admin needs an explicit way to wipe it before starting
|
||||
a new search. Esc would be lovely too but adding a
|
||||
global key handler inside a modal is more risk than
|
||||
this control is worth. */}
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearQuery}
|
||||
disabled={saveMutation.isPending}
|
||||
aria-label={t('customers.assignedEvents.clearSearchAria', 'Clear search')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-50"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-neutral-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results dropdown — inline (not a popover) since this is
|
||||
already inside a modal, no nested-popover headaches. */}
|
||||
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden bg-white dark:bg-neutral-800">
|
||||
{!query.trim() ? (
|
||||
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.assignedEvents.searchHint', 'Start typing to find galleries.')}
|
||||
</p>
|
||||
) : isSearching ? (
|
||||
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('common.searching', 'Searching…')}
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('customers.assignedEvents.noResults', 'No matching galleries.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul role="listbox">
|
||||
{results.map((ev) => (
|
||||
<li key={ev.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(ev)}
|
||||
disabled={saveMutation.isPending}
|
||||
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-neutral-50 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<CalendarIcon className="w-4 h-4 flex-shrink-0 text-neutral-400" />
|
||||
<span className="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{ev.event_name}
|
||||
</span>
|
||||
</span>
|
||||
{ev.event_date && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 flex-shrink-0">
|
||||
{ev.event_date}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
isLoading={saveMutation.isPending}
|
||||
>
|
||||
{t('customers.assignedEvents.save', 'Save assignments')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -84,22 +84,37 @@ export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
|
||||
* mount-time sessionStorage cache and stays stale until a hard reload.
|
||||
*/
|
||||
const refreshSession = React.useCallback(async () => {
|
||||
// Contract (see customerService.session()):
|
||||
// - object → fresh data, store it.
|
||||
// - null → server says we're explicitly logged out (401);
|
||||
// clear local state.
|
||||
// - throw → transient error (network blip, 5xx, timeout).
|
||||
// KEEP whatever state we have — logging the user out
|
||||
// on a flaky network call is the wrong default. The
|
||||
// old code clobbered local state on any error, which
|
||||
// caused mysterious "customer keeps getting kicked
|
||||
// out" reports during unrelated admin saves and on
|
||||
// brief connection drops.
|
||||
let response: Awaited<ReturnType<typeof customerService.session>>;
|
||||
try {
|
||||
const response = await customerService.session();
|
||||
if (response?.customer) {
|
||||
setCustomerState(response.customer);
|
||||
setFeatures(response.features);
|
||||
setBranding(response.branding);
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
|
||||
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
|
||||
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
|
||||
} else {
|
||||
setCustomerState(null);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem(FEATURES_KEY);
|
||||
sessionStorage.removeItem(BRANDING_KEY);
|
||||
}
|
||||
} catch {
|
||||
response = await customerService.session();
|
||||
} catch (err) {
|
||||
// Transient. Don't touch state. The next focus/visibility tick
|
||||
// will retry; if the customer really is unauthenticated the
|
||||
// retry will see the 401 and clear properly.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomerAuth] session refresh failed transiently, keeping current state', err);
|
||||
return;
|
||||
}
|
||||
if (response?.customer) {
|
||||
setCustomerState(response.customer);
|
||||
setFeatures(response.features);
|
||||
setBranding(response.branding);
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
|
||||
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
|
||||
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
|
||||
} else {
|
||||
// Explicit 401 — server says no.
|
||||
setCustomerState(null);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem(FEATURES_KEY);
|
||||
|
||||
@@ -2938,7 +2938,8 @@
|
||||
"success": "Passwort-Reset-E-Mail gesendet",
|
||||
"error": "Passwort-Reset konnte nicht gesendet werden",
|
||||
"inactive": "Aktiviere den Kunden, bevor du einen Reset sendest."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Galerien verwalten"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reaktivieren",
|
||||
@@ -2953,6 +2954,24 @@
|
||||
"confirmInFlight": "Lösche…",
|
||||
"success": "Kunde gelöscht",
|
||||
"error": "Kunde konnte nicht gelöscht werden"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Zugewiesene Galerien verwalten",
|
||||
"subtitle": "Wähle jede Galerie aus, auf die dieser Kunde über sein Dashboard zugreifen können soll. Eine hier entfernte Galerie wird beim nächsten Aufruf des Kunden sofort gesperrt.",
|
||||
"currentLabel": "Zugewiesene Galerien",
|
||||
"empty": "Noch keine Galerien zugewiesen. Suche unten, um eine hinzuzufügen.",
|
||||
"searchLabel": "Galerie hinzufügen",
|
||||
"searchPlaceholder": "Nach Eventname suchen",
|
||||
"searchHint": "Beginne zu tippen, um Galerien zu finden.",
|
||||
"noResults": "Keine passenden Galerien.",
|
||||
"removeAria": "{{name}} entfernen",
|
||||
"save": "Zuweisungen speichern",
|
||||
"saved": "Zuweisungen aktualisiert",
|
||||
"savedDiff": "Zuweisungen aktualisiert: {{parts}}",
|
||||
"addedN": "{{count}} hinzugefügt",
|
||||
"removedN": "{{count}} entfernt",
|
||||
"error": "Zuweisungen konnten nicht aktualisiert werden",
|
||||
"clearSearchAria": "Suche löschen"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -2938,7 +2938,8 @@
|
||||
"success": "Password reset email sent",
|
||||
"error": "Could not send password reset",
|
||||
"inactive": "Reactivate the customer before sending a reset."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Manage galleries"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reactivate",
|
||||
@@ -2953,6 +2954,24 @@
|
||||
"confirmInFlight": "Erasing…",
|
||||
"success": "Customer erased",
|
||||
"error": "Could not erase customer"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Manage assigned galleries",
|
||||
"subtitle": "Pick every gallery this customer should be able to access from their dashboard. Removing a gallery here revokes access immediately on the customer's next request.",
|
||||
"currentLabel": "Assigned galleries",
|
||||
"empty": "No galleries assigned yet. Search below to add one.",
|
||||
"searchLabel": "Add a gallery",
|
||||
"searchPlaceholder": "Search by event name",
|
||||
"searchHint": "Start typing to find galleries.",
|
||||
"noResults": "No matching galleries.",
|
||||
"removeAria": "Remove {{name}}",
|
||||
"save": "Save assignments",
|
||||
"saved": "Assignments updated",
|
||||
"savedDiff": "Assignments updated: {{parts}}",
|
||||
"addedN": "{{count}} added",
|
||||
"removedN": "{{count}} removed",
|
||||
"error": "Could not update assignments",
|
||||
"clearSearchAria": "Clear search"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -2892,7 +2892,8 @@
|
||||
"success": "E-mail de réinitialisation envoyé",
|
||||
"error": "Impossible d'envoyer la réinitialisation",
|
||||
"inactive": "Réactivez le client avant d'envoyer une réinitialisation."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Gérer les galeries"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Réactiver",
|
||||
@@ -2907,6 +2908,24 @@
|
||||
"confirmInFlight": "Effacement…",
|
||||
"success": "Client effacé",
|
||||
"error": "Impossible d'effacer le client"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Gérer les galeries assignées",
|
||||
"subtitle": "Sélectionnez toutes les galeries auxquelles ce client doit pouvoir accéder depuis son tableau de bord. Retirer une galerie ici révoque l'accès immédiatement à la prochaine requête du client.",
|
||||
"currentLabel": "Galeries assignées",
|
||||
"empty": "Aucune galerie assignée pour le moment. Recherchez ci-dessous pour en ajouter une.",
|
||||
"searchLabel": "Ajouter une galerie",
|
||||
"searchPlaceholder": "Rechercher par nom d'événement",
|
||||
"searchHint": "Commencez à taper pour trouver des galeries.",
|
||||
"noResults": "Aucune galerie correspondante.",
|
||||
"removeAria": "Retirer {{name}}",
|
||||
"save": "Enregistrer les assignations",
|
||||
"saved": "Assignations mises à jour",
|
||||
"savedDiff": "Assignations mises à jour : {{parts}}",
|
||||
"addedN": "{{count}} ajoutée(s)",
|
||||
"removedN": "{{count}} retirée(s)",
|
||||
"error": "Impossible de mettre à jour les assignations",
|
||||
"clearSearchAria": "Effacer la recherche"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -2938,7 +2938,8 @@
|
||||
"success": "Wachtwoord-resetmail verzonden",
|
||||
"error": "Wachtwoordreset kon niet worden verzonden",
|
||||
"inactive": "Reactiveer de klant voordat je een reset verstuurt."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Galerijen beheren"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Heractiveren",
|
||||
@@ -2953,6 +2954,24 @@
|
||||
"confirmInFlight": "Bezig met wissen…",
|
||||
"success": "Klant gewist",
|
||||
"error": "Klant kon niet worden gewist"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Toegewezen galerijen beheren",
|
||||
"subtitle": "Selecteer elke galerij waartoe deze klant toegang moet hebben vanaf zijn dashboard. Een hier verwijderde galerij wordt bij het volgende verzoek van de klant direct geblokkeerd.",
|
||||
"currentLabel": "Toegewezen galerijen",
|
||||
"empty": "Nog geen galerijen toegewezen. Zoek hieronder om er een toe te voegen.",
|
||||
"searchLabel": "Galerij toevoegen",
|
||||
"searchPlaceholder": "Zoeken op evenementnaam",
|
||||
"searchHint": "Begin met typen om galerijen te vinden.",
|
||||
"noResults": "Geen overeenkomende galerijen.",
|
||||
"removeAria": "{{name}} verwijderen",
|
||||
"save": "Toewijzingen opslaan",
|
||||
"saved": "Toewijzingen bijgewerkt",
|
||||
"savedDiff": "Toewijzingen bijgewerkt: {{parts}}",
|
||||
"addedN": "{{count}} toegevoegd",
|
||||
"removedN": "{{count}} verwijderd",
|
||||
"error": "Toewijzingen konden niet worden bijgewerkt",
|
||||
"clearSearchAria": "Zoekopdracht wissen"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -2971,7 +2971,8 @@
|
||||
"success": "E-mail de redefinição enviado",
|
||||
"error": "Não foi possível enviar a redefinição",
|
||||
"inactive": "Reative o cliente antes de enviar uma redefinição."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Gerenciar galerias"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reativar",
|
||||
@@ -2986,6 +2987,24 @@
|
||||
"confirmInFlight": "Apagando…",
|
||||
"success": "Cliente apagado",
|
||||
"error": "Não foi possível apagar o cliente"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Gerenciar galerias atribuídas",
|
||||
"subtitle": "Selecione todas as galerias às quais este cliente deve poder acessar a partir do seu painel. Remover uma galeria aqui revoga o acesso imediatamente na próxima solicitação do cliente.",
|
||||
"currentLabel": "Galerias atribuídas",
|
||||
"empty": "Nenhuma galeria atribuída ainda. Pesquise abaixo para adicionar uma.",
|
||||
"searchLabel": "Adicionar uma galeria",
|
||||
"searchPlaceholder": "Pesquisar por nome do evento",
|
||||
"searchHint": "Comece a digitar para encontrar galerias.",
|
||||
"noResults": "Nenhuma galeria correspondente.",
|
||||
"removeAria": "Remover {{name}}",
|
||||
"save": "Salvar atribuições",
|
||||
"saved": "Atribuições atualizadas",
|
||||
"savedDiff": "Atribuições atualizadas: {{parts}}",
|
||||
"addedN": "{{count}} adicionada(s)",
|
||||
"removedN": "{{count}} removida(s)",
|
||||
"error": "Não foi possível atualizar as atribuições",
|
||||
"clearSearchAria": "Limpar pesquisa"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -3004,7 +3004,8 @@
|
||||
"success": "Письмо для сброса пароля отправлено",
|
||||
"error": "Не удалось отправить сброс пароля",
|
||||
"inactive": "Активируйте клиента перед отправкой сброса."
|
||||
}
|
||||
},
|
||||
"manageEvents": "Управление галереями"
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Активировать",
|
||||
@@ -3019,6 +3020,24 @@
|
||||
"confirmInFlight": "Стираем…",
|
||||
"success": "Данные клиента стёрты",
|
||||
"error": "Не удалось стереть данные клиента"
|
||||
},
|
||||
"assignedEvents": {
|
||||
"title": "Управление назначенными галереями",
|
||||
"subtitle": "Выберите все галереи, к которым этот клиент должен иметь доступ из своей панели. Удалённая здесь галерея станет недоступной для клиента при его следующем запросе.",
|
||||
"currentLabel": "Назначенные галереи",
|
||||
"empty": "Галерей пока не назначено. Воспользуйтесь поиском ниже, чтобы добавить.",
|
||||
"searchLabel": "Добавить галерею",
|
||||
"searchPlaceholder": "Поиск по названию события",
|
||||
"searchHint": "Начните вводить, чтобы найти галереи.",
|
||||
"noResults": "Подходящих галерей нет.",
|
||||
"removeAria": "Удалить {{name}}",
|
||||
"save": "Сохранить назначения",
|
||||
"saved": "Назначения обновлены",
|
||||
"savedDiff": "Назначения обновлены: {{parts}}",
|
||||
"addedN": "добавлено: {{count}}",
|
||||
"removedN": "удалено: {{count}}",
|
||||
"error": "Не удалось обновить назначения",
|
||||
"clearSearchAria": "Очистить поиск"
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
|
||||
@@ -15,11 +15,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
|
||||
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft,
|
||||
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
@@ -53,6 +54,11 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
|
||||
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||
const [confirmErase, setConfirmErase] = useState(false);
|
||||
// Drives the "Manage galleries" modal launched from the Assigned
|
||||
// events card. We hold open-state here (rather than inside the
|
||||
// dialog) so the parent decides when to mount/unmount and the
|
||||
// dialog can hard-reset its internal state per open.
|
||||
const [assignedDialogOpen, setAssignedDialogOpen] = useState(false);
|
||||
|
||||
// Hydrate the form from the fetched record once. We deliberately do NOT
|
||||
// re-sync on every refetch so an admin's in-progress edits aren't blown
|
||||
@@ -284,6 +290,87 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Section order rationale (follow-up reorder request): the
|
||||
customer detail page now flows from "who they are" (Personal)
|
||||
→ "what we know about them" (Notes) → "what they've worked
|
||||
with us on" (Events) → "how to bill them" (Billing) → "what
|
||||
they can do in the portal" (Features) → "destructive admin
|
||||
actions" (Actions). Notes + Events promoted out from below
|
||||
billing/features because they're the surfaces admins glance
|
||||
at most when opening a customer record. */}
|
||||
|
||||
{/* Notes (admin-only) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
|
||||
</p>
|
||||
<textarea
|
||||
value={form.notes || ''}
|
||||
onChange={setField('notes') as any}
|
||||
rows={4}
|
||||
className="input w-full"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Assigned events */}
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center justify-between gap-4 mb-4 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
|
||||
</h2>
|
||||
{/* Manage galleries: opens the multi-select dialog that
|
||||
replaces the customer's full assignment list. Disabled
|
||||
for deactivated customers because their login is off
|
||||
anyway — re-enable first if the admin wants to plan
|
||||
their access. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<SettingsIcon className="w-4 h-4" />}
|
||||
onClick={() => setAssignedDialogOpen(true)}
|
||||
disabled={!customer.isActive}
|
||||
>
|
||||
{t('customers.detail.manageEvents', 'Manage galleries')}
|
||||
</Button>
|
||||
</div>
|
||||
{customer.events.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noEvents', 'Not assigned to any events yet. Use "Manage galleries" to add some.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{customer.events.map((ev) => (
|
||||
<li key={ev.id} className="py-2 flex items-center justify-between">
|
||||
<Link to={`/admin/events/${ev.id}`} className="text-theme hover:underline">
|
||||
{ev.eventName}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">
|
||||
{ev.eventDate ? formatDate(ev.eventDate) : ''}
|
||||
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AssignedEventsDialog
|
||||
customerId={customer.id}
|
||||
isOpen={assignedDialogOpen}
|
||||
initial={customer.events.map((ev) => ({
|
||||
id: ev.id,
|
||||
eventName: ev.eventName,
|
||||
eventDate: ev.eventDate || null,
|
||||
}))}
|
||||
onClose={() => setAssignedDialogOpen(false)}
|
||||
onSaved={() => {
|
||||
// Parent refetch is handled by the dialog's invalidateQueries.
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Address + billing */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
@@ -408,48 +495,6 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notes (admin-only) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
|
||||
</p>
|
||||
<textarea
|
||||
value={form.notes || ''}
|
||||
onChange={setField('notes') as any}
|
||||
rows={4}
|
||||
className="input w-full"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Assigned events */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
|
||||
</h2>
|
||||
{customer.events.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noEvents', 'Not assigned to any events yet. Add this customer to an event from the event form.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{customer.events.map((ev) => (
|
||||
<li key={ev.id} className="py-2 flex items-center justify-between">
|
||||
<Link to={`/admin/events/${ev.id}`} className="text-theme hover:underline">
|
||||
{ev.eventName}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">
|
||||
{ev.eventDate ? formatDate(ev.eventDate) : ''}
|
||||
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -126,6 +126,23 @@ export const customerService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the current customer session.
|
||||
*
|
||||
* Return contract:
|
||||
* - object → fresh customer + features + branding from the server.
|
||||
* - null → backend says we are NOT authenticated (401). The
|
||||
* caller should clear local state and bounce to login.
|
||||
* - throws → any other error (network blip, 5xx, timeout, 410
|
||||
* from a feature-flag flip mid-flight). The caller
|
||||
* should KEEP whatever state it has — punishing the
|
||||
* user with a logout for a transient failure is the
|
||||
* wrong default. Previously this catch swallowed
|
||||
* everything and returned null, which logged the
|
||||
* customer out on the slightest server hiccup
|
||||
* (including the brief window while the admin saves
|
||||
* an unrelated change like gallery assignments).
|
||||
*/
|
||||
async session(): Promise<{
|
||||
customer: CustomerProfile;
|
||||
features: { calendar: boolean; quotes: boolean; bills: boolean };
|
||||
@@ -142,8 +159,14 @@ export const customerService = {
|
||||
features: response.data.features || { calendar: false, quotes: false, bills: false },
|
||||
branding: response.data.branding || { showLogo: true, showCompanyName: true },
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
// Only treat an explicit 401 as "session is gone". Anything else
|
||||
// (network failure, server 500, etc.) is a transient problem
|
||||
// and should not log the customer out.
|
||||
if (error?.response?.status === 401) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -159,6 +159,26 @@ export const customerAdminService = {
|
||||
return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string };
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the full set of events this customer is assigned to.
|
||||
* Empty array clears every assignment. The backend rejects any
|
||||
* archived event ids it sees, so the response { added, removed }
|
||||
* counts may be lower than the input length if the admin selected
|
||||
* something stale — surface the numbers in a toast.
|
||||
*
|
||||
* Access revocation: gallery middleware re-checks the assignment
|
||||
* row on every customer-minted JWT, so removing an event here
|
||||
* immediately blocks the customer's next request to that gallery.
|
||||
* No separate token-blacklist call needed.
|
||||
*/
|
||||
async setEvents(id: number, eventIds: number[]): Promise<{ added: number; removed: number }> {
|
||||
const response = await api.put<{ data: { added: number; removed: number } } | { added: number; removed: number }>(
|
||||
`/admin/customers/${id}/events`,
|
||||
{ event_ids: eventIds },
|
||||
);
|
||||
return ((response.data as any).data ?? response.data) as { added: number; removed: number };
|
||||
},
|
||||
|
||||
/**
|
||||
* Invite a customer. `prefill` is an optional set of profile fields the
|
||||
* admin can pre-populate on the invitation row — the customer sees them
|
||||
|
||||
Reference in New Issue
Block a user