├── src ├── i18n │ ├── el.json │ ├── fa.json │ ├── hu.json │ ├── it.json │ ├── mn.json │ └── vi.json ├── lib │ ├── assets │ │ └── logo.png │ ├── schema.ts │ ├── components │ │ ├── admin │ │ │ ├── Settings │ │ │ │ ├── Email │ │ │ │ │ └── index.ts │ │ │ │ ├── General │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── Groups.svelte │ │ │ │ │ ├── General.svelte │ │ │ │ │ └── Lists.svelte │ │ │ │ ├── Security │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Security.svelte │ │ │ │ ├── SettingsGroup.svelte │ │ │ │ ├── index.ts │ │ │ │ └── Setting.svelte │ │ │ ├── ActionsForm.svelte │ │ │ ├── SMTPAlert.svelte │ │ │ └── Users.svelte │ │ ├── toasts.ts │ │ ├── md │ │ │ ├── Link.svelte │ │ │ ├── List.svelte │ │ │ └── Headers.svelte │ │ ├── Image.svelte │ │ ├── wishlists │ │ │ ├── chips │ │ │ │ ├── ManageListChip.svelte │ │ │ │ ├── ClaimFilter.svelte │ │ │ │ ├── ReorderChip.svelte │ │ │ │ ├── ClaimsGroupBy.svelte │ │ │ │ ├── SortBy.svelte │ │ │ │ ├── ListViewModeChip.svelte │ │ │ │ └── ListFilterChip.svelte │ │ │ ├── ItemCard │ │ │ │ ├── components │ │ │ │ │ ├── ItemNameHeader.svelte │ │ │ │ │ ├── ItemFooter.svelte │ │ │ │ │ └── ItemImage.svelte │ │ │ │ └── ListItemCard.svelte │ │ │ └── util.ts │ │ ├── navigation │ │ │ ├── navigation.ts │ │ │ ├── NavigationLoadingBar.svelte │ │ │ ├── BottomTabs.svelte │ │ │ └── NavigationDrawer.svelte │ │ ├── Markdown.svelte │ │ ├── Backdrop.svelte │ │ ├── Avatar.svelte │ │ ├── Drawer.svelte │ │ ├── Search.svelte │ │ ├── Alert.svelte │ │ ├── account │ │ │ └── LinkOAuth.svelte │ │ ├── BackButton.svelte │ │ ├── ClearableInput.svelte │ │ ├── Tooltip.svelte │ │ ├── MarkdownEditor.svelte │ │ ├── modals │ │ │ └── GroupSelectModal.svelte │ │ └── TokenCopy.svelte │ ├── stores │ │ ├── is-installed.ts │ │ ├── smtp-acknowledge.ts │ │ ├── viewed-items.ts │ │ └── list-view-preference.svelte.ts │ ├── util.ts │ ├── server │ │ ├── prisma.ts │ │ ├── events │ │ │ ├── emitters.ts │ │ │ └── sse.ts │ │ ├── token.ts │ │ ├── logger.ts │ │ ├── sort-filter-util.ts │ │ ├── shopping │ │ │ └── helpers.ts │ │ ├── i18n.ts │ │ ├── api-common.ts │ │ ├── password.ts │ │ └── items.ts │ ├── dtos │ │ ├── group-mapper.ts │ │ └── item-dto.ts │ ├── api │ │ ├── claims.ts │ │ └── items.ts │ └── zxcvbn.ts ├── routes │ ├── admin │ │ ├── actions │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ │ ├── groups │ │ │ ├── +page.svelte │ │ │ ├── [groupId] │ │ │ │ ├── +page.server.ts │ │ │ │ ├── +layout.server.ts │ │ │ │ ├── settings │ │ │ │ │ └── +page.svelte │ │ │ │ └── members │ │ │ │ │ └── +page.server.ts │ │ │ └── +page.server.ts │ │ ├── users │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ │ ├── +page.server.ts │ │ ├── about │ │ │ └── +page.svelte │ │ └── +layout.svelte │ ├── login │ │ └── oidc │ │ │ └── +server.ts │ ├── group-error │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── setup-wizard │ │ ├── step │ │ │ └── [step] │ │ │ │ ├── +layout.svelte │ │ │ │ ├── steps.ts │ │ │ │ ├── +page.server.ts │ │ │ │ ├── InviteUsersStep.svelte │ │ │ │ └── CreateAccountStep.svelte │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── logout │ │ └── +server.ts │ ├── api │ │ ├── assets │ │ │ └── [id] │ │ │ │ └── +server.ts │ │ ├── groups │ │ │ ├── [groupId] │ │ │ │ └── auth.ts │ │ │ └── +server.ts │ │ ├── users │ │ │ ├── [userId] │ │ │ │ ├── groups │ │ │ │ │ └── [groupId] │ │ │ │ │ │ └── +server.ts │ │ │ │ └── +server.ts │ │ │ └── public │ │ │ │ └── +server.ts │ │ └── lists │ │ │ └── [listId] │ │ │ ├── +server.ts │ │ │ └── items │ │ │ └── +server.ts │ ├── +layout.ts │ ├── +layout.server.ts │ ├── lists │ │ ├── create │ │ │ └── +page.svelte │ │ └── [id] │ │ │ └── manage │ │ │ └── +page.svelte │ ├── wishlists │ │ ├── [username] │ │ │ └── +page.server.ts │ │ └── me │ │ │ └── +page.server.ts │ ├── signup │ │ └── +page.svelte │ ├── +error.svelte │ ├── items │ │ └── [itemId] │ │ │ └── edit │ │ │ └── +page.svelte │ ├── +page.server.ts │ └── forgot-password │ │ └── +page.server.ts ├── app.html └── app.d.ts ├── tests ├── ui │ └── claims.spec.ts ├── constants.ts ├── docker │ └── docker-compose.yaml ├── types.ts ├── modules │ ├── list-settings.ts │ ├── change-group-modal.ts │ ├── toast.ts │ ├── create-group-modal.ts │ ├── add-member-modal.ts │ ├── add-manager-modal.ts │ ├── suggestions-settings.ts │ ├── create-account-form.ts │ ├── chip.ts │ ├── list-managers-selector.ts │ ├── modal.ts │ ├── delete-item-modal.ts │ ├── list-card.ts │ └── list-form.ts ├── pageObjects │ ├── base.page.ts │ ├── create-list.page.ts │ ├── edit-item.page.ts │ ├── signin.page.ts │ ├── signup.page.ts │ ├── create-item.page.ts │ └── admin.page.ts ├── global.setup.ts ├── TODO.md └── helpers │ └── suggestions.ts ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── lint.yml │ └── build_push.yml ├── prisma ├── migrations │ ├── 20250912200907_sqlite_wal_mode │ │ └── migration.sql │ ├── 20230411193602_lucia_1_0 │ │ └── migration.sql │ ├── 20250329195127_add_oauthid │ │ └── migration.sql │ ├── 20230306173006_add_key_expires_in │ │ └── migration.sql │ ├── 20230306211437_add_profile_picture │ │ └── migration.sql │ ├── 20250729023729_list_description │ │ └── migration.sql │ ├── 20240825030505_add_display_order │ │ └── migration.sql │ ├── 20250729011701_user_preferred_language │ │ └── migration.sql │ ├── 20231022025934_add_default_currency_symbols │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20231128030637_add_claim_show_name_config_option │ │ └── migration.sql │ ├── 20230203145115_system_config │ │ └── migration.sql │ ├── 20241209020045_add_patches │ │ └── migration.sql │ ├── 20241231233744_remove_public_list_model │ │ └── migration.sql │ ├── 20250804095000_add_require_email_config │ │ └── migration.sql │ ├── 20240118052807_fix_initial_config_generation │ │ └── migration.sql │ ├── 20250730141611_add_unique_username_name_combination │ │ └── migration.sql │ ├── 20251026215400_add_list_managers │ │ └── migration.sql │ ├── 20221229153655_signup_token │ │ └── migration.sql │ ├── 20240618010500_add_public_lists │ │ └── migration.sql │ ├── 20240330024912_standardize_session_casing │ │ └── migration.sql │ ├── 20230811191732_lucia_v2 │ │ └── migration.sql │ ├── 20240330005940_lucia_v3_update_session_expires_at │ │ └── migration.sql │ ├── 20221229183508_email_required │ │ └── migration.sql │ ├── 20250914222933_email_case_insensitivity │ │ └── migration.sql │ ├── 20240330011054_lucia_v3_keys_removal │ │ └── migration.sql │ ├── 20230116175745_item_approval │ │ └── migration.sql │ ├── 20250623171719_nullable_item_quantity │ │ └── migration.sql │ ├── 20230221220353_add_item_purchased │ │ └── migration.sql │ ├── 20251208032804_item_most_wanted │ │ └── migration.sql │ ├── 20231017150829_remove_expires_in │ │ └── migration.sql │ ├── 20250329193312_item_claim_cascade_delete │ │ └── migration.sql │ ├── 20231011193501_token_uuid │ │ └── migration.sql │ ├── 20250220150346_move_item_claim_to_item │ │ └── migration.sql │ └── 20250209175703_remove_item_group_relation │ │ └── migration.sql ├── patches │ ├── index.ts │ └── list-relationship.ts └── client.ts ├── static ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── fonts │ ├── Lato-Black.woff │ ├── Lato-Bold.woff │ ├── Lato-Bold.woff2 │ ├── Lato-Light.woff │ ├── Lato-Black.woff2 │ ├── Lato-Italic.woff │ ├── Lato-Italic.woff2 │ ├── Lato-Light.woff2 │ ├── Lato-Regular.woff │ ├── Lato-BoldItalic.woff │ ├── Lato-Hairline.woff │ ├── Lato-Hairline.woff2 │ ├── Lato-Regular.woff2 │ ├── Lato-BlackItalic.woff │ ├── Lato-BlackItalic.woff2 │ ├── Lato-BoldItalic.woff2 │ ├── Lato-LightItalic.woff │ ├── Lato-LightItalic.woff2 │ ├── Lato-HairlineItalic.woff │ └── Lato-HairlineItalic.woff2 ├── android-chrome-192x192.png └── android-chrome-512x512.png ├── assets ├── wish-form.png ├── wishes-mobile.png ├── homepage-desktop.png ├── homepage-mobile.png └── my-wishes-mobile.png ├── pnpm-workspace.yaml ├── .dockerignore ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── docker-compose.yaml ├── prisma.config.ts ├── .gitignore ├── entrypoint.sh ├── .env.example ├── postcss.config.cjs ├── svelte.config.js ├── Caddyfile ├── tsconfig.json ├── tailwind.config.ts ├── LICENSE ├── DEVELOPMENT.md ├── renovate.json └── templates ├── password-reset.mjml └── invite.mjml /src/i18n/el.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/fa.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/hu.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/i18n/it.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /tests/ui/claims.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cmintey] 2 | -------------------------------------------------------------------------------- /tests/constants.ts: -------------------------------------------------------------------------------- 1 | export const adminAuthFile = "playwright/.auth/admin.json"; 2 | -------------------------------------------------------------------------------- /prisma/migrations/20250912200907_sqlite_wal_mode/migration.sql: -------------------------------------------------------------------------------- 1 | PRAGMA journal_mode=WAL; -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /assets/wish-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/assets/wish-form.png -------------------------------------------------------------------------------- /src/lib/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/src/lib/assets/logo.png -------------------------------------------------------------------------------- /assets/wishes-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/assets/wishes-mobile.png -------------------------------------------------------------------------------- /src/lib/schema.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | USER = 1, 3 | ADMIN, 4 | GROUP_MANAGER 5 | } 6 | -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/mstile-150x150.png -------------------------------------------------------------------------------- /assets/homepage-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/assets/homepage-desktop.png -------------------------------------------------------------------------------- /assets/homepage-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/assets/homepage-mobile.png -------------------------------------------------------------------------------- /assets/my-wishes-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/assets/my-wishes-mobile.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/fonts/Lato-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Black.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Bold.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Bold.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Light.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Black.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Italic.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Italic.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Light.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Regular.woff -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/fonts/Lato-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-BoldItalic.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Hairline.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Hairline.woff -------------------------------------------------------------------------------- /static/fonts/Lato-Hairline.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Hairline.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-Regular.woff2 -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/Email/index.ts: -------------------------------------------------------------------------------- 1 | import Email from "./Email.svelte"; 2 | 3 | export default Email; 4 | -------------------------------------------------------------------------------- /static/fonts/Lato-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-BlackItalic.woff -------------------------------------------------------------------------------- /static/fonts/Lato-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-BlackItalic.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-BoldItalic.woff2 -------------------------------------------------------------------------------- /static/fonts/Lato-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-LightItalic.woff -------------------------------------------------------------------------------- /static/fonts/Lato-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-LightItalic.woff2 -------------------------------------------------------------------------------- /prisma/migrations/20230411193602_lucia_1_0/migration.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `key` 2 | RENAME COLUMN `primary` to `primary_key`; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250329195127_add_oauthid/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user" ADD COLUMN "oauthId" TEXT; 3 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/General/index.ts: -------------------------------------------------------------------------------- 1 | import General from "./General.svelte"; 2 | 3 | export default General; 4 | -------------------------------------------------------------------------------- /static/fonts/Lato-HairlineItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-HairlineItalic.woff -------------------------------------------------------------------------------- /static/fonts/Lato-HairlineItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cmintey/wishlist/HEAD/static/fonts/Lato-HairlineItalic.woff2 -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/Security/index.ts: -------------------------------------------------------------------------------- 1 | import Security from "./Security.svelte"; 2 | 3 | export default Security; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20230306173006_add_key_expires_in/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "key" ADD COLUMN "expires" BIGINT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230306211437_add_profile_picture/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user" ADD COLUMN "picture" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250729023729_list_description/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "list" ADD COLUMN "description" TEXT; 3 | -------------------------------------------------------------------------------- /src/lib/stores/is-installed.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | 3 | export const isInstalled = writable(false); 4 | -------------------------------------------------------------------------------- /prisma/migrations/20240825030505_add_display_order/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "items" ADD COLUMN "displayOrder" INTEGER; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250729011701_user_preferred_language/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "user" ADD COLUMN "preferredLanguage" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/patches/index.ts: -------------------------------------------------------------------------------- 1 | await import("./item-price"); 2 | await import("./list-relationship"); 3 | await import("./list-item-ids"); 4 | 5 | export {}; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20231022025934_add_default_currency_symbols/migration.sql: -------------------------------------------------------------------------------- 1 | UPDATE items 2 | SET price = '$' || price 3 | WHERE price <> '' AND price NOT LIKE '$%' -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "sqlite" 4 | -------------------------------------------------------------------------------- /src/routes/admin/actions/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /prisma/migrations/20231128030637_add_claim_show_name_config_option/migration.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO system_config ("key", "value", "groupId") VALUES ('claims.showName', 'true', 'global'); -------------------------------------------------------------------------------- /src/routes/login/oidc/+server.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from "./$types"; 2 | import { authorizeRedirect } from "$lib/server/openid"; 3 | 4 | export const GET: RequestHandler = authorizeRedirect; 5 | -------------------------------------------------------------------------------- /src/routes/group-error/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad } from "../$types"; 2 | import { requireLogin } from "$lib/server/auth"; 3 | 4 | export const load: PageServerLoad = async () => { 5 | requireLogin(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/stores/smtp-acknowledge.ts: -------------------------------------------------------------------------------- 1 | import type { Writable } from "svelte/store"; 2 | import { localStorageStore } from "@skeletonlabs/skeleton"; 3 | 4 | export const smtpAcknowledged: Writable = localStorageStore("smtpAcknowledged", false); 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | engineStrict: true 2 | autoInstallPeers: false 3 | strictPeerDependencies: false 4 | ignoredBuiltDependencies: 5 | - sharp 6 | supportedArchitectures: 7 | os: 8 | - linux 9 | cpu: 10 | - x64 11 | - arm64 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .svelte-kit/ 2 | build/ 3 | node_modules/ 4 | .eslint* 5 | .prettier* 6 | README.md 7 | 8 | Dockerfile 9 | templates/*.mjml 10 | uploads/ 11 | data/ 12 | prisma/*.db 13 | tests/ 14 | test-results/ 15 | playwright* 16 | .github 17 | .vscode 18 | dev-dist -------------------------------------------------------------------------------- /src/lib/components/admin/ActionsForm.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 | 8 |
9 | -------------------------------------------------------------------------------- /prisma/migrations/20230203145115_system_config/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "system_config" ( 3 | "key" TEXT NOT NULL PRIMARY KEY, 4 | "value" TEXT 5 | ); 6 | 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "system_config_key_key" ON "system_config"("key"); 9 | -------------------------------------------------------------------------------- /src/routes/admin/groups/+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/routes/admin/actions/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { requireRole } from "$lib/server/auth"; 3 | import type { PageServerLoad } from "./$types"; 4 | 5 | export const load: PageServerLoad = async () => { 6 | await requireRole(Role.ADMIN); 7 | }; 8 | -------------------------------------------------------------------------------- /tests/docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | container_name: wishlist 4 | image: wishlist:test 5 | build: 6 | context: ../../ 7 | network_mode: host 8 | environment: 9 | ORIGIN: http://localhost:3280 10 | -------------------------------------------------------------------------------- /prisma/migrations/20241209020045_add_patches/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "patch" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "executed_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 5 | ); 6 | 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "patch_id_key" ON "patch"("id"); 9 | -------------------------------------------------------------------------------- /tests/types.ts: -------------------------------------------------------------------------------- 1 | export interface UserData { 2 | id: string; 3 | name: string; 4 | username: string; 5 | email: string; 6 | password: string; 7 | groups: GroupData[]; 8 | } 9 | 10 | export interface GroupData { 11 | id: string; 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | .github 10 | 11 | # Ignore files for PNPM, NPM and YARN 12 | pnpm-lock.yaml 13 | package-lock.json 14 | yarn.lock 15 | 16 | # Ignore the template files 17 | templates 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "lokalise.i18n-ally", 4 | "esbenp.prettier-vscode", 5 | "svelte.svelte-vscode", 6 | "bradlc.vscode-tailwindcss", 7 | "prisma.prisma", 8 | "vunguyentuan.vscode-postcss" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "none", 4 | "printWidth": 120, 5 | "htmlWhitespaceSensitivity": "ignore", 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/admin/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | container_name: wishlist-app 4 | image: ghcr.io/cmintey/wishlist 5 | ports: 6 | - 3280:3280 7 | volumes: 8 | - ./uploads:/usr/src/app/uploads 9 | - ./data:/usr/src/app/data 10 | env_file: 11 | - .env 12 | -------------------------------------------------------------------------------- /prisma/migrations/20241231233744_remove_public_list_model/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `public_list` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | PRAGMA foreign_keys=off; 9 | DROP TABLE "public_list"; 10 | PRAGMA foreign_keys=on; 11 | -------------------------------------------------------------------------------- /prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "../src/lib/generated/prisma/client"; 2 | import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; 3 | import "dotenv/config"; 4 | 5 | export const prisma = new PrismaClient({ 6 | adapter: new PrismaBetterSqlite3({ 7 | url: process.env.DATABASE_URL 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/components/toasts.ts: -------------------------------------------------------------------------------- 1 | import { type ToastStore } from "@skeletonlabs/skeleton"; 2 | 3 | export const errorToast = (toastStore: ToastStore, message: string) => { 4 | toastStore.trigger({ 5 | message, 6 | background: "variant-filled-error", 7 | autohide: true, 8 | timeout: 5000 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/step/[step]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | {@render children?.()} 12 | 13 | -------------------------------------------------------------------------------- /src/routes/admin/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { redirect } from "@sveltejs/kit"; 3 | import type { PageServerLoad } from "./$types"; 4 | import { requireRole } from "$lib/server/auth"; 5 | 6 | export const load: PageServerLoad = async () => { 7 | await requireRole(Role.ADMIN); 8 | 9 | redirect(302, "/admin/users"); 10 | }; 11 | -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { defineConfig } from "prisma/config"; 3 | 4 | export default defineConfig({ 5 | schema: "prisma/schema.prisma", 6 | migrations: { 7 | path: "prisma/migrations", 8 | seed: "tsx prisma/seed.ts" 9 | }, 10 | datasource: { 11 | url: process.env.DATABASE_URL || "prisma/dev.db" 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20250804095000_add_require_email_config/migration.sql: -------------------------------------------------------------------------------- 1 | -- Add the claims.requireEmail config option with default value true for global config 2 | INSERT INTO system_config ("key", "value", "groupId") 3 | SELECT 'claims.requireEmail', 'true', 'global' 4 | WHERE NOT EXISTS ( 5 | SELECT 1 FROM system_config 6 | WHERE "key" = 'claims.requireEmail' AND "groupId" = 'global' 7 | ); 8 | -------------------------------------------------------------------------------- /src/routes/admin/groups/[groupId]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import type { PageServerLoad } from "./$types"; 3 | import { requireAdminOrManager } from "$lib/server/auth"; 4 | 5 | export const load: PageServerLoad = async ({ url, params }) => { 6 | await requireAdminOrManager(params.groupId); 7 | 8 | redirect(302, `${url.pathname}/members`); 9 | }; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20240118052807_fix_initial_config_generation/migration.sql: -------------------------------------------------------------------------------- 1 | -- Clean up the bad config 2 | DELETE FROM system_config WHERE "key" = 'claims.showName' AND "groupId" = 'global'; 3 | 4 | -- Now conditionally add the config option 5 | INSERT OR IGNORE INTO system_config ("key", "value", "groupId") 6 | SELECT 'claims.showName', 'true', 'global' 7 | WHERE (select count(*) > 0 from system_config ); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | /dev-dist 12 | 13 | data/ 14 | uploads/ 15 | prisma/*.db* 16 | src/lib/generated/ 17 | 18 | # Playwright 19 | /test-results/ 20 | /playwright-report/ 21 | /blob-report/ 22 | /playwright/.cache/ 23 | /playwright/.auth/ 24 | -------------------------------------------------------------------------------- /src/lib/components/md/Link.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | e.stopPropagation()} {title}>{@render children?.()} 13 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | export PROTOCOL_HEADER=x-forwarded-proto 2 | export HOST_HEADER=x-forwarded-host 3 | export DATABASE_URL="file:/usr/src/app/data/prod.db" 4 | export PUBLIC_DEFAULT_CURRENCY=${DEFAULT_CURRENCY} 5 | export BODY_SIZE_LIMIT=${MAX_IMAGE_SIZE:-5000000} 6 | 7 | caddy start --config /usr/src/app/Caddyfile 8 | 9 | pnpm prisma migrate deploy && \ 10 | pnpm prisma db seed && \ 11 | pnpm db:patch && \ 12 | pnpm start -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | export const trimToNull = (s: string | null) => { 2 | if (!s) { 3 | return null; 4 | } 5 | const sTrimed = s.trim(); 6 | if (sTrimed.length === 0) { 7 | return null; 8 | } 9 | return sTrimed; 10 | }; 11 | 12 | export const rgbToHex = (r: number, g: number, b: number) => { 13 | return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/server/prisma.ts: -------------------------------------------------------------------------------- 1 | import { env } from "$env/dynamic/private"; 2 | import { PrismaClient } from "$lib/generated/prisma/client"; 3 | import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; 4 | 5 | export const client = new PrismaClient({ 6 | // log: ["query", "info", "warn", "error"] 7 | log: ["warn", "error"], 8 | adapter: new PrismaBetterSqlite3({ 9 | url: env.DATABASE_URL 10 | }) 11 | }); 12 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { client } from "$lib/server/prisma"; 2 | import { redirect } from "@sveltejs/kit"; 3 | import type { PageServerLoad } from "./$types"; 4 | 5 | export const load: PageServerLoad = async ({ url }) => { 6 | const userCount = await client.user.count(); 7 | if (userCount > 0) { 8 | return redirect(302, url.searchParams.get("redirectTo") ?? "/"); 9 | } 10 | return {}; 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/step/[step]/steps.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from "svelte"; 2 | import CreateAccountStep from "./CreateAccountStep.svelte"; 3 | import GlobalSettingsStep from "./GlobalSettingsStep.svelte"; 4 | import InviteUsersStep from "./InviteUsersStep.svelte"; 5 | 6 | export interface Props { 7 | onSuccess: () => void; 8 | } 9 | export const steps: Component[] = [CreateAccountStep, GlobalSettingsStep, InviteUsersStep]; 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # If behind a reverse proxy, set this to your domain 2 | # i.e. https://wishlist.your-domain.org 3 | ORIGIN= 4 | # Hours until signup and password reset tokens expire 5 | TOKEN_TIME=72 6 | # The currency to use when a product search does not return a currency 7 | DEFAULT_CURRENCY=USD 8 | # Set the logging level: trace | debug | info | warn | error | fatal | silent 9 | LOG_LEVEL=info 10 | # Maxinum image size that can be uploaded (in bytes) 11 | MAX_IMAGE_SIZE=5000000 -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const tailwindcss = require("tailwindcss"); 3 | const autoprefixer = require("autoprefixer"); 4 | 5 | const config = { 6 | plugins: [ 7 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 8 | tailwindcss(), 9 | //But others, like autoprefixer, need to run after, 10 | autoprefixer 11 | ] 12 | }; 13 | 14 | module.exports = config; 15 | -------------------------------------------------------------------------------- /tests/modules/list-settings.ts: -------------------------------------------------------------------------------- 1 | import type { Locator, Page } from "@playwright/test"; 2 | 3 | export class ListSettings { 4 | private readonly page: Page; 5 | private readonly allowPublicListsCheckbox: Locator; 6 | 7 | constructor(page: Page) { 8 | this.page = page; 9 | this.allowPublicListsCheckbox = page.getByLabel("Allow Public Lists"); 10 | } 11 | 12 | async allowPublicLists() { 13 | await this.allowPublicListsCheckbox.check(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/dtos/group-mapper.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import type { Group, Role as RoleModel } from "$lib/generated/prisma/client"; 3 | 4 | export const toGroupInformation = (membership: { 5 | group: Group; 6 | active: boolean; 7 | role: RoleModel; 8 | }): GroupInformation => { 9 | return { 10 | ...membership.group, 11 | active: membership.active, 12 | isManager: membership.role.id === Role.GROUP_MANAGER || membership.role.id === Role.ADMIN 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/routes/admin/groups/[groupId]/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { client } from "$lib/server/prisma"; 2 | import type { LayoutServerLoad } from "./$types"; 3 | 4 | export const load: LayoutServerLoad = async ({ params }) => { 5 | const group = await client.group.findUniqueOrThrow({ 6 | where: { 7 | id: params.groupId 8 | }, 9 | select: { 10 | id: true, 11 | name: true 12 | } 13 | }); 14 | 15 | return { 16 | group 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/SettingsGroup.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |

{title}

15 | {@render children()} 16 |
17 | -------------------------------------------------------------------------------- /src/lib/components/md/List.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if ordered} 14 |
    {@render children?.()}
15 | {:else} 16 |
    {@render children?.()}
17 | {/if} 18 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from "@sveltejs/adapter-node"; 2 | import { sveltePreprocess } from "svelte-preprocess"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://github.com/sveltejs/svelte-preprocess 7 | // for more information about preprocessors 8 | preprocess: [ 9 | sveltePreprocess({ 10 | postcss: true 11 | }) 12 | ], 13 | 14 | kit: { 15 | adapter: adapter() 16 | } 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /src/lib/components/Image.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if imgError || !src} 15 | {@render children?.()} 16 | {:else} 17 | (imgError = true)} {src} /> 18 | {/if} 19 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/index.ts: -------------------------------------------------------------------------------- 1 | import Email from "./Email"; 2 | import General from "./General"; 3 | import Security from "./Security"; 4 | import type { MessageFormatter } from "$lib/server/i18n"; 5 | 6 | const options = [ 7 | { label: ($t: MessageFormatter) => $t("admin.general"), hash: "#general" }, 8 | { label: ($t: MessageFormatter) => $t("auth.email"), hash: "#email" }, 9 | { label: ($t: MessageFormatter) => $t("admin.security"), hash: "#security" } 10 | ]; 11 | 12 | export { Email, General, Security, options }; 13 | -------------------------------------------------------------------------------- /src/routes/group-error/+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |

{$t("errors.cant-find-your-group")}

10 | {$t("a11y.a-person-looking-at-an-empty-board")} 11 |

{$t("errors.no-group-friendly-msg")}

12 |
13 | -------------------------------------------------------------------------------- /tests/modules/change-group-modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Page } from "@playwright/test"; 2 | import { Modal } from "./modal"; 3 | 4 | export class ChangeGroupModal extends Modal { 5 | constructor(page: Page) { 6 | super(page, { submitButtonText: "Change Group" }); 7 | } 8 | 9 | async selectGroup(name: string) { 10 | await expect(this.modal).toBeVisible(); 11 | await this.modal.getByText(name).click(); 12 | await this.submit(); 13 | await expect(this.modal).not.toBeVisible(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/pageObjects/base.page.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from "@playwright/test"; 2 | 3 | export abstract class BasePage { 4 | protected readonly page: Page; 5 | protected readonly urlPath: string; 6 | 7 | constructor(page: Page, urlPath: string) { 8 | this.page = page; 9 | this.urlPath = urlPath; 10 | } 11 | 12 | abstract at(): Promise; 13 | 14 | async goto(opts?: { skipAssert: boolean }) { 15 | await this.page.goto(this.urlPath); 16 | if (!opts?.skipAssert) await this.at(); 17 | return this; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/Setting.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | {@render children()} 16 | {#if description} 17 | 18 | {@render description()} 19 | 20 | {/if} 21 |
22 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ManageListChip.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 18 |
19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "html", "markdown", "svelte"], 3 | "i18n-ally.localesPaths": ["src/i18n"], 4 | "i18n-ally.sortKeys": true, 5 | "i18n-ally.keepFulfilled": false, 6 | "i18n-ally.keystyle": "nested", 7 | "i18n-ally.indent": 4, 8 | "i18n-ally.dirStructure": "file", 9 | "i18n-ally.keysInUse": [ 10 | "general.moderate", 11 | "general.off", 12 | "general.strong", 13 | "general.very-strong", 14 | "general.very-weak", 15 | "general.weak" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/components/navigation/navigation.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "$app/paths"; 2 | 3 | export const navItems = [ 4 | { 5 | labelKey: "app.home", 6 | href: () => resolve("/lists"), 7 | icon: "ion:home" 8 | }, 9 | { 10 | labelKey: "wishes.my-lists", 11 | href: (user) => resolve("/lists") + (user ? "?" + new URLSearchParams({ users: user.id }).toString() : ""), 12 | icon: "ion:gift" 13 | }, 14 | { 15 | labelKey: "app.my-claims", 16 | href: () => resolve("/claims"), 17 | icon: "ion:albums" 18 | } 19 | ] satisfies NavItem[]; 20 | -------------------------------------------------------------------------------- /src/lib/components/Markdown.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/routes/logout/+server.ts: -------------------------------------------------------------------------------- 1 | import { deleteSessionTokenCookie, invalidateSession } from "$lib/server/auth"; 2 | import type { RequestHandler } from "@sveltejs/kit"; 3 | 4 | export const POST: RequestHandler = async ({ locals, cookies }) => { 5 | if (!locals.session) return new Response(null, { status: 401 }); 6 | await invalidateSession(locals.session?.id); 7 | deleteSessionTokenCookie(cookies); 8 | 9 | cookies.set("direct", "1", { 10 | path: "/", 11 | httpOnly: true, 12 | maxAge: 60 * 10, 13 | sameSite: "lax" 14 | }); 15 | 16 | return new Response(null, { status: 201 }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/components/Backdrop.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
14 |
15 | 16 |
17 | {text} 18 |
19 | -------------------------------------------------------------------------------- /src/lib/server/events/emitters.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | import type { 3 | ItemCreateHandler, 4 | ItemDeleteHandler, 5 | ItemEvent, 6 | ItemsUpdateHandler, 7 | ItemUpdateHandler 8 | } from "../../events"; 9 | 10 | interface ItemEvents { 11 | [ItemEvent.ITEM_UPDATE]: Parameters; 12 | [ItemEvent.ITEM_CREATE]: Parameters; 13 | [ItemEvent.ITEM_DELETE]: Parameters; 14 | [ItemEvent.ITEMS_UPDATE]: Parameters; 15 | } 16 | 17 | export const itemEmitter = new EventEmitter(); 18 | -------------------------------------------------------------------------------- /prisma/migrations/20250730141611_add_unique_username_name_combination/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA defer_foreign_keys=ON; 3 | PRAGMA foreign_keys=OFF; 4 | CREATE TABLE "new_system_user" ( 5 | "id" TEXT NOT NULL PRIMARY KEY, 6 | "username" TEXT DEFAULT NULL, 7 | "name" TEXT DEFAULT 'ANONYMOUS_NAME' 8 | ); 9 | INSERT INTO "new_system_user" ("id", "name", "username") SELECT "id", "name", "username" FROM "system_user"; 10 | DROP TABLE "system_user"; 11 | ALTER TABLE "new_system_user" RENAME TO "system_user"; 12 | CREATE UNIQUE INDEX "system_user_id_key" ON "system_user"("id"); 13 | PRAGMA foreign_keys=ON; 14 | PRAGMA defer_foreign_keys=OFF; 15 | -------------------------------------------------------------------------------- /src/lib/server/token.ts: -------------------------------------------------------------------------------- 1 | const { randomBytes, createHash } = await import("node:crypto"); 2 | 3 | export const generateToken = (): Promise => { 4 | return new Promise((resolve, reject) => 5 | randomBytes(48, (err, buffer) => { 6 | if (err) { 7 | reject(err.message); 8 | } else { 9 | const token = buffer.toString("hex"); 10 | resolve(token); 11 | } 12 | }) 13 | ); 14 | }; 15 | 16 | export const hashToken = (token: string): string => { 17 | return createHash("sha3-256").update(token).digest("hex"); 18 | }; 19 | 20 | export default generateToken; 21 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/Security/Security.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |

{$t("admin.security")}

17 | 18 | 19 |
20 | -------------------------------------------------------------------------------- /prisma/migrations/20251026215400_add_list_managers/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "list_manager" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "listId" TEXT NOT NULL, 6 | CONSTRAINT "list_manager_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 7 | CONSTRAINT "list_manager_listId_fkey" FOREIGN KEY ("listId") REFERENCES "list" ("id") ON DELETE CASCADE ON UPDATE CASCADE 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "list_manager_id_key" ON "list_manager"("id"); 12 | 13 | -- CreateIndex 14 | CREATE INDEX "list_manager_listId_idx" ON "list_manager"("listId"); 15 | -------------------------------------------------------------------------------- /prisma/migrations/20221229153655_signup_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "signup_tokens" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "expiresIn" INTEGER NOT NULL, 6 | "hashedToken" TEXT NOT NULL, 7 | "redeemed" BOOLEAN NOT NULL DEFAULT false 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "signup_tokens_id_key" ON "signup_tokens"("id"); 12 | 13 | -- CreateIndex 14 | CREATE INDEX "signup_tokens_hashedToken_idx" ON "signup_tokens"("hashedToken"); 15 | 16 | -- CreateIndex 17 | CREATE INDEX "password_resets_hashedToken_idx" ON "password_resets"("hashedToken"); 18 | -------------------------------------------------------------------------------- /src/lib/components/Avatar.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | x + y.at(0), "")} 19 | src={props.user?.picture ? `/api/assets/${props.user.picture}` : ""} 20 | {...props} 21 | > 22 | {@render children()} 23 | 24 | -------------------------------------------------------------------------------- /prisma/migrations/20240618010500_add_public_lists/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "public_list" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "groupId" TEXT NOT NULL, 6 | CONSTRAINT "public_list_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 7 | CONSTRAINT "public_list_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "group" ("id") ON DELETE CASCADE ON UPDATE CASCADE 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "public_list_id_key" ON "public_list"("id"); 12 | 13 | -- CreateIndex 14 | CREATE INDEX "public_list_userId_groupId_idx" ON "public_list"("userId", "groupId"); 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Logs** 24 | Provide relevant container and/or browser console logs. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | -------------------------------------------------------------------------------- /src/routes/api/assets/[id]/+server.ts: -------------------------------------------------------------------------------- 1 | import { getFormatter } from "$lib/server/i18n"; 2 | import { requireLoginOrError } from "$lib/server/auth"; 3 | import { error, type RequestHandler } from "@sveltejs/kit"; 4 | import { readFileSync } from "fs"; 5 | 6 | export const GET: RequestHandler = async ({ params }) => { 7 | await requireLoginOrError(); 8 | const $t = await getFormatter(); 9 | 10 | if (!params.id) { 11 | error(400, $t("errors.must-specify-asset-id")); 12 | } 13 | 14 | try { 15 | const asset = readFileSync(`uploads/${params.id}`); 16 | return new Response(asset); 17 | } catch { 18 | error(404, $t("errors.asset-not-found")); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/i18n/mn.json: -------------------------------------------------------------------------------- 1 | { 2 | "a11y": { 3 | "close": "Хаах", 4 | "menu": "Цэс", 5 | "password-strength": "Нууц үгийн бат бөх байдал", 6 | "save-group-name": "Группийн нэрийг хадгалах", 7 | "edit-group-name": "Группийн нэрийг засах", 8 | "increase-priority": "{name} -ийн эрэмбийг нэмэх", 9 | "cancel-editing": "Засварыг цуцлах", 10 | "wishlist-logo": "Хүслийн жагсаалтын лого", 11 | "upload-profile-image": "Профайл зураг байршуул", 12 | "a-person-looking-at-an-empty-board": "Хоосон байна" 13 | }, 14 | "admin": { 15 | "add-member": "Гишүүн нэмэх", 16 | "account": "Бүртгэл", 17 | "about": "Тухай" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/server/logger.ts: -------------------------------------------------------------------------------- 1 | import { dev } from "$app/environment"; 2 | import { env } from "$env/dynamic/private"; 3 | import pino from "pino"; 4 | 5 | const transport = dev 6 | ? { 7 | target: "pino-pretty", 8 | options: { 9 | colorize: true 10 | } 11 | } 12 | : undefined; 13 | 14 | export const logger = pino({ 15 | level: env.LOG_LEVEL ?? "info", 16 | formatters: { 17 | level(label) { 18 | return { level: label.toUpperCase() }; 19 | } 20 | }, 21 | base: undefined, 22 | timestamp: pino.stdTimeFunctions.isoTime, 23 | transport 24 | }); 25 | 26 | export const oidcLogger = logger.child({}, { msgPrefix: "[OIDC] " }); 27 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | auto_https off 3 | admin off 4 | } 5 | 6 | :3280 { 7 | @static { 8 | file 9 | path *.ico *.css *.js *.gif *.jpg *.jpeg *.png *.svg *.woff *.woff2 *.webp 10 | } 11 | 12 | @not_sse { 13 | not path /wishlists/*/events 14 | not path /lists/*/events 15 | } 16 | 17 | # Handles User Images 18 | handle_path /api/assets/* { 19 | header @static Cache-Control max-age=31536000 20 | root * /usr/src/app/uploads/ 21 | file_server 22 | } 23 | 24 | # gzip makes sse not work so use it for everything but the events api 25 | encode @not_sse { 26 | gzip 27 | } 28 | 29 | reverse_proxy :3000 { 30 | flush_interval -1 31 | } 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "types": ["vite-plugin-pwa/info", "vite-plugin-pwa/svelte"] 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // 16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 17 | // from the referenced tsconfig.json - TypeScript does not merge them in 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature: ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/global.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, test as setup } from "@playwright/test"; 2 | import { SetupWizardPage } from "./pageObjects/setup-wizard.page"; 3 | import { adminAuthFile } from "./constants"; 4 | 5 | setup("setup wizard", async ({ page }) => { 6 | await page.goto("/"); 7 | 8 | if (new URL(page.url()).pathname === "/login") { 9 | return; 10 | } 11 | 12 | const setupWizardPage = new SetupWizardPage(page); 13 | 14 | await setupWizardPage.getStarted(); 15 | await setupWizardPage.createAdminAccount(); 16 | await setupWizardPage.completeSteps(); 17 | 18 | await expect(page.getByRole("heading", { name: "Lists" })).toBeVisible(); 19 | 20 | await page.context().storageState({ path: adminAuthFile }); 21 | }); 22 | -------------------------------------------------------------------------------- /prisma/migrations/20240330024912_standardize_session_casing/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_session" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "userId" TEXT NOT NULL, 6 | "expiresAt" DATETIME NOT NULL, 7 | CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 8 | ); 9 | INSERT INTO "new_session" ("expiresAt", "id", "userId") SELECT "expiresAt", "id", "user_id" FROM "session"; 10 | DROP TABLE "session"; 11 | ALTER TABLE "new_session" RENAME TO "session"; 12 | CREATE UNIQUE INDEX "session_id_key" ON "session"("id"); 13 | CREATE INDEX "session_userId_idx" ON "session"("userId"); 14 | PRAGMA foreign_key_check; 15 | PRAGMA foreign_keys=ON; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20230811191732_lucia_v2/migration.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "key" 2 | WHERE expires != null; 3 | 4 | -- RedefineTables 5 | PRAGMA foreign_keys=OFF; 6 | CREATE TABLE "new_key" ( 7 | "id" TEXT NOT NULL PRIMARY KEY, 8 | "hashed_password" TEXT, 9 | "user_id" TEXT NOT NULL, 10 | CONSTRAINT "key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 11 | ); 12 | INSERT INTO "new_key" ("hashed_password", "id", "user_id") SELECT "hashed_password", "id", "user_id" FROM "key"; 13 | DROP TABLE "key"; 14 | ALTER TABLE "new_key" RENAME TO "key"; 15 | CREATE UNIQUE INDEX "key_id_key" ON "key"("id"); 16 | CREATE INDEX "key_user_id_idx" ON "key"("user_id"); 17 | PRAGMA foreign_key_check; 18 | PRAGMA foreign_keys=ON; 19 | -------------------------------------------------------------------------------- /tests/modules/toast.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | 3 | export class Toast { 4 | private readonly page: Page; 5 | private readonly toast: Locator; 6 | private readonly dismissToastButton: Locator; 7 | 8 | constructor(page: Page) { 9 | this.page = page; 10 | this.toast = page.getByTestId("toast"); 11 | this.dismissToastButton = this.toast.getByRole("button", { name: "Dismiss toast" }); 12 | } 13 | 14 | async waitForToastWithText(text: string) { 15 | await expect(this.toast.filter({ hasText: new RegExp(text) })).toBeVisible(); 16 | return this; 17 | } 18 | 19 | async dismissToast() { 20 | await this.dismissToastButton.click(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /prisma/migrations/20240330005940_lucia_v3_update_session_expires_at/migration.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "session"; 2 | 3 | -- RedefineTables 4 | PRAGMA foreign_keys=OFF; 5 | CREATE TABLE "new_session" ( 6 | "id" TEXT NOT NULL PRIMARY KEY, 7 | "user_id" TEXT NOT NULL, 8 | "expiresAt" DATETIME NOT NULL, 9 | CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 10 | ); 11 | INSERT INTO "new_session" ("id", "user_id") SELECT "id", "user_id" FROM "session"; 12 | DROP TABLE "session"; 13 | ALTER TABLE "new_session" RENAME TO "session"; 14 | CREATE UNIQUE INDEX "session_id_key" ON "session"("id"); 15 | CREATE INDEX "session_user_id_idx" ON "session"("user_id"); 16 | PRAGMA foreign_key_check; 17 | PRAGMA foreign_keys=ON; 18 | -------------------------------------------------------------------------------- /tests/modules/create-group-modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Modal } from "./modal"; 3 | import { randomString } from "../util"; 4 | 5 | export class CreateGroupModal extends Modal { 6 | private readonly input: Locator; 7 | 8 | constructor(page: Page) { 9 | super(page); 10 | this.input = this.modal.getByRole("textbox"); 11 | } 12 | 13 | async createGroup(name?: string) { 14 | const groupName = name ?? randomString(); 15 | await expect(this.modal).toBeVisible(); 16 | await this.input.fill(groupName); 17 | await this.submit(); 18 | await expect(this.modal).not.toBeVisible(); 19 | await this.page.reload(); 20 | return groupName; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/step/[step]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "$lib/server/config"; 2 | import { client } from "$lib/server/prisma"; 3 | import { redirect } from "@sveltejs/kit"; 4 | import type { PageServerLoad } from "./$types"; 5 | 6 | export const load: PageServerLoad = async ({ params }) => { 7 | const userCount = await client.user.count(); 8 | const step = Number.parseInt(params.step); 9 | if (userCount === 0 && step > 1) { 10 | return redirect(302, "/setup-wizard/step/1"); 11 | } 12 | 13 | if (userCount > 0 && step === 1) { 14 | return redirect(302, "/setup-wizard/step/2"); 15 | } 16 | 17 | const [config, groups] = await Promise.all([getConfig(), client.group.findMany()]); 18 | 19 | return { config, groups }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/i18n/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "a11y": { 3 | "close": "đóng", 4 | "cancel-editing": "huỷ thay đổi", 5 | "edit-group-name": "sửa tên nhóm", 6 | "decrease-priority": "giảm ưu tiên của {name}" 7 | }, 8 | "admin": { 9 | "account": "Tài khoản", 10 | "actions": "Hành động", 11 | "default-group": "Nhóm Mặc định", 12 | "delete-group-title": "Xoá nhóm", 13 | "delete-user": "Xoá người dùng", 14 | "group-name": "Tên Nhóm", 15 | "group-settings": "Cài đặt Nhóm", 16 | "id-field": "Id người dùng: {id}", 17 | "add-member": "Thêm thành viên", 18 | "delete-group-message": "Bạn có chắc rằng mình muốn xoá nhóm này không? Hành động này là không thể hoàn tác!", 19 | "groups": "Nhóm" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/components/navigation/NavigationLoadingBar.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | {#if progress.current} 21 |
22 | 23 |
24 | {/if} 25 | -------------------------------------------------------------------------------- /src/lib/server/sort-filter-util.ts: -------------------------------------------------------------------------------- 1 | import type { ItemOnListDTO } from "$lib/dtos/item-dto"; 2 | 3 | export const claimFilter = (filter: string | null, userId: string | null) => { 4 | if (filter === "unclaimed") { 5 | return (item: ItemOnListDTO) => item.isClaimable; 6 | } else if (filter === "claimed") { 7 | return (item: ItemOnListDTO) => { 8 | const userHasClaimed = item.claims.find((c) => userId && c.claimedBy?.id === userId); 9 | return !item.isClaimable || userHasClaimed; 10 | }; 11 | } 12 | return (_item: ItemOnListDTO) => true; 13 | }; 14 | 15 | export const decodeMultiValueFilter = (filter: string | null) => { 16 | if (filter === null) { 17 | return [] as string[]; 18 | } 19 | return decodeURIComponent(filter).split(","); 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutLoad } from "./$types"; 2 | import { getClosestAvailableLocale, getClosestAvailablePreferredLanguage, initFormatter, initLang } from "$lib/i18n"; 3 | import { browser } from "$app/environment"; 4 | 5 | export const load = (async ({ data }) => { 6 | let locale: string; 7 | if (data.user?.preferredLanguage) { 8 | locale = getClosestAvailablePreferredLanguage(data.user.preferredLanguage)?.code ?? data.locale; 9 | } else if (browser) { 10 | locale = getClosestAvailableLocale(window.navigator.languages).code; 11 | } else { 12 | locale = data.locale; 13 | } 14 | await initLang(locale); 15 | 16 | return { 17 | t: await initFormatter(locale), 18 | ...data, 19 | locale 20 | }; 21 | }) satisfies LayoutLoad; 22 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/step/[step]/InviteUsersStep.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |

{$t("setup.invite-users")}

17 | {$t("setup.invite-users-subtext")} 18 | 19 |
20 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ClaimFilter.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/routes/admin/groups/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { client } from "$lib/server/prisma"; 3 | import type { PageServerLoad } from "./$types"; 4 | import { requireRole } from "$lib/server/auth"; 5 | 6 | export const load: PageServerLoad = async () => { 7 | await requireRole(Role.ADMIN); 8 | 9 | const groups = await client.group 10 | .findMany({ 11 | select: { 12 | id: true, 13 | name: true, 14 | _count: { 15 | select: { 16 | UserGroupMembership: true 17 | } 18 | } 19 | } 20 | }) 21 | .then((groups) => groups.map((group) => ({ userCount: group._count.UserGroupMembership, ...group }))); 22 | 23 | return { 24 | groups 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/components/Drawer.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | {#if $drawerStore.id === "nav"} 20 | 21 | {:else} 22 | 23 | {/if} 24 | 25 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ReorderChip.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | {#if reordering} 15 | 19 | {:else} 20 | 24 | {/if} 25 |
26 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import type { Config } from "tailwindcss"; 3 | import { skeleton } from "@skeletonlabs/tw-plugin"; 4 | import { theme } from "./theme"; 5 | import forms from "@tailwindcss/forms"; 6 | 7 | const config = { 8 | darkMode: "class", 9 | content: [ 10 | "./src/**/*.{html,js,svelte,ts}", 11 | join(require.resolve("@skeletonlabs/skeleton"), "../**/*.{html,js,svelte,ts}") 12 | ], 13 | theme: { 14 | extend: {} 15 | }, 16 | plugins: [ 17 | forms, 18 | skeleton({ 19 | themes: { 20 | custom: [theme] 21 | } 22 | }) 23 | ], 24 | safelist: [ 25 | // The following are classes defined in i18n files and are used 26 | "text-secondary-700-200-token", 27 | "font-bold" 28 | ] 29 | } satisfies Config; 30 | 31 | export default config; 32 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/ItemCard/components/ItemNameHeader.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | {#if item.url} 9 | e.stopPropagation()} 15 | rel="noreferrer" 16 | target="_blank" 17 | > 18 | {item.name} 19 | 20 | {:else} 21 | 22 | {item.name} 23 | 24 | {/if} 25 |
26 | -------------------------------------------------------------------------------- /src/lib/stores/viewed-items.ts: -------------------------------------------------------------------------------- 1 | import type { Item } from "$lib/generated/prisma/client"; 2 | import { localStorageStore } from "@skeletonlabs/skeleton"; 3 | import type { Writable } from "svelte/store"; 4 | 5 | export const viewedItems: Writable> = localStorageStore("viewedItems", {}); 6 | 7 | export const hashItems = async (items: Partial[]): Promise => { 8 | const itemIds = items 9 | .map(({ id }) => id) 10 | .toSorted() 11 | .join(""); 12 | return await hash(itemIds); 13 | }; 14 | 15 | export const hash = async (data: string): Promise => { 16 | if (!window || !window.isSecureContext) { 17 | return Promise.resolve(""); 18 | } 19 | const encoder = new TextEncoder(); 20 | const digest = await crypto.subtle.digest("SHA-256", encoder.encode(data)); 21 | return btoa(String.fromCharCode(...new Uint8Array(digest))); 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { toGroupInformation } from "$lib/dtos/group-mapper"; 2 | import { client } from "$lib/server/prisma"; 3 | import type { LayoutServerLoad } from "./$types"; 4 | 5 | export const load = (async ({ locals }) => { 6 | let groups: GroupInformation[] | null = null; 7 | if (locals.user) { 8 | const membership = await client.userGroupMembership.findMany({ 9 | where: { 10 | userId: locals.user.id 11 | }, 12 | select: { 13 | group: true, 14 | active: true, 15 | role: true 16 | } 17 | }); 18 | 19 | groups = membership.map(toGroupInformation).toSorted((a, b) => a.name.localeCompare(b.name, locals.locale)); 20 | } 21 | 22 | return { user: locals.user, groups, isProxyUser: locals.isProxyUser, locale: locals.locale }; 23 | }) satisfies LayoutServerLoad; 24 | -------------------------------------------------------------------------------- /src/routes/lists/create/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | {$t("wishes.create-list")} 25 | 26 | -------------------------------------------------------------------------------- /src/lib/stores/list-view-preference.svelte.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "svelte"; 2 | 3 | type ListViewPreference = "list" | "tile"; 4 | type ListViewPreferenceState = { value: ListViewPreference }; 5 | 6 | // Context to share across components 7 | const [getPreference, setPreference] = createContext(); 8 | 9 | // Initialize the context with a reactive object storing the preference 10 | export const initListViewPreference = (preference: ListViewPreference) => { 11 | const preferenceState: ListViewPreferenceState = $state({ value: preference }); 12 | setPreference(preferenceState); 13 | }; 14 | 15 | // Get the reactive object from the context and update the value 16 | export const setListViewPreference = (preference: ListViewPreference) => { 17 | getPreference().value = preference; 18 | }; 19 | 20 | export const getListViewPreference = () => { 21 | return getPreference().value; 22 | }; 23 | -------------------------------------------------------------------------------- /src/lib/server/shopping/helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-useless-escape: 0 */ 2 | export const toPriceFormat = (price: string | undefined | null) => { 3 | if (!price) return; 4 | 5 | if (typeof price === "string") { 6 | // remove all non-numeric characters and symbols like $, € and others others. 7 | // except for '.' and ',' 8 | price = price.replace(/[^\d\.\,]/g, ""); 9 | 10 | price = /^(\d+\.?){1}(\.\d{2,3})*\,\d{1,2}$/.test(price) 11 | ? price.replace(/\./g, "").replace(",", ".") // case 1: price is formatted as '12.345,67' 12 | : price.replace(/,/g, ""); // case 2: price is formatted as '12,345.67' 13 | } 14 | 15 | const num = parseFloat(price); 16 | 17 | if (Number.isNaN(num)) { 18 | return; 19 | } 20 | 21 | return +num.toFixed(2); 22 | }; 23 | 24 | export const getHostname = (url: string) => { 25 | return new URL(url).hostname.replace("www.", ""); 26 | }; 27 | -------------------------------------------------------------------------------- /tests/modules/add-member-modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Modal } from "./modal"; 3 | 4 | export class AddMemberModal extends Modal { 5 | private readonly searchInput: Locator; 6 | private readonly searchResultsContainer: Locator; 7 | 8 | constructor(page: Page) { 9 | super(page, { submitButtonText: "Add User" }); 10 | this.searchInput = page.getByLabel("Search"); 11 | this.searchResultsContainer = page.getByRole("listbox"); 12 | } 13 | 14 | async searchAndSelect(name: string) { 15 | await expect(this.modal).toBeVisible(); 16 | await expect(this.searchInput).toBeVisible(); 17 | await this.searchInput.fill(name); 18 | const result = this.searchResultsContainer.getByRole("option", { name }); 19 | await expect(result).toBeVisible(); 20 | await result.click(); 21 | await this.submit(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/routes/lists/[id]/manage/+page.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | {$t("wishes.manage-list")} 26 | 27 | -------------------------------------------------------------------------------- /tests/TODO.md: -------------------------------------------------------------------------------- 1 | Features to test 2 | 3 | - [x] Login 4 | - [x] Signup 5 | - [ ] Signup with invite code 6 | - [ ] Password reset flow 7 | - [ ] List operations 8 | - [x] Create 9 | - [x] Edit 10 | - [x] Delete 11 | - [x] Filtering / Sorting 12 | - [ ] Manage list only visible by owner 13 | - [ ] Create item 14 | - [ ] Invalid form 15 | - [x] Valid form 16 | - [x] Add to multiple lists 17 | - [x] Suggestions 18 | - [ ] Item operations 19 | - [x] Edit 20 | - [ ] Delete 21 | - [ ] Claim 22 | - [ ] Validate claimed name text 23 | - [ ] Claim multiple 24 | - [ ] Claim partial 25 | - [ ] Purchase 26 | - [ ] Item reactivity 27 | - [ ] Create 28 | - [ ] Update Delete 29 | - [ ] Admin operations 30 | - [ ] Generate password reset link 31 | - [ ] Invite user 32 | - [x] Add user to group 33 | - [ ] Remove user from group 34 | - [ ] Group manager 35 | - [ ] Group operations 36 | -------------------------------------------------------------------------------- /src/routes/api/groups/[groupId]/auth.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { requireLoginOrError } from "$lib/server/auth"; 3 | import { client } from "$lib/server/prisma"; 4 | 5 | export const requireAccessToGroup = async (groupId: string) => { 6 | const authUser = await requireLoginOrError(); 7 | 8 | const user = await client.user.findFirstOrThrow({ 9 | where: { 10 | id: authUser.id 11 | }, 12 | select: { 13 | id: true, 14 | roleId: true, 15 | UserGroupMembership: { 16 | where: { 17 | groupId: groupId 18 | }, 19 | select: { 20 | roleId: true 21 | } 22 | } 23 | } 24 | }); 25 | 26 | return { 27 | authenticated: user.roleId === Role.ADMIN || user.UserGroupMembership[0]?.roleId === Role.GROUP_MANAGER, 28 | user 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/routes/api/users/[userId]/groups/[groupId]/+server.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { error } from "@sveltejs/kit"; 3 | import type { RequestHandler } from "./$types"; 4 | import { getFormatter } from "$lib/server/i18n"; 5 | import { requireLoginOrError } from "$lib/server/auth"; 6 | import { setActiveMembership } from "$lib/server/group-membership"; 7 | 8 | export const PATCH: RequestHandler = async ({ params, request }) => { 9 | const user = await requireLoginOrError(); 10 | const $t = await getFormatter(); 11 | if (params.userId !== user.id && user.roleId !== Role.ADMIN) { 12 | error(401, $t("errors.not-authorized")); 13 | } 14 | 15 | const data = await request.json(); 16 | 17 | if (data.active) { 18 | const membership = await setActiveMembership(params.userId, params.groupId); 19 | 20 | return new Response(JSON.stringify({ membership })); 21 | } 22 | 23 | return new Response(); 24 | }; 25 | -------------------------------------------------------------------------------- /tests/modules/add-manager-modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Modal } from "./modal"; 3 | 4 | export class AddManagerModal extends Modal { 5 | private readonly searchInput: Locator; 6 | private readonly searchResultsContainer: Locator; 7 | 8 | constructor(page: Page) { 9 | super(page, { submitButtonText: "Add manager" }); 10 | this.searchInput = this.modal.getByLabel("Search"); 11 | this.searchResultsContainer = this.modal.getByRole("listbox"); 12 | } 13 | 14 | async searchAndSelect(name: string) { 15 | await expect(this.modal).toBeVisible(); 16 | await expect(this.searchInput).toBeVisible(); 17 | await this.searchInput.fill(name); 18 | const result = this.searchResultsContainer.getByRole("option", { name }); 19 | await expect(result).toBeVisible(); 20 | await result.click(); 21 | await this.submit(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ClaimsGroupBy.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 | {$t("wishes.group-by")} 18 | 19 | 20 | {$t("wishes.purchased")} 21 | 22 | {$t("general.user")} 23 | 24 |
25 | -------------------------------------------------------------------------------- /tests/modules/suggestions-settings.ts: -------------------------------------------------------------------------------- 1 | import type { Locator, Page } from "@playwright/test"; 2 | 3 | export type Method = "surprise" | "auto-approval" | "approval"; 4 | 5 | export class SuggestionsSettings { 6 | private readonly enableCheckbox: Locator; 7 | private readonly methodDropdown: Locator; 8 | 9 | constructor(page: Page) { 10 | const suggestionsSection = page.getByRole("region", { name: "Suggestions" }); 11 | this.enableCheckbox = suggestionsSection.getByLabel("Enable"); 12 | this.methodDropdown = suggestionsSection.getByLabel("Suggestion Method"); 13 | } 14 | 15 | async enable() { 16 | await this.enableCheckbox.check(); 17 | return this; 18 | } 19 | 20 | async disable() { 21 | await this.enableCheckbox.uncheck(); 22 | return this; 23 | } 24 | 25 | async selectMethod(method: Method) { 26 | await this.methodDropdown.selectOption(method); 27 | return this; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/SortBy.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/routes/admin/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |

Wishlist

17 | 18 | {@html $t("admin.version", { values: { version: version ? version : $t("admin.dev") } })} 19 | 20 | 21 | {@html $t("admin.build", { values: { sha: sha ? sha : $t("admin.dev") } })} 22 | 23 | 24 | {$t("admin.build-date", { 25 | values: { buildDate: builtAt ? date.format(builtAt) : $t("admin.unknown") } 26 | })} 27 | 28 |
29 | -------------------------------------------------------------------------------- /src/routes/wishlists/[username]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from "@sveltejs/kit"; 2 | import type { PageServerLoad } from "./$types"; 3 | import { client } from "$lib/server/prisma"; 4 | import { getActiveMembership } from "$lib/server/group-membership"; 5 | import { getFormatter } from "$lib/server/i18n"; 6 | import { requireLogin } from "$lib/server/auth"; 7 | 8 | export const load: PageServerLoad = async ({ params }) => { 9 | const user = requireLogin(); 10 | const $t = await getFormatter(); 11 | 12 | const activeMembership = await getActiveMembership(user); 13 | const list = await client.list.findFirst({ 14 | select: { 15 | id: true 16 | }, 17 | where: { 18 | owner: { 19 | username: params.username 20 | }, 21 | groupId: activeMembership.groupId 22 | } 23 | }); 24 | 25 | if (!list) { 26 | error(404, $t("errors.list-not-found")); 27 | } 28 | 29 | redirect(302, `/lists/${list.id}`); 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/components/Search.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /src/lib/server/i18n.ts: -------------------------------------------------------------------------------- 1 | import { getRequestEvent } from "$app/server"; 2 | import { defaultLang } from "$lib/i18n"; 3 | import { t, waitLocale, format } from "svelte-i18n"; 4 | import { get } from "svelte/store"; 5 | 6 | const _f = ($t: typeof t) => get($t); 7 | type MessageFormatter_ = ReturnType; 8 | type MessageFormatterParams = Parameters; 9 | export type MessageObject = Exclude; 10 | export type MessageFormatter = Awaited>; 11 | 12 | export async function getFormatter(locale?: string) { 13 | if (!locale) { 14 | locale = getLocale() || defaultLang.code; 15 | } 16 | await waitLocale(locale); 17 | return (id: string, options?: Omit) => { 18 | let options_: Omit = { locale }; 19 | if (options) { 20 | options_ = { ...options }; 21 | } 22 | return get(format)(id, options_); 23 | }; 24 | } 25 | 26 | export function getLocale() { 27 | return getRequestEvent().locals.locale; 28 | } 29 | -------------------------------------------------------------------------------- /tests/pageObjects/create-list.page.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { BasePage } from "./base.page"; 3 | import { ListForm } from "../modules/list-form"; 4 | 5 | export class CreateListPage extends BasePage { 6 | private readonly header: Locator; 7 | private readonly listForm: ListForm; 8 | 9 | constructor(page: Page) { 10 | super(page, "/lists/create"); 11 | this.header = page.getByRole("heading", { name: "Create List" }); 12 | this.listForm = new ListForm(page); 13 | } 14 | 15 | async at() { 16 | await expect(this.header).toBeVisible(); 17 | return this; 18 | } 19 | 20 | async createDefault() { 21 | await this.listForm.create(); 22 | await expect(this.page.getByRole("heading", { name: "My Wishes" })).toBeVisible(); 23 | } 24 | 25 | async create(name: string) { 26 | await this.listForm.create(name); 27 | await expect(this.page.getByRole("heading", { name })).toBeVisible(); 28 | } 29 | 30 | async cancel() { 31 | return this.listForm.cancel(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/pageObjects/edit-item.page.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { BasePage } from "./base.page"; 3 | import { ItemForm } from "../modules/item-form"; 4 | 5 | interface Props { 6 | itemId?: string; 7 | } 8 | 9 | export class EditItemPage extends BasePage { 10 | private readonly header: Locator; 11 | private readonly itemForm: ItemForm; 12 | private readonly saveButton: Locator; 13 | 14 | constructor(page: Page, props?: Props) { 15 | const itemId = props?.itemId ?? new URL(page.url()).pathname.split("/").at(-2); 16 | super(page, `/items/${itemId}/edit`); 17 | this.header = page.getByRole("heading", { name: "Edit Wish" }); 18 | this.itemForm = new ItemForm(page); 19 | this.saveButton = page.getByRole("button", { name: "Save" }); 20 | } 21 | 22 | async at() { 23 | await expect(this.header).toBeVisible(); 24 | return this; 25 | } 26 | 27 | async getForm() { 28 | return this.itemForm; 29 | } 30 | 31 | async save() { 32 | await this.saveButton.click(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Carter Mintey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/modules/create-account-form.ts: -------------------------------------------------------------------------------- 1 | import type { Locator, Page } from "@playwright/test"; 2 | 3 | export class CreateAccountForm { 4 | readonly page: Page; 5 | readonly nameField: Locator; 6 | readonly usernameField: Locator; 7 | readonly emailField: Locator; 8 | readonly passwordField: Locator; 9 | readonly confirmPasswordField: Locator; 10 | 11 | constructor(page: Page) { 12 | this.page = page; 13 | this.nameField = page.getByLabel("Name", { exact: true }); 14 | this.usernameField = page.getByLabel("Username"); 15 | this.emailField = page.getByLabel("Email"); 16 | this.passwordField = page.getByLabel("Password", { exact: true }); 17 | this.confirmPasswordField = page.getByLabel("Confirm Password"); 18 | } 19 | 20 | async fill(name: string, username: string, email: string, password: string) { 21 | await this.nameField.fill(name); 22 | await this.usernameField.fill(username); 23 | await this.emailField.fill(email); 24 | await this.passwordField.fill(password); 25 | await this.confirmPasswordField.fill(password); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/components/md/Headers.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | {#if depth === 1} 20 |

{@render children?.()}

21 | {:else if depth === 2} 22 |

{@render children?.()}

23 | {:else if depth === 3} 24 |

{@render children?.()}

25 | {:else if depth === 4} 26 |

{@render children?.()}

27 | {:else if depth === 5} 28 |
{@render children?.()}
29 | {:else if depth === 6} 30 |
{@render children?.()}
31 | {:else} 32 | {raw} 33 | {/if} 34 | -------------------------------------------------------------------------------- /tests/modules/chip.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | 3 | export class Chip { 4 | protected readonly page: Page; 5 | private readonly triggerButton: Locator; 6 | private readonly dropdown: Locator; 7 | private readonly applyButton: Locator; 8 | 9 | constructor(page: Page, testId: string) { 10 | this.page = page; 11 | const chipContainer = page.getByTestId(testId); 12 | this.triggerButton = chipContainer.getByRole("button").first(); 13 | this.dropdown = chipContainer.getByRole("navigation"); 14 | this.applyButton = chipContainer.getByRole("button", { name: "Apply" }); 15 | } 16 | 17 | async open() { 18 | await this.triggerButton.click(); 19 | await expect(this.dropdown).toBeVisible(); 20 | } 21 | 22 | async selectOption(text: string) { 23 | const option = this.dropdown.getByText(text); 24 | await expect(option).toBeVisible(); 25 | await option.click(); 26 | } 27 | 28 | async applyFilter() { 29 | await this.applyButton.click(); 30 | await this.page.waitForTimeout(200); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /prisma/migrations/20221229183508_email_required/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_user" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "provider_id" TEXT NOT NULL, 6 | "hashed_password" TEXT, 7 | "username" TEXT NOT NULL, 8 | "name" TEXT NOT NULL, 9 | "email" TEXT NOT NULL DEFAULT 'changeme@email.com', 10 | "roleId" INTEGER NOT NULL DEFAULT 1, 11 | CONSTRAINT "user_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 12 | ); 13 | INSERT INTO "new_user" ("email", "hashed_password", "id", "name", "provider_id", "roleId", "username") SELECT coalesce("email", 'changeme@email.com') AS "email", "hashed_password", "id", "name", "provider_id", "roleId", "username" FROM "user"; 14 | DROP TABLE "user"; 15 | ALTER TABLE "new_user" RENAME TO "user"; 16 | CREATE UNIQUE INDEX "user_id_key" ON "user"("id"); 17 | CREATE UNIQUE INDEX "user_provider_id_key" ON "user"("provider_id"); 18 | CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); 19 | CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); 20 | PRAGMA foreign_key_check; 21 | PRAGMA foreign_keys=ON; 22 | -------------------------------------------------------------------------------- /src/lib/components/Alert.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | -------------------------------------------------------------------------------- /src/lib/components/account/LinkOAuth.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |

{$t("general.oauth")}

18 | 19 | {#if oauthId} 20 |
21 | {$t("auth.your-account-is-currently-linked-with-oauth", { values: { providerName } })} 22 | 25 |
26 | {:else} 27 | 28 | {$t("auth.account-is-not-linked-with-oauth", { values: { providerName } })} 29 | 30 | {/if} 31 |
32 | -------------------------------------------------------------------------------- /src/lib/api/claims.ts: -------------------------------------------------------------------------------- 1 | export class ClaimAPI { 2 | private claimId: string; 3 | constructor(claimId: string) { 4 | this.claimId = claimId; 5 | } 6 | 7 | _makeRequest = async (method: string, data?: Record) => { 8 | const options: RequestInit = { 9 | method, 10 | headers: { 11 | "content-type": "application/json", 12 | accept: "application/json" 13 | } 14 | }; 15 | 16 | if (data) { 17 | options.body = JSON.stringify(data); 18 | } 19 | 20 | const url = `/api/claims/${this.claimId}`; 21 | return await fetch(url, options); 22 | }; 23 | 24 | purchase = async () => { 25 | return await this._makeRequest("PATCH", { purchased: true }); 26 | }; 27 | 28 | unpurchase = async () => { 29 | return await this._makeRequest("PATCH", { purchased: false }); 30 | }; 31 | 32 | updateQuantity = async (quantity: number) => { 33 | return await this._makeRequest("PATCH", { quantity }); 34 | }; 35 | 36 | unclaim = async () => { 37 | return await this._makeRequest("DELETE"); 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /prisma/migrations/20250914222933_email_case_insensitivity/migration.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF; 2 | CREATE TABLE "new_user" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "username" TEXT NOT NULL COLLATE NOCASE, 5 | "name" TEXT NOT NULL, 6 | "email" TEXT NOT NULL DEFAULT 'changeme@email.com' COLLATE NOCASE, 7 | "picture" TEXT, 8 | "roleId" INTEGER NOT NULL DEFAULT 1, 9 | "hashedPassword" TEXT NOT NULL, 10 | "oauthId" TEXT, 11 | "preferredLanguage" TEXT, 12 | CONSTRAINT "user_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 13 | ); 14 | 15 | INSERT INTO "new_user" ("id", "username", "name", "email", "picture", "roleId", "hashedPassword", "oauthId", "preferredLanguage") 16 | SELECT "id", "username", "name", "email", "picture", "roleId", "hashedPassword", "oauthId", "preferredLanguage" 17 | FROM "user"; 18 | 19 | DROP TABLE "user"; 20 | 21 | ALTER TABLE "new_user" RENAME TO "user"; 22 | CREATE UNIQUE INDEX "user_id_key" ON "user"("id"); 23 | CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); 24 | CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); 25 | 26 | PRAGMA foreign_key_check; 27 | PRAGMA foreign_keys=ON; 28 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Wishlist Development 2 | 3 | ## Prerequisites 4 | 5 | - node v22.x 6 | - [pnpn](https://pnpm.io/installation) v10.x 7 | 8 | ## Install dependencies 9 | 10 | ```sh 11 | pnpm install 12 | ``` 13 | 14 | ## Building Docker 15 | 16 | ```sh 17 | docker build . --tag wishlist-dev:latest 18 | ``` 19 | 20 | Specific platform, current linux/amd64 and linux/arm64 are confirmed supported 21 | 22 | ```sh 23 | docker build . --tag wishlist-dev:amd64 --platform linux/amd64 24 | docker build . --tag wishlist-dev:amd64 --platform linux/arm64 25 | ``` 26 | 27 | ## Running Locally 28 | 29 | ### Create an env file 30 | 31 | An example env file for local development. You might want to customize the database URL to your needs: 32 | 33 | ```sh 34 | #.env.development 35 | 36 | export ORIGIN=http://localhost:3000 37 | export DATABASE_URL="file:$(pwd)//prod.db?connection_limit=1" 38 | ``` 39 | 40 | ### First Time Run 41 | 42 | ```sh 43 | source .env.development 44 | pnpm prisma migrate deploy 45 | pnpm prisma db seed 46 | pnpm db:patch 47 | ``` 48 | 49 | ### Start dev server 50 | 51 | ```sh 52 | pnpm dev 53 | ``` 54 | 55 | ### Build 56 | 57 | ```sh 58 | source .env.development 59 | pnpm run build 60 | ``` 61 | -------------------------------------------------------------------------------- /src/lib/components/BackButton.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | {#if disabled} 32 | {@render header()} 33 | {:else} 34 | 38 | {/if} 39 | -------------------------------------------------------------------------------- /src/routes/signup/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |

{$t("auth.create-account")}

20 | 21 |
{ 25 | signingIn = true; 26 | return async ({ update }) => { 27 | signingIn = false; 28 | return update(); 29 | }; 30 | }} 31 | > 32 | 33 | 34 |
35 | 36 | 37 | {$t("auth.create-an-account")} 38 | 39 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "docker:pinDigests"], 4 | "minimumReleaseAge": "5 days", 5 | "schedule": "before 9am on sunday", 6 | "ignorePaths": ["**/docker-compose.yaml"], 7 | "packageRules": [ 8 | { 9 | "matchManagers": ["github-actions"], 10 | "groupName": "github-actions" 11 | }, 12 | { 13 | "matchDatasources": ["npm"], 14 | "rangeStrategy": "bump", 15 | "groupName": "node", 16 | "versioning": "node", 17 | "matchPackageNames": ["node", "@types/node"] 18 | }, 19 | { 20 | "groupName": "node", 21 | "matchDatasources": ["docker"], 22 | "matchPackageNames": ["node"], 23 | "versionCompatibility": "^(?[^-]+)(?-.*)?$", 24 | "versioning": "node" 25 | }, 26 | { 27 | "groupName": "packages", 28 | "matchDatasources": ["npm"], 29 | "matchUpdateTypes": ["minor", "patch"], 30 | "matchPackageNames": ["!node", "!@types/node"] 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/components/admin/SMTPAlert.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if !$smtpAcknowledged && !smtpEnable} 14 | 30 | {/if} 31 | -------------------------------------------------------------------------------- /src/lib/components/ClearableInput.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
20 | {@render lead?.()} 21 | 22 | {#if showClearButton()} 23 | 34 | {/if} 35 |
36 | -------------------------------------------------------------------------------- /tests/pageObjects/signin.page.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { BasePage } from "./base.page"; 3 | import type { UserData } from "../types"; 4 | import { UserMenu } from "../modules/user-menu"; 5 | 6 | export class SigninPage extends BasePage { 7 | private readonly header: Locator; 8 | private readonly usernameField: Locator; 9 | private readonly passwordField: Locator; 10 | private readonly submitButton: Locator; 11 | 12 | constructor(page: Page) { 13 | super(page, "/login"); 14 | this.header = page.getByRole("heading", { name: "Sign In" }); 15 | this.usernameField = page.getByLabel("Username"); 16 | this.passwordField = page.getByLabel("Password", { exact: true }); 17 | this.submitButton = page.getByRole("button", { name: "Sign in" }); 18 | } 19 | 20 | async at() { 21 | await expect(this.header).toBeVisible(); 22 | return this; 23 | } 24 | 25 | async login(userData: UserData) { 26 | await this.usernameField.fill(userData.username); 27 | await this.passwordField.fill(userData.password); 28 | await this.submitButton.click(); 29 | await new UserMenu(this.page).isVisible(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /prisma/migrations/20240330011054_lucia_v3_keys_removal/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "key_user_id_idx"; 3 | 4 | -- DropIndex 5 | DROP INDEX "key_id_key"; 6 | 7 | -- RedefineTables 8 | PRAGMA foreign_keys=OFF; 9 | CREATE TABLE "new_user" ( 10 | "id" TEXT NOT NULL PRIMARY KEY, 11 | "username" TEXT NOT NULL, 12 | "name" TEXT NOT NULL, 13 | "email" TEXT NOT NULL DEFAULT 'changeme@email.com', 14 | "picture" TEXT, 15 | "roleId" INTEGER NOT NULL DEFAULT 1, 16 | "hashedPassword" TEXT NOT NULL, 17 | CONSTRAINT "user_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 18 | ); 19 | INSERT INTO "new_user" ("email", "id", "name", "picture", "roleId", "username", "hashedPassword") 20 | SELECT "email", u."id", "name", "picture", "roleId", "username", "hashed_password" 21 | FROM "user" u 22 | JOIN "key" k on k.user_id = u.id; 23 | DROP TABLE "user"; 24 | ALTER TABLE "new_user" RENAME TO "user"; 25 | 26 | CREATE UNIQUE INDEX "user_id_key" ON "user"("id"); 27 | CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); 28 | CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); 29 | 30 | -- DropTable 31 | DROP TABLE "key"; 32 | 33 | PRAGMA foreign_key_check; 34 | PRAGMA foreign_keys=ON; 35 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | %sveltekit.head% 21 | 22 | 23 |
%sveltekit.body%
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/lib/server/api-common.ts: -------------------------------------------------------------------------------- 1 | import { getLocale } from "$lib/server/i18n"; 2 | import { getMinorUnits } from "$lib/price-formatter"; 3 | import type { Prisma } from "$lib/generated/prisma/client"; 4 | 5 | export const patchItem = (body: Record) => { 6 | const data: Prisma.ItemUpdateInput & { id?: number } = { 7 | id: body.id as number 8 | }; 9 | let deleteOldImage = false; 10 | 11 | if (body.name && typeof body.name === "string") data.name = body.name; 12 | if (body.url && typeof body.url === "string") data.url = body.url; 13 | if (body.note && typeof body.note === "string") data.note = body.note; 14 | if (body.image_url && typeof body.image_url === "string") { 15 | data.imageUrl = body.image_url; 16 | deleteOldImage = true; 17 | } 18 | if (body.price && typeof body.price === "number" && body.currency && typeof body.currency === "string") { 19 | data.itemPrice = { 20 | create: { 21 | value: getMinorUnits(body.price, body.currency, getLocale()), 22 | currency: body.currency 23 | }, 24 | delete: true 25 | }; 26 | } 27 | 28 | return { 29 | data, 30 | deleteOldImage 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /tests/helpers/suggestions.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from "@playwright/test"; 2 | import { UserMenu } from "../modules/user-menu"; 3 | import type { Method } from "../modules/suggestions-settings"; 4 | import { test } from "../fixtures"; 5 | 6 | export async function setSuggestionMethod(page: Page, method: Method) { 7 | await test.step(`set suggestion method to '${method}'`, async () => { 8 | await new UserMenu(page) 9 | .manageGroup() 10 | .then((page) => page.clickSettingsTab()) 11 | .then(async (page) => { 12 | await page 13 | .getSuggestionsSettings() 14 | .then((s) => s.enable()) 15 | .then((s) => s.selectMethod(method)); 16 | return page; 17 | }) 18 | .then((page) => page.saveSettings()); 19 | }); 20 | } 21 | 22 | export async function disableSuggestions(page: Page) { 23 | await new UserMenu(page) 24 | .manageGroup() 25 | .then((page) => page.clickSettingsTab()) 26 | .then(async (page) => { 27 | await page.getSuggestionsSettings().then((s) => s.disable()); 28 | return page; 29 | }) 30 | .then((page) => page.saveSettings()); 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/wishlists/me/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | import type { PageServerLoad } from "./$types"; 3 | import { getActiveMembership } from "$lib/server/group-membership"; 4 | import { getConfig } from "$lib/server/config"; 5 | import { client } from "$lib/server/prisma"; 6 | import { error } from "@sveltejs/kit"; 7 | import { getFormatter } from "$lib/server/i18n"; 8 | import { requireLogin } from "$lib/server/auth"; 9 | 10 | export const load: PageServerLoad = async () => { 11 | const user = requireLogin(); 12 | const $t = await getFormatter(); 13 | 14 | const activeMembership = await getActiveMembership(user); 15 | const config = await getConfig(activeMembership.groupId); 16 | if (config.listMode === "registry") { 17 | const list = await client.list.findFirst({ 18 | select: { 19 | id: true 20 | }, 21 | where: { 22 | ownerId: user.id, 23 | groupId: activeMembership.groupId 24 | } 25 | }); 26 | if (list) { 27 | redirect(302, `/lists/${list.id}`); 28 | } 29 | error(404, $t("errors.list-not-found")); 30 | } 31 | 32 | redirect(302, `/lists?users=${user.id}`); 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/components/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | {@render props.label()} 24 | 33 |
34 | {@render props.description()} 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /prisma/migrations/20230116175745_item_approval/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_items" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "name" TEXT NOT NULL, 6 | "price" TEXT, 7 | "url" TEXT, 8 | "note" TEXT, 9 | "image_url" TEXT, 10 | "userId" TEXT NOT NULL, 11 | "addedById" TEXT NOT NULL, 12 | "pledgedById" TEXT, 13 | "approved" BOOLEAN NOT NULL DEFAULT true, 14 | CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 15 | CONSTRAINT "items_addedById_fkey" FOREIGN KEY ("addedById") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 16 | CONSTRAINT "items_pledgedById_fkey" FOREIGN KEY ("pledgedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE 17 | ); 18 | INSERT INTO "new_items" ("addedById", "id", "image_url", "name", "note", "pledgedById", "price", "url", "userId") SELECT "addedById", "id", "image_url", "name", "note", "pledgedById", "price", "url", "userId" FROM "items"; 19 | DROP TABLE "items"; 20 | ALTER TABLE "new_items" RENAME TO "items"; 21 | CREATE UNIQUE INDEX "items_id_key" ON "items"("id"); 22 | CREATE INDEX "items_userId_idx" ON "items"("userId"); 23 | PRAGMA foreign_key_check; 24 | PRAGMA foreign_keys=ON; 25 | -------------------------------------------------------------------------------- /tests/modules/list-managers-selector.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { AddManagerModal } from "./add-manager-modal"; 3 | 4 | export class ListManagersSelector { 5 | private readonly page: Page; 6 | private readonly field: Locator; 7 | private readonly list: Locator; 8 | private readonly addButton: Locator; 9 | 10 | constructor(page: Page) { 11 | this.page = page; 12 | this.field = page.getByRole("group", { name: "List managers" }); 13 | this.list = this.field.getByTestId("list-managers-list"); 14 | this.addButton = this.field.getByRole("button", { name: "Add manager" }); 15 | } 16 | 17 | async getManagers() { 18 | return this.list.locator(`[data-part="name"]`).allTextContents(); 19 | } 20 | 21 | async expectNoManagers() { 22 | return expect(this.list.getByText("No managers")).toBeVisible(); 23 | } 24 | 25 | async removeManager(name: string) { 26 | await this.list.getByRole("button", { name: `Remove ${name}` }).click(); 27 | return this; 28 | } 29 | 30 | async addManager(name: string) { 31 | await this.addButton.click(); 32 | const modal = new AddManagerModal(this.page); 33 | await modal.searchAndSelect(name); 34 | return this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /templates/password-reset.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Wishlist | {{previewText}} 9 | 10 | 11 | 12 | 13 | 14 | Wishlist 15 | 16 | 17 | 18 | 19 | 20 | {{titleText}} 21 | {{bodyText}} 22 | {{buttonText}} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/pageObjects/signup.page.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { CreateAccountForm } from "../modules/create-account-form"; 3 | import { randomString } from "../util"; 4 | import { BasePage } from "./base.page"; 5 | import type { UserData } from "../types"; 6 | 7 | export class SignupPage extends BasePage { 8 | private readonly header: Locator; 9 | private readonly form: CreateAccountForm; 10 | private readonly submitButton: Locator; 11 | 12 | constructor(page: Page) { 13 | super(page, "/signup"); 14 | this.header = page.getByRole("heading", { name: "Create Account" }); 15 | this.form = new CreateAccountForm(page); 16 | this.submitButton = page.getByRole("button", { name: "Create account" }); 17 | } 18 | 19 | async at() { 20 | await expect(this.header).toBeVisible(); 21 | return this; 22 | } 23 | 24 | async createAccount(): Promise> { 25 | const name = randomString(); 26 | const username = randomString(); 27 | const email = username + "@example.com"; 28 | const password = randomString(12); 29 | await this.form.fill(name, username, email, password); 30 | await this.submitButton.click(); 31 | return { name, username, email, password }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/General/Groups.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 32 | 33 | {#snippet description()} 34 | {$t("admin.default-group-tooltip")} 35 | {/snippet} 36 | 37 | 38 | -------------------------------------------------------------------------------- /prisma/migrations/20250623171719_nullable_item_quantity/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA defer_foreign_keys=ON; 3 | PRAGMA foreign_keys=OFF; 4 | CREATE TABLE "new_items" ( 5 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | "name" TEXT NOT NULL, 7 | "price" TEXT, 8 | "itemPriceId" TEXT, 9 | "url" TEXT, 10 | "note" TEXT, 11 | "imageUrl" TEXT, 12 | "quantity" INTEGER, 13 | "userId" TEXT NOT NULL, 14 | "createdById" TEXT NOT NULL, 15 | CONSTRAINT "items_itemPriceId_fkey" FOREIGN KEY ("itemPriceId") REFERENCES "item_price" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 16 | CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 17 | CONSTRAINT "items_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 18 | ); 19 | INSERT INTO "new_items" ("createdById", "id", "imageUrl", "itemPriceId", "name", "note", "price", "quantity", "url", "userId") SELECT "createdById", "id", "imageUrl", "itemPriceId", "name", "note", "price", "quantity", "url", "userId" FROM "items"; 20 | DROP TABLE "items"; 21 | ALTER TABLE "new_items" RENAME TO "items"; 22 | CREATE UNIQUE INDEX "items_id_key" ON "items"("id"); 23 | CREATE INDEX "items_userId_idx" ON "items"("userId"); 24 | PRAGMA foreign_keys=ON; 25 | PRAGMA defer_foreign_keys=OFF; 26 | -------------------------------------------------------------------------------- /src/lib/dtos/item-dto.ts: -------------------------------------------------------------------------------- 1 | import type { Item, ItemPrice, SystemUser, User } from "$lib/generated/prisma/client"; 2 | 3 | type MinimalUser = Pick; 4 | 5 | interface UserWithGroups extends MinimalUser { 6 | groups: string[]; 7 | } 8 | 9 | type BaseClaim = { 10 | claimId: string; 11 | quantity: number; 12 | listId: string; 13 | }; 14 | 15 | interface Claimed extends BaseClaim { 16 | claimedBy: UserWithGroups; 17 | publicClaimedBy: undefined; 18 | purchased: boolean; 19 | } 20 | 21 | interface PubliclyClaimed extends BaseClaim { 22 | claimedBy: undefined; 23 | publicClaimedBy: Pick; 24 | purchased: undefined; 25 | } 26 | 27 | export type ClaimDTO = Claimed | PubliclyClaimed; 28 | 29 | interface ListItem { 30 | listId: string; 31 | addedBy: MinimalUser; 32 | approved: boolean; 33 | displayOrder: number | null; 34 | claims: ClaimDTO[]; 35 | } 36 | 37 | export interface ItemOnListDTO extends Item, ListItem { 38 | itemPrice: ItemPrice | null; 39 | user: MinimalUser; 40 | listCount: number; 41 | readonly claimedQuantity: number; 42 | readonly remainingQuantity: number; 43 | readonly isClaimable: boolean; 44 | } 45 | 46 | export interface ItemDTO extends Item { 47 | itemPrice: ItemPrice | null; 48 | user: MinimalUser; 49 | lists: ListItem[]; 50 | } 51 | -------------------------------------------------------------------------------- /prisma/migrations/20230221220353_add_item_purchased/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_items" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "name" TEXT NOT NULL, 6 | "price" TEXT, 7 | "url" TEXT, 8 | "note" TEXT, 9 | "image_url" TEXT, 10 | "userId" TEXT NOT NULL, 11 | "addedById" TEXT NOT NULL, 12 | "pledgedById" TEXT, 13 | "approved" BOOLEAN NOT NULL DEFAULT true, 14 | "purchased" BOOLEAN NOT NULL DEFAULT false, 15 | CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 16 | CONSTRAINT "items_addedById_fkey" FOREIGN KEY ("addedById") REFERENCES "user" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 17 | CONSTRAINT "items_pledgedById_fkey" FOREIGN KEY ("pledgedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE 18 | ); 19 | INSERT INTO "new_items" ("addedById", "approved", "id", "image_url", "name", "note", "pledgedById", "price", "url", "userId") SELECT "addedById", "approved", "id", "image_url", "name", "note", "pledgedById", "price", "url", "userId" FROM "items"; 20 | DROP TABLE "items"; 21 | ALTER TABLE "new_items" RENAME TO "items"; 22 | CREATE UNIQUE INDEX "items_id_key" ON "items"("id"); 23 | CREATE INDEX "items_userId_idx" ON "items"("userId"); 24 | PRAGMA foreign_key_check; 25 | PRAGMA foreign_keys=ON; 26 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/util.ts: -------------------------------------------------------------------------------- 1 | import type { ClaimDTO, ItemOnListDTO } from "$lib/dtos/item-dto"; 2 | import { getFormatter } from "$lib/i18n"; 3 | import { get } from "svelte/store"; 4 | import type { PartialUser } from "./ItemCard/ItemCard.svelte"; 5 | 6 | export const getClaimedName = ({ claimedBy, publicClaimedBy }: ClaimDTO) => { 7 | if (claimedBy) { 8 | return claimedBy.name; 9 | } 10 | if (publicClaimedBy?.name && publicClaimedBy.name !== "ANONYMOUS_NAME") { 11 | return publicClaimedBy.name; 12 | } 13 | const t = getFormatter(); 14 | return get(t)("wishes.anonymous"); 15 | }; 16 | 17 | export const shouldShowName = ( 18 | item: ItemOnListDTO, 19 | showNameConfig: boolean, 20 | showForOwner: boolean, 21 | user: PartialUser | undefined, 22 | claim?: ClaimDTO 23 | ) => { 24 | // Completely disabled 25 | if (!showNameConfig) { 26 | return false; 27 | } 28 | // No logged in user 29 | if (!user) { 30 | return false; 31 | } 32 | // List owner can only view if the config is set 33 | if (item.user.id === user.id && showForOwner) { 34 | return true; 35 | } 36 | // Everyone else can only see claims by users in their group 37 | if (claim && (claim.publicClaimedBy || !claim.claimedBy?.groups.includes(user.activeGroupId))) { 38 | return false; 39 | } 40 | return true; 41 | }; 42 | -------------------------------------------------------------------------------- /prisma/migrations/20251208032804_item_most_wanted/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA defer_foreign_keys=ON; 3 | PRAGMA foreign_keys=OFF; 4 | CREATE TABLE "new_items" ( 5 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 6 | "name" TEXT NOT NULL, 7 | "price" TEXT, 8 | "itemPriceId" TEXT, 9 | "url" TEXT, 10 | "note" TEXT, 11 | "imageUrl" TEXT, 12 | "quantity" INTEGER, 13 | "mostWanted" BOOLEAN NOT NULL DEFAULT false, 14 | "userId" TEXT NOT NULL, 15 | "createdById" TEXT NOT NULL, 16 | CONSTRAINT "items_itemPriceId_fkey" FOREIGN KEY ("itemPriceId") REFERENCES "item_price" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 17 | CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 18 | CONSTRAINT "items_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 19 | ); 20 | INSERT INTO "new_items" ("createdById", "id", "imageUrl", "itemPriceId", "name", "note", "price", "quantity", "url", "userId") SELECT "createdById", "id", "imageUrl", "itemPriceId", "name", "note", "price", "quantity", "url", "userId" FROM "items"; 21 | DROP TABLE "items"; 22 | ALTER TABLE "new_items" RENAME TO "items"; 23 | CREATE UNIQUE INDEX "items_id_key" ON "items"("id"); 24 | CREATE INDEX "items_userId_idx" ON "items"("userId"); 25 | PRAGMA foreign_keys=ON; 26 | PRAGMA defer_foreign_keys=OFF; 27 | -------------------------------------------------------------------------------- /src/lib/zxcvbn.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | import { getPrimaryLang, getLocale, defaultLang } from "./i18n"; 3 | 4 | export const loadOptions = async (locale?: string) => { 5 | const locale_ = locale ? locale : browser ? getLocale() : defaultLang.code; 6 | 7 | const langCommon = await import("@zxcvbn-ts/language-common"); 8 | const langEn = await import("@zxcvbn-ts/language-en"); 9 | let langUser: any; 10 | if (locale_.toLowerCase() === "es-es") { 11 | langUser = await import("@zxcvbn-ts/language-es-es"); 12 | } else if (getPrimaryLang(locale_) === "fr") { 13 | langUser = await import("@zxcvbn-ts/language-fr"); 14 | } else if (getPrimaryLang(locale_) === "de") { 15 | langUser = await import("@zxcvbn-ts/language-de"); 16 | } 17 | 18 | return { 19 | dictionary: { 20 | ...langCommon.dictionary, 21 | ...langEn.dictionary, 22 | ...langUser?.dictionary 23 | }, 24 | graphs: langCommon.adjacencyGraphs, 25 | translations: { 26 | ...langEn.translations, 27 | ...langUser?.translations 28 | } 29 | }; 30 | }; 31 | 32 | export const meterLabel = [ 33 | "general.very-weak", 34 | "general.weak", 35 | "general.moderate", 36 | "general.strong", 37 | "general.very-strong" 38 | ]; 39 | export const strengthOptions = ["general.off", ...meterLabel]; 40 | -------------------------------------------------------------------------------- /src/lib/components/admin/Settings/General/General.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |

{$t("admin.general")}

28 | 29 | {#if forGroup} 30 | 31 | {/if} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {#if !forGroup} 40 | 41 | {/if} 42 |
43 | -------------------------------------------------------------------------------- /src/routes/setup-wizard/step/[step]/CreateAccountStep.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 |

{$t("setup.create-your-account")}

22 | {$t("setup.first-account-admin")} 23 |
{ 29 | return async ({ result }) => { 30 | await applyAction(result); 31 | if (result.type === "success" && result.data?.success) { 32 | onSuccess(); 33 | } 34 | }; 35 | }} 36 | > 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /tests/modules/modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | 3 | interface Props { 4 | modalName?: string; 5 | submitButtonText?: string; 6 | cancelButtonText?: string; 7 | } 8 | 9 | export class Modal { 10 | protected readonly page: Page; 11 | protected readonly modal: Locator; 12 | private readonly modalHeader: Locator; 13 | private readonly cancelButton: Locator; 14 | private readonly submitButton: Locator; 15 | 16 | constructor(page: Page, props?: Props) { 17 | this.page = page; 18 | this.modal = page.getByRole("dialog"); 19 | this.modalHeader = this.modal.getByRole("heading", { name: props?.modalName }); 20 | this.cancelButton = this.modal.getByRole("button", { name: props?.cancelButtonText ?? "Cancel" }); 21 | this.submitButton = this.modal.getByRole("button", { name: props?.submitButtonText ?? "Submit" }); 22 | } 23 | 24 | async assertTitle(title: string) { 25 | await expect(this.modal).toBeVisible(); 26 | await expect(this.modalHeader).toHaveText(title); 27 | } 28 | 29 | async cancel() { 30 | await expect(this.modal).toBeVisible(); 31 | await this.cancelButton.click(); 32 | } 33 | 34 | async submit() { 35 | await expect(this.modal).toBeVisible(); 36 | await this.submitButton.click(); 37 | await expect(this.modal).not.toBeVisible(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 |

{page.status}

35 | {errorMessage} 36 |

{errorMessage}

37 | {$t("general.return-home")} 38 |
39 | -------------------------------------------------------------------------------- /src/routes/admin/+layout.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | {#each tabs as { label, href }, value} 29 | goto(`/admin${href}`, { replaceState: true })} 34 | > 35 | {label} 36 | 37 | {/each} 38 | 39 | {#snippet panel()} 40 | {@render children?.()} 41 | {/snippet} 42 | 43 | 44 | 45 | {$t("admin.administration")} 46 | 47 | -------------------------------------------------------------------------------- /src/routes/admin/users/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "$lib/schema"; 2 | import { getConfig } from "$lib/server/config"; 3 | import { client } from "$lib/server/prisma"; 4 | import type { PageServerLoad } from "./$types"; 5 | import { requireRole } from "$lib/server/auth"; 6 | 7 | export const load: PageServerLoad = async () => { 8 | const user = await requireRole(Role.ADMIN); 9 | 10 | const usersQuery = client.user.findMany({ 11 | select: { 12 | id: true, 13 | username: true, 14 | name: true, 15 | email: true, 16 | role: { 17 | select: { 18 | id: true 19 | } 20 | }, 21 | UserGroupMembership: { 22 | select: { 23 | group: { 24 | select: { 25 | name: true 26 | } 27 | } 28 | } 29 | } 30 | } 31 | }); 32 | 33 | const [users, groups, config] = await Promise.all([usersQuery, client.group.findMany(), getConfig()]); 34 | 35 | return { 36 | user: { 37 | isAdmin: true, 38 | ...user 39 | }, 40 | users: users.map((user) => ({ 41 | isAdmin: user.role.id === Role.ADMIN, 42 | groups: user.UserGroupMembership.map(({ group }) => group.name), 43 | ...user 44 | })), 45 | config, 46 | groups 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /templates/invite.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{previewText}} 9 | 10 | 11 | 12 | 13 | 14 | Wishlist 15 | 16 | 17 | 18 | 19 | 20 | {{titleText}} 21 | {{bodyText}} 22 | {{buttonText}} 23 | 24 | 25 | 26 | 27 | 28 | {{footerText}} 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/lib/components/MarkdownEditor.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 | 20 | {$t("wishes.write")} 21 | {$t("wishes.preview")} 22 | 23 | 24 | {#if previewNote} 25 | {#if value} 26 |
30 | 31 |
32 | {/if} 33 | {/if} 34 | 35 | 36 | {$t("wishes.supports-markdown")} 37 | 38 |
39 | -------------------------------------------------------------------------------- /prisma/migrations/20231017150829_remove_expires_in/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_password_resets" ( 4 | "id" TEXT NOT NULL PRIMARY KEY, 5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "userId" TEXT NOT NULL, 7 | "hashedToken" TEXT NOT NULL, 8 | "redeemed" BOOLEAN NOT NULL DEFAULT false 9 | ); 10 | INSERT INTO "new_password_resets" ("createdAt", "hashedToken", "id", "redeemed", "userId") SELECT "createdAt", "hashedToken", "id", "redeemed", "userId" FROM "password_resets"; 11 | DROP TABLE "password_resets"; 12 | ALTER TABLE "new_password_resets" RENAME TO "password_resets"; 13 | CREATE UNIQUE INDEX "password_resets_id_key" ON "password_resets"("id"); 14 | CREATE INDEX "password_resets_hashedToken_idx" ON "password_resets"("hashedToken"); 15 | CREATE TABLE "new_signup_tokens" ( 16 | "id" TEXT NOT NULL PRIMARY KEY, 17 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | "hashedToken" TEXT NOT NULL, 19 | "redeemed" BOOLEAN NOT NULL DEFAULT false, 20 | "groupId" TEXT NOT NULL DEFAULT 'global' 21 | ); 22 | INSERT INTO "new_signup_tokens" ("createdAt", "groupId", "hashedToken", "id", "redeemed") SELECT "createdAt", "groupId", "hashedToken", "id", "redeemed" FROM "signup_tokens"; 23 | DROP TABLE "signup_tokens"; 24 | ALTER TABLE "new_signup_tokens" RENAME TO "signup_tokens"; 25 | CREATE UNIQUE INDEX "signup_tokens_id_key" ON "signup_tokens"("id"); 26 | CREATE INDEX "signup_tokens_hashedToken_idx" ON "signup_tokens"("hashedToken"); 27 | PRAGMA foreign_key_check; 28 | PRAGMA foreign_keys=ON; 29 | -------------------------------------------------------------------------------- /prisma/migrations/20250329193312_item_claim_cascade_delete/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA defer_foreign_keys=ON; 3 | PRAGMA foreign_keys=OFF; 4 | CREATE TABLE "new_list_item_claim" ( 5 | "id" TEXT NOT NULL PRIMARY KEY, 6 | "itemId" INTEGER NOT NULL, 7 | "listId" TEXT NOT NULL, 8 | "claimedById" TEXT, 9 | "publicClaimedById" TEXT, 10 | "purchased" BOOLEAN NOT NULL DEFAULT false, 11 | CONSTRAINT "list_item_claim_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 12 | CONSTRAINT "list_item_claim_listId_fkey" FOREIGN KEY ("listId") REFERENCES "list" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 13 | CONSTRAINT "list_item_claim_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 14 | CONSTRAINT "list_item_claim_publicClaimedById_fkey" FOREIGN KEY ("publicClaimedById") REFERENCES "system_user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 15 | ); 16 | INSERT INTO "new_list_item_claim" ("claimedById", "id", "itemId", "listId", "publicClaimedById", "purchased") SELECT "claimedById", "id", "itemId", "listId", "publicClaimedById", "purchased" FROM "list_item_claim"; 17 | DROP TABLE "list_item_claim"; 18 | ALTER TABLE "new_list_item_claim" RENAME TO "list_item_claim"; 19 | CREATE UNIQUE INDEX "list_item_claim_id_key" ON "list_item_claim"("id"); 20 | CREATE INDEX "list_item_claim_itemId_idx" ON "list_item_claim"("itemId"); 21 | CREATE INDEX "list_item_claim_claimedById_idx" ON "list_item_claim"("claimedById"); 22 | PRAGMA foreign_keys=ON; 23 | PRAGMA defer_foreign_keys=OFF; 24 | -------------------------------------------------------------------------------- /src/routes/api/users/[userId]/+server.ts: -------------------------------------------------------------------------------- 1 | import { getFormatter } from "$lib/server/i18n"; 2 | import { Role } from "$lib/schema"; 3 | import { requireLoginOrError } from "$lib/server/auth"; 4 | import { tryDeleteImage } from "$lib/server/image-util"; 5 | import { client } from "$lib/server/prisma"; 6 | import { type RequestHandler, error } from "@sveltejs/kit"; 7 | 8 | export const DELETE: RequestHandler = async ({ params }) => { 9 | const authUser = await requireLoginOrError(); 10 | const $t = await getFormatter(); 11 | if (authUser.roleId !== Role.ADMIN) error(401, $t("errors.not-authorized")); 12 | 13 | if (!params.userId) { 14 | error(400, $t("errors.must-specify-an-item-to-delete")); 15 | } 16 | 17 | const user = await client.user.findUnique({ 18 | where: { 19 | id: params.userId 20 | }, 21 | select: { 22 | id: true 23 | } 24 | }); 25 | 26 | if (!user) { 27 | error(404, $t("errors.user-not-found")); 28 | } 29 | 30 | if (user.id === authUser.id) { 31 | error(400, $t("errors.cannot-delete-yourself")); 32 | } 33 | 34 | try { 35 | const deletedUser = await client.user.delete({ 36 | where: { 37 | id: user.id 38 | } 39 | }); 40 | if (deletedUser && deletedUser.picture) { 41 | await tryDeleteImage(deletedUser.picture); 42 | } 43 | 44 | return new Response(JSON.stringify(deletedUser), { status: 200 }); 45 | } catch { 46 | error(404, $t("errors.user-not-found")); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/routes/items/[itemId]/edit/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {#if data.item} 18 |
{ 22 | saving = true; 23 | return async ({ result, update }) => { 24 | saving = false; 25 | if (result.type === "error") { 26 | errorToast(toastStore, (result.error?.message as string) || $t("general.oops")); 27 | return; 28 | } else { 29 | toastStore.trigger({ 30 | message: $t("wishes.updated-success"), 31 | autohide: true, 32 | timeout: 5000 33 | }); 34 | update(); 35 | } 36 | }; 37 | }} 38 | > 39 | 40 | 41 | {/if} 42 | 43 | 44 | {$t("wishes.edit-wish")} 45 | 46 | -------------------------------------------------------------------------------- /prisma/patches/list-relationship.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../client"; 2 | import { init, isCuid } from "@paralleldrive/cuid2"; 3 | 4 | const PATCH_ID = "list-relationship"; 5 | const createId = init({ 6 | length: 10 7 | }); 8 | 9 | const isPatchApplied = 10 | (await prisma.patch.findUnique({ 11 | where: { 12 | id: PATCH_ID 13 | } 14 | })) !== null; 15 | 16 | if (isPatchApplied) { 17 | console.log("Skipping already applied patch: '%s'", PATCH_ID); 18 | } else { 19 | const listsToUpdate = await prisma.list 20 | .findMany({ 21 | select: { 22 | id: true 23 | } 24 | }) 25 | .then((lists) => lists.filter((list) => !isCuid(list.id))) 26 | .then((lists) => lists.map((list) => ({ oldId: list.id, newId: createId() }))); 27 | 28 | const actions = listsToUpdate.map((list) => 29 | prisma.list.update({ 30 | data: { 31 | id: list.newId 32 | }, 33 | where: { 34 | id: list.oldId 35 | } 36 | }) 37 | ); 38 | 39 | await prisma 40 | .$transaction(actions) 41 | .then(() => 42 | prisma.patch.create({ 43 | data: { 44 | id: PATCH_ID 45 | } 46 | }) 47 | ) 48 | .then(() => console.log("Patch '%s' applied successfully.", PATCH_ID)) 49 | .catch((e) => { 50 | console.error("Error applying patch '%s'", PATCH_ID); 51 | console.error(e); 52 | }); 53 | } 54 | 55 | await prisma.$disconnect(); 56 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ListViewModeChip.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 32 | 33 | 34 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/routes/api/lists/[listId]/+server.ts: -------------------------------------------------------------------------------- 1 | import { client } from "$lib/server/prisma"; 2 | import { publicListCreateSchema } from "$lib/server/validations"; 3 | import { error } from "@sveltejs/kit"; 4 | import { getConfig } from "$lib/server/config"; 5 | import { getFormatter } from "$lib/server/i18n"; 6 | import { getById } from "$lib/server/list"; 7 | import type { Prisma } from "$lib/generated/prisma/client"; 8 | import type { RequestHandler } from "./$types"; 9 | import { requireLoginOrError } from "$lib/server/auth"; 10 | 11 | export const PATCH: RequestHandler = async ({ request, params }) => { 12 | await requireLoginOrError(); 13 | const $t = await getFormatter(); 14 | 15 | const list = await getById(params.listId); 16 | if (!list) { 17 | error(404, $t("errors.list-not-found")); 18 | } 19 | 20 | const config = await getConfig(list.groupId); 21 | if (config.listMode !== "registry") { 22 | error(422, $t("errors.group-is-not-in-registry-mode-cannot-get-a-public-link")); 23 | } 24 | 25 | const data = await request.json().then(publicListCreateSchema.safeParse); 26 | 27 | if (!data.success) { 28 | error(422, data.error.message); 29 | } 30 | const publicList = data.data.public; 31 | 32 | const updateData: Prisma.ListUpdateInput = {}; 33 | if (publicList !== undefined) updateData.public = publicList; 34 | 35 | const updatedList = await client.list.update({ 36 | where: { 37 | id: params.listId 38 | }, 39 | data: updateData 40 | }); 41 | 42 | return new Response(JSON.stringify(updatedList), { status: 200 }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/lib/server/events/sse.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/sanrafa/sveltekit-sse-example/tree/main 2 | import type EventEmitter from "node:events"; 3 | import type { BaseEventHandler } from "../../events"; 4 | import { logger } from "../logger"; 5 | 6 | export function createSSE(retry = 0) { 7 | const { readable, writable } = new TransformStream({ 8 | start(ctr) { 9 | if (retry > 0) ctr.enqueue(`retry: ${retry}\n\n`); 10 | }, 11 | transform({ event, data }, ctr) { 12 | let msg = data?.id ? `id: ${String(data.id)}\n` : ": hi\n\n"; 13 | if (event) msg += `event: ${event}\n`; 14 | if (typeof data === "string") { 15 | msg += "data: " + data.trim().replace(/\n+/gm, "\ndata: ") + "\n\n"; 16 | } else { 17 | msg += `data: ${JSON.stringify(data)}\n\n`; 18 | } 19 | ctr.enqueue(msg); 20 | } 21 | }); 22 | 23 | const writer = writable.getWriter(); 24 | 25 | return { 26 | readable, 27 | async subscribeToEvent(emitter: EventEmitter, handler: BaseEventHandler) { 28 | function listener(data: T) { 29 | if (handler.predicate(data)) { 30 | writer.write({ event: handler.getEventId(), data: handler.handle(data) }); 31 | } 32 | } 33 | emitter.on(handler.getEventId(), listener); 34 | await writer.closed.catch((err) => { 35 | if (err) logger.error({ err }); 36 | }); 37 | emitter.off(handler.getEventId(), listener); 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /prisma/migrations/20231011193501_token_uuid/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `password_resets` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - The primary key for the `signup_tokens` table will be changed. If it partially fails, the table could be left without primary key constraint. 6 | 7 | */ 8 | -- RedefineTables 9 | PRAGMA foreign_keys=OFF; 10 | CREATE TABLE "new_password_resets" ( 11 | "id" TEXT NOT NULL PRIMARY KEY, 12 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "expiresIn" INTEGER NOT NULL, 14 | "userId" TEXT NOT NULL, 15 | "hashedToken" TEXT NOT NULL, 16 | "redeemed" BOOLEAN NOT NULL DEFAULT false 17 | ); 18 | DROP TABLE "password_resets"; 19 | ALTER TABLE "new_password_resets" RENAME TO "password_resets"; 20 | CREATE UNIQUE INDEX "password_resets_id_key" ON "password_resets"("id"); 21 | CREATE INDEX "password_resets_hashedToken_idx" ON "password_resets"("hashedToken"); 22 | CREATE TABLE "new_signup_tokens" ( 23 | "id" TEXT NOT NULL PRIMARY KEY, 24 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "expiresIn" INTEGER NOT NULL, 26 | "hashedToken" TEXT NOT NULL, 27 | "redeemed" BOOLEAN NOT NULL DEFAULT false, 28 | "groupId" TEXT NOT NULL DEFAULT 'global' 29 | ); 30 | DROP TABLE "signup_tokens"; 31 | ALTER TABLE "new_signup_tokens" RENAME TO "signup_tokens"; 32 | CREATE UNIQUE INDEX "signup_tokens_id_key" ON "signup_tokens"("id"); 33 | CREATE INDEX "signup_tokens_hashedToken_idx" ON "signup_tokens"("hashedToken"); 34 | PRAGMA foreign_key_check; 35 | PRAGMA foreign_keys=ON; 36 | -------------------------------------------------------------------------------- /src/lib/server/password.ts: -------------------------------------------------------------------------------- 1 | import { scryptAsync } from "@noble/hashes/scrypt.js"; 2 | import { constantTimeEqual } from "@oslojs/crypto/subtle"; 3 | import { decodeHex, encodeHexLowerCase } from "@oslojs/encoding"; 4 | 5 | const generateScryptKey = async (data: string, salt: string, blockSize = 16) => { 6 | const encodedData = new TextEncoder().encode(data); 7 | const encodedSalt = new TextEncoder().encode(salt); 8 | 9 | const keyUint8Array = await scryptAsync(encodedData, encodedSalt, { 10 | N: 16384, 11 | r: blockSize, 12 | p: 1, 13 | dkLen: 64 14 | }); 15 | 16 | return new Uint8Array(keyUint8Array); 17 | }; 18 | 19 | export const hashPassword = async (password: string) => { 20 | const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16))); 21 | const key = await generateScryptKey(password.normalize("NFKC"), salt); 22 | return `s2:${salt}:${encodeHexLowerCase(key)}`; 23 | }; 24 | 25 | export const verifyPasswordHash = async (hash: string, password: string) => { 26 | const parts = hash.split(":"); 27 | if (parts.length === 2) { 28 | const [salt, key] = parts; 29 | const targetKey = await generateScryptKey(password.normalize("NFKC"), salt, 8); 30 | const result = constantTimeEqual(targetKey, decodeHex(key)); 31 | return result; 32 | } 33 | if (parts.length !== 3) return false; 34 | const [version, salt, key] = parts; 35 | if (version === "s2") { 36 | const targetKey = await generateScryptKey(password.normalize("NFKC"), salt); 37 | return constantTimeEqual(targetKey, decodeHex(key)); 38 | } 39 | return false; 40 | }; 41 | -------------------------------------------------------------------------------- /tests/modules/delete-item-modal.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Modal } from "./modal"; 3 | 4 | export class DeleteItemModal extends Modal { 5 | private readonly allListsButton: Locator; 6 | private readonly thisListButton: Locator; 7 | 8 | constructor(page: Page) { 9 | super(page, { modalName: "Please Confirm", submitButtonText: "Confirm" }); 10 | this.allListsButton = page.getByRole("button", { name: "All lists" }); 11 | this.thisListButton = page.getByRole("button", { name: "This list" }); 12 | } 13 | 14 | async allLists() { 15 | await expect(this.modal).toBeVisible(); 16 | await this.allListsButton.click(); 17 | } 18 | 19 | async thisList() { 20 | await expect(this.modal).toBeVisible(); 21 | await this.thisListButton.click(); 22 | } 23 | 24 | async assertAllListsVisible() { 25 | await expect(this.modal).toBeVisible(); 26 | await expect(this.allListsButton).toBeVisible(); 27 | return this; 28 | } 29 | 30 | async assertAllListsNotVisible() { 31 | await expect(this.modal).toBeVisible(); 32 | await expect(this.allListsButton).not.toBeVisible(); 33 | return this; 34 | } 35 | 36 | async assertThisListsVisible() { 37 | await expect(this.modal).toBeVisible(); 38 | await expect(this.thisListButton).toBeVisible(); 39 | return this; 40 | } 41 | 42 | async assertThisListsNotVisible() { 43 | await expect(this.modal).toBeVisible(); 44 | await expect(this.thisListButton).not.toBeVisible(); 45 | return this; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error, fail, redirect } from "@sveltejs/kit"; 2 | import type { Actions, PageServerLoad } from "./$types"; 3 | import { requireLogin, requireLoginOrError } from "$lib/server/auth"; 4 | import { z } from "zod"; 5 | import { client } from "$lib/server/prisma"; 6 | import { logger } from "$lib/server/logger"; 7 | import { getFormatter } from "$lib/server/i18n"; 8 | 9 | export const load = (async () => { 10 | requireLogin(); 11 | 12 | redirect(302, "/lists"); 13 | }) satisfies PageServerLoad; 14 | 15 | export const actions = { 16 | language: async ({ request }) => { 17 | const user = await requireLoginOrError(); 18 | const $t = await getFormatter(); 19 | 20 | const schema = z.object({ 21 | language: z.string().nullable() 22 | }); 23 | const result = await request.formData().then((data) => { 24 | return schema.safeParseAsync({ language: data.get("language") || null }); 25 | }); 26 | 27 | if (result.error) { 28 | return fail(422, { message: $t("errors.language-is-required") }); 29 | } 30 | 31 | try { 32 | await client.user.update({ 33 | data: { 34 | preferredLanguage: result.data.language 35 | }, 36 | where: { 37 | id: user.id 38 | } 39 | }); 40 | } catch (err) { 41 | logger.error({ err, lang: result.data.language, userId: user.id }, "Unable to update user's language"); 42 | error(500, $t("errors.unable-to-update-preferred-language")); 43 | } 44 | } 45 | } satisfies Actions; 46 | -------------------------------------------------------------------------------- /prisma/migrations/20250220150346_move_item_claim_to_item/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA defer_foreign_keys=ON; 3 | PRAGMA foreign_keys=OFF; 4 | CREATE TABLE "new_list_item_claim" ( 5 | "id" TEXT NOT NULL PRIMARY KEY, 6 | "itemId" INTEGER NOT NULL, 7 | "listId" TEXT NOT NULL, 8 | "claimedById" TEXT, 9 | "publicClaimedById" TEXT, 10 | "purchased" BOOLEAN NOT NULL DEFAULT false, 11 | CONSTRAINT "list_item_claim_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 12 | CONSTRAINT "list_item_claim_listId_fkey" FOREIGN KEY ("listId") REFERENCES "list" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 13 | CONSTRAINT "list_item_claim_claimedById_fkey" FOREIGN KEY ("claimedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 14 | CONSTRAINT "list_item_claim_publicClaimedById_fkey" FOREIGN KEY ("publicClaimedById") REFERENCES "system_user" ("id") ON DELETE CASCADE ON UPDATE CASCADE 15 | ); 16 | 17 | INSERT INTO "new_list_item_claim" ("claimedById", "id", "publicClaimedById", "purchased", "itemId", "listId") 18 | SELECT lic."claimedById", lic."id", lic."publicClaimedById", lic."purchased", li."itemId", li."listId" 19 | FROM "list_item_claim" lic 20 | JOIN "list_item" li on lic.listItemId = li.id; 21 | 22 | DROP TABLE "list_item_claim"; 23 | ALTER TABLE "new_list_item_claim" RENAME TO "list_item_claim"; 24 | CREATE UNIQUE INDEX "list_item_claim_id_key" ON "list_item_claim"("id"); 25 | CREATE INDEX "list_item_claim_itemId_idx" ON "list_item_claim"("itemId"); 26 | CREATE INDEX "list_item_claim_claimedById_idx" ON "list_item_claim"("claimedById"); 27 | PRAGMA foreign_keys=ON; 28 | PRAGMA defer_foreign_keys=OFF; 29 | -------------------------------------------------------------------------------- /src/lib/components/modals/GroupSelectModal.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 |
{$t("general.select-group")}
26 | 27 | {#each groups.toSorted((a, b) => a.name.localeCompare(b.name, locale)) as group} 28 | 29 | {group.name} 30 | 31 | {/each} 32 | 33 | 34 |
35 | 38 | 39 |
40 |
41 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/chips/ListFilterChip.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 52 | -------------------------------------------------------------------------------- /src/lib/api/items.ts: -------------------------------------------------------------------------------- 1 | export class ItemAPI { 2 | itemId: number; 3 | constructor(itemId: number) { 4 | this.itemId = itemId; 5 | } 6 | 7 | _makeRequest = async (method: string, body?: Record) => { 8 | const options: RequestInit = { 9 | method, 10 | headers: { 11 | "content-type": "application/json", 12 | accept: "application/json" 13 | } 14 | }; 15 | 16 | if (body) options.body = JSON.stringify(body); 17 | 18 | return await fetch(`/api/items/${this.itemId}`, options); 19 | }; 20 | 21 | delete = async () => { 22 | return await this._makeRequest("DELETE"); 23 | }; 24 | } 25 | 26 | export class ItemsAPI { 27 | _makeRequest = async (method: string, path: string, body?: any) => { 28 | const options: RequestInit = { 29 | method, 30 | headers: { 31 | "content-type": "application/json", 32 | accept: "application/json" 33 | } 34 | }; 35 | 36 | if (body) options.body = JSON.stringify(body); 37 | 38 | return await fetch(`/api/items${path}`, options); 39 | }; 40 | 41 | clearItemsFromLists = async (groupId?: string, claimed?: boolean) => { 42 | const searchParams = new URLSearchParams(); 43 | if (groupId) searchParams.append("groupId", groupId); 44 | if (claimed) searchParams.append("claimed", `${claimed}`); 45 | return await this._makeRequest("DELETE", "?" + searchParams.toString()); 46 | }; 47 | 48 | updateMany = async (items: (Record & { id: number })[]) => { 49 | return await this._makeRequest("PATCH", "", items); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/server/items.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "$lib/generated/prisma/client"; 2 | 3 | export const getItemInclusions = (listId?: string) => { 4 | return { 5 | lists: { 6 | select: { 7 | listId: true, 8 | approved: true, 9 | displayOrder: true, 10 | addedBy: { 11 | select: { 12 | id: true, 13 | name: true 14 | } 15 | } 16 | }, 17 | where: { 18 | listId 19 | } 20 | }, 21 | claims: { 22 | select: { 23 | id: true, 24 | quantity: true, 25 | purchased: true, 26 | listId: true, 27 | claimedBy: { 28 | select: { 29 | id: true, 30 | name: true, 31 | UserGroupMembership: { 32 | select: { 33 | groupId: true 34 | } 35 | } 36 | } 37 | }, 38 | publicClaimedBy: { 39 | select: { 40 | id: true, 41 | name: true 42 | } 43 | } 44 | } 45 | }, 46 | user: { 47 | select: { 48 | id: true, 49 | name: true 50 | } 51 | }, 52 | itemPrice: true, 53 | _count: { 54 | select: { 55 | lists: true 56 | } 57 | } 58 | } satisfies Prisma.ItemInclude; 59 | }; 60 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from "svelte/elements"; 2 | 3 | declare global { 4 | // See https://kit.svelte.dev/docs/types#app 5 | // for information about these interfaces 6 | // and what to do when importing types 7 | /// 8 | declare namespace App { 9 | // Locals must be an interface and not a type 10 | interface Locals { 11 | user: LocalUser | null; 12 | isProxyUser: boolean; 13 | session: import("$lib/generated/prisma/client").Session | null; 14 | locale: string; 15 | } 16 | } 17 | 18 | // App version 19 | declare const __VERSION__: string; 20 | // git commit sha 21 | declare const __COMMIT_SHA__: string; 22 | // Date built 23 | declare const __LASTMOD__: string; 24 | 25 | interface IconifyIconHTMLElement extends HTMLAttributes { 26 | icon: string; 27 | width?: string | number; 28 | height?: string | number; 29 | rotate?: string | number; 30 | flip?: string; 31 | mode?: "style" | "bg" | "mask" | "svg"; 32 | inline?: boolean; 33 | noobserver?: boolean; 34 | loadIcons?: (icons: string[], callback?: IconifyIconLoaderCallback) => IconifyIconLoaderAbort; 35 | } 36 | 37 | declare namespace svelteHTML { 38 | interface IntrinsicElements { 39 | "iconify-icon": IconifyIconHTMLElement; 40 | } 41 | } 42 | } 43 | 44 | type IconifyIconLoaderCallback = ( 45 | loaded: IconifyIconName[], 46 | missing: IconifyIconName[], 47 | pending: IconifyIconName[], 48 | unsubscribe: IconifyIconLoaderAbort 49 | ) => void; 50 | 51 | export type IconifyIconLoaderAbort = () => void; 52 | 53 | export {}; 54 | -------------------------------------------------------------------------------- /src/lib/components/navigation/BottomTabs.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {#if user && $isInstalled} 22 | 31 | {#each navItems as navItem, value} 32 | goto(navItem.href(user))} 37 | > 38 |
39 | 40 | {$t(navItem.labelKey)} 41 |
42 |
43 | {/each} 44 |
45 | {/if} 46 | 47 | 52 | -------------------------------------------------------------------------------- /src/routes/api/lists/[listId]/items/+server.ts: -------------------------------------------------------------------------------- 1 | import { getFormatter } from "$lib/server/i18n"; 2 | import { itemEmitter } from "$lib/server/events/emitters"; 3 | import { client } from "$lib/server/prisma"; 4 | import type { RequestHandler } from "./$types"; 5 | import { error } from "@sveltejs/kit"; 6 | import { listItemsUpdateSchema } from "$lib/server/validations"; 7 | import { ItemEvent } from "$lib/events"; 8 | import { requireLoginOrError } from "$lib/server/auth"; 9 | import { logger } from "$lib/server/logger"; 10 | 11 | export const PATCH: RequestHandler = async ({ request, params }) => { 12 | await requireLoginOrError(); 13 | const $t = await getFormatter(); 14 | 15 | const body = (await request.json()) as Record[]; 16 | const updateData = listItemsUpdateSchema.array().safeParse(body); 17 | 18 | if (updateData.error) { 19 | error(422, $t("errors.one-or-more-items-missing-an-id")); 20 | } 21 | 22 | try { 23 | await client.$transaction( 24 | updateData.data.map((d) => { 25 | return client.listItem.update({ 26 | where: { 27 | listId_itemId: { 28 | listId: params.listId, 29 | itemId: d.itemId 30 | } 31 | }, 32 | data: { 33 | displayOrder: d.displayOrder 34 | } 35 | }); 36 | }) 37 | ); 38 | 39 | itemEmitter.emit(ItemEvent.ITEMS_UPDATE); 40 | 41 | return new Response(null, { status: 200 }); 42 | } catch (err) { 43 | logger.error({ err }, "Error patching list items"); 44 | error(404, $t("errors.item-not-found")); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/components/wishlists/ItemCard/components/ItemFooter.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |
33 | {#if reorderActions} 34 | 35 | {:else} 36 | 46 | 47 | onApproval?.(true)} 50 | {onDelete} 51 | onDeny={() => onApproval?.(false)} 52 | {onEdit} 53 | {user} 54 | {userCanManage} 55 | /> 56 | {/if} 57 |
58 | -------------------------------------------------------------------------------- /src/routes/api/groups/+server.ts: -------------------------------------------------------------------------------- 1 | import { client } from "$lib/server/prisma"; 2 | import { error } from "@sveltejs/kit"; 3 | import type { RequestHandler } from "./$types"; 4 | import { Role } from "$lib/schema"; 5 | import { getFormatter } from "$lib/server/i18n"; 6 | import { create } from "$lib/server/list"; 7 | import { getConfig } from "$lib/server/config"; 8 | import { requireLoginOrError } from "$lib/server/auth"; 9 | 10 | export const PUT: RequestHandler = async ({ request }) => { 11 | const user = await requireLoginOrError(); 12 | const $t = await getFormatter(); 13 | 14 | const data = await request.json(); 15 | 16 | if (!data.name) error(400, $t("errors.must-specify-group-name-in-body")); 17 | 18 | const group = await client.group.create({ 19 | data: { 20 | name: data.name 21 | } 22 | }); 23 | 24 | if (!data.createOnly) { 25 | const config = await getConfig(group.id); 26 | 27 | const hasActiveMembership = 28 | (await client.userGroupMembership.findFirst({ 29 | where: { 30 | userId: user.id, 31 | active: true 32 | } 33 | })) !== null; 34 | 35 | const createList = config.enableDefaultListCreation ? create(user.id, group.id) : Promise.resolve(); 36 | await Promise.all([ 37 | client.userGroupMembership.create({ 38 | data: { 39 | userId: user.id, 40 | groupId: group.id, 41 | roleId: Role.GROUP_MANAGER, 42 | active: !hasActiveMembership 43 | } 44 | }), 45 | createList 46 | ]); 47 | } 48 | 49 | return new Response(JSON.stringify({ group }), { status: 201 }); 50 | }; 51 | -------------------------------------------------------------------------------- /tests/modules/list-card.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator } from "@playwright/test"; 2 | import { ListPage } from "../pageObjects/list.page"; 3 | 4 | export class ListCard { 5 | private readonly card: Locator; 6 | private readonly name: Locator; 7 | private readonly owner: Locator; 8 | private readonly itemCount: Locator; 9 | 10 | constructor(card: Locator) { 11 | this.card = card; 12 | this.name = card.getByTestId("list-name"); 13 | this.owner = card.getByTestId("list-owner"); 14 | this.itemCount = card.getByTestId("item-count"); 15 | } 16 | 17 | async assertName(name: string) { 18 | await expect(this.name).toHaveText(name); 19 | return this; 20 | } 21 | 22 | async assertOwner(name: string) { 23 | await expect(this.owner).toHaveText(name); 24 | return this; 25 | } 26 | 27 | async assertItemCount(count: number) { 28 | await expect(this.itemCount).toHaveText(count.toString()); 29 | return this; 30 | } 31 | 32 | async click() { 33 | const name = await this.getName(); 34 | expect(name).not.toBeNull(); 35 | const owner = await this.owner.textContent(); 36 | const listPageName = name === `${owner}'s Wishes` ? "My Wishes" : name!; 37 | await this.card.click(); 38 | await this.card.page().waitForURL(/\/lists\/.*/); 39 | return new ListPage(this.card.page(), { name: listPageName }); 40 | } 41 | 42 | async getName() { 43 | const name = await this.name.textContent(); 44 | expect(name).not.toBeNull(); 45 | return name!; 46 | } 47 | 48 | async assertApprovalBanner() { 49 | this.card.locator("p", { hasText: /You have \d items? awaiting your approval/ }); 50 | return this; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/routes/admin/groups/[groupId]/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
{ 21 | saving = true; 22 | if (!config.enableDefaultListCreation) { 23 | formData.append("enableDefaultListCreation", ""); 24 | } 25 | 26 | return ({ result }) => { 27 | saving = false; 28 | if (result.type === "success") { 29 | toastStore.trigger({ message: $t("admin.settings-saved-toast") }); 30 | } 31 | }; 32 | }} 33 | > 34 |