Skip to content

Store-manager customer management + per-store customer identity#717

Open
KrzysztofPajak wants to merge 17 commits into
developfrom
feature/store-customer-management
Open

Store-manager customer management + per-store customer identity#717
KrzysztofPajak wants to merge 17 commits into
developfrom
feature/store-customer-management

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Summary

Adds customer management for store managers in Grand.Web.Store and an opt-in per-store customer identity mode (e-mail/username unique per store instead of globally), configured from appsettings.json.

Both features are off by default (Customer:RegisterCustomersPerStore = false) → no behavioural change unless enabled.

Workstream A — Per-store customer identity (appsettings)

  • New CustomerConfig.RegisterCustomersPerStore ("Customer" section, bound in StartupBase, documented in all host appsettings). When on, the uniqueness key becomes e-mail/username + StoreId; customers stay in one collection (discriminated by StoreId).
  • ICustomerService.GetCustomerByEmail/GetCustomerByUsername gained an optional storeId:
    • store-scoped lookup: prefers the store's own customer, falls back to the store-independent (system/admin) account;
    • global lookup: prefers the store-independent account (so admin login isn't shadowed), falls back to any match.
  • Wired through the identity flows (only scoped when the flag is on): registration, storefront login/2FA/recovery/activation, CustomerManagerService.LoginCustomer/ChangePassword, profile & sub-account validators, AdminShared CustomerValidator, and the Frontend API (tokens carry a CustomerId claim; auth resolves by GetCustomerById, with e-mail/guid fallback for old tokens).
  • Compound Email_StoreId / Username_StoreId indexes added.
  • Back-office panel login (BaseLoginController) and backend/admin API stay global by design.
  • Hardening: backend DELETE /Customer/{email} refuses to delete a store-independent (admin/back-office) account under per-store; storefront Store-portal link now shown only to real staff (StaffStoreId non-empty); vendor merchandise-return search scoped to the current store.

Workstream B — Store-manager customer panel (Grand.Web.Store)

  • New scoped CustomerController managing only the Registered customers of the manager's store, reusing the AdminShared ICustomerViewModelService/CustomerModel. Ownership guard + server-forced StoreId/Registered group/cleared Owner/Vendor/Staff/Se.
  • Store-area views adapted from Admin; TabInfo strips role/Owner/Vendor/Staff/Store fields; tabs shown except Documents, Notes and ActivityLog. No export, no impersonation.
  • The whole panel is gated: when per-store identity is off, every action routes to a PerStoreDisabled page.
  • ManageCustomers added to StoreManager default permissions.

Tests

  • CustomerService: full branch coverage for the store-aware/storeless lookups.
  • CustomerManagerService: storeId threading for login/change-password.
  • Grand.Web.Store CustomerController: per-store gate + forced store/registered-only constraints.
  • Solution builds; affected suites green.

Notes / known caveat

  • The flag must be identical across all hosts (shared DB).
  • Admin/system accounts are created without a store and must stay globally unique; a store customer reusing a staff e-mail (staff has a StoreId) is an un-addressed edge — would need back-office e-mail reservation.

🤖 Generated with Claude Code

KrzysztofPajak and others added 12 commits June 15, 2026 20:13
Workstream A - per-store customer identity (appsettings-driven):
- New CustomerConfig.RegisterCustomersPerStore flag (bound from the "Customer"
  section; documented in all host appsettings.json). Default false keeps
  behaviour unchanged.
- ICustomerService.GetCustomerByEmail/GetCustomerByUsername gain an optional
  storeId (empty => global). Wired through registration, storefront login/2FA,
  password recovery, account activation, profile/sub-account editors and the
  AdminShared CustomerValidator (scoped only when the flag is on).
- CookieAuthenticationService now writes a customer-id claim and re-resolves the
  session via GetCustomerById (unambiguous with per-store), with a fallback for
  pre-existing cookies. ChangePassword callers holding a customer pass StoreId.
- Compound Email+StoreId / Username+StoreId indexes added (non-unique;
  uniqueness enforced in the app layer).

Workstream B - store-manager customer panel (Grand.Web.Store):
- New CustomerController managing only the Registered customers of the manager's
  store, reusing AdminShared ICustomerViewModelService/CustomerModel. Ownership
  guard + server-forced StoreId/Registered group/cleared Owner/Vendor/Staff/Se.
- Store-area views adapted from Admin; TabInfo strips role/Owner/Vendor/Staff/
  Store fields; tabs shown except Documents, Notes and ActivityLog. No export,
  no impersonation.
- ManageCustomers added to StoreManager default permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issued storefront-API tokens now carry a CustomerId claim and authentication
resolves the customer by GetCustomerById (unambiguous with per-store identity),
falling back to e-mail/guid for tokens issued before the claim existed:
- TokenWebController.Login resolves email+store and adds the CustomerId claim;
  Refresh rebuilds the claims from the resolved customer.
- ApiAuthenticationService (JWT + frontend scheme) and
  JwtBearerCustomerAuthenticationService prefer the CustomerId claim.
- LoginWebValidator scopes the credential check to the current store.

Backend/admin API and back-office panel login stay global by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The store-manager customer panel only makes sense with per-store customer
identity. When Customer:RegisterCustomersPerStore is off, a controller gate
routes every action to a dedicated PerStoreDisabled action/view explaining how
to enable the setting (no ViewBag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CustomerService: store-scoped GetCustomerByEmail/GetCustomerByUsername
  (matching store, no-match returns null).
- CustomerManagerService: LoginCustomer/ChangePassword thread the storeId into
  the lookup.
- Store CustomerController: per-store gate (redirect to PerStoreDisabled when off,
  allowed when on / for the notice action) and Create forcing store-scoped,
  registered-only constraints regardless of posted values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The customer auth cookie is host-only, so a different subdomain already gets a
separate session. As defense in depth (covers shared parent-domain cookies or
several stores on one host), WorkContextSetter now rejects a cookie-authenticated
storefront shopper whose StoreId differs from the current store when
Customer:RegisterCustomersPerStore is on. Back-office accounts (admin/store
manager/sales/vendor) are excluded, so panel login is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped default must stay off (consistent with the docs and the other hosts);
the flag was left on from local testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The WorkContextSetter store-isolation guard (and IsStorefrontShopper) is removed:
the customer auth cookie is host-only, so a different subdomain already gets a
separate session. The guard only helped atypical deployments (shared
parent-domain cookie / several stores on one host) at the cost of back-office
role exclusions. Impersonation is unchanged (admin-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With per-store identity on, a store customer could reuse the admin's e-mail
(admin has no StoreId), and the global GetCustomerByEmail/Username used by
back-office panel login could resolve the store customer instead of the admin,
locking the admin out.

Global (empty-storeId) lookups now prefer the store-independent account
(StoreId null/"") and fall back to any match, only when the flag is on.
CustomerService takes CustomerConfig. Added a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- GetCustomerByEmail/Username: a store-scoped lookup (storefront login) now falls
  back to the store-independent system/back-office account (e.g. the storeless
  administrator) when the store has no matching customer, so the admin can sign
  in through the storefront under per-store identity.
- HeaderLinks: show the Store portal link only when StaffStoreId is non-empty
  (it can be "" after a customer is saved via the editor), not just non-null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tity

The backend customer API addresses customers by e-mail, which is not unique once
per-store identity is on - and the global lookup prefers the store-independent
account. DeleteCustomer only blocks built-in system accounts, not the admin, so a
DELETE /Customer/{email} meant for a store customer could resolve and delete the
administrator. Refuse to delete a store-independent (StoreId empty) account through
this by-email endpoint while per-store identity is enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ore identity

PrepareMerchandiseReturnModel resolved the search customer by e-mail globally;
with per-store identity the same e-mail can exist in several stores. Scope the
lookup to the current store (empty when the flag is off) so the search matches
this store's customer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers: empty/null/whitespace email, case-insensitivity, store-scoped match,
store-scoped fallback to the storeless account (StoreId "" and null), store wins
over storeless, no-match-no-storeless returns null, global prefer-storeless,
global fallback to any match, and the per-store-off branches (exact store match,
no fallback, global match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
{
var customAttributes =
await model.Address.ParseCustomAddressAttributes(_addressAttributeParser, _addressAttributeService);
address = await _customerViewModelService.UpdateAddressModel(customer, address, model, customAttributes);
Comment thread src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs Dismissed
Comment thread src/Web/Grand.Web.Store/Controllers/CustomerController.cs Fixed
Comment thread src/Web/Grand.Web.Store/Controllers/CustomerController.cs Fixed
Comment thread src/Web/Grand.Web.Store/Controllers/CustomerController.cs Fixed
Comment thread src/Web/Grand.Web.Store/Controllers/CustomerController.cs Fixed
KrzysztofPajak and others added 5 commits June 16, 2026 20:27
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
- JwtBearerCustomerAuthenticationService / CookieAuthenticationService: resolve
  the authenticated customer by the CustomerId claim (GetCustomerById) instead of
  e-mail.
- PermissionProvider: StoreManager default permissions include ManageCustomers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
19.0% Coverage on New Code (required ≥ 80%)
10.7% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant