Skip to content

feat(shopping-lists): Share a list by revocable link (version 1) - #149

Merged
OffCrazyFreak merged 26 commits into
devfrom
feat/shopping-list-sharing
Aug 6, 2026
Merged

feat(shopping-lists): Share a list by revocable link (version 1)#149
OffCrazyFreak merged 26 commits into
devfrom
feat/shopping-list-sharing

Conversation

@OffCrazyFreak

Copy link
Copy Markdown
Owner

Replaces the half-built isPublic sharing with a capability-token model: an owner turns on a link, picks what it grants, and the link can be revoked by turning sharing off and on again.

What changed

Backend

  • New ListAccess enum (NONE, VIEW, SHOP, EDIT, OWNER) with intent-named helpers instead of ordinal comparisons at call sites.
  • ShoppingListAccessService is the single authorization rule. Owner wins outright, otherwise the link level applies, and anonymous callers are capped at VIEW so every write stays attributable to an account.
  • ShoppingList drops isPublic and gains a nullable link_access plus a unique share_token. The token is deliberately not the list id, so turning sharing off and on is a real revoke rather than handing the same URL back to everyone who kept it.
  • New SharedShoppingListController at /api/shared, the only path a token flows through: GET /{token} (VIEW), PUT /{token} (EDIT, title), PUT /{token}/items/{itemId} (SHOP for the in-the-shop fields, EDIT for the rest), DELETE /{token}/items/{itemId} (EDIT). An unknown, malformed or disabled token is a 404, never a 403, so the endpoint cannot confirm a list exists.
  • Deleted the || isPublic write holes in ShoppingListItemService. Any logged-in stranger holding a list UUID could previously add, edit and delete items on a public list; the authenticated endpoints are now strictly owner-only.
  • ShoppingListItemController now binds and passes the {listId} path variable it previously declared and ignored.
  • New ShoppingListMapper retires the item-to-DTO mapping that was duplicated across two services, and only populates linkAccess/shareToken when the caller is the owner.

Frontend

  • Share settings modal at ?modal=shopping-list/share, a settings panel rather than a form: each control saves on change, the only footer action is closing. The existing text share ("Podijeli kao tekst") moves in here rather than being lost.
  • New public route /s/[token], reusing the existing item, price history and store summary sections so there is one set of components, not two. Logged out gets the full read-only page plus a login banner; the owner following their own link is replaced onto the real list page.
  • Item controls are gated on the caller's access: checkbox and store select disable, amount controls and the remove button hide.
  • New shared CopyButton, extracted from two byte-identical copyEmail() helpers.

Privacy details worth a look

  • The shared query key is ["sharedShoppingList", token], deliberately outside the shoppingLists root. The offline IndexedDB cache is browser-wide and only purges on a logout transition, so a visitor who never logs in would otherwise keep someone else's list on disk for seven days.
  • sw.ts gets a NetworkOnly matcher for /s/, above defaultCache, which would otherwise keep the server-rendered list title in Cache Storage for 24 days keyed by URL alone.
  • X-Robots-Tag: noindex rather than a robots.txt disallow, because a disallow stops the crawler fetching the page and therefore stops it ever seeing the directive.
  • Copying a shared list produces a private list by construction, and the toast says so.

Deploy requirement

ALTER TABLE shopping_list DROP COLUMN is_public; must run before this deploys. ddl-auto=update never drops columns and is_public is NOT NULL with no default, so without it every insert into shopping_list fails. Already run against local.

Four lists had is_public = true and are now private. They need re-sharing by hand if wanted.

Notes

Versions 2 (named members, invite links, per-person revocation) and 3 (invite by email or username, blocked on the notifications rework) are designed but not built.

This is intended to merge before #142, #143 and #144. Their conventions (LOADING_LABELS, the query key factory, the modal registry split) are pre-applied here so their hunks land as no-ops.

Checks: prettier, next typegen + tsc --noEmit, and pnpm lint all pass. next build could not run in this worktree (Turbopack rejects the node_modules symlink); no dependencies changed. Backend verified by compiling all sources with javac; the Maven build is yours to run.

Changes:
- Add a ListAccess enum (NONE, VIEW, SHOP, EDIT, OWNER) with capability helpers
- Replace shopping_list.is_public with a nullable link_access column and a
  unique share_token, minted on enable and nulled on disable
- Add ShoppingListAccessService as the single authorization rule
- Add SharedShoppingListService and /api/shared/{token} for read, rename and
  item update or delete, permit-all at the filter chain
- Extract ShoppingListMapper, retiring the item mapping duplicated across
  ShoppingListService and ShoppingListItemService
- Drop the "or the list is public" write fallbacks, making the authenticated
  endpoints strictly owner-only
- Bind and verify the previously ignored {listId} on item update and delete

isPublic was unreachable speculative code that granted write access to any
logged-in stranger holding the list UUID, and it could never be revoked because
the link was the primary key. A separate token means turning sharing off and on
again actually invalidates the old link. Anonymous callers resolve to VIEW at
most, so every write stays attributable to an account.

Notes:
- Requires ALTER TABLE shopping_list DROP COLUMN is_public; before deploy.
  ddl-auto=update never drops columns and is_public is NOT NULL with no default,
  so inserts fail until it is gone.
- Verified with javac against the local m2 repository: 81 sources, no errors.
  Maven build not run.
Changes:
- Replace isPublic in the zod schemas with linkAccess, shareToken and
  myAccess, and clear the three creation call sites that hardcoded it
- Add shared-list fetchers and hooks against /api/shared/{token}
- Key the shared read as ["sharedShoppingList", token], deliberately outside
  the offline persistence allowlist
- Add resolveShoppingListAccess, mirroring the backend rule from myAccess
- Add shareListUrl, building /s/{token} rather than exposing the list id
- Register sharedItemUpdate and sharedItemDelete as offline mutations, with
  an onError on their replay defaults
- Give the visibility indicator three shared states instead of two
- Bump the offline CACHE_BUSTER to "2"

The persisted ShoppingListDto shape changed, so a restored pre-change list
would have no myAccess and every capability check would read that as no
access. Hence the buster bump.

Copying a list deliberately does not carry sharing over, and now says so in
its toast. A copy is a new object, and inheriting a capability token would
mint a live secret nobody chose to hand out. This matches AnyList, Todoist,
Notion, Trello, Keep and Drive; Asana is the only mainstream product that
inherits, and that is a team ACL rather than a secret link.

Notes:
- constants/loading-labels.ts and lib/api/shopping-lists/keys.ts are taken
  verbatim from feat/button-loading-labels and feat/loading-skeletons so those
  branches rebase onto this one as no-op hunks. keys.ts adds one byToken entry.
- feat/loading-skeletons should use CACHE_BUSTER "3", not "2".
- Checks: tsc --noEmit clean, eslint 0 errors, prettier applied. The 27 eslint
  warnings are pre-existing and in untouched files.
Changes:
- Thread canCheck and canEditItems into the item row from myAccess, disabling
  the checkbox and store select below SHOP and hiding amount buttons and the
  remove button below EDIT
- Keep the amount visible when read-only, with an sr-only "Količina" prefix,
  since the number is information rather than only a control
- Route item writes through /api/shared/{token} when the list was opened from
  a share link, including the optimistic cache key
- Show the "add products" empty-state CTA only to the owner, since version 1
  has no way for anyone else to add items

Controls that would fail server-side should not be offered. Hiding removal
rather than disabling it keeps a read-only row free of permanently dead
buttons, while the checkbox stays visible and disabled because its state is
part of the list's content.

Notes:
- Shared writes deliberately do not toast at the call site; their replay
  defaults own that message, and doing both would show it twice.
- Checks: tsc --noEmit clean, prettier applied.
Changes:
- Add ?modal=shopping-list/share, a settings panel that saves on change rather
  than behind a submit button, with a sharing switch, the three access levels
  and the link with a copy button
- Keep "Podijeli kao tekst" in the same modal, so the existing plain-text share
  is relocated rather than lost
- Point the owner's share action at the modal; everyone else still gets the
  direct share, with the link they already hold or plain text
- Hide edit, delete and the visibility badge from non-owners
- Extract components/custom/common/copy-button.tsx and retire the two identical
  copyEmail helpers in admin-contact-row and contact-channels
- Split the shopping-list and digital-card cases in parseModalParam and
  entity-modal-outlet, which the share action needs anyway

Sharing has to save immediately because the server mints the token: there is no
link to show until a save returns, so a submit button would leave the primary
content of the dialog empty until pressed.

Notes:
- The action components now derive their labels the way feat/button-loading-labels
  does, and the modal-registry and outlet splits match feat/digital-cards-rework,
  so both rebase onto this branch as no-op hunks.
- Checks: tsc --noEmit clean, eslint clean on changed files, prettier applied.
Changes:
- Add /s/[token], rendering the full list (items, prices, price history, store
  summary) for anyone holding the link, with controls gated on the resolved
  access level
- Read the list through /api/shared/{token} by teaching useShoppingListData to
  take a token instead of an id
- Show a login banner to logged-out visitors, who are capped at read-only
- Redirect owners to /shopping-lists/{id}, where sharing and deleting live
- Build the link preview server-side from the list title and item count, with
  Croatian item pluralisation
- Add a NetworkOnly service worker rule for /s/ and X-Robots-Tag: noindex for
  /s/ and /shopping-lists/

Serwist's defaultCache keeps documents for 24 days keyed by URL alone, with no
notion of who requested them, so server-rendering someone else's list title
without the NetworkOnly rule would leave it readable to the next person on that
device. noindex is a header rather than a robots.txt rule because a disallow
stops the crawler fetching the page and therefore seeing the directive at all.

Notes:
- next build could not run here: Turbopack rejects the worktree's node_modules
  symlink as pointing outside the filesystem root. No dependencies changed, so
  AGENTS.md does not require a build. Worth running once on the main checkout.
- Checks: tsc --noEmit clean, eslint clean on changed files, prettier applied.
Changes:
- Hide the copy action from the shared page unless the visitor is signed in
- Hide the "back to shopping lists" link for the same reason

Copying calls POST /api/shopping-lists, so for an anonymous visitor the button
was a guaranteed 401, and the list index it linked back to is itself behind a
login gate. Both were reachable only through a share link, which is why they
survived until the page existed.
Copilot AI review requested due to automatic review settings August 2, 2026 10:29
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@OffCrazyFreak, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 413fee11-6eef-445b-8b50-28c01b24c636

📥 Commits

Reviewing files that changed from the base of the PR and between 72129a6 and bc410b2.

📒 Files selected for processing (9)
  • docs/LANDING.md
  • docs/PWA.md
  • docs/SHARING.md
  • docs/STATE-PERSISTENCE.md
  • frontend/src/app/(user)/shopping-lists/components/forms/share-list-modal.tsx
  • frontend/src/app/(user)/shopping-lists/hooks/use-share-list-modal.ts
  • frontend/src/app/s/[token]/components/shared-shopping-list-client.tsx
  • frontend/src/components/custom/store-chain/store-chain-select.tsx
  • frontend/src/context/user-context.tsx

Summary by CodeRabbit

  • New Features
    • Share shopping lists through private links with view, shop, or edit access.
    • Owners can generate, update, copy, and revoke sharing links.
    • Shared lists support anonymous viewing and permitted item updates or deletions.
    • Added access guidance, unavailable-link messaging, and sharing controls.
  • Bug Fixes
    • Restricted list modifications to authorized access levels.
    • Improved offline caching, identity separation, and recovery for shared-list changes.
    • Enhanced accessibility labels, focus restoration, and loading states.
  • Documentation
    • Documented shopping-list sharing, access levels, authentication, deployment, and offline behavior.

Walkthrough

Shopping lists now support revocable share tokens with view, shop, and edit access. The backend adds token-based shared-list endpoints and access control. The frontend adds shared-list views, sharing controls, offline support, privacy headers, and access-aware item actions.

Changes

Shared shopping-list access

Layer / File(s) Summary
Backend sharing contracts and security
backend/src/main/java/disscount/config/*, backend/src/main/java/disscount/shoppingList/domain/*, backend/src/main/java/disscount/shoppingList/dto/*, backend/src/main/java/disscount/shoppingList/dao/*, docs/AUTH.md
Added link and resolved access levels, share tokens, access-aware DTOs, repository lookup, and optional bearer authentication for shared endpoints.
Backend shared-list operations
backend/src/main/java/disscount/shoppingList/service/*, backend/src/main/java/disscount/shoppingList/rest/*, backend/src/main/java/disscount/shoppingListItem/*
Added centralized access resolution, filtered mapping, token-based list and item operations, owner-only regular writes, soft deletion, and activity tracking.
Frontend API and offline state
frontend/src/lib/api/shopping-lists/*, frontend/src/lib/offline/*, frontend/src/lib/sentry/*
Added shared-list schemas, queries, mutations, optimistic cache handling, identity-scoped persistence, offline replay handling, and share-token scrubbing.
Sharing controls and list UI
frontend/src/app/(user)/shopping-lists/*, frontend/src/components/custom/*, frontend/src/lib/modal/*
Added sharing modal flows, access-aware actions and item controls, token propagation, loading labels, copy controls, and modal focus restoration.
Public route and safeguards
frontend/src/app/s/[token]/*, frontend/src/app/sw.ts, frontend/next.config.ts, frontend/src/context/user-context.tsx, frontend/src/instrumentation-client.ts, docs/*, README.md
Added the public shared-list page, metadata, unavailable states, service-worker caching, no-index headers, cache purging, telemetry scrubbing, deployment notes, and sharing documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Poem

A bunny hops with token bright,
Through view and shop and edit right.
The shared list hums in guarded space,
With caches scoped to time and place.
A carrot toast, a copy click,
And privacy stays swift and slick.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: sharing shopping lists through revocable links.
Description check ✅ Passed The description directly explains the capability-token sharing model and the related backend, frontend, privacy, and deployment changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/shopping-list-sharing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@netlify

netlify Bot commented Aug 2, 2026

Copy link
Copy Markdown

Deploy Preview for disscount ready!

Name Link
🔨 Latest commit bc410b2
🔍 Latest deploy log https://app.netlify.com/projects/disscount/deploys/6a742fcfb954a60008339b86
😎 Deploy Preview https://deploy-preview-149--disscount.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the previous isPublic shopping list sharing approach with a revocable capability-token link model, adding a dedicated shared-list API surface on the backend and a /s/[token] read and limited-write experience on the frontend.

Changes:

  • Backend: Introduces ListAccess, token-based shared list endpoints (/api/shared/**), and centralizes DTO mapping and access resolution.
  • Frontend: Adds share settings modal and /s/[token] page, gates UI actions by myAccess, and prevents shared-list data from being persisted offline.
  • Offline and UX: Bumps React Query cache buster for the DTO shape change, adds shared-list offline mutation replay handlers, and extracts common copy and loading-label utilities.

Reviewed changes

Copilot reviewed 50 out of 50 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/src/lib/offline/persister.ts Bumps persisted cache buster due to ShoppingList DTO shape change.
frontend/src/lib/offline/offline-mutations.ts Adds offline replay support for shared-list item mutations and shared-write error toast.
frontend/src/lib/offline/offline-mutation-keys.ts Defines mutation keys for shared-list item update/delete.
frontend/src/lib/modal/modal-registry.ts Adds shopping-list/share modal target parsing.
frontend/src/lib/api/shopping-lists/queries.ts Adds token-based shared list query/mutation functions.
frontend/src/lib/api/shopping-lists/keys.ts Introduces centralized query-key factory, including non-persisted shared root.
frontend/src/lib/api/shopping-lists/hooks.ts Replaces inline keys with factory and adds shared-list hooks and mutations.
frontend/src/lib/api/schemas/shopping-list.ts Updates ShoppingList schemas for linkAccess, shareToken, and myAccess.
frontend/src/constants/loading-labels.ts Adds shared loading label constants used by pending UI.
frontend/src/components/custom/modal-router/entity-modal-outlet.tsx Routes share action to the new share modal.
frontend/src/components/custom/contact/contact-channels.tsx Switches email copying to shared CopyButton.
frontend/src/components/custom/common/copy-button.tsx New reusable clipboard copy icon button with confirmation state.
frontend/src/app/sw.ts Adds NetworkOnly runtime caching rule for /s/ pages to avoid caching titles.
frontend/src/app/s/[token]/page.tsx Adds shared-list route and dynamic metadata generation.
frontend/src/app/s/[token]/get-shared-list-preview.ts Server-only preview fetch for link metadata and Croatian item-count formatting.
frontend/src/app/s/[token]/components/shared-shopping-list-client.tsx Shared-list client page composition and logged-out banner gating.
frontend/src/app/products/hooks/use-add-to-list-submit.ts Removes isPublic from list creation payload.
frontend/src/app/dashboard/components/admin-contact-row.tsx Uses shared CopyButton for copying sender email.
frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx Removes isPublic from discounted list creation.
frontend/src/app/(user)/shopping-lists/utils/shopping-list-access.ts Adds frontend access resolution helpers based on myAccess.
frontend/src/app/(user)/shopping-lists/utils/share-list-url.ts Adds helper for building /s/{token} share URL.
frontend/src/app/(user)/shopping-lists/hooks/use-share-list-modal.ts Adds share modal state and save-on-change logic for link access.
frontend/src/app/(user)/shopping-lists/components/shopping-list-visibility-indicator.tsx Replaces public/private indicator with link-access levels.
frontend/src/app/(user)/shopping-lists/components/shopping-list-item.tsx Passes linkAccess into the visibility indicator.
frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx Removes isPublic from form defaults and reset behavior.
frontend/src/app/(user)/shopping-lists/components/forms/share-list-modal.tsx New modal UI for enabling, revoking, and copying share links.
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts Ensures copied lists are private and updates copy toast wording.
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts Routes item updates/deletes to shared endpoints when opened by token, with optimistic updates.
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts Adds support for fetching list data by token or by id.
frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-actions.ts Routes owners to the share settings modal and updates non-owner share behavior.
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx Uses loading labels for mobile menu actions.
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header.tsx Gates edit/delete/back-link visibility based on ownership and signed-in state.
frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-desktop-actions.tsx Uses loading labels for icon-only action tooltips and aria-labels.
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsx Gates item UI actions based on resolved access and passes shared token for writes.
frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx Disables or hides controls based on access, and threads the new props.
frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx Adds read-only rendering mode for amount when edits are not allowed.
frontend/next.config.ts Adds X-Robots-Tag: noindex, nofollow headers for /s/ and /shopping-lists/.
backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java Removes public-list write holes and centralizes item DTO mapping.
backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java Fixes controller to bind and pass listId into service methods.
backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java Removes isPublic, adds link-access handling, and uses shared mapper.
backend/src/main/java/disscount/shoppingList/service/ShoppingListMapper.java New mapper for list and item DTO conversion, with owner-only fields.
backend/src/main/java/disscount/shoppingList/service/ShoppingListAccessService.java New centralized access resolution for owner vs token vs anonymous.
backend/src/main/java/disscount/shoppingList/service/SharedShoppingListService.java New shared-list operations by token, with per-access write rules.
backend/src/main/java/disscount/shoppingList/rest/SharedShoppingListController.java New /api/shared/** controller returning 404 for unknown/disabled tokens.
backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java Replaces isPublic with linkAccess.
backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java Adds linkAccess, shareToken, and myAccess fields.
backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java Replaces isPublic with nullable link_access and unique share_token.
backend/src/main/java/disscount/shoppingList/domain/ListAccess.java New enum expressing list permissions with helper methods.
backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java Adds repository lookup by share token.
backend/src/main/java/disscount/config/SecurityConfig.java Permits /api/shared/** for anonymous callers, with auth enforced by token and service rule.
Suppressed comments (1)

frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx:73

  • onCheckedChange from Radix Checkbox can yield the string "indeterminate". Casting checked as boolean will pass a non-boolean through to mutations and can break the shared/owned item update request payload. Convert it explicitly to a boolean before calling onUpdate, and avoid the ! on chainCode so null stays representable.
            onCheckedChange={(checked) =>
              onUpdate({
                isChecked: checked as boolean,
                amount: item.amount || 1,
                chainCode: item.chainCode!,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +29 to +41
const router = useRouter();
const { isAuthenticated, isLoading: isUserLoading } = useUser();

const {
shoppingList,
isLoading,
error,
listUpdatedAt,
cheapestStores,
averagePrices,
storePrices,
isPricesLoading,
} = useShoppingListData("", token);
Comment on lines 61 to 65
const text = formatShoppingListForSharing(shoppingList);
const url = shoppingList.isPublic
? `${appUrl()}/shopping-lists/${encodeURIComponent(shoppingList.id)}`
const url = shoppingList.shareToken
? shareListUrl(shoppingList.shareToken)
: undefined;
const outcome = await shareOrCopy({
…-sharing

Reconciles the sharing rework with the card-actions rework and the UTC
timestamp sweep from #150.

Conflict resolutions:
- ShoppingListItemService: took Timestamps.nowUtc(), kept the owner-only
  variable the sharing rework introduced.
- shopping-list-header and shopping-list-actions-sheet: dropped the
  client-side ownerId comparison in favour of the server-resolved myAccess,
  which is also correct for a link recipient.
- shopping-list-item (index card): kept the long-press card and passed the
  visibility indicator linkAccess instead of isPublic.
- use-add-to-list-submit: kept the empty-title guard, dropped isPublic.

Follow-on fixes:
- SharedShoppingListService now stamps through Timestamps.nowUtc() too, so
  shared writes are not the one path still writing local time.
- handleShare takes IOpenModalOptions, and the actions sheet replaces rather
  than closes when the owner's branch opens the share modal. closeModalUrl
  pops with history.back(), so closing and pushing straight after would land
  the push and then lose it, exactly as handleEdit already documents.
- shareListUrl moved into utils/shopping-list-links.ts, next to
  shoppingListPath, and the now-dead shoppingListPageUrl is gone.
Brings in the squashed #150. Because dev squashed it, git re-presented the
whole of #150 against the merge this branch already carried, so most conflicts
were re-runs of resolutions already made in 203b8b0.

Took dev's side outright for the files the sharing work does not touch, since
dev's copy is the newer one: product-item, product-summary, camera-view,
install-card, install-perk and shopping-list-actions-outlet.

Kept the sharing side everywhere the two models disagree, because dev's copy is
the pre-sharing isPublic model that this branch replaces:
- ShoppingListItemService: owner-only, no isPublic write hole.
- shopping-list-header and shopping-list-actions-sheet: ownership from the
  server-resolved myAccess rather than a client-side ownerId comparison.
- shopping-list-item, shopping-list-summary: visibility indicator on linkAccess.
- use-shopping-list-actions: share URL built from the token, and handleShare
  takes modal options.
- shopping-list-links: shareListUrl, not the now-dead shoppingListPageUrl.
- use-add-to-list-submit: no isPublic on create.

Dev's own improvements inside those files were kept: handleConfirmDelete is
still async and still awaits confirmDelete.
Changes:
- Require every artifact path to be absolute, resolved with realpath, and on its own line
- Record the worktree trap: reviews/ is gitignored, so no git operation moves the file
- Make the HTML variant follow prefers-color-scheme, with dark as the base palette
- Force light in @media print so PDF export stays legible
- Require every colour to be a CSS variable, with a snippet that greps for stray literals
- Add a slug to the review filename so several runs stay distinguishable

The last review was handed over as a light-only page and as a path into the
worktree it was generated in, while the reader was in the main checkout. Both
are avoidable by rule rather than by remembering. Dark is the base rather than
the override so an absent or unknown preference lands on dark.

Notes:
This commit is unrelated to shopping list sharing and is meant to be
cherry-picked onto dev.
Brings in 45 commits, including the main-into-dev release merge, the shared
search matcher, the multi-select split, and the Croatian comparator.

Conflict resolutions:
- 03-triage-doc-format.md: took dev, which is my own cherry-picked commit plus
  its prettier pass and a more robust colour-literal check.
- shopping-list-items.tsx: kept both, dev's compareHr sort and the capability
  gating this branch adds.
- use-shopping-list-actions, mobile and desktop actions, actions sheet: adopted
  dev's removal of the isSharing pending state, since share either opens a modal
  or hands off to the OS sheet and never awaits a fetch. Kept this branch's
  server-resolved canManageShare rather than dev's client-side ownerId compare,
  which is wrong for a link recipient.
- product-item and install-card: took dev outright, both are areas this branch
  does not touch.

Checks: tsc clean, eslint 0 errors (24 pre-existing warnings), prettier clean,
82 backend sources compile.
Changes:
- Null ownerId in the list DTO unless the caller is the owner
- Give toItemDto the caller's access and null updatedByUserId for everyone else
- Pass the resolved access through from both the shared and the owner-only services

linkAccess and shareToken were already gated on ownership, but ownerId and each
item's updatedByUserId were not. An anonymous caller holding only a share token
received the owner's account id, and on a SHOP or EDIT list the account id of
every collaborator who had touched an item. Those are stable cross-request
identifiers, the same value the JWT carries as sub, so a forwarded link handed
out a way to correlate two share links as belonging to one person.

Nothing on the frontend reads ownerId any more, and no UI renders attribution
yet, so this removes data rather than breaking a caller.
…e links

Changes:
- Add OptionalBearerAuthenticationFilter, which authenticates a bearer token when
  it decodes and lets the request through anonymously when it does not
- Give /api/shared/** its own @order(1) filter chain that uses it
- Add a LinkAccess enum without OWNER and bind ShoppingListRequest to it

permitAll only decides authorization. BearerTokenAuthenticationFilter still ran
on every request and answered 401 for an expired or malformed token before the
authorization rules were consulted, so a user whose cached token had gone stale,
or who signed out in another tab, could not open a share link at all. Dropping
the resource server from the shared chain would have fixed that and broken the
other half, since a signed-in recipient would then be seen as anonymous and
capped at VIEW however generous the link is. The optional filter is what lets
both callers work.

ShoppingListRequest previously accepted the full ListAccess, including OWNER.
applyLinkAccess rejected it, so this was never exploitable, but the guard was the
only thing preventing a caller from granting themselves share management. OWNER
is now unrepresentable on the wire and Jackson rejects it before any service runs.

Notes:
- Verified by reading the chain: oauth2ResourceServer installs the bearer filter
  chain-wide, and its entry point commits the response rather than continuing.
- Also drops a LocalDateTime import left dead by the earlier UTC timestamp sweep.
Changes:
- Add scrubShareToken, rewriting /s/<token> and /api/shared/<token> to a placeholder
- Wire it into beforeSend, beforeSendTransaction and beforeBreadcrumb on the client
- Wire it into the server config, which sees the token through the preview fetch
- Send Referrer-Policy: no-referrer on /s/

The token is a bearer capability: whoever holds the URL can read the list, and
can tick items off or edit them depending on the link level. Sentry attaches the
page URL to every event, records fetch breadcrumbs carrying the request URL, and
replays navigations, none of which sendDefaultPii: false covers, since that gates
IP, cookies and headers rather than URLs. Any error on a shared page, or a
one-in-ten sampled clean session, shipped a working link to Sentry.

Notes:
- Also documents why the /shopping-lists X-Robots-Tag rule is inert but kept:
  robots.ts already disallows the prefix, so a compliant crawler never reads it.
- Proxy access logs still record the full path. That is a Dokploy-side log format
  change, recorded in the docs batch rather than fixed here.
…ng on every load

Changes:
- Add cache-identity.ts, which scopes the IndexedDB entry to the signed-in account
- Resolve the storage key per call, so an identity change lands on the next read or write
- Purge only when the identity actually changes, not whenever there is no session
- Delete the pages and cijene-api service worker buckets as part of the purge
- Bump CACHE_BUSTER to "3", since the old shared entry is now orphaned

Two bugs, one cause. The persisted cache was a single browser-wide blob shared by
every account on a device, with a destructive purge as the only defence. And that
purge ran from an effect whose condition was "there is no session", which is true
on every page load for a visitor who never logs in, not just at the moment they
log out. So an anonymous visitor who ticked items off a shared list while offline
lost the queue on the next boot: no error, nothing to replay, and removeClient had
already deleted the snapshot that would have recovered it.

Cache Storage had the mirror of the same problem. Nothing in the app called
caches.delete(), so the pages bucket kept server-rendered documents and cijene-api
kept one entry per product looked at. Each product is public on its own, but the
set of them is the contents of whichever list was open.

Notes:
- The identity is mirrored in localStorage because the persister must choose a key
  synchronously at boot, before the session resolves.
Changes:
- Persist the sharedShoppingList query root
- Serve /s/ documents NetworkFirst instead of NetworkOnly, with a one-day expiry
- Correct the byToken comment, whose stated premise was wrong

A shared list had its writes queued for offline replay but its reads kept out of
the persisted cache, so a reload with no signal found nothing on disk, the query
failed, and the page rendered "the owner stopped sharing this list" for a link
that was perfectly valid. NetworkOnly on the document compounded it: the offline
navigation fell through to the /offline page, so the queued writes were not even
reachable to look at.

Both were deliberate privacy choices when a persisted shared list meant someone
else's data in one browser-wide blob. Now that the cache is keyed by identity and
purged when that identity changes, the trade no longer has to be paid.

Notes:
- The byToken comment claimed purgeOfflineCache only runs on a logout transition.
  It never did, and reasoning from that is what produced the original design.
…tate

Changes:
- Add optimistic-items.ts: patch, remove and restore a single item in the cache
- Move cancel, snapshot and setQueryData into onMutate on all four item mutations
- Restore only the affected item on error, at its original index
- Invalidate onSettled rather than onSuccess
- Give the shared replay handler the error, and only blame access loss on 403 or 404

Three bugs with one cause. The optimistic write lived in the event handler, so
React Query did not own it: a write queued offline and replayed after a reload
applied no optimistic state and had nothing to roll back, which is why the offline
toast had to be bolted on in the first place.

The rollback restored a whole-list snapshot. Tick item A, tick item B a moment
later, and if A fails after B has already succeeded, restoring the pre-A list also
un-ticks B, which the server has recorded as bought. Nothing refetched to correct
it, because the owned path only invalidated on success.

And the replay toast claimed lost access for every failure, so a collaborator whose
request hit a timeout was told the owner had revoked their link. It now says that
only for 403 and 404, which also covers a replayed delete of an already-deleted
item.
…om cache

Changes:
- Replace every raw ["shoppingLists"] literal with SHOPPING_LIST_QUERY_KEYS
- Invalidate the flat item list after a copy, not just the list roots
- Remove cached shared lists when sharing is set back to NONE
- Give the DTO schema its own object instead of extending the request schema
- Encode the token and item id in the four shared request paths

Copying a list creates items, but only the list roots were invalidated, so the
copied items were invisible to watchlist suggestions for the rest of the session.

The DTO schema inherited the create form's title length rules, which the backend
does not enforce, so a title the server accepts would have failed to parse the
moment response validation was added. It also had ownerId as required, which is no
longer true now that the server nulls it for non-owners.

Revoking a link killed the token server-side but left the list readable from this
browser's cache for the remaining staleTime.
…g at

Changes:
- Thread the route token into useShoppingListActions, through the action buttons
- Show the revoked-link screen only for a 404, with a retry state for anything else
- Split the shared page into access-banner and unavailable components
- Tell a recipient what access they have, and point disabled controls at it
- Make isSignedIn required rather than defaulting to true
- Add a timeout to the server-side preview fetch
- Widen onShare to take modal options, and stop passing it straight to onClick
- Give useGetShoppingListById an explicit enabled instead of an empty id
- Make pluralizeCroatian three-form and drop the server-only item counter

The headline bug: shareToken is nulled for everyone but the owner, and on /s/<token>
the caller is by definition not the owner, so the share action always fell through to
plain text while the link sat in the address bar. Forwarding a shared list is the most
likely thing to do on that page and it silently did the wrong thing.

Every query error rendered as "the owner stopped sharing this list", so a shopper on
flaky mobile data was told their access had been revoked and sent off to ask for a link
they already had. Only a 404 means that.

A recipient was never told what they could do, so the greyed-out checkbox and store
select had no stated reason, which a screen reader renders as "dimmed" and nothing more.

Notes:
- Widening onShare surfaced a latent hazard: it was passed directly as onClick, so a
  MouseEvent would have arrived where IOpenModalOptions was expected.
- Kept the checkbox and store select disabled rather than hidden, which is the opposite
  of what the plan said. Their state is information a viewer needs (what has been bought,
  from which shop), so hiding them would remove it. Explaining them costs nothing.
- pluralizeCroatian was two-form and could not produce the 5-and-up genitive. Its two
  existing callers were accidentally correct, because for "trgovina" and "cijena" that
  form matches the singular. It now takes a third form, defaulting to the first so those
  callers are unchanged.
Changes:
- Hold the pending level locally so the switch and select stop snapping back
- Announce the save through a polite live region
- Gate turning sharing off behind a ConfirmDialog, and keep the warning always visible
- Disable the level select while saving, and mark the panel busy
- Say when a change is queued because the device is offline
- Reduce the visibility indicator to shared or private, and make it focusable
- Move the level labels and hints into LINK_ACCESS_LABELS beside the schema
- Correct the EDIT hint, which promised a rename no recipient can reach
- Add disabled and describedById to LabeledSelect
- Split the link row out of the modal, and delete the unused shared-rename hook

The modal saves on change with no submit button, so both halves of the feedback loop
were missing: the control showed the old value until the round trip finished, and a
successful save was announced to nobody.

Revoking a live link cost one unguarded click, and the sentence explaining that the link
would die lived inside the branch that unmounts when sharing is turned off, so it
vanished at the exact moment it became true.

The indicator used Globe for both VIEW and EDIT, so the two states it most needed to
distinguish looked identical. It is now binary, private or shared, with the level in the
tooltip and the accessible name. Google Docs draws the same line: the glance icon answers
whether a thing is shared, and the level lives in the dialog.

Notes:
- The level strings were duplicated across the modal's options, its hints and the
  indicator's own map. One map beside the schema now, matching ACCOUNT_TYPE_LABELS.
ModalShell prevented Radix's focus restoration outright, on the grounds that a URL-driven
modal has no trigger to restore to and Radix's body fallback jumps the scroll. That holds
for the routed modals, but not for one opened from a button still on the page: closing it
dropped focus to body, so a keyboard user restarted tabbing from the top of the document.
AGENTS.md's checklist asks for restoration.

The opener is captured in onOpenAutoFocus, which fires while Radix is about to move focus
in and therefore still sees the element that had it. Restoration is skipped when that
element has since left the DOM, keeping the old behaviour for the routed case.

Notes:
- Shared by every modal in the app, not just sharing, so it is on its own commit.
- Captured in the event rather than during render: writing a ref in the render body is
  what react-hooks/refs forbids, and eslint caught it.
Changes:
- Add docs/SHARING.md and index it in docs/README.md
- Document the three-step column drop in DEPLOYMENT.md, with the pending is_public case
- Correct AUTH.md's claim that every non-Swagger endpoint needs a token
- Update PWA.md: the /s/ rule, cache-identity, the corrected purge trigger, buster "3"
- Add the X-Robots-Tag row to LANDING.md and drop sharing from its coming-soon TODO
- Move sharing from Coming soon to Live in README.md
- Un-gate the landing feature card, which still said the feature did not exist

The deploy runbook said app-table schema changes were fully automatic. They are
additive only: ddl-auto=update never drops a column, so a NOT NULL column with no
default outlives the code that filled it and rejects every insert the new code makes.
Deploys fire on push, so a single DROP COLUMN cannot be ordered safely in either
direction. Section 10.1 now spells out the three steps and names is_public as pending.

Notes:
- Un-gating the landing card is code, not docs, but it is the same finding: the docs
  TODO and the feature flag were two records of one fact.
@OffCrazyFreak

Copy link
Copy Markdown
Owner Author

Update: review findings landed

All 57 selected findings from the multi-tool review are implemented, plus a merge of 45 commits from dev. Excluded by request: rows 10 (backend tests, later task), 41 (no lists are public yet), 55, 57, 58.

The four that mattered most

  • A recipient could not forward the link they were looking at. shareToken is nulled for non-owners, so the share action always fell through to plain text while the link sat in the address bar. Two reviewers found this independently.
  • Anonymous offline writes were silently dropped. purgeOfflineCache ran whenever there was no session, which is every page load for a visitor who never logs in, not only at logout. It cleared the mutation cache and deleted the IndexedDB snapshot, so queued ticks vanished with no error and nothing left to replay.
  • The DROP COLUMN plan in the PR description was unsafe. is_public is NOT NULL with no default and deploys fire on push, so dropping before the deploy breaks the still-running image and dropping after breaks every insert until it runs. It needs three steps, now documented in docs/DEPLOYMENT.md §10.1.
  • Account ids leaked to link visitors. ownerId and each item's updatedByUserId reached anonymous callers.

Shared lists now work offline

This required the offline cache to gain per-identity scoping, which it never had: one browser-wide IndexedDB blob served every account on a device, with a destructive purge as the only defence. That is lib/offline/cache-identity.ts, and it fixes a bug wider than sharing. The purge also now clears the service worker buckets, which nothing in the app had ever done.

Worth a reviewer's attention

  • OptionalBearerAuthenticationFilter plus a second filter chain for /api/shared/**. permitAll does not stop the bearer filter answering 401 for a stale token, but simply dropping the resource server would have capped signed-in recipients at VIEW. Optional authentication is the only shape where both callers work.
  • ModalShell now restores focus on close. That is shared by every modal in the app, so it is on its own commit.
  • The visibility indicator is binary, Lock or Globe, with the level in the tooltip only.
  • The checkbox and store select stay disabled rather than hidden for a viewer, which is the opposite of what the plan said. Their state is information a viewer needs (what has been bought, from which shop), so they carry an aria-describedby explanation instead.
  • One CodeRabbit finding was a false positive (an em dash in a comment that does not exist) and is not implemented.

Checks

tsc, eslint (0 errors, 24 pre-existing warnings), prettier on src and on docs, and 82 backend sources compiling under javac. next build cannot run in this worktree: Turbopack rejects the symlinked node_modules and fails before compiling anything, so CI is the first real build. Maven is yours to run.

Filed #154 for shopping list images.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/lib/offline/purge.ts (1)

45-52: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Sensitive Data Exposure (CWE-524)

Reachability: Internal

Reachability path
● Entry
  frontend/src/app/s/[token]/page.tsx:12
  generateMetadata
│
▼
● Hop
  frontend/src/app/s/[token]/components/shared-shopping-list-client.tsx:22
  SharedShoppingListClient: The owner following their own link belongs on the real list, where sharing, editing
│
▼
● Hop
  frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts
│
▼
● Hop
  frontend/src/context/user-context.tsx:52
  UserProvider: Seeded from the persisted value so a reload as the same account is not read as a
│
▼
● Sink
  frontend/src/lib/offline/purge.ts

Isolate the Cache Storage purge from the IndexedDB purge.

If offlinePersister.removeClient() rejects, purgeServiceWorkerCaches() never runs and the non-identity-scoped offline buckets can keep state from the previous account. Use Promise.allSettled for both cleanups so one delete failure cannot cancel the other.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/offline/purge.ts` around lines 45 - 52, Update the cleanup
block in the purge flow to run offlinePersister.removeClient and
purgeServiceWorkerCaches through Promise.allSettled, ensuring both cleanup
operations are attempted even if one rejects. Preserve the existing
error-reporting behavior for failed cleanup results.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/main/java/disscount/config/SecurityConfig.java`:
- Around line 51-68: In SecurityConfig, define disabled FilterRegistrationBean
entries for both userProvisioningFilter and optionalBearerAuthenticationFilter
to prevent servlet auto-registration. Keep the existing addFilterBefore
registrations in sharedShoppingListChain, preserving their order so bearer
authentication runs before provisioning.

In `@docs/PWA.md`:
- Around line 300-301: Update the “Three pieces make replay-after-reload
correct” lead-in in docs/PWA.md to match the four bullets that follow, changing
it to “Four pieces” without altering the bullet content.

In `@docs/SHARING.md`:
- Around line 32-48: The access-model introduction should distinguish the two
enums instead of claiming that ListAccess represents both concepts. Update the
opening description to state that LinkAccess defines selectable link grants,
while ListAccess represents resolved caller access, including OWNER; keep the
existing table and later explanation consistent with this distinction.

In
`@frontend/src/app/`(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts:
- Around line 45-57: Update the mutation data construction in the visible item
update handler so the unchecked branch explicitly clears both avgPrice and
storePrice after spreading the cached item. Preserve the existing checked-branch
capture from averagePrices and storePrices, while ensuring unchecking or
clearing chainCode cannot resend stale captured prices.

In
`@frontend/src/app/`(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts:
- Around line 26-32: Update the previous cache snapshot lookup in the mutation
flow to use SHOPPING_LIST_QUERY_KEYS.me instead of the inline ["shoppingLists",
"me"] key, keeping it consistent with cancelQueries and setQueryData so rollback
restores the correct cache entry.

In `@frontend/src/app/s/`[token]/get-shared-list-preview.ts:
- Around line 30-32: Update getSharedListPreview to validate the parsed response
with the existing shoppingListDtoSchema from lib/api/schemas/shopping-list.ts
instead of casting it to ShoppingListDto. Return null when schema validation
fails, while preserving the existing non-OK response fallback and valid-payload
return behavior.

In `@frontend/src/lib/api/schemas/shopping-list.ts`:
- Around line 59-65: Update the VIEW, SHOP, and EDIT values in LINK_ACCESS_HINTS
to address the recipient in second-person singular, replacing the first-person
“Mogu” wording with equivalent “Možeš” phrasing while preserving each
permission’s meaning and the existing NONE hint.

---

Outside diff comments:
In `@frontend/src/lib/offline/purge.ts`:
- Around line 45-52: Update the cleanup block in the purge flow to run
offlinePersister.removeClient and purgeServiceWorkerCaches through
Promise.allSettled, ensuring both cleanup operations are attempted even if one
rejects. Preserve the existing error-reporting behavior for failed cleanup
results.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 060c8871-8745-4ed7-b012-90ef427b0e45

📥 Commits

Reviewing files that changed from the base of the PR and between c3aaf0f and 22cc862.

📒 Files selected for processing (80)
  • README.md
  • backend/src/main/java/disscount/config/OptionalBearerAuthenticationFilter.java
  • backend/src/main/java/disscount/config/SecurityConfig.java
  • backend/src/main/java/disscount/shoppingList/dao/ShoppingListRepository.java
  • backend/src/main/java/disscount/shoppingList/domain/LinkAccess.java
  • backend/src/main/java/disscount/shoppingList/domain/ListAccess.java
  • backend/src/main/java/disscount/shoppingList/domain/ShoppingList.java
  • backend/src/main/java/disscount/shoppingList/dto/ShoppingListDto.java
  • backend/src/main/java/disscount/shoppingList/dto/ShoppingListRequest.java
  • backend/src/main/java/disscount/shoppingList/rest/SharedShoppingListController.java
  • backend/src/main/java/disscount/shoppingList/service/SharedShoppingListService.java
  • backend/src/main/java/disscount/shoppingList/service/ShoppingListAccessService.java
  • backend/src/main/java/disscount/shoppingList/service/ShoppingListMapper.java
  • backend/src/main/java/disscount/shoppingList/service/ShoppingListService.java
  • backend/src/main/java/disscount/shoppingListItem/rest/ShoppingListItemController.java
  • backend/src/main/java/disscount/shoppingListItem/service/ShoppingListItemService.java
  • docs/AUTH.md
  • docs/DEPLOYMENT.md
  • docs/LANDING.md
  • docs/PWA.md
  • docs/README.md
  • docs/SHARING.md
  • frontend/next.config.ts
  • frontend/sentry.server.config.ts
  • frontend/src/app/(root)/data/features.ts
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/item-amount-controls.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-items.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-action-buttons.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-desktop-actions.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-detail-client.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-header.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/components/shopping-list-mobile-actions.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-actions.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-data.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts
  • frontend/src/app/(user)/shopping-lists/components/forms/share-link-row.tsx
  • frontend/src/app/(user)/shopping-lists/components/forms/share-list-modal.tsx
  • frontend/src/app/(user)/shopping-lists/components/forms/shopping-list-modal.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-list-actions-sheet.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-list-item.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-list-summary.tsx
  • frontend/src/app/(user)/shopping-lists/components/shopping-list-visibility-indicator.tsx
  • frontend/src/app/(user)/shopping-lists/hooks/use-share-list-modal.ts
  • frontend/src/app/(user)/shopping-lists/hooks/use-shopping-list-modal.ts
  • frontend/src/app/(user)/shopping-lists/utils/shopping-list-access.ts
  • frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx
  • frontend/src/app/dashboard/components/admin-contact-row.tsx
  • frontend/src/app/products/hooks/use-add-to-list-submit.ts
  • frontend/src/app/s/[token]/components/shared-list-access-banner.tsx
  • frontend/src/app/s/[token]/components/shared-list-unavailable.tsx
  • frontend/src/app/s/[token]/components/shared-shopping-list-client.tsx
  • frontend/src/app/s/[token]/get-shared-list-preview.ts
  • frontend/src/app/s/[token]/page.tsx
  • frontend/src/app/sw.ts
  • frontend/src/components/custom/common/copy-button.tsx
  • frontend/src/components/custom/common/labeled-select.tsx
  • frontend/src/components/custom/contact/contact-channels.tsx
  • frontend/src/components/custom/modal-router/entity-modal-outlet.tsx
  • frontend/src/components/custom/modal/modal-shell.tsx
  • frontend/src/components/custom/store-chain/store-chain-select.tsx
  • frontend/src/constants/loading-labels.ts
  • frontend/src/context/user-context.tsx
  • frontend/src/instrumentation-client.ts
  • frontend/src/lib/api/schemas/shopping-list.ts
  • frontend/src/lib/api/shopping-lists/hooks.ts
  • frontend/src/lib/api/shopping-lists/keys.ts
  • frontend/src/lib/api/shopping-lists/optimistic-items.ts
  • frontend/src/lib/api/shopping-lists/queries.ts
  • frontend/src/lib/modal/modal-registry.ts
  • frontend/src/lib/offline/cache-identity.ts
  • frontend/src/lib/offline/cached-query-keys.ts
  • frontend/src/lib/offline/offline-mutation-keys.ts
  • frontend/src/lib/offline/offline-mutations.ts
  • frontend/src/lib/offline/persister.ts
  • frontend/src/lib/offline/purge.ts
  • frontend/src/lib/sentry/scrub-share-token.ts
  • frontend/src/utils/shopping-list-links.ts
  • frontend/src/utils/strings.ts
💤 Files with no reviewable changes (1)
  • frontend/src/app/(user)/watchlist/components/create-discounted-list-button.tsx

Comment thread backend/src/main/java/disscount/config/SecurityConfig.java
Comment thread docs/PWA.md Outdated
Comment thread docs/SHARING.md Outdated
Comment on lines +45 to +57
// Prices are captured at the moment of ticking, so they have to be resolved here
// where the component's price maps live, not inside the mutation. They travel in the
// request, which is also what the optimistic patch applies.
const data = { ...item, ...updatedItem };

// If checking the item, include the current average price and store price
if (updatedItem.isChecked) {
const currentAvgPrice = averagePrices[item.id];
if (currentAvgPrice !== undefined) {
updateData.avgPrice = currentAvgPrice;
}
const avgPrice = averagePrices[item.id];
if (avgPrice !== undefined) data.avgPrice = avgPrice;

// Include the store price from the selected store
if (
updatedItem.chainCode &&
storePrices[item.id]?.[updatedItem.chainCode]
) {
updateData.storePrice = storePrices[item.id][updatedItem.chainCode];
}
const storePrice =
updatedItem.chainCode && storePrices[item.id]?.[updatedItem.chainCode];
if (storePrice) data.storePrice = storePrice;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear the captured prices when an item is unchecked.

data starts as a copy of the cached item, so avgPrice and storePrice from an earlier check survive an uncheck and are re-sent. The comment states that prices are captured at the moment of ticking. Under the current code, an item unchecked and then re-checked at a different chain still carries the first capture until the new values overwrite it, and storePrice keeps the old value whenever chainCode is cleared. Reset both fields on the uncheck branch.

🐛 Proposed fix
     if (updatedItem.isChecked) {
       const avgPrice = averagePrices[item.id];
       if (avgPrice !== undefined) data.avgPrice = avgPrice;
 
       const storePrice =
         updatedItem.chainCode && storePrices[item.id]?.[updatedItem.chainCode];
       if (storePrice) data.storePrice = storePrice;
+    } else {
+      // A capture belongs to one tick. Keeping it would re-apply a stale price, and a
+      // different chain on the next tick.
+      data.avgPrice = null;
+      data.storePrice = null;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Prices are captured at the moment of ticking, so they have to be resolved here
// where the component's price maps live, not inside the mutation. They travel in the
// request, which is also what the optimistic patch applies.
const data = { ...item, ...updatedItem };
// If checking the item, include the current average price and store price
if (updatedItem.isChecked) {
const currentAvgPrice = averagePrices[item.id];
if (currentAvgPrice !== undefined) {
updateData.avgPrice = currentAvgPrice;
}
const avgPrice = averagePrices[item.id];
if (avgPrice !== undefined) data.avgPrice = avgPrice;
// Include the store price from the selected store
if (
updatedItem.chainCode &&
storePrices[item.id]?.[updatedItem.chainCode]
) {
updateData.storePrice = storePrices[item.id][updatedItem.chainCode];
}
const storePrice =
updatedItem.chainCode && storePrices[item.id]?.[updatedItem.chainCode];
if (storePrice) data.storePrice = storePrice;
}
// Prices are captured at the moment of ticking, so they have to be resolved here
// where the component's price maps live, not inside the mutation. They travel in the
// request, which is also what the optimistic patch applies.
const data = { ...item, ...updatedItem };
if (updatedItem.isChecked) {
const avgPrice = averagePrices[item.id];
if (avgPrice !== undefined) data.avgPrice = avgPrice;
const storePrice =
updatedItem.chainCode && storePrices[item.id]?.[updatedItem.chainCode];
if (storePrice) data.storePrice = storePrice;
} else {
// A capture belongs to one tick. Keeping it would re-apply a stale price, and a
// different chain on the next tick.
data.avgPrice = null;
data.storePrice = null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frontend/src/app/`(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts
around lines 45 - 57, Update the mutation data construction in the visible item
update handler so the unchecked branch explicitly clears both avgPrice and
storePrice after spreading the cached item. Preserve the existing checked-branch
capture from averagePrices and storePrices, while ensuring unchecking or
clearing chainCode cannot resend stale captured prices.

Comment thread frontend/src/app/s/[token]/get-shared-list-preview.ts Outdated
Comment on lines +59 to +65
export const LINK_ACCESS_HINTS: Record<LinkAccess, string> = {
NONE: "Popis je privatan i vidiš ga samo ti.",
VIEW: "Mogu vidjeti popis i cijene, ali ne mogu ništa mijenjati.",
SHOP: "Mogu označavati stavke kao kupljene i birati trgovinu.",
// Renaming is deliberately absent: the backend allows it, but no rename control is
// rendered for a recipient, so promising it would be a dead end.
EDIT: "Mogu mijenjati količine i brisati stavke.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use second-person singular in the access hints.

VIEW, SHOP, and EDIT use first-person forms such as Mogu. Use Možeš forms so the hints address the recipient consistently.

Proposed wording
-  VIEW: "Mogu vidjeti popis i cijene, ali ne mogu ništa mijenjati.",
-  SHOP: "Mogu označavati stavke kao kupljene i birati trgovinu.",
-  EDIT: "Mogu mijenjati količine i brisati stavke.",
+  VIEW: "Možeš vidjeti popis i cijene, ali ne možeš ništa mijenjati.",
+  SHOP: "Možeš označavati stavke kao kupljene i birati trgovinu.",
+  EDIT: "Možeš mijenjati količine i brisati stavke.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const LINK_ACCESS_HINTS: Record<LinkAccess, string> = {
NONE: "Popis je privatan i vidiš ga samo ti.",
VIEW: "Mogu vidjeti popis i cijene, ali ne mogu ništa mijenjati.",
SHOP: "Mogu označavati stavke kao kupljene i birati trgovinu.",
// Renaming is deliberately absent: the backend allows it, but no rename control is
// rendered for a recipient, so promising it would be a dead end.
EDIT: "Mogu mijenjati količine i brisati stavke.",
export const LINK_ACCESS_HINTS: Record<LinkAccess, string> = {
NONE: "Popis je privatan i vidiš ga samo ti.",
VIEW: "Možeš vidjeti popis i cijene, ali ne možeš ništa mijenjati.",
SHOP: "Možeš označavati stavke kao kupljene i birati trgovinu.",
// Renaming is deliberately absent: the backend allows it, but no rename control is
// rendered for a recipient, so promising it would be a dead end.
EDIT: "Možeš mijenjati količine i brisati stavke.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/api/schemas/shopping-list.ts` around lines 59 - 65, Update
the VIEW, SHOP, and EDIT values in LINK_ACCESS_HINTS to address the recipient in
second-person singular, replacing the first-person “Mogu” wording with
equivalent “Možeš” phrasing while preserving each permission’s meaning and the
existing NONE hint.

Source: Coding guidelines

Changes:
- Do not purge the cache when the session lookup itself failed
- Purge the departing identity by name, not whichever one is current when it awaits
- Show a restored shared list rather than the error screen when a refetch fails
- Give /s/ documents their own cache bucket and match cache names exactly
- Disable servlet auto-registration for both security-chain filters
- Anchor provisioning on the bearer filter instead of relying on a stable sort
- Accept a case-insensitive bearer scheme
- Parse the link-preview response instead of casting it
- Clear the captured prices when an item is unchecked
- Extend the Sentry scrub to spans, traces and exception messages, and skip replay on /s/
- Gate the share modal's in-flight guard on isSaving, not the never-clearing isPending
- Restore focus with preventScroll, and render the access banner unconditionally
- Move SHARED_ACCESS_BANNER_ID next to the access resolver

Two of these are the same class of bug as the one the offline work set out to fix,
reintroduced through different doors.

/api/auth/get-session is NetworkOnly in the service worker, so offline it rejects and
useSession settles to data: null with an error set. The identity check read that as a
logout and purged, which wiped a signed-in user's cache and every queued write on the
first offline reload. The ref only stopped that for callers who had never signed in.

The purge was fired with void and the identity switched synchronously right after, so
the awaited IndexedDB delete resolved its key against the account that had just
arrived. On login it deleted the new user's snapshot and left the previous one's in
place, which is backwards on both counts.

Separately, `error || !shoppingList` showed the unavailable screen even with a
restored list in hand, because React Query keeps data and sets error when a background
refetch fails. That is the shop-with-bad-signal case the offline work exists for.

Notes:
- cacheName "pages" collided with serwist's own defaultCache bucket, so two
  ExpirationPlugins trimmed one cache to different limits. The purge's substring match
  would also have deleted pages-rsc and pages-rsc-prefetch, every RSC payload in the
  app. Now a distinct name, matched by equality, plus "others", which is where
  serwist actually puts navigations.
- Corrected the onMutate comment: query-core skips onMutate for a mutation restored as
  already pending, so what survives a reload is the persisted query snapshot.
- Not applied: CodeRabbit's "use Možeš not Mogu". That panel is owner-only and the
  hints describe what other people can do. The label was the ambiguous part and is now
  "Što drugi mogu raditi".
…ring

# Conflicts:
#	frontend/src/context/user-context.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/SHARING.md (1)

119-121: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the cache-bucket description.

Line 121 says that the purge deletes the pages bucket. purgeServiceWorkerCaches deletes shared-list-pages, cijene-api, and others. It deliberately does not delete pages, pages-rsc, or pages-rsc-prefetch.

Update this sentence to name the actual buckets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/SHARING.md` around lines 119 - 121, Update the cache-bucket description
in the sharing documentation to state that purgeServiceWorkerCaches deletes
shared-list-pages and cijene-api (alongside others if covered), not pages.
Preserve the surrounding explanation about the /s/ service worker rule and
identity changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/main/java/disscount/config/SecurityConfig.java`:
- Around line 91-97: Make the custom filter ordering explicit in SecurityConfig
by assigning userProvisioningFilter a strictly later order than
optionalBearerAuthenticationFilter, using a shared earlier standard anchor or
explicit `@Order-backed` registration rather than insertion-dependent same-order
sorting. Preserve the OAuth2 resource-server configuration and keep
SecurityFilterChain filterChain(...) intact.

---

Outside diff comments:
In `@docs/SHARING.md`:
- Around line 119-121: Update the cache-bucket description in the sharing
documentation to state that purgeServiceWorkerCaches deletes shared-list-pages
and cijene-api (alongside others if covered), not pages. Preserve the
surrounding explanation about the /s/ service worker rule and identity changes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 50ac1454-28d3-4ed1-b59e-2a12000f9d2d

📥 Commits

Reviewing files that changed from the base of the PR and between 22cc862 and 72129a6.

📒 Files selected for processing (23)
  • backend/src/main/java/disscount/config/OptionalBearerAuthenticationFilter.java
  • backend/src/main/java/disscount/config/SecurityConfig.java
  • docs/PWA.md
  • docs/SHARING.md
  • frontend/src/app/(user)/shopping-lists/[id]/components/items/shopping-list-item.tsx
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-item-mutations.ts
  • frontend/src/app/(user)/shopping-lists/[id]/hooks/use-shopping-list-mutations.ts
  • frontend/src/app/(user)/shopping-lists/components/forms/share-link-row.tsx
  • frontend/src/app/(user)/shopping-lists/components/forms/share-list-modal.tsx
  • frontend/src/app/(user)/shopping-lists/hooks/use-share-list-modal.ts
  • frontend/src/app/(user)/shopping-lists/utils/shopping-list-access.ts
  • frontend/src/app/s/[token]/components/shared-list-access-banner.tsx
  • frontend/src/app/s/[token]/components/shared-shopping-list-client.tsx
  • frontend/src/app/s/[token]/get-shared-list-preview.ts
  • frontend/src/app/sw.ts
  • frontend/src/components/custom/modal/modal-shell.tsx
  • frontend/src/context/user-context.tsx
  • frontend/src/instrumentation-client.ts
  • frontend/src/lib/api/shopping-lists/hooks.ts
  • frontend/src/lib/offline/cache-identity.ts
  • frontend/src/lib/offline/persister.ts
  • frontend/src/lib/offline/purge.ts
  • frontend/src/lib/sentry/scrub-share-token.ts

Comment on lines +91 to +97
// Provisioning is anchored on the bearer filter rather than sharing the
// anonymous anchor with it. Two addFilterBefore calls against one anchor get the
// same order and only stay in sequence because the sort happens to be stable,
// which is not something to depend on: provisioning has to see the
// authentication the bearer filter produced.
.addFilterBefore(optionalBearerAuthenticationFilter, AnonymousAuthenticationFilter.class)
.addFilterAfter(userProvisioningFilter, OptionalBearerAuthenticationFilter.class);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'Security-chain registration:'
rg -n -C 4 'addFilterBefore|addFilterAfter|AnonymousAuthenticationFilter|SecurityContextHolderFilter' \
  backend/src/main/java/disscount/config/SecurityConfig.java

printf '%s\n' 'Spring Security ordering implementation, if dependency sources are cached:'
fd -a 'spring-security-config-*-sources.jar' "${HOME}/.m2" 2>/dev/null |
while IFS= read -r jar; do
  unzip -p "$jar" '*FilterOrderRegistration.java' 2>/dev/null |
    rg -n -C 3 'addFilterBefore|addFilterAfter|order - 1|order \+ 1' || true
done

Repository: OffCrazyFreak/Disscount

Length of output: 2044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'SecurityConfig relevant sections:'
sed -n '1,150p' backend/src/main/java/disscount/config/SecurityConfig.java | nl -ba

printf '%s\n' 'Filter bean ordering if present:'
rg -n -C 3 'UserProvisioningFilter|OptionalBearerAuthenticationFilter|SecurityFilterChain|FilterRegistrationBean|Ordered|`@Order`|after|before' \
  backend/src/main/java/disscount/config backend/src/main/java/disscount || true

printf '%s\n' 'Repository dependency versions:'
for f in pom.xml backend/pom.xml build/prod/pom.xml gradle.properties; do
  [ -f "$f" ] && { printf '\n--- %s ---\n'; sed -n '1,220p' "$f"; }
done

printf '%s\n' 'Lockfiles for Spring Security packages:'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn.lock|gradle.lockfile|.*gradle\.lockfile|pom\.xml)$' | while read -r f; do
  if rg -q 'spring-security|spring-boot-starter-security' "$f" 2>/dev/null; then
    printf '\n--- %s spring-security references ---\n' "$f"
    rg -n 'spring-security|spring-boot-starter-security|spring-boot-starter-web' "$f" || true
  fi
done

Repository: OffCrazyFreak/Disscount

Length of output: 235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' 'SecurityConfig relevant sections:'
sed -n '1,150p' backend/src/main/java/disscount/config/SecurityConfig.java | awk '{ printf "%6d\t%s\n", NR, $0 }'

printf '%s\n' 'Filter bean ordering if present:'
rg -n -C 3 'UserProvisioningFilter|OptionalBearerAuthenticationFilter|SecurityFilterChain|FilterRegistrationBean|Ordered|`@Order`|after|before' \
  backend/src/main/java/disscount/config backend/src/main/java/disscount || true

printf '%s\n' 'Repository dependency versions:'
for f in pom.xml backend/pom.xml build/prod/pom.xml gradle.properties; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    sed -n '1,220p' "$f"
  fi
done

printf '%s\n' 'Lockfiles for Spring Security packages:'
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn.lock|gradle.lockfile|.*gradle\.lockfile|pom\.xml)$' | while read -r f; do
  if rg -q 'spring-security|spring-boot-starter-security' "$f" 2>/dev/null; then
    printf '\n--- %s spring-security references ---\n' "$f"
    rg -n 'spring-security|spring-boot-starter-security|spring-boot-starter-web' "$f" || true
  fi
done

Repository: OffCrazyFreak/Disscount

Length of output: 38851


🌐 Web query:

Spring Security SecurityFilterChainBuilder addFilterBefore addFilterAfter equal order same position

💡 Result:

In Spring Security, the HttpSecurity methods addFilterBefore, addFilterAfter, and addFilterAt are used to place custom filters relative to existing filters within the SecurityFilterChain [1][2][3]. These methods calculate a specific numerical "order" value to determine the execution sequence [4]. Key behaviors regarding ordering and positioning: 1. How Positions are Calculated: The framework uses a registered list of filter classes and their associated order values (e.g., in FilterOrderRegistration.java) [5][4]. When you call addFilterBefore or addFilterAfter, the framework calculates the new filter's order based on the offset relative to the target filter's order [4]. For example, addFilterAfter generally adds a filter at (targetFilterOrder + 1) [4]. 2. Same Position (Numerical Order) Behavior: It is possible for multiple filters to end up with the same numerical order value [6]. Spring Security does not necessarily prevent this at the API level during configuration [6]. When filters have the same order, their relative execution order within the chain is typically determined by the order in which they were added or by the underlying collection implementation, though this can lead to unpredictable or difficult-to-debug behavior [6][7]. 3. Issues with Same Class Instances: A known complication occurs when the same filter instance or filters of the same class are added multiple times or at different relative positions [8][7]. Because the framework sometimes maps order values by class type internally, subsequent calls for the same filter class can override previous registrations, potentially causing a filter to appear in an unexpected location [8]. 4. Recommendations: To ensure predictable ordering: - Avoid relying on implicit ordering or overlapping positions. - If you need multiple filters to run in a specific sequence, explicitly chain them or use separate classes for each filter [6]. - If you intend to replace a standard filter, use addFilterAt to ensure the custom filter takes the exact position of the original, rather than just adding it before or after [1][3]. - If you encounter order conflicts, verify the generated filter order by enabling debug logging, which helps identify the final placement of filters in the chain [9]. For authoritative details on the default ordering of standard filters, you can inspect the FilterOrderRegistration class in the Spring Security source code [1][5].

Citations:


🌐 Web query:

github spring-security FilterOrderRegistration addFilterBefore addFilterAfter compare insertionOrder sameOrderStableSort

💡 Result:

In Spring Security, the FilterOrderRegistration class acts as a central registry that assigns default integer order values to built-in security filters [1][2]. This ensures that when multiple filters are involved, the framework can maintain a predictable sequence (e.g., ensuring authentication filters run before authorization filters) [3]. When you use HttpSecurity methods like addFilterBefore(Filter, Class) or addFilterAfter(Filter, Class), Spring Security uses the order values stored in FilterOrderRegistration to determine where to place your custom filter relative to a known (registered) filter class [4][5]. Key points regarding the registration and ordering mechanics include: 1. Known Filters: The methods addFilterBefore and addFilterAfter operate relative to "known" filter classes [4][5]. A filter is considered "known" if it is a built-in filter already tracked in FilterOrderRegistration or if it has been previously registered via these same HttpSecurity methods [4][5]. 2. Internal Mechanism: While early implementations used a simple map that could lead to overwrites if the same class was registered in multiple positions [6], current versions manage these positions more robustly. However, attempting to register multiple filters in the exact same position (e.g., using addFilterAt) results in non-deterministic ordering among those specific filters, as the framework does not provide an explicit tie-breaking mechanism for same-position registration [4]. 3. Stable Sort: The overall filter chain is typically constructed by sorting these assigned order values [3]. Because the registry uses a step-based increment (e.g., ORDER_STEP = 100) [1], there is generally "room" between standard filters to inject custom filters without collisions. In summary, you should rely on addFilterBefore or addFilterAfter when you need to ensure a specific relative order [3]. If you are concerned about identical positions, avoid multiple registrations at the same target class [4], as the framework is designed to handle relative positioning rather than absolute index-based ordering. [4][3]

Citations:


🌐 Web query:

org.springframework.security.config.annotation.web.SecurityBuilder#doBuild sameOrderStableSort SpringSecurityFilterChainFilterFilter

💡 Result:

In Spring Security, the terms "doBuild" and "sameOrderStableSort" relate to the internal mechanism for constructing and ordering the security filter chain [1][2][3]. The doBuild method is part of the AbstractConfiguredSecurityBuilder class, which is a base class for builders like HttpSecurity [2][4][3]. This method is responsible for orchestrating the build process, which includes invoking configuration methods on applied SecurityConfigurers and finally calling performBuild to construct the target object (e.g., DefaultSecurityFilterChain) [2][5]. While "sameOrderStableSort" is not a public API method, it refers to the internal sorting logic used during the construction of the filter chain to ensure that filters are executed in the correct, predictable order [6]. Spring Security uses the FilterOrderRegistration class to maintain a registry of filter classes and their associated order values [1][6]. This ensures that essential security filters (like those for authentication and authorization) are executed in a specific sequence regardless of how they are added to the configuration [6]. If you are investigating the filter order: 1. FilterOrderRegistration: This is the definitive source of truth for the default order of standard Spring Security filters [1][6]. You can check this class in the Spring Security source code to see exactly how filters are ordered [1]. 2. HttpSecurity: When configuring your application using HttpSecurity, you can influence this order using methods like addFilterBefore and addFilterAfter, which allow you to place your custom filters relative to the standard ones [7]. 3. Architecture: The FilterChainProxy is the entry point that uses the ordered SecurityFilterChain to delegate requests to the appropriate filters [6][8]. If you find discrepancies in documentation or need to understand why a filter is placed in a specific position, the FilterOrderRegistration class remains the primary reference [1][9][6].

Citations:


Make the custom filter order explicit.

addFilterAfter(userProvisioningFilter, OptionalBearerAuthenticationFilter.class) assigns the same order as the anchor filter, so the provisioning-before-anonymous sequence is an implementation-detail dependency on stable same-order sorting. Anchor both custom filters with a strict relative order, such as a single chain using an explicit earlier standard anchor or an @Order-backed bean registration, instead of relying on insertion-dependent tie-breaking.

Preserve the OAuth2 resource-server path, which still needs SecurityFilterChain filterChain(...) after this change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/main/java/disscount/config/SecurityConfig.java` around lines 91 -
97, Make the custom filter ordering explicit in SecurityConfig by assigning
userProvisioningFilter a strictly later order than
optionalBearerAuthenticationFilter, using a shared earlier standard anchor or
explicit `@Order-backed` registration rather than insertion-dependent same-order
sorting. Preserve the OAuth2 resource-server configuration and keep
SecurityFilterChain filterChain(...) intact.

Source: Coding guidelines

…re modal

Changes:
- Drop isSharingText from useShareListModal and the spinner it drove in ShareListModal
- Route the owner redirect on /s/[token] through shoppingListPath

The share modal reintroduced the pending flag a298cd0 removed and
shareOrCopy's own doc prohibits. navigator.share does not reliably settle
when the OS sheet is dismissed on mobile, so the finally never ran and
"Podijeli kao tekst" stayed disabled and spinning until a reload. The
modal stays mounted while the sheet is up, unlike the long-press sheet,
so this was reachable rather than theoretical.

The owner redirect hand-spelled the list route unencoded, which is the
exact drift shoppingListPath was extracted to stop.
Changes:
- Name the shared-list-pages bucket in the PWA caching table and the purge list
- Record that scoped bucket names are matched exactly, not by substring
- Point the persistence diagram at removePersistedCacheFor and the per-identity key
- Split the Sentry claim: Replay is excluded on /s/, not scrubbed
- Say the data cache is keyed per account and purged on identity change

The docs still described the pre-rework cache layout: a "pages" bucket
that no longer exists, removeClient() which persister.ts explicitly
rejects, and blanket beforeBreadcrumb scrubbing the server config never
had. PWA.md also contradicted itself, naming both "pages" and
"shared-list-pages" for the same rule.
@OffCrazyFreak
OffCrazyFreak merged commit 5b12836 into dev Aug 6, 2026
7 checks passed
@OffCrazyFreak
OffCrazyFreak deleted the feat/shopping-list-sharing branch August 6, 2026 07:02
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.

2 participants