diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 00000000..630a78f4 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,33 @@ +# Third-party data & attributions + +TREK bundles and uses third-party data that requires attribution. + +## geoBoundaries — country & sub-national boundaries + +The Atlas map's administrative boundaries (admin-0 countries and admin-1 +provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and +`server/assets/atlas/admin1.geojson.gz` and generated by +`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**. + +> Runfola, D. et al. (2020) geoBoundaries: A global database of political +> administrative boundaries. PLoS ONE 15(4): e0231866. +> https://doi.org/10.1371/journal.pone.0231866 + +geoBoundaries is licensed under **CC BY 4.0** +(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/ + +The bundled files are simplified (coordinate-quantized) and re-tagged with the +property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries +CGAZ composite; sub-national regions (`admin1`) derive from the per-country open +(gbOpen) release. + +## OpenStreetMap — geocoding + +Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is +© OpenStreetMap contributors, licensed under the Open Database License (ODbL). +https://www.openstreetmap.org/copyright + +## OurAirports — airport reference data + +`server/assets/airports.json` is built from **OurAirports** +(https://ourairports.com/data/), released into the public domain. diff --git a/README.md b/README.md index 463cd8d7..6c3a6f67 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,13 @@ Caddy handles TLS and WebSockets automatically.
+## Data sources + +The Atlas map's country and sub-national (province/county) boundaries come from +[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed +[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md) +for full third-party attributions. + ## License TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence. diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f31b80d0..c0df237f 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -394,6 +394,7 @@ export const packingApi = { reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data), getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data), setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data), + listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data), applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data), saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data), setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data), diff --git a/client/src/components/Packing/ApplyTemplateButton.tsx b/client/src/components/Packing/ApplyTemplateButton.tsx index b32c7d5b..50c37571 100644 --- a/client/src/components/Packing/ApplyTemplateButton.tsx +++ b/client/src/components/Packing/ApplyTemplateButton.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react' import { Package } from 'lucide-react' -import { adminApi, packingApi } from '../../api/client' +import { packingApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' @@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT const { t } = useTranslation() useEffect(() => { - adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {}) + packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {}) }, [tripId]) useEffect(() => { diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 2dbbb01c..f75b27fc 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; +import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import PackingListPanel, { itemWeight } from './PackingListPanel'; describe('itemWeight (bag total weight calc)', () => { @@ -34,10 +34,10 @@ beforeEach(() => { http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({ assignees: {} }) ), - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: false }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: false, addons: [] }) ), - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [] }) ), ); @@ -381,7 +381,7 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-030: packing template button present when templates available', async () => { server.use( - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] }) ) ); @@ -457,8 +457,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => { server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] }) @@ -556,8 +556,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) @@ -585,8 +585,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => { server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] }) @@ -601,26 +601,36 @@ describe('PackingListPanel', () => { }); }); - it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => { + it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => { + seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true }); const user = userEvent.setup(); const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; - const { container } = render(); + render(); - // Save-as-template button uses FolderPlus icon and "Save as template" text - const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button'); - expect(folderPlusBtn).toBeTruthy(); + // Save-as-template button shows its label "Save as template" + const saveBtn = screen.getByText('Save as template').closest('button'); + expect(saveBtn).toBeTruthy(); // Click to show the name input - await user.click(folderPlusBtn!); + await user.click(saveBtn!); // Template name input appears expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument(); }); + it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => { + // Default seeded user (beforeEach) is a non-admin trip owner with edit rights. + const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; + render(); + + // The "Save as template" action must not be available to normal users. + expect(screen.queryByText('Save as template')).not.toBeInTheDocument(); + }); + it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] }) ) ); @@ -658,8 +668,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => { server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] }) @@ -706,6 +716,7 @@ describe('PackingListPanel', () => { }); it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => { + seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true }); const user = userEvent.setup(); let savedTemplateName = ''; server.use( @@ -714,16 +725,16 @@ describe('PackingListPanel', () => { savedTemplateName = String(body.name); return HttpResponse.json({ success: true }); }), - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [] }) ) ); const items = [buildPackingItem({ name: 'Item', category: 'Test' })]; - const { container } = render(); + render(); - // Click the FolderPlus "Save as template" button - const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button'); - await user.click(folderPlusBtn!); + // Click the "Save as template" button + const saveBtn = screen.getByText('Save as template').closest('button'); + await user.click(saveBtn!); // Type template name const nameInput = await screen.findByPlaceholderText('Template name'); @@ -736,8 +747,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] }) @@ -765,8 +776,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) @@ -805,8 +816,8 @@ describe('PackingListPanel', () => { let putBody: Record | null = null; const itemId = 120; server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] }) @@ -861,8 +872,8 @@ describe('PackingListPanel', () => { const itemId = 130; let putBody: Record | null = null; server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] }) @@ -930,8 +941,8 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => { const itemId = 140; server.use( - http.get('/api/admin/bag-tracking', () => - HttpResponse.json({ enabled: true }) + http.get('/api/addons', () => + HttpResponse.json({ bagTracking: true, addons: [] }) ), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] }) @@ -957,7 +968,7 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] }) ) ); @@ -1124,7 +1135,7 @@ describe('PackingListPanel', () => { const user = userEvent.setup(); let applyCalled = false; server.use( - http.get('/api/admin/packing-templates', () => + http.get('/api/trips/:id/packing/templates', () => HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] }) ), http.post('/api/trips/1/packing/apply-template/5', () => { @@ -1177,7 +1188,7 @@ describe('PackingListPanel', () => { const user = userEvent.setup(); let createBody: Record | null = null; server.use( - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), // Start with one bag so the sidebar renders (sidebar requires bags.length > 0) http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) @@ -1207,7 +1218,7 @@ describe('PackingListPanel', () => { const user = userEvent.setup(); let deleteCalled = false; server.use( - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) ), @@ -1235,7 +1246,7 @@ describe('PackingListPanel', () => { const user = userEvent.setup(); let updateBody: Record | null = null; server.use( - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] }) ), @@ -1273,7 +1284,7 @@ describe('PackingListPanel', () => { current_user_id: 1, }) ), - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] }) ) @@ -1314,7 +1325,7 @@ describe('PackingListPanel', () => { current_user_id: 1, }) ), - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] }) ), @@ -1352,7 +1363,7 @@ describe('PackingListPanel', () => { it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => { let createBody: Record | null = null; server.use( - http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })), + http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })), http.post('/api/trips/1/packing/bags', async ({ request }) => { createBody = await request.json() as Record; diff --git a/client/src/components/Packing/PackingListPanelHeader.tsx b/client/src/components/Packing/PackingListPanelHeader.tsx index 58c76b2a..cad39f86 100644 --- a/client/src/components/Packing/PackingListPanelHeader.tsx +++ b/client/src/components/Packing/PackingListPanelHeader.tsx @@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel' export function PackingHeader(S: PackingState) { const { - inlineHeader, t, items, abgehakt, fortschritt, canEdit, + inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin, showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate, setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate, @@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) { ) : }
- {canEdit && items.length > 0 && showSaveTemplate && ( + {canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
)} - {inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && ( + {inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (