├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github ├── DISCUSSION_TEMPLATE │ └── ideas.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── pull_request_template.md ├── scripts │ └── update_currencies.py └── workflows │ ├── binaries-publish.yaml │ ├── clear-stale-docker-images.yml │ ├── docker-publish-rootless.yaml │ ├── docker-publish.yaml │ ├── e2e-partial.yaml │ ├── partial-backend.yaml │ ├── partial-frontend.yaml │ ├── pull-requests.yaml │ ├── update-currencies.yml │ └── update-currencies │ └── requirements.txt ├── .gitignore ├── .scaffold └── model │ ├── scaffold.yaml │ └── templates │ └── model.go ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.rootless ├── LICENSE ├── README.md ├── SECURITY.md ├── Taskfile.yml ├── backend ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── app │ ├── api │ │ ├── app.go │ │ ├── bgrunner.go │ │ ├── demo.go │ │ ├── handlers │ │ │ ├── debughandlers │ │ │ │ └── debug.go │ │ │ └── v1 │ │ │ │ ├── assets │ │ │ │ └── QRIcon.png │ │ │ │ ├── controller.go │ │ │ │ ├── helpers.go │ │ │ │ ├── partials.go │ │ │ │ ├── query_params.go │ │ │ │ ├── v1_ctrl_actions.go │ │ │ │ ├── v1_ctrl_assets.go │ │ │ │ ├── v1_ctrl_auth.go │ │ │ │ ├── v1_ctrl_group.go │ │ │ │ ├── v1_ctrl_items.go │ │ │ │ ├── v1_ctrl_items_attachments.go │ │ │ │ ├── v1_ctrl_labelmaker.go │ │ │ │ ├── v1_ctrl_labels.go │ │ │ │ ├── v1_ctrl_locations.go │ │ │ │ ├── v1_ctrl_maint_entry.go │ │ │ │ ├── v1_ctrl_maintenance.go │ │ │ │ ├── v1_ctrl_notifiers.go │ │ │ │ ├── v1_ctrl_qrcode.go │ │ │ │ ├── v1_ctrl_reporting.go │ │ │ │ ├── v1_ctrl_statistics.go │ │ │ │ └── v1_ctrl_user.go │ │ ├── logger.go │ │ ├── main.go │ │ ├── middleware.go │ │ ├── providers │ │ │ ├── doc.go │ │ │ ├── extractors.go │ │ │ └── local.go │ │ ├── routes.go │ │ └── static │ │ │ ├── docs │ │ │ ├── docs.go │ │ │ ├── swagger.json │ │ │ └── swagger.yaml │ │ │ └── public │ │ │ └── .gitkeep │ └── tools │ │ └── typegen │ │ └── main.go ├── cosign.key ├── cosign.pub ├── go.mod ├── go.sum ├── internal │ ├── core │ │ ├── currencies │ │ │ ├── currencies.go │ │ │ └── currencies.json │ │ └── services │ │ │ ├── all.go │ │ │ ├── contexts.go │ │ │ ├── contexts_test.go │ │ │ ├── main_test.go │ │ │ ├── reporting │ │ │ ├── .testdata │ │ │ │ └── import │ │ │ │ │ ├── fields.csv │ │ │ │ │ ├── minimal.csv │ │ │ │ │ └── types.csv │ │ │ ├── bill_of_materials.go │ │ │ ├── eventbus │ │ │ │ └── eventbus.go │ │ │ ├── import.go │ │ │ ├── io_row.go │ │ │ ├── io_sheet.go │ │ │ ├── io_sheet_test.go │ │ │ ├── value_parsers.go │ │ │ └── value_parsers_test.go │ │ │ ├── service_background.go │ │ │ ├── service_group.go │ │ │ ├── service_items.go │ │ │ ├── service_items_attachments.go │ │ │ ├── service_items_attachments_test.go │ │ │ ├── service_user.go │ │ │ └── service_user_defaults.go │ ├── data │ │ ├── ent │ │ │ ├── attachment.go │ │ │ ├── attachment │ │ │ │ ├── attachment.go │ │ │ │ └── where.go │ │ │ ├── attachment_create.go │ │ │ ├── attachment_delete.go │ │ │ ├── attachment_query.go │ │ │ ├── attachment_update.go │ │ │ ├── authroles.go │ │ │ ├── authroles │ │ │ │ ├── authroles.go │ │ │ │ └── where.go │ │ │ ├── authroles_create.go │ │ │ ├── authroles_delete.go │ │ │ ├── authroles_query.go │ │ │ ├── authroles_update.go │ │ │ ├── authtokens.go │ │ │ ├── authtokens │ │ │ │ ├── authtokens.go │ │ │ │ └── where.go │ │ │ ├── authtokens_create.go │ │ │ ├── authtokens_delete.go │ │ │ ├── authtokens_query.go │ │ │ ├── authtokens_update.go │ │ │ ├── client.go │ │ │ ├── ent.go │ │ │ ├── enttest │ │ │ │ └── enttest.go │ │ │ ├── external.go │ │ │ ├── generate.go │ │ │ ├── group.go │ │ │ ├── group │ │ │ │ ├── group.go │ │ │ │ └── where.go │ │ │ ├── group_create.go │ │ │ ├── group_delete.go │ │ │ ├── group_query.go │ │ │ ├── group_update.go │ │ │ ├── groupinvitationtoken.go │ │ │ ├── groupinvitationtoken │ │ │ │ ├── groupinvitationtoken.go │ │ │ │ └── where.go │ │ │ ├── groupinvitationtoken_create.go │ │ │ ├── groupinvitationtoken_delete.go │ │ │ ├── groupinvitationtoken_query.go │ │ │ ├── groupinvitationtoken_update.go │ │ │ ├── has_id.go │ │ │ ├── hook │ │ │ │ └── hook.go │ │ │ ├── item.go │ │ │ ├── item │ │ │ │ ├── item.go │ │ │ │ └── where.go │ │ │ ├── item_create.go │ │ │ ├── item_delete.go │ │ │ ├── item_query.go │ │ │ ├── item_update.go │ │ │ ├── itemfield.go │ │ │ ├── itemfield │ │ │ │ ├── itemfield.go │ │ │ │ └── where.go │ │ │ ├── itemfield_create.go │ │ │ ├── itemfield_delete.go │ │ │ ├── itemfield_query.go │ │ │ ├── itemfield_update.go │ │ │ ├── label.go │ │ │ ├── label │ │ │ │ ├── label.go │ │ │ │ └── where.go │ │ │ ├── label_create.go │ │ │ ├── label_delete.go │ │ │ ├── label_query.go │ │ │ ├── label_update.go │ │ │ ├── location.go │ │ │ ├── location │ │ │ │ ├── location.go │ │ │ │ └── where.go │ │ │ ├── location_create.go │ │ │ ├── location_delete.go │ │ │ ├── location_query.go │ │ │ ├── location_update.go │ │ │ ├── maintenanceentry.go │ │ │ ├── maintenanceentry │ │ │ │ ├── maintenanceentry.go │ │ │ │ └── where.go │ │ │ ├── maintenanceentry_create.go │ │ │ ├── maintenanceentry_delete.go │ │ │ ├── maintenanceentry_query.go │ │ │ ├── maintenanceentry_update.go │ │ │ ├── migrate │ │ │ │ ├── migrate.go │ │ │ │ └── schema.go │ │ │ ├── mutation.go │ │ │ ├── notifier.go │ │ │ ├── notifier │ │ │ │ ├── notifier.go │ │ │ │ └── where.go │ │ │ ├── notifier_create.go │ │ │ ├── notifier_delete.go │ │ │ ├── notifier_query.go │ │ │ ├── notifier_update.go │ │ │ ├── predicate │ │ │ │ └── predicate.go │ │ │ ├── runtime.go │ │ │ ├── runtime │ │ │ │ └── runtime.go │ │ │ ├── schema │ │ │ │ ├── attachment.go │ │ │ │ ├── auth_roles.go │ │ │ │ ├── auth_tokens.go │ │ │ │ ├── group.go │ │ │ │ ├── group_invitation_token.go │ │ │ │ ├── item.go │ │ │ │ ├── item_field.go │ │ │ │ ├── label.go │ │ │ │ ├── location.go │ │ │ │ ├── maintenance_entry.go │ │ │ │ ├── mixins │ │ │ │ │ └── base.go │ │ │ │ ├── notifier.go │ │ │ │ ├── templates │ │ │ │ │ └── has_id.tmpl │ │ │ │ └── user.go │ │ │ ├── tx.go │ │ │ ├── user.go │ │ │ ├── user │ │ │ │ ├── user.go │ │ │ │ └── where.go │ │ │ ├── user_create.go │ │ │ ├── user_delete.go │ │ │ ├── user_query.go │ │ │ └── user_update.go │ │ ├── migrations │ │ │ ├── migrations.go │ │ │ ├── postgres │ │ │ │ ├── 20241027025146_init.sql │ │ │ │ ├── 20250112202302_sync_children.go │ │ │ │ ├── 20250419184104_merge_docs_attachments.sql │ │ │ │ └── main.go │ │ │ └── sqlite3 │ │ │ │ ├── 20220929052825_init.sql │ │ │ │ ├── 20241226183416_sync_children.go │ │ │ │ ├── 20250405050521_merge_docs_and_attachments.sql │ │ │ │ └── main.go │ │ ├── repo │ │ │ ├── asset_id_type.go │ │ │ ├── asset_id_type_test.go │ │ │ ├── automappers.go │ │ │ ├── id_set.go │ │ │ ├── main_test.go │ │ │ ├── map_helpers.go │ │ │ ├── pagination.go │ │ │ ├── query_helpers.go │ │ │ ├── repo_group.go │ │ │ ├── repo_group_test.go │ │ │ ├── repo_item_attachments.go │ │ │ ├── repo_item_attachments_test.go │ │ │ ├── repo_items.go │ │ │ ├── repo_items_test.go │ │ │ ├── repo_labels.go │ │ │ ├── repo_labels_test.go │ │ │ ├── repo_locations.go │ │ │ ├── repo_locations_test.go │ │ │ ├── repo_maintenance.go │ │ │ ├── repo_maintenance_entry.go │ │ │ ├── repo_maintenance_entry_test.go │ │ │ ├── repo_notifier.go │ │ │ ├── repo_tokens.go │ │ │ ├── repo_tokens_test.go │ │ │ ├── repo_users.go │ │ │ ├── repo_users_test.go │ │ │ └── repos_all.go │ │ └── types │ │ │ └── date.go │ ├── sys │ │ ├── analytics │ │ │ └── analytics.go │ │ ├── config │ │ │ ├── conf.go │ │ │ ├── conf_database.go │ │ │ ├── conf_logger.go │ │ │ ├── conf_mailer.go │ │ │ └── conf_mailer_test.go │ │ └── validate │ │ │ ├── errors.go │ │ │ └── validate.go │ └── web │ │ ├── adapters │ │ ├── actions.go │ │ ├── adapters.go │ │ ├── command.go │ │ ├── decoders.go │ │ ├── doc.go │ │ └── query.go │ │ └── mid │ │ ├── doc.go │ │ ├── errors.go │ │ └── logger.go └── pkgs │ ├── cgofreesqlite │ └── sqlite.go │ ├── faker │ ├── random.go │ └── randoms_test.go │ ├── hasher │ ├── doc.go │ ├── password.go │ ├── password_test.go │ ├── token.go │ └── token_test.go │ ├── labelmaker │ └── labelmaker.go │ ├── mailer │ ├── mailer.go │ ├── mailer_test.go │ ├── message.go │ ├── message_test.go │ ├── templates.go │ ├── templates │ │ └── welcome.html │ └── test-mailer-template.json │ └── set │ ├── funcs.go │ ├── funcs_test.go │ ├── set.go │ └── set_test.go ├── docker-compose.yml ├── docs ├── .vitepress │ ├── config.mts │ ├── menus │ │ └── en.mts │ └── theme │ │ ├── index.ts │ │ └── style.css ├── en │ ├── analytics │ │ ├── index.md │ │ └── privacy.md │ ├── api │ │ ├── index.md │ │ ├── openapi-2.0.json │ │ └── openapi-2.0.yaml │ ├── configure.md │ ├── contribute │ │ ├── bounty.md │ │ ├── get-started.md │ │ └── shadcn.md │ ├── images │ │ └── home-screen.png │ ├── import-csv.md │ ├── index.md │ ├── installation.md │ ├── migration.md │ ├── quick-start.md │ ├── upgrade.md │ └── user-guide │ │ ├── organizing-items.md │ │ └── tips-tricks.md └── public │ ├── _redirects │ ├── favicon.svg │ ├── homebox-email-banner.jpg │ └── lilbox.svg ├── frontend ├── .eslintrc.js ├── .nuxtignore ├── app.vue ├── assets │ └── css │ │ └── main.css ├── components.json ├── components │ ├── App │ │ ├── CreateModal.vue │ │ ├── HeaderDecor.vue │ │ ├── HeaderText.vue │ │ ├── ImportDialog.vue │ │ ├── LanguageSelector.vue │ │ ├── Logo.vue │ │ ├── OutdatedModal.vue │ │ ├── QuickMenuModal.vue │ │ └── ScannerModal.vue │ ├── Base │ │ ├── Card.vue │ │ ├── Container.vue │ │ └── SectionHeader.vue │ ├── DetailAction.vue │ ├── Form │ │ ├── Checkbox.vue │ │ ├── DatePicker.vue │ │ ├── Password.vue │ │ ├── TextArea.vue │ │ └── TextField.vue │ ├── Item │ │ ├── AttachmentsList.vue │ │ ├── Card.vue │ │ ├── CreateModal.vue │ │ ├── Selector.vue │ │ └── View │ │ │ ├── Selectable.vue │ │ │ ├── Table.types.ts │ │ │ └── Table.vue │ ├── Label │ │ ├── Chip.vue │ │ ├── CreateModal.vue │ │ └── Selector.vue │ ├── Location │ │ ├── Card.vue │ │ ├── CreateModal.vue │ │ ├── Selector.vue │ │ └── Tree │ │ │ ├── Node.vue │ │ │ ├── Root.vue │ │ │ └── tree-state.ts │ ├── Maintenance │ │ ├── EditModal.vue │ │ └── ListView.vue │ ├── ModalConfirm.vue │ ├── Search │ │ └── Filter.vue │ ├── global │ │ ├── CopyText.vue │ │ ├── Currency.vue │ │ ├── DateTime.vue │ │ ├── DetailsSection │ │ │ ├── DetailsSection.vue │ │ │ └── types.ts │ │ ├── DropZone.vue │ │ ├── LabelMaker.vue │ │ ├── Markdown.vue │ │ ├── PageQRCode.vue │ │ ├── PasswordScore.vue │ │ ├── Spacer.vue │ │ ├── StatCard │ │ │ ├── StatCard.vue │ │ │ └── types.ts │ │ ├── Subtitle.vue │ │ └── Table.types.ts │ └── ui │ │ ├── alert-dialog │ │ ├── AlertDialog.vue │ │ ├── AlertDialogAction.vue │ │ ├── AlertDialogCancel.vue │ │ ├── AlertDialogContent.vue │ │ ├── AlertDialogDescription.vue │ │ ├── AlertDialogFooter.vue │ │ ├── AlertDialogHeader.vue │ │ ├── AlertDialogTitle.vue │ │ ├── AlertDialogTrigger.vue │ │ └── index.ts │ │ ├── badge │ │ ├── Badge.vue │ │ └── index.ts │ │ ├── breadcrumb │ │ ├── Breadcrumb.vue │ │ ├── BreadcrumbEllipsis.vue │ │ ├── BreadcrumbItem.vue │ │ ├── BreadcrumbLink.vue │ │ ├── BreadcrumbList.vue │ │ ├── BreadcrumbPage.vue │ │ ├── BreadcrumbSeparator.vue │ │ └── index.ts │ │ ├── button │ │ ├── Button.vue │ │ ├── ButtonGroup.vue │ │ └── index.ts │ │ ├── card │ │ ├── Card.vue │ │ ├── CardContent.vue │ │ ├── CardDescription.vue │ │ ├── CardFooter.vue │ │ ├── CardHeader.vue │ │ ├── CardTitle.vue │ │ └── index.ts │ │ ├── checkbox │ │ ├── Checkbox.vue │ │ └── index.ts │ │ ├── command │ │ ├── Command.vue │ │ ├── CommandDialog.vue │ │ ├── CommandEmpty.vue │ │ ├── CommandGroup.vue │ │ ├── CommandInput.vue │ │ ├── CommandItem.vue │ │ ├── CommandList.vue │ │ ├── CommandSeparator.vue │ │ ├── CommandShortcut.vue │ │ └── index.ts │ │ ├── dialog-provider │ │ ├── DialogProvider.vue │ │ ├── index.ts │ │ └── utils.ts │ │ ├── dialog │ │ ├── Dialog.vue │ │ ├── DialogClose.vue │ │ ├── DialogContent.vue │ │ ├── DialogDescription.vue │ │ ├── DialogFooter.vue │ │ ├── DialogHeader.vue │ │ ├── DialogScrollContent.vue │ │ ├── DialogTitle.vue │ │ ├── DialogTrigger.vue │ │ └── index.ts │ │ ├── drawer │ │ ├── Drawer.vue │ │ ├── DrawerContent.vue │ │ ├── DrawerDescription.vue │ │ ├── DrawerFooter.vue │ │ ├── DrawerHeader.vue │ │ ├── DrawerOverlay.vue │ │ ├── DrawerTitle.vue │ │ └── index.ts │ │ ├── dropdown-menu │ │ ├── DropdownMenu.vue │ │ ├── DropdownMenuCheckboxItem.vue │ │ ├── DropdownMenuContent.vue │ │ ├── DropdownMenuGroup.vue │ │ ├── DropdownMenuItem.vue │ │ ├── DropdownMenuLabel.vue │ │ ├── DropdownMenuRadioGroup.vue │ │ ├── DropdownMenuRadioItem.vue │ │ ├── DropdownMenuSeparator.vue │ │ ├── DropdownMenuShortcut.vue │ │ ├── DropdownMenuSub.vue │ │ ├── DropdownMenuSubContent.vue │ │ ├── DropdownMenuSubTrigger.vue │ │ ├── DropdownMenuTrigger.vue │ │ └── index.ts │ │ ├── input │ │ ├── Input.vue │ │ └── index.ts │ │ ├── label │ │ ├── Label.vue │ │ └── index.ts │ │ ├── pagination │ │ ├── PaginationEllipsis.vue │ │ ├── PaginationFirst.vue │ │ ├── PaginationLast.vue │ │ ├── PaginationNext.vue │ │ ├── PaginationPrev.vue │ │ └── index.ts │ │ ├── popover │ │ ├── Popover.vue │ │ ├── PopoverContent.vue │ │ ├── PopoverTrigger.vue │ │ └── index.ts │ │ ├── progress │ │ ├── Progress.vue │ │ └── index.ts │ │ ├── select │ │ ├── Select.vue │ │ ├── SelectContent.vue │ │ ├── SelectGroup.vue │ │ ├── SelectItem.vue │ │ ├── SelectItemText.vue │ │ ├── SelectLabel.vue │ │ ├── SelectScrollDownButton.vue │ │ ├── SelectScrollUpButton.vue │ │ ├── SelectSeparator.vue │ │ ├── SelectTrigger.vue │ │ ├── SelectValue.vue │ │ └── index.ts │ │ ├── separator │ │ ├── Separator.vue │ │ └── index.ts │ │ ├── sheet │ │ ├── Sheet.vue │ │ ├── SheetClose.vue │ │ ├── SheetContent.vue │ │ ├── SheetDescription.vue │ │ ├── SheetFooter.vue │ │ ├── SheetHeader.vue │ │ ├── SheetTitle.vue │ │ ├── SheetTrigger.vue │ │ └── index.ts │ │ ├── shortcut │ │ ├── Shortcut.vue │ │ └── index.ts │ │ ├── sidebar │ │ ├── Sidebar.vue │ │ ├── SidebarContent.vue │ │ ├── SidebarFooter.vue │ │ ├── SidebarGroup.vue │ │ ├── SidebarGroupAction.vue │ │ ├── SidebarGroupContent.vue │ │ ├── SidebarGroupLabel.vue │ │ ├── SidebarHeader.vue │ │ ├── SidebarInput.vue │ │ ├── SidebarInset.vue │ │ ├── SidebarMenu.vue │ │ ├── SidebarMenuAction.vue │ │ ├── SidebarMenuBadge.vue │ │ ├── SidebarMenuButton.vue │ │ ├── SidebarMenuButtonChild.vue │ │ ├── SidebarMenuItem.vue │ │ ├── SidebarMenuLink.vue │ │ ├── SidebarMenuSkeleton.vue │ │ ├── SidebarMenuSub.vue │ │ ├── SidebarMenuSubButton.vue │ │ ├── SidebarMenuSubItem.vue │ │ ├── SidebarProvider.vue │ │ ├── SidebarRail.vue │ │ ├── SidebarSeparator.vue │ │ ├── SidebarTrigger.vue │ │ ├── index.ts │ │ └── utils.ts │ │ ├── skeleton │ │ ├── Skeleton.vue │ │ └── index.ts │ │ ├── sonner │ │ ├── Sonner.vue │ │ ├── index.ts │ │ └── toast.ts │ │ ├── switch │ │ ├── Switch.vue │ │ └── index.ts │ │ ├── table │ │ ├── Table.vue │ │ ├── TableBody.vue │ │ ├── TableCaption.vue │ │ ├── TableCell.vue │ │ ├── TableEmpty.vue │ │ ├── TableFooter.vue │ │ ├── TableHead.vue │ │ ├── TableHeader.vue │ │ ├── TableRow.vue │ │ └── index.ts │ │ ├── tags-input │ │ ├── TagsInput.vue │ │ ├── TagsInputInput.vue │ │ ├── TagsInputItem.vue │ │ ├── TagsInputItemDelete.vue │ │ ├── TagsInputItemText.vue │ │ └── index.ts │ │ ├── textarea │ │ ├── Textarea.vue │ │ └── index.ts │ │ └── tooltip │ │ ├── Tooltip.vue │ │ ├── TooltipContent.vue │ │ ├── TooltipProvider.vue │ │ ├── TooltipTrigger.vue │ │ └── index.ts ├── composables │ ├── use-api.ts │ ├── use-auth-context.ts │ ├── use-confirm.ts │ ├── use-css-var.ts │ ├── use-defer.ts │ ├── use-formatters.ts │ ├── use-item-search.ts │ ├── use-location-helpers.ts │ ├── use-min-loader.ts │ ├── use-password-score.ts │ ├── use-preferences.ts │ ├── use-route-params.ts │ ├── use-server-events.ts │ ├── use-theme.ts │ ├── utils.test.ts │ └── utils.ts ├── error.vue ├── global.d.ts ├── layouts │ ├── default.vue │ └── empty.vue ├── lib │ ├── api │ │ ├── __test__ │ │ │ ├── factories │ │ │ │ └── index.ts │ │ │ ├── public.test.ts │ │ │ ├── test-utils.ts │ │ │ └── user │ │ │ │ ├── group.test.ts │ │ │ │ ├── items.test.ts │ │ │ │ ├── labels.test.ts │ │ │ │ ├── locations.test.ts │ │ │ │ ├── notifier.test.ts │ │ │ │ ├── stats.test.ts │ │ │ │ └── user.test.ts │ │ ├── base │ │ │ ├── base-api.test.ts │ │ │ ├── base-api.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── urls.ts │ │ ├── classes │ │ │ ├── actions.ts │ │ │ ├── assets.ts │ │ │ ├── group.ts │ │ │ ├── items.ts │ │ │ ├── labels.ts │ │ │ ├── locations.ts │ │ │ ├── maintenance.ts │ │ │ ├── notifiers.ts │ │ │ ├── reports.ts │ │ │ ├── stats.ts │ │ │ └── users.ts │ │ ├── public.ts │ │ ├── types │ │ │ ├── data-contracts.ts │ │ │ └── non-generated.ts │ │ └── user.ts │ ├── data │ │ └── themes.ts │ ├── datelib │ │ ├── datelib.test.ts │ │ └── datelib.ts │ ├── passwords │ │ ├── index.test.ts │ │ └── index.ts │ ├── requests │ │ ├── index.ts │ │ └── requests.ts │ └── utils.ts ├── locales │ ├── bs-BA.json │ ├── ca.json │ ├── cs-CZ.json │ ├── da-DK.json │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fi-FI.json │ ├── fr.json │ ├── hu.json │ ├── id-ID.json │ ├── it.json │ ├── ja-JP.json │ ├── ko-KR.json │ ├── lb-LU.json │ ├── lt-LT.json │ ├── nb-NO.json │ ├── nl.json │ ├── pl.json │ ├── pt-BR.json │ ├── pt-PT.json │ ├── ro-RO.json │ ├── ru.json │ ├── sk-SK.json │ ├── sl.json │ ├── sq-AL.json │ ├── sv.json │ ├── ta-IN.json │ ├── th-TH.json │ ├── tr.json │ ├── uk-UA.json │ ├── zh-CN.json │ ├── zh-HK.json │ ├── zh-MO.json │ └── zh-TW.json ├── middleware │ └── auth.ts ├── nuxt.config.ts ├── package.json ├── pages │ ├── a │ │ └── [id].vue │ ├── assets │ │ └── [id].vue │ ├── home │ │ ├── index.vue │ │ ├── statistics.ts │ │ └── table.ts │ ├── index.vue │ ├── item │ │ └── [id] │ │ │ ├── index.vue │ │ │ └── index │ │ │ ├── edit.vue │ │ │ └── maintenance.vue │ ├── items.vue │ ├── label │ │ └── [id].vue │ ├── location │ │ └── [id].vue │ ├── locations.vue │ ├── maintenance.vue │ ├── profile.vue │ ├── reports │ │ └── label-generator.vue │ └── tools.vue ├── plugins │ ├── i18n.ts │ └── scroll.client.ts ├── pnpm-lock.yaml ├── public │ ├── favicon.svg │ ├── no-image.jpg │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── set-theme.js ├── stores │ ├── labels.ts │ └── locations.ts ├── tailwind.config.js ├── test │ ├── config.ts │ ├── e2e │ │ └── login.browser.spec.ts │ ├── playwright.config.ts │ ├── playwright.teardown.ts │ ├── setup.ts │ └── vitest.config.ts └── tsconfig.json ├── package.json └── pnpm-lock.yaml /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:22-bullseye 2 | 3 | RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin 4 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile" 7 | }, 8 | 9 | // Configure tool-specific properties. 10 | "customizations": { 11 | // Configure properties specific to VS Code. 12 | "vscode": { 13 | // Add the IDs of extensions you want installed when the container is created. 14 | "extensions": [ 15 | "dbaeumer.vscode-eslint" 16 | ] 17 | } 18 | }, 19 | 20 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 21 | "forwardPorts": [ 22 | 7745, 23 | 3000 24 | ], 25 | 26 | // Use 'postCreateCommand' to run commands after the container is created. 27 | "postCreateCommand": "go install github.com/go-task/task/v3/cmd/task@latest && npm install -g pnpm && task setup", 28 | 29 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 30 | "remoteUser": "node", 31 | "features": { 32 | "ghcr.io/devcontainers/features/go:1": "1.21" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/bin 15 | **/charts 16 | **/docker-compose* 17 | **/compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | README.md 25 | !Dockerfile.rootless 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tankerkiller125,katosdev,tonyaellie] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/sysadminsmedia/homebox/discussions/categories/support 5 | about: Get support for issues here 6 | - name: Feature Requests 7 | url: https://github.com/sysadminsmedia/homebox/discussions/categories/ideas 8 | about: Have an idea for Homebox? Share it in our discussions forum. If we decide to take it on we will create an issue for it. 9 | - name: Translate 10 | url: https://translate.sysadminsmedia.com 11 | about: Help us translate Homebox! All contributions and all languages welcome! 12 | -------------------------------------------------------------------------------- /.github/workflows/binaries-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Release Binaries 2 | 3 | on: 4 | push: 5 | tags: [ 'v*.*.*' ] 6 | 7 | jobs: 8 | goreleaser: 9 | name: goreleaser 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 9.15.3 23 | 24 | - name: Build Frontend and Copy to Backend 25 | working-directory: frontend 26 | run: | 27 | pnpm install 28 | pnpm run build 29 | cp -r ./.output/public ../backend/app/api/static/ 30 | 31 | - name: Install CoSign 32 | working-directory: backend 33 | run: | 34 | go install github.com/sigstore/cosign/cmd/cosign@latest 35 | 36 | - name: Run GoReleaser 37 | uses: goreleaser/goreleaser-action@v5 38 | with: 39 | workdir: "backend" 40 | distribution: goreleaser 41 | version: "~> v2" 42 | args: release --clean 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/partial-backend.yaml: -------------------------------------------------------------------------------- 1 | name: Go Build/Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | Go: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: "1.21" 16 | 17 | - name: Install Task 18 | uses: arduino/setup-task@v1 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v7 24 | with: 25 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 26 | version: latest 27 | 28 | # Optional: working directory, useful for monorepos 29 | working-directory: backend 30 | args: --timeout=6m 31 | 32 | - name: Build API 33 | run: task go:build 34 | 35 | - name: Test 36 | run: task go:coverage 37 | 38 | - name: Validate OpenAPI definition 39 | uses: swaggerexpert/swagger-editor-validate@v1 40 | with: 41 | definition-file: backend/app/api/static/docs/swagger.json 42 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - vnext 8 | 9 | paths: 10 | - 'backend/**' 11 | - 'frontend/**' 12 | - '.github/workflows/**' 13 | 14 | jobs: 15 | backend-tests: 16 | name: "Backend Server Tests" 17 | uses: ./.github/workflows/partial-backend.yaml 18 | 19 | frontend-tests: 20 | name: "Frontend Tests" 21 | uses: ./.github/workflows/partial-frontend.yaml 22 | 23 | e2e-tests: 24 | name: "End-to-End Playwright Tests" 25 | uses: ./.github/workflows/e2e-partial.yaml -------------------------------------------------------------------------------- /.github/workflows/update-currencies/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /.scaffold/model/scaffold.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # yaml-language-server: $schema=https://hay-kot.github.io/scaffold/schema.json 3 | messages: 4 | pre: | 5 | # Ent Model Generation 6 | 7 | With Boilerplate! 8 | post: | 9 | Complete! 10 | 11 | questions: 12 | - name: "model" 13 | prompt: 14 | message: "What is the name of the model? (PascalCase)" 15 | required: true 16 | 17 | - name: "by_group" 18 | prompt: 19 | confirm: "Include a Group Edge? (group_id -> id)" 20 | required: true 21 | 22 | rewrites: 23 | - from: 'templates/model.go' 24 | to: 'backend/internal/data/ent/schema/{{ lower .Scaffold.model }}.go' 25 | 26 | inject: 27 | - name: "Insert Groups Edge" 28 | path: 'backend/internal/data/ent/schema/group.go' 29 | at: // $scaffold_edge 30 | template: | 31 | {{- if .Scaffold.by_group -}} 32 | owned("{{ lower .Scaffold.model }}s", {{ .Scaffold.model }}.Type), 33 | {{- end -}} 34 | -------------------------------------------------------------------------------- /.scaffold/model/templates/model.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | 6 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 7 | ) 8 | 9 | type {{ .Scaffold.model }} struct { 10 | ent.Schema 11 | } 12 | 13 | func ({{ .Scaffold.model }}) Mixin() []ent.Mixin { 14 | return []ent.Mixin{ 15 | mixins.BaseMixin{}, 16 | {{- if .Scaffold.by_group }} 17 | GroupMixin{ref: "{{ snakecase .Scaffold.model }}s"}, 18 | {{- end }} 19 | } 20 | } 21 | 22 | // Fields of the {{ .Scaffold.model }}. 23 | func ({{ .Scaffold.model }}) Fields() []ent.Field { 24 | return []ent.Field{ 25 | // field.String("name"). 26 | } 27 | } 28 | 29 | // Edges of the {{ .Scaffold.model }}. 30 | func ({{ .Scaffold.model }}) Edges() []ent.Edge { 31 | return []ent.Edge{ 32 | // edge.From("group", Group.Type). 33 | } 34 | } 35 | 36 | func ({{ .Scaffold.model }}) Indexes() []ent.Index { 37 | return []ent.Index{ 38 | // index.Fields("token"), 39 | } 40 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Since this software is still considered beta/WIP support is always only given for the latest version. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please open a normal public issue for minor security issues or general security inquires. 10 | 11 | For major or critical security issues, please open a private github security issue. -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /backend/app/api/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/sysadminsmedia/homebox/backend/internal/core/services" 5 | "github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus" 6 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent" 7 | "github.com/sysadminsmedia/homebox/backend/internal/data/repo" 8 | "github.com/sysadminsmedia/homebox/backend/internal/sys/config" 9 | "github.com/sysadminsmedia/homebox/backend/pkgs/mailer" 10 | ) 11 | 12 | type app struct { 13 | conf *config.Config 14 | mailer mailer.Mailer 15 | db *ent.Client 16 | repos *repo.AllRepos 17 | services *services.AllServices 18 | bus *eventbus.EventBus 19 | } 20 | 21 | func new(conf *config.Config) *app { 22 | s := &app{ 23 | conf: conf, 24 | } 25 | 26 | s.mailer = mailer.Mailer{ 27 | Host: s.conf.Mailer.Host, 28 | Port: s.conf.Mailer.Port, 29 | Username: s.conf.Mailer.Username, 30 | Password: s.conf.Mailer.Password, 31 | From: s.conf.Mailer.From, 32 | } 33 | 34 | return s 35 | } 36 | -------------------------------------------------------------------------------- /backend/app/api/bgrunner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type BackgroundTask struct { 9 | name string 10 | Interval time.Duration 11 | Fn func(context.Context) 12 | } 13 | 14 | func (tsk *BackgroundTask) Name() string { 15 | return tsk.name 16 | } 17 | 18 | func NewTask(name string, interval time.Duration, fn func(context.Context)) *BackgroundTask { 19 | return &BackgroundTask{ 20 | Interval: interval, 21 | Fn: fn, 22 | } 23 | } 24 | 25 | func (tsk *BackgroundTask) Start(ctx context.Context) error { 26 | tsk.Fn(ctx) 27 | 28 | timer := time.NewTimer(tsk.Interval) 29 | for { 30 | select { 31 | case <-ctx.Done(): 32 | return nil 33 | case <-timer.C: 34 | timer.Reset(tsk.Interval) 35 | tsk.Fn(ctx) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/app/api/handlers/debughandlers/debug.go: -------------------------------------------------------------------------------- 1 | // Package debughandlers provides handlers for debugging. 2 | package debughandlers 3 | 4 | import ( 5 | "expvar" 6 | "net/http" 7 | "net/http/pprof" 8 | ) 9 | 10 | func New(mux *http.ServeMux) { 11 | mux.HandleFunc("/debug/pprof", pprof.Index) 12 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 13 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 14 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 15 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) 16 | mux.Handle("/debug/vars", expvar.Handler()) 17 | } 18 | -------------------------------------------------------------------------------- /backend/app/api/handlers/v1/assets/QRIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/backend/app/api/handlers/v1/assets/QRIcon.png -------------------------------------------------------------------------------- /backend/app/api/handlers/v1/helpers.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | func GetHBURL(refererHeader, fallback string) (hbURL string) { 10 | hbURL = refererHeader 11 | if hbURL == "" { 12 | hbURL = fallback 13 | } 14 | 15 | return stripPathFromURL(hbURL) 16 | } 17 | 18 | // stripPathFromURL removes the path from a URL. 19 | // ex. https://example.com/tools -> https://example.com 20 | func stripPathFromURL(rawURL string) string { 21 | parsedURL, err := url.Parse(rawURL) 22 | if err != nil { 23 | log.Err(err).Msg("failed to parse URL") 24 | return "" 25 | } 26 | 27 | strippedURL := url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host} 28 | 29 | return strippedURL.String() 30 | } 31 | -------------------------------------------------------------------------------- /backend/app/api/handlers/v1/partials.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/google/uuid" 8 | "github.com/sysadminsmedia/homebox/backend/internal/sys/validate" 9 | ) 10 | 11 | // routeID extracts the ID from the request URL. If the ID is not in a valid 12 | // format, an error is returned. If a error is returned, it can be directly returned 13 | // from the handler. the validate.ErrInvalidID error is known by the error middleware 14 | // and will be handled accordingly. 15 | // 16 | // Example: /api/v1/ac614db5-d8b8-4659-9b14-6e913a6eb18a -> uuid.UUID{ac614db5-d8b8-4659-9b14-6e913a6eb18a} 17 | func (ctrl *V1Controller) routeID(r *http.Request) (uuid.UUID, error) { 18 | return ctrl.routeUUID(r, "id") 19 | } 20 | 21 | func (ctrl *V1Controller) routeUUID(r *http.Request, key string) (uuid.UUID, error) { 22 | ID, err := uuid.Parse(chi.URLParam(r, key)) 23 | if err != nil { 24 | return uuid.Nil, validate.NewRouteKeyError(key) 25 | } 26 | return ID, nil 27 | } 28 | -------------------------------------------------------------------------------- /backend/app/api/handlers/v1/query_params.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func queryUUIDList(params url.Values, key string) []uuid.UUID { 11 | var ids []uuid.UUID 12 | for _, id := range params[key] { 13 | uid, err := uuid.Parse(id) 14 | if err != nil { 15 | continue 16 | } 17 | ids = append(ids, uid) 18 | } 19 | return ids 20 | } 21 | 22 | func queryIntOrNegativeOne(s string) int { 23 | i, err := strconv.Atoi(s) 24 | if err != nil { 25 | return -1 26 | } 27 | return i 28 | } 29 | 30 | func queryBool(s string) bool { 31 | b, err := strconv.ParseBool(s) 32 | if err != nil { 33 | return false 34 | } 35 | return b 36 | } 37 | -------------------------------------------------------------------------------- /backend/app/api/handlers/v1/v1_ctrl_reporting.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/hay-kot/httpkit/errchain" 5 | "github.com/sysadminsmedia/homebox/backend/internal/core/services" 6 | "net/http" 7 | ) 8 | 9 | // HandleBillOfMaterialsExport godoc 10 | // 11 | // @Summary Export Bill of Materials 12 | // @Tags Reporting 13 | // @Produce json 14 | // @Success 200 {string} string "text/csv" 15 | // @Router /v1/reporting/bill-of-materials [GET] 16 | // @Security Bearer 17 | func (ctrl *V1Controller) HandleBillOfMaterialsExport() errchain.HandlerFunc { 18 | return func(w http.ResponseWriter, r *http.Request) error { 19 | actor := services.UseUserCtx(r.Context()) 20 | 21 | csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), actor.GroupID) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | w.Header().Set("Content-Type", "text/csv") 27 | w.Header().Set("Content-Disposition", "attachment; filename=bill-of-materials.csv") 28 | _, err = w.Write(csv) 29 | return err 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/app/api/logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "github.com/sysadminsmedia/homebox/backend/internal/sys/config" 9 | ) 10 | 11 | // setupLogger initializes the zerolog config 12 | // for the shared logger. 13 | func (a *app) setupLogger() { 14 | // Logger Init 15 | // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 16 | if a.conf.Log.Format != config.LogFormatJSON { 17 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).With().Caller().Logger() 18 | } 19 | 20 | level, err := zerolog.ParseLevel(a.conf.Log.Level) 21 | if err == nil { 22 | zerolog.SetGlobalLevel(level) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/app/api/providers/doc.go: -------------------------------------------------------------------------------- 1 | // Package providers provides a authentication abstraction for the backend. 2 | package providers 3 | -------------------------------------------------------------------------------- /backend/app/api/providers/local.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/sysadminsmedia/homebox/backend/internal/core/services" 7 | ) 8 | 9 | type LocalProvider struct { 10 | service *services.UserService 11 | } 12 | 13 | func NewLocalProvider(service *services.UserService) *LocalProvider { 14 | return &LocalProvider{ 15 | service: service, 16 | } 17 | } 18 | 19 | func (p *LocalProvider) Name() string { 20 | return "local" 21 | } 22 | 23 | func (p *LocalProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) { 24 | loginForm, err := getLoginForm(r) 25 | if err != nil { 26 | return services.UserAuthTokenDetail{}, err 27 | } 28 | 29 | return p.service.Login(r.Context(), loginForm.Username, loginForm.Password, loginForm.StayLoggedIn) 30 | } 31 | -------------------------------------------------------------------------------- /backend/app/api/static/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/backend/app/api/static/public/.gitkeep -------------------------------------------------------------------------------- /backend/cosign.key: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY----- 2 | eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 3 | OCwicCI6MX0sInNhbHQiOiJ3bmU3TTd2dndlL2FBS1piUEE2QktsdFNzMkhkSk9v 4 | eXlvOTNLMnByRXdJPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 5 | Iiwibm9uY2UiOiJoOWdIMHRsYk9zMnZIbVBTYk5zaGxBQU5TYUlkcVZoQiJ9LCJj 6 | aXBoZXJ0ZXh0IjoiTERiQk5ac3ZlVnRMbTlQdkRTa2t6bzRrWGExVGRTTEY5VzVO 7 | cGd6M05GNVJLRWlGRmJQRDJDYzhnTWNkRmkrTU8xd2FTUzFGWWdXU3BIdnI3QXZ3 8 | K0tUTXVWLzhSZ1pnOE9ieHNJY2xKSlZldHRLTzdzWXY2aWgxM09iZlVBV0lQcGpS 9 | ZUQ5UmE3WjJwbWd0SkpBdjl2dlk1RGNNeGRKcFFrOEY1UStLZytSbnhLRUd6Z1ZN 10 | MWUxdjF3UGhsOWhVRGRMSFVSTzE5Z0w3aFE9PSJ9 11 | -----END ENCRYPTED SIGSTORE PRIVATE KEY----- 12 | -------------------------------------------------------------------------------- /backend/cosign.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2DXKcerPznDayM+rMJ/25w+ubI8g 3 | e3ZTbm07VqLFz6uI2vXqN8X7/72dygtJlUw07FpR0oLXaSia0adaywz1JA== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /backend/internal/core/services/contexts_test.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/uuid" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/sysadminsmedia/homebox/backend/internal/data/repo" 10 | ) 11 | 12 | func Test_SetAuthContext(t *testing.T) { 13 | user := &repo.UserOut{ 14 | ID: uuid.New(), 15 | } 16 | 17 | token := uuid.New().String() 18 | 19 | ctx := SetUserCtx(context.Background(), user, token) 20 | 21 | ctxUser := UseUserCtx(ctx) 22 | 23 | assert.NotNil(t, ctxUser) 24 | assert.Equal(t, user.ID, ctxUser.ID) 25 | 26 | ctxUserToken := UseTokenCtx(ctx) 27 | assert.NotEmpty(t, ctxUserToken) 28 | } 29 | 30 | func Test_SetAuthContext_Nulls(t *testing.T) { 31 | ctx := SetUserCtx(context.Background(), nil, "") 32 | 33 | ctxUser := UseUserCtx(ctx) 34 | 35 | assert.Nil(t, ctxUser) 36 | 37 | ctxUserToken := UseTokenCtx(ctx) 38 | assert.Empty(t, ctxUserToken) 39 | } 40 | -------------------------------------------------------------------------------- /backend/internal/core/services/reporting/.testdata/import/fields.csv: -------------------------------------------------------------------------------- 1 | HB.location,HB.name,HB.quantity,HB.description,HB.field.Custom Field 1,HB.field.Custom Field 2,HB.field.Custom Field 3 2 | loc,Item 1,1,Description 1,Value 1[1],Value 1[2],Value 1[3] 3 | loc,Item 2,2,Description 2,Value 2[1],Value 2[2],Value 2[3] 4 | loc,Item 3,3,Description 3,Value 3[1],Value 3[2],Value 3[3] 5 | 6 | -------------------------------------------------------------------------------- /backend/internal/core/services/reporting/.testdata/import/minimal.csv: -------------------------------------------------------------------------------- 1 | HB.location,HB.name,HB.quantity,HB.description 2 | loc,Item 1,1,Description 1 3 | loc,Item 2,2,Description 2 4 | loc,Item 3,3,Description 3 -------------------------------------------------------------------------------- /backend/internal/core/services/reporting/.testdata/import/types.csv: -------------------------------------------------------------------------------- 1 | HB.name,HB.asset_id,HB.location,HB.labels 2 | Item 1,1,Path / To / Location 1,L1 ; L2 ; L3 3 | Item 2,000-002,Path /To/ Location 2,L1;L2;L3 4 | Item 3,1000-003,Path / To /Location 3 , L1;L2; L3 -------------------------------------------------------------------------------- /backend/internal/core/services/reporting/value_parsers.go: -------------------------------------------------------------------------------- 1 | package reporting 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func parseSeparatedString(s string, sep string) ([]string, error) { 9 | list := strings.Split(s, sep) 10 | 11 | csf := make([]string, 0, len(list)) 12 | for _, s := range list { 13 | trimmed := strings.TrimSpace(s) 14 | if trimmed != "" { 15 | csf = append(csf, trimmed) 16 | } 17 | } 18 | 19 | return csf, nil 20 | } 21 | 22 | func parseFloat(s string) float64 { 23 | if s == "" { 24 | return 0 25 | } 26 | f, _ := strconv.ParseFloat(s, 64) 27 | return f 28 | } 29 | 30 | func parseBool(s string) bool { 31 | b, _ := strconv.ParseBool(s) 32 | return b 33 | } 34 | 35 | func parseInt(s string) int { 36 | i, _ := strconv.Atoi(s) 37 | return i 38 | } 39 | -------------------------------------------------------------------------------- /backend/internal/core/services/service_group.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/sysadminsmedia/homebox/backend/internal/data/repo" 8 | "github.com/sysadminsmedia/homebox/backend/pkgs/hasher" 9 | ) 10 | 11 | type GroupService struct { 12 | repos *repo.AllRepos 13 | } 14 | 15 | func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) { 16 | if data.Name == "" { 17 | data.Name = ctx.User.GroupName 18 | } 19 | 20 | if data.Currency == "" { 21 | return repo.Group{}, errors.New("currency cannot be empty") 22 | } 23 | 24 | return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data) 25 | } 26 | 27 | func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) { 28 | token := hasher.GenerateToken() 29 | 30 | _, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{ 31 | Token: token.Hash, 32 | Uses: uses, 33 | ExpiresAt: expiresAt, 34 | }) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | return token.Raw, nil 40 | } 41 | -------------------------------------------------------------------------------- /backend/internal/core/services/service_user_defaults.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/sysadminsmedia/homebox/backend/internal/data/repo" 5 | ) 6 | 7 | func defaultLocations() []repo.LocationCreate { 8 | return []repo.LocationCreate{ 9 | { 10 | Name: "Living Room", 11 | }, 12 | { 13 | Name: "Garage", 14 | }, 15 | { 16 | Name: "Kitchen", 17 | }, 18 | { 19 | Name: "Bedroom", 20 | }, 21 | { 22 | Name: "Bathroom", 23 | }, 24 | { 25 | Name: "Office", 26 | }, 27 | { 28 | Name: "Attic", 29 | }, 30 | { 31 | Name: "Basement", 32 | }, 33 | } 34 | } 35 | 36 | func defaultLabels() []repo.LabelCreate { 37 | return []repo.LabelCreate{ 38 | { 39 | Name: "Appliances", 40 | }, 41 | { 42 | Name: "IOT", 43 | }, 44 | { 45 | Name: "Electronics", 46 | }, 47 | { 48 | Name: "Servers", 49 | }, 50 | { 51 | Name: "General", 52 | }, 53 | { 54 | Name: "Important", 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /backend/internal/data/ent/external.go: -------------------------------------------------------------------------------- 1 | package ent 2 | 3 | import ( 4 | "database/sql" 5 | 6 | entsql "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | // Sql exposes the underlying database connection in the ent client 10 | // so that we can use it to perform custom queries. 11 | func (c *Client) Sql() *sql.DB { 12 | return c.driver.(*entsql.Driver).DB() 13 | } 14 | -------------------------------------------------------------------------------- /backend/internal/data/ent/generate.go: -------------------------------------------------------------------------------- 1 | package ent 2 | 3 | //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema --template=./schema/templates/has_id.tmpl 4 | -------------------------------------------------------------------------------- /backend/internal/data/ent/has_id.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import "github.com/google/uuid" 6 | 7 | func (a *Attachment) GetID() uuid.UUID { 8 | return a.ID 9 | } 10 | 11 | func (ar *AuthRoles) GetID() int { 12 | return ar.ID 13 | } 14 | 15 | func (at *AuthTokens) GetID() uuid.UUID { 16 | return at.ID 17 | } 18 | 19 | func (gr *Group) GetID() uuid.UUID { 20 | return gr.ID 21 | } 22 | 23 | func (git *GroupInvitationToken) GetID() uuid.UUID { 24 | return git.ID 25 | } 26 | 27 | func (i *Item) GetID() uuid.UUID { 28 | return i.ID 29 | } 30 | 31 | func (_if *ItemField) GetID() uuid.UUID { 32 | return _if.ID 33 | } 34 | 35 | func (l *Label) GetID() uuid.UUID { 36 | return l.ID 37 | } 38 | 39 | func (l *Location) GetID() uuid.UUID { 40 | return l.ID 41 | } 42 | 43 | func (me *MaintenanceEntry) GetID() uuid.UUID { 44 | return me.ID 45 | } 46 | 47 | func (n *Notifier) GetID() uuid.UUID { 48 | return n.ID 49 | } 50 | 51 | func (u *User) GetID() uuid.UUID { 52 | return u.ID 53 | } 54 | -------------------------------------------------------------------------------- /backend/internal/data/ent/runtime/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package runtime 4 | 5 | // The schema-stitching logic is generated in github.com/sysadminsmedia/homebox/backend/internal/data/ent/runtime.go 6 | 7 | const ( 8 | Version = "v0.14.4" // Version of ent codegen. 9 | Sum = "h1:/DhDraSLXIkBhyiVoJeSshr4ZYi7femzhj6/TckzZuI=" // Sum of ent codegen. 10 | ) 11 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/attachment.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 8 | ) 9 | 10 | // Attachment holds the schema definition for the Attachment entity. 11 | type Attachment struct { 12 | ent.Schema 13 | } 14 | 15 | func (Attachment) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | mixins.BaseMixin{}, 18 | } 19 | } 20 | 21 | // Fields of the Attachment. 22 | func (Attachment) Fields() []ent.Field { 23 | return []ent.Field{ 24 | field.Enum("type").Values("photo", "manual", "warranty", "attachment", "receipt").Default("attachment"), 25 | field.Bool("primary").Default(false), 26 | field.String("title").Default(""), 27 | field.String("path").Default(""), 28 | } 29 | } 30 | 31 | // Edges of the Attachment. 32 | func (Attachment) Edges() []ent.Edge { 33 | return []ent.Edge{ 34 | edge.From("item", Item.Type). 35 | Ref("attachments"). 36 | Required(). 37 | Unique(), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/auth_roles.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | ) 8 | 9 | // AuthRoles holds the schema definition for the AuthRoles entity. 10 | type AuthRoles struct { 11 | ent.Schema 12 | } 13 | 14 | // Fields of the AuthRoles. 15 | func (AuthRoles) Fields() []ent.Field { 16 | return []ent.Field{ 17 | field.Enum("role"). 18 | Default("user"). 19 | Values( 20 | "admin", // can do everything - currently unused 21 | "user", // default login role 22 | "attachments", // Read Attachments 23 | ), 24 | } 25 | } 26 | 27 | // Edges of the AuthRoles. 28 | func (AuthRoles) Edges() []ent.Edge { 29 | return []ent.Edge{ 30 | edge.From("token", AuthTokens.Type). 31 | Ref("roles"). 32 | Unique(), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/group_invitation_token.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/edge" 8 | "entgo.io/ent/schema/field" 9 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 10 | ) 11 | 12 | // GroupInvitationToken holds the schema definition for the GroupInvitationToken entity. 13 | type GroupInvitationToken struct { 14 | ent.Schema 15 | } 16 | 17 | func (GroupInvitationToken) Mixin() []ent.Mixin { 18 | return []ent.Mixin{ 19 | mixins.BaseMixin{}, 20 | } 21 | } 22 | 23 | // Fields of the GroupInvitationToken. 24 | func (GroupInvitationToken) Fields() []ent.Field { 25 | return []ent.Field{ 26 | field.Bytes("token"). 27 | Unique(), 28 | field.Time("expires_at"). 29 | Default(func() time.Time { return time.Now().Add(time.Hour * 24 * 7) }), 30 | field.Int("uses"). 31 | Default(0), 32 | } 33 | } 34 | 35 | // Edges of the GroupInvitationToken. 36 | func (GroupInvitationToken) Edges() []ent.Edge { 37 | return []ent.Edge{ 38 | edge.From("group", Group.Type). 39 | Ref("invitation_tokens"). 40 | Unique(), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/item_field.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/edge" 8 | "entgo.io/ent/schema/field" 9 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 10 | ) 11 | 12 | // ItemField holds the schema definition for the ItemField entity. 13 | type ItemField struct { 14 | ent.Schema 15 | } 16 | 17 | func (ItemField) Mixin() []ent.Mixin { 18 | return []ent.Mixin{ 19 | mixins.BaseMixin{}, 20 | mixins.DetailsMixin{}, 21 | } 22 | } 23 | 24 | // Fields of the ItemField. 25 | func (ItemField) Fields() []ent.Field { 26 | return []ent.Field{ 27 | field.Enum("type"). 28 | Values("text", "number", "boolean", "time"), 29 | field.String("text_value"). 30 | MaxLen(500). 31 | Optional(), 32 | field.Int("number_value"). 33 | Optional(), 34 | field.Bool("boolean_value"). 35 | Default(false), 36 | field.Time("time_value"). 37 | Default(time.Now), 38 | } 39 | } 40 | 41 | // Edges of the ItemField. 42 | func (ItemField) Edges() []ent.Edge { 43 | return []ent.Edge{ 44 | edge.From("item", Item.Type). 45 | Ref("fields"). 46 | Unique(), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/label.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 8 | ) 9 | 10 | // Label holds the schema definition for the Label entity. 11 | type Label struct { 12 | ent.Schema 13 | } 14 | 15 | func (Label) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | mixins.BaseMixin{}, 18 | mixins.DetailsMixin{}, 19 | GroupMixin{ref: "labels"}, 20 | } 21 | } 22 | 23 | // Fields of the Label. 24 | func (Label) Fields() []ent.Field { 25 | return []ent.Field{ 26 | field.String("color"). 27 | MaxLen(255). 28 | Optional(), 29 | } 30 | } 31 | 32 | // Edges of the Label. 33 | func (Label) Edges() []ent.Edge { 34 | return []ent.Edge{ 35 | edge.To("items", Item.Type), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/location.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/dialect/entsql" 6 | "entgo.io/ent/schema/edge" 7 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 8 | ) 9 | 10 | // Location holds the schema definition for the Location entity. 11 | type Location struct { 12 | ent.Schema 13 | } 14 | 15 | func (Location) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | mixins.BaseMixin{}, 18 | mixins.DetailsMixin{}, 19 | GroupMixin{ref: "locations"}, 20 | } 21 | } 22 | 23 | // Fields of the Location. 24 | func (Location) Fields() []ent.Field { 25 | return nil 26 | } 27 | 28 | // Edges of the Location. 29 | func (Location) Edges() []ent.Edge { 30 | return []ent.Edge{ 31 | edge.To("children", Location.Type). 32 | From("parent"). 33 | Unique(), 34 | edge.To("items", Item.Type). 35 | Annotations(entsql.Annotation{ 36 | OnDelete: entsql.Cascade, 37 | }), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/maintenance_entry.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/edge" 6 | "entgo.io/ent/schema/field" 7 | "github.com/google/uuid" 8 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 9 | ) 10 | 11 | type MaintenanceEntry struct { 12 | ent.Schema 13 | } 14 | 15 | func (MaintenanceEntry) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | mixins.BaseMixin{}, 18 | } 19 | } 20 | 21 | func (MaintenanceEntry) Fields() []ent.Field { 22 | return []ent.Field{ 23 | field.UUID("item_id", uuid.UUID{}), 24 | field.Time("date"). 25 | Optional(), 26 | field.Time("scheduled_date"). 27 | Optional(), 28 | field.String("name"). 29 | MaxLen(255). 30 | NotEmpty(), 31 | field.String("description"). 32 | MaxLen(2500). 33 | Optional(), 34 | field.Float("cost"). 35 | Default(0.0), 36 | } 37 | } 38 | 39 | // Edges of the ItemField. 40 | func (MaintenanceEntry) Edges() []ent.Edge { 41 | return []ent.Edge{ 42 | edge.From("item", Item.Type). 43 | Field("item_id"). 44 | Ref("maintenance_entries"). 45 | Required(). 46 | Unique(), 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/mixins/base.go: -------------------------------------------------------------------------------- 1 | package mixins 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/field" 8 | "entgo.io/ent/schema/mixin" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type BaseMixin struct { 13 | mixin.Schema 14 | } 15 | 16 | func (BaseMixin) Fields() []ent.Field { 17 | return []ent.Field{ 18 | field.UUID("id", uuid.UUID{}). 19 | Default(uuid.New), 20 | field.Time("created_at"). 21 | Immutable(). 22 | Default(time.Now), 23 | field.Time("updated_at"). 24 | Default(time.Now). 25 | UpdateDefault(time.Now), 26 | } 27 | } 28 | 29 | type DetailsMixin struct { 30 | mixin.Schema 31 | } 32 | 33 | func (DetailsMixin) Fields() []ent.Field { 34 | return []ent.Field{ 35 | field.String("name"). 36 | MaxLen(255). 37 | NotEmpty(), 38 | field.String("description"). 39 | MaxLen(1000). 40 | Optional(), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/notifier.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "entgo.io/ent" 5 | "entgo.io/ent/schema/field" 6 | "entgo.io/ent/schema/index" 7 | 8 | "github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins" 9 | ) 10 | 11 | type Notifier struct { 12 | ent.Schema 13 | } 14 | 15 | func (Notifier) Mixin() []ent.Mixin { 16 | return []ent.Mixin{ 17 | mixins.BaseMixin{}, 18 | GroupMixin{ 19 | ref: "notifiers", 20 | field: "group_id", 21 | }, 22 | UserMixin{ 23 | ref: "notifiers", 24 | field: "user_id", 25 | }, 26 | } 27 | } 28 | 29 | // Fields of the Notifier. 30 | func (Notifier) Fields() []ent.Field { 31 | return []ent.Field{ 32 | field.String("name"). 33 | MaxLen(255). 34 | NotEmpty(), 35 | field.String("url"). 36 | Sensitive(). 37 | MaxLen(2083). // supposed max length of URL 38 | NotEmpty(), 39 | field.Bool("is_active"). 40 | Default(true), 41 | } 42 | } 43 | 44 | func (Notifier) Indexes() []ent.Index { 45 | return []ent.Index{ 46 | index.Fields("user_id"), 47 | index.Fields("user_id", "is_active"), 48 | index.Fields("group_id"), 49 | index.Fields("group_id", "is_active"), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/internal/data/ent/schema/templates/has_id.tmpl: -------------------------------------------------------------------------------- 1 | {{/* The line below tells Intellij/GoLand to enable the autocompletion based on the *gen.Graph type. */}} 2 | {{/* gotype: entgo.io/ent/entc/gen.Graph */}} 3 | 4 | {{ define "has_id" }} 5 | 6 | {{/* Add the base header for the generated file */}} 7 | {{ $pkg := base $.Config.Package }} 8 | {{ template "header" $ }} 9 | import "github.com/google/uuid" 10 | {{/* Loop over all nodes and implement the "HasID" interface */}} 11 | {{ range $n := $.Nodes }} 12 | {{ if not $n.ID }} 13 | {{/* If the node doesn't have an ID field, we skip it. */}} 14 | {{ continue }} 15 | {{ end }} 16 | {{/* The "HasID" interface is implemented by the "ID" method. */}} 17 | {{ $receiver := $n.Receiver }} 18 | func ({{ $receiver }} *{{ $n.Name }}) GetID() {{ $n.ID.Type }} { 19 | return {{ $receiver }}.ID 20 | } 21 | {{ end }} 22 | 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /backend/internal/data/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | package migrations 3 | 4 | import ( 5 | "embed" 6 | "github.com/rs/zerolog/log" 7 | ) 8 | 9 | //go:embed all:postgres 10 | var postgresFiles embed.FS 11 | 12 | //go:embed all:sqlite3 13 | var sqliteFiles embed.FS 14 | 15 | // Migrations returns the embedded file system containing the SQL migration files 16 | // for the specified SQL dialect. It uses the "embed" package to include the 17 | // migration files in the binary at build time. The function takes a string 18 | // parameter "dialect" which specifies the SQL dialect to use. It returns an 19 | // embedded file system containing the migration files for the specified dialect. 20 | func Migrations(dialect string) embed.FS { 21 | switch dialect { 22 | case "postgres": 23 | return postgresFiles 24 | case "sqlite3": 25 | return sqliteFiles 26 | default: 27 | log.Fatal().Str("dialect", dialect).Msg("unknown sql dialect") 28 | } 29 | // This should never get hit, but just in case 30 | return sqliteFiles 31 | } 32 | -------------------------------------------------------------------------------- /backend/internal/data/migrations/postgres/20250112202302_sync_children.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | //nolint:gochecknoinits 11 | func init() { 12 | goose.AddMigrationContext(Up20250112202302, Down20250112202302) 13 | } 14 | 15 | func Up20250112202302(ctx context.Context, tx *sql.Tx) error { 16 | columnName := "sync_child_items_locations" 17 | query := ` 18 | SELECT column_name 19 | FROM information_schema.columns 20 | WHERE table_name = 'items' AND column_name = 'sync_child_items_locations'; 21 | ` 22 | err := tx.QueryRowContext(ctx, query).Scan(&columnName) 23 | if err != nil { 24 | // Column does not exist, proceed with migration 25 | _, err = tx.ExecContext(ctx, ` 26 | ALTER TABLE "items" ADD COLUMN "sync_child_items_locations" boolean NOT NULL DEFAULT false; 27 | `) 28 | if err != nil { 29 | return fmt.Errorf("failed to execute migration: %w", err) 30 | } 31 | } 32 | return nil 33 | } 34 | 35 | func Down20250112202302(ctx context.Context, tx *sql.Tx) error { 36 | // This migration is a no-op for Postgres. 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /backend/internal/data/migrations/postgres/20250419184104_merge_docs_attachments.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | -- Step 1: Modify "attachments" table to add new columns 4 | ALTER TABLE "attachments" ADD COLUMN "title" character varying NOT NULL DEFAULT '', ADD COLUMN "path" character varying NOT NULL DEFAULT ''; 5 | 6 | -- Update existing rows in "attachments" with data from "documents" 7 | UPDATE "attachments" 8 | SET "title" = d."title", 9 | "path" = d."path" 10 | FROM "documents" d 11 | WHERE "attachments"."document_attachments" = d."id"; 12 | 13 | -- Step 3: Drop foreign key constraints referencing "documents" 14 | ALTER TABLE "attachments" DROP CONSTRAINT IF EXISTS "attachments_documents_attachments"; 15 | 16 | -- Step 4: Drop the "document_attachments" column 17 | ALTER TABLE "attachments" DROP COLUMN IF EXISTS "document_attachments"; 18 | 19 | -- Step 5: Drop the "documents" table 20 | DROP TABLE IF EXISTS "documents"; -------------------------------------------------------------------------------- /backend/internal/data/migrations/postgres/main.go: -------------------------------------------------------------------------------- 1 | // Package postgres provides the PostgreSQL database migration 2 | package postgres 3 | 4 | // This file exists to make Goose happy. It really doesn't do anything else. 5 | -------------------------------------------------------------------------------- /backend/internal/data/migrations/sqlite3/main.go: -------------------------------------------------------------------------------- 1 | // Package sqlite3 provides the SQLite3 database migration 2 | package sqlite3 3 | 4 | // This file exists to make Goose happy. It really doesn't do anything else. 5 | -------------------------------------------------------------------------------- /backend/internal/data/repo/automappers.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | type MapFunc[T any, U any] func(T) U 4 | 5 | func (a MapFunc[T, U]) Map(v T) U { 6 | return a(v) 7 | } 8 | 9 | func (a MapFunc[T, U]) MapEach(v []T) []U { 10 | result := make([]U, len(v)) 11 | for i, item := range v { 12 | result[i] = a(item) 13 | } 14 | return result 15 | } 16 | 17 | func (a MapFunc[T, U]) MapErr(v T, err error) (U, error) { 18 | if err != nil { 19 | var zero U 20 | return zero, err 21 | } 22 | 23 | return a(v), nil 24 | } 25 | 26 | func (a MapFunc[T, U]) MapEachErr(v []T, err error) ([]U, error) { 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return a.MapEach(v), nil 32 | } 33 | -------------------------------------------------------------------------------- /backend/internal/data/repo/id_set.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/sysadminsmedia/homebox/backend/pkgs/set" 6 | ) 7 | 8 | // HasID is an interface to entities that have an ID uuid.UUID field and a GetID() method. 9 | // This interface is fulfilled by all entities generated by entgo.io/ent via a custom template 10 | type HasID interface { 11 | GetID() uuid.UUID 12 | } 13 | 14 | func newIDSet[T HasID](entities []T) set.Set[uuid.UUID] { 15 | uuids := make([]uuid.UUID, 0, len(entities)) 16 | for _, e := range entities { 17 | uuids = append(uuids, e.GetID()) 18 | } 19 | 20 | return set.New(uuids...) 21 | } 22 | -------------------------------------------------------------------------------- /backend/internal/data/repo/pagination.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | type PaginationResult[T any] struct { 4 | Page int `json:"page"` 5 | PageSize int `json:"pageSize"` 6 | Total int `json:"total"` 7 | Items []T `json:"items"` 8 | } 9 | 10 | func calculateOffset(page, pageSize int) int { 11 | return (page - 1) * pageSize 12 | } 13 | -------------------------------------------------------------------------------- /backend/internal/data/repo/query_helpers.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "time" 4 | 5 | func sqliteDateFormat(t time.Time) string { 6 | return t.Format("2006-01-02 15:04:05") 7 | } 8 | 9 | // orDefault returns the value of the pointer if it is not nil, otherwise it returns the default value 10 | // 11 | // This is used for nullable or potentially nullable fields (or aggregates) in the database when running 12 | // queries. If the field is null, the pointer will be nil, so we return the default value instead. 13 | func orDefault[T any](v *T, def T) T { 14 | if v == nil { 15 | return def 16 | } 17 | return *v 18 | } 19 | -------------------------------------------------------------------------------- /backend/internal/sys/config/conf_database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | DriverSqlite3 = "sqlite3" 5 | ) 6 | 7 | type Storage struct { 8 | // Data is the path to the root directory 9 | Data string `yaml:"data" conf:"default:./.data"` 10 | } 11 | 12 | type Database struct { 13 | Driver string `yaml:"driver" conf:"default:sqlite3"` 14 | Username string `yaml:"username"` 15 | Password string `yaml:"password"` 16 | Host string `yaml:"host"` 17 | Port string `yaml:"port"` 18 | Database string `yaml:"database"` 19 | SslMode string `yaml:"ssl_mode"` 20 | SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"` 21 | } 22 | -------------------------------------------------------------------------------- /backend/internal/sys/config/conf_logger.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | LogFormatJSON = "json" 5 | LogFormatText = "text" 6 | ) 7 | 8 | type LoggerConf struct { 9 | Level string `conf:"default:info"` 10 | Format string `conf:"default:text"` 11 | } 12 | -------------------------------------------------------------------------------- /backend/internal/sys/config/conf_mailer.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type MailerConf struct { 4 | Host string `conf:""` 5 | Port int `conf:""` 6 | Username string `conf:""` 7 | Password string `conf:""` 8 | From string `conf:""` 9 | } 10 | 11 | // Ready is a simple check to ensure that the configuration is not empty. 12 | // or with it's default state. 13 | func (mc *MailerConf) Ready() bool { 14 | return mc.Host != "" && mc.Port != 0 && mc.Username != "" && mc.Password != "" && mc.From != "" 15 | } 16 | -------------------------------------------------------------------------------- /backend/internal/sys/config/conf_mailer_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_MailerReady_Success(t *testing.T) { 10 | mc := &MailerConf{ 11 | Host: "host", 12 | Port: 1, 13 | Username: "username", 14 | Password: "password", 15 | From: "from", 16 | } 17 | 18 | assert.True(t, mc.Ready()) 19 | } 20 | 21 | func Test_MailerReady_Failure(t *testing.T) { 22 | mc := &MailerConf{} 23 | assert.False(t, mc.Ready()) 24 | 25 | mc.Host = "host" 26 | assert.False(t, mc.Ready()) 27 | 28 | mc.Port = 1 29 | assert.False(t, mc.Ready()) 30 | 31 | mc.Username = "username" 32 | assert.False(t, mc.Ready()) 33 | 34 | mc.Password = "password" 35 | assert.False(t, mc.Ready()) 36 | 37 | mc.From = "from" 38 | assert.True(t, mc.Ready()) 39 | } 40 | -------------------------------------------------------------------------------- /backend/internal/web/adapters/adapters.go: -------------------------------------------------------------------------------- 1 | package adapters 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type AdapterFunc[T any, Y any] func(*http.Request, T) (Y, error) 10 | type IDFunc[T any, Y any] func(*http.Request, uuid.UUID, T) (Y, error) 11 | -------------------------------------------------------------------------------- /backend/internal/web/adapters/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package adapters offers common adapters for turing regular functions into HTTP Handlers 3 | There are three types of adapters 4 | 5 | - Query adapters 6 | - Action adapters 7 | - Command adapters 8 | */ 9 | package adapters 10 | -------------------------------------------------------------------------------- /backend/internal/web/mid/doc.go: -------------------------------------------------------------------------------- 1 | // Package mid provides web middleware. 2 | package mid 3 | -------------------------------------------------------------------------------- /backend/pkgs/faker/random.go: -------------------------------------------------------------------------------- 1 | // Package faker provides a simple interface for generating fake data for testing. 2 | package faker 3 | 4 | import ( 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 10 | 11 | type Faker struct{} 12 | 13 | func NewFaker() *Faker { 14 | return &Faker{} 15 | } 16 | 17 | func (f *Faker) Time() time.Time { 18 | return time.Now().Add(time.Duration(f.Num(1, 100)) * time.Hour) 19 | } 20 | 21 | func (f *Faker) Str(length int) string { 22 | b := make([]rune, length) 23 | for i := range b { 24 | b[i] = letters[rand.Intn(len(letters))] 25 | } 26 | return string(b) 27 | } 28 | 29 | func (f *Faker) Path() string { 30 | return "/" + f.Str(10) + "/" + f.Str(10) + "/" + f.Str(10) 31 | } 32 | 33 | func (f *Faker) Email() string { 34 | return f.Str(10) + "@example.com" 35 | } 36 | 37 | func (f *Faker) Bool() bool { 38 | return rand.Intn(2) == 1 39 | } 40 | 41 | func (f *Faker) Num(min, max int) int { 42 | return rand.Intn(max-min) + min 43 | } 44 | -------------------------------------------------------------------------------- /backend/pkgs/hasher/doc.go: -------------------------------------------------------------------------------- 1 | // Package hasher provides a simple interface for hashing and verifying passwords. 2 | package hasher 3 | -------------------------------------------------------------------------------- /backend/pkgs/hasher/password.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | var enabled = true 11 | 12 | func init() { // nolint: gochecknoinits 13 | disableHas := os.Getenv("UNSAFE_DISABLE_PASSWORD_PROJECTION") == "yes_i_am_sure" 14 | 15 | if disableHas { 16 | fmt.Println("WARNING: Password protection is disabled. This is unsafe in production.") 17 | enabled = false 18 | } 19 | } 20 | 21 | func HashPassword(password string) (string, error) { 22 | if !enabled { 23 | return password, nil 24 | } 25 | 26 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) 27 | return string(bytes), err 28 | } 29 | 30 | func CheckPasswordHash(password, hash string) bool { 31 | if !enabled { 32 | return password == hash 33 | } 34 | 35 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 36 | return err == nil 37 | } 38 | -------------------------------------------------------------------------------- /backend/pkgs/hasher/password_test.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import "testing" 4 | 5 | func TestHashPassword(t *testing.T) { 6 | t.Parallel() 7 | type args struct { 8 | password string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | wantErr bool 14 | }{ 15 | { 16 | name: "letters_and_numbers", 17 | args: args{ 18 | password: "password123456788", 19 | }, 20 | }, 21 | { 22 | name: "letters_number_and_special", 23 | args: args{ 24 | password: "!2afj3214pofajip3142j;fa", 25 | }, 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | got, err := HashPassword(tt.args.password) 31 | if (err != nil) != tt.wantErr { 32 | t.Errorf("HashPassword() error = %v, wantErr %v", err, tt.wantErr) 33 | return 34 | } 35 | if !CheckPasswordHash(tt.args.password, got) { 36 | t.Errorf("CheckPasswordHash() failed to validate password=%v against hash=%v", tt.args.password, got) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/pkgs/hasher/token.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "encoding/base32" 7 | ) 8 | 9 | type Token struct { 10 | Raw string 11 | Hash []byte 12 | } 13 | 14 | func GenerateToken() Token { 15 | randomBytes := make([]byte, 16) 16 | _, _ = rand.Read(randomBytes) 17 | 18 | plainText := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) 19 | hash := HashToken(plainText) 20 | 21 | return Token{ 22 | Raw: plainText, 23 | Hash: hash, 24 | } 25 | } 26 | 27 | func HashToken(plainTextToken string) []byte { 28 | hash := sha256.Sum256([]byte(plainTextToken)) 29 | return hash[:] 30 | } 31 | -------------------------------------------------------------------------------- /backend/pkgs/hasher/token_test.go: -------------------------------------------------------------------------------- 1 | package hasher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | const ITERATIONS = 200 10 | 11 | func Test_NewToken(t *testing.T) { 12 | t.Parallel() 13 | tokens := make([]Token, ITERATIONS) 14 | for i := 0; i < ITERATIONS; i++ { 15 | tokens[i] = GenerateToken() 16 | } 17 | 18 | // Check if they are unique 19 | for i := 0; i < 5; i++ { 20 | for j := i + 1; j < 5; j++ { 21 | if tokens[i].Raw == tokens[j].Raw { 22 | t.Errorf("NewToken() failed to generate unique tokens") 23 | } 24 | } 25 | } 26 | } 27 | 28 | func Test_HashToken_CheckTokenHash(t *testing.T) { 29 | t.Parallel() 30 | for i := 0; i < ITERATIONS; i++ { 31 | token := GenerateToken() 32 | 33 | // Check raw text is reltively random 34 | for j := 0; j < 5; j++ { 35 | assert.NotEqual(t, token.Raw, GenerateToken().Raw) 36 | } 37 | 38 | // Check token length is less than 32 characters 39 | assert.Less(t, len(token.Raw), 32) 40 | 41 | // Check hash is the same 42 | assert.Equal(t, token.Hash, HashToken(token.Raw)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/pkgs/mailer/message.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import "net/mail" 4 | 5 | type Message struct { 6 | Subject string 7 | To mail.Address 8 | From mail.Address 9 | Body string 10 | } 11 | 12 | type MessageBuilder struct { 13 | subject string 14 | to mail.Address 15 | from mail.Address 16 | body string 17 | } 18 | 19 | func NewMessageBuilder() *MessageBuilder { 20 | return &MessageBuilder{} 21 | } 22 | 23 | func (mb *MessageBuilder) Build() *Message { 24 | return &Message{ 25 | Subject: mb.subject, 26 | To: mb.to, 27 | From: mb.from, 28 | Body: mb.body, 29 | } 30 | } 31 | 32 | func (mb *MessageBuilder) SetSubject(subject string) *MessageBuilder { 33 | mb.subject = subject 34 | return mb 35 | } 36 | 37 | func (mb *MessageBuilder) SetTo(name, to string) *MessageBuilder { 38 | mb.to = mail.Address{ 39 | Name: name, 40 | Address: to, 41 | } 42 | return mb 43 | } 44 | 45 | func (mb *MessageBuilder) SetFrom(name, from string) *MessageBuilder { 46 | mb.from = mail.Address{ 47 | Name: name, 48 | Address: from, 49 | } 50 | return mb 51 | } 52 | 53 | func (mb *MessageBuilder) SetBody(body string) *MessageBuilder { 54 | mb.body = body 55 | return mb 56 | } 57 | -------------------------------------------------------------------------------- /backend/pkgs/mailer/message_test.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_MessageBuilder(t *testing.T) { 10 | t.Parallel() 11 | 12 | mb := NewMessageBuilder(). 13 | SetBody("Hello World!"). 14 | SetSubject("Hello"). 15 | SetTo("John Doe", "john@doe.com"). 16 | SetFrom("Jane Doe", "jane@doe.com") 17 | 18 | msg := mb.Build() 19 | 20 | assert.Equal(t, "Hello", msg.Subject) 21 | assert.Equal(t, "Hello World!", msg.Body) 22 | assert.Equal(t, "John Doe", msg.To.Name) 23 | assert.Equal(t, "john@doe.com", msg.To.Address) 24 | assert.Equal(t, "Jane Doe", msg.From.Name) 25 | assert.Equal(t, "jane@doe.com", msg.From.Address) 26 | } 27 | -------------------------------------------------------------------------------- /backend/pkgs/mailer/test-mailer-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "", 3 | "port": 465, 4 | "username": "", 5 | "password": "", 6 | "from": "" 7 | } -------------------------------------------------------------------------------- /backend/pkgs/set/set.go: -------------------------------------------------------------------------------- 1 | // Package set provides a simple set implementation. 2 | package set 3 | 4 | type key interface { 5 | comparable 6 | } 7 | 8 | type Set[T key] struct { 9 | mp map[T]struct{} 10 | } 11 | 12 | func Make[T key](size int) Set[T] { 13 | return Set[T]{ 14 | mp: make(map[T]struct{}, size), 15 | } 16 | } 17 | 18 | func New[T key](v ...T) Set[T] { 19 | mp := make(map[T]struct{}, len(v)) 20 | 21 | s := Set[T]{mp} 22 | 23 | s.Insert(v...) 24 | return s 25 | } 26 | 27 | func (s Set[T]) Insert(v ...T) { 28 | for _, e := range v { 29 | s.mp[e] = struct{}{} 30 | } 31 | } 32 | 33 | func (s Set[T]) Remove(v ...T) { 34 | for _, e := range v { 35 | delete(s.mp, e) 36 | } 37 | } 38 | 39 | func (s Set[T]) Contains(v T) bool { 40 | _, ok := s.mp[v] 41 | return ok 42 | } 43 | 44 | func (s Set[T]) ContainsAll(v ...T) bool { 45 | for _, e := range v { 46 | if !s.Contains(e) { 47 | return false 48 | } 49 | } 50 | return true 51 | } 52 | 53 | func (s Set[T]) Slice() []T { 54 | slice := make([]T, 0, len(s.mp)) 55 | for k := range s.mp { 56 | slice = append(slice, k) 57 | } 58 | return slice 59 | } 60 | 61 | func (s Set[T]) Len() int { 62 | return len(s.mp) 63 | } 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | homebox: 3 | image: homebox 4 | build: 5 | context: . 6 | dockerfile: ./Dockerfile 7 | args: 8 | - COMMIT=head 9 | - BUILD_TIME=0001-01-01T00:00:00Z 10 | ports: 11 | - 3100:7745 12 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import { h } from 'vue' 3 | import type { Theme } from 'vitepress' 4 | import DefaultTheme from 'vitepress/theme' 5 | import './style.css' 6 | 7 | export default { 8 | extends: DefaultTheme, 9 | Layout: () => { 10 | return h(DefaultTheme.Layout, null, { 11 | // https://vitepress.dev/guide/extending-default-theme#layout-slots 12 | }) 13 | }, 14 | enhanceApp({ app, router, siteData }) { 15 | // ... 16 | } 17 | } satisfies Theme 18 | -------------------------------------------------------------------------------- /docs/en/analytics/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | aside: false 3 | --- 4 | 5 | ## What is This? 6 | We collect non-identifying information from users of Homebox that have opted in to analytics collection. By default users do not send us anything, however once opted in that data gets sent to our own Plausibe instance and the data below is live from that instance. 7 | 8 | We make this data public so that everyone knows exactly what's being collected, and so that they can see the data we see as it helps us make some decisions. 9 | 10 | ## Current Analytics Collected 11 | 12 | 13 |
Stats powered by Plausible Analytics hosted on our own instance in the UK
14 | -------------------------------------------------------------------------------- /docs/en/images/home-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/docs/en/images/home-screen.png -------------------------------------------------------------------------------- /docs/en/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | > [!TIP] 4 | > If you're currently running the original version of Homebox ([https://github.com/hay-kot/homebox](https://github.com/hay-kot/homebox)) switching is easy, just follow the instructions in the [Migration Guide](./migration) to switch to the new version. 5 | 1. Install Homebox either by using [the latest Docker image](./installation#docker), or by downloading the correct executable for your Operating System from the [Releases](https://github.com/sysadminsmedia/homebox/releases). (See [Installation](./installation) for more details) 6 | 7 | 2. Browse to `http://SERVER_IP:3100` (if Using Docker) or `http://SERVER_IP:7745` (if installed locally) to access the included web User Interface. 8 | 9 | 3. Register your first user. 10 | 11 | 4. Login with the user you just created and start adding your locations and items! 12 | 13 | > [!TIP] 14 | > If you want other users to see your items and locations, they will need to sign up using your invite link, otherwise they will only see their own items. Go to the **Profile** section in the left navigation bar and under **User Profile**, click **Generate Invite Link**. -------------------------------------------------------------------------------- /docs/en/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade 2 | 3 | ## From v0.17.x to v0.18+ 4 | 5 | ::: danger Breaking Changes 6 | This upgrade process involves some potentially breaking changes, please review this documentation carefully before beginning the upgrade process, and follow it closely during your upgrade. 7 | ::: 8 | 9 | ### Configuration Changes 10 | #### Database Configuration 11 | - `HBOX_STORAGE_SQLITE_URL` has been replaced by `HBOX_DATABASE_SQLITE_PATH` 12 | - `HBOX_DATABASE_DRIVER` has been added to set the database type, valid options are `sqlite3` and `postgres` 13 | - `HBOX_DATABASE_HOST`, `HBOX_DATABASE_PORT`, `HBOX_DATABASE_USERNAME`, `HBOX_DATABASE_DATABASE`, and `HBOX_DATABASE_SSL_MODE` have been added to configure postgres connection options. 14 | 15 | ::: tip 16 | If you don't have `HBOX_STORAGE_SQLITE_URL` set, you can ignore this change, as the default value for `HBOX_DATABASE_DRIVER` is `sqlite3`, and the default value for `HBOX_DATABASE_SQLITE_PATH` is the same as the old `HBOX_STORAGE_SQLITE_URL` value. 17 | ::: -------------------------------------------------------------------------------- /docs/public/_redirects: -------------------------------------------------------------------------------- 1 | / /en/ 302 2 | 3 | # This is an example for a french redirect 4 | # /* /fr/:splat 302 Language=fr -------------------------------------------------------------------------------- /docs/public/homebox-email-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/docs/public/homebox-email-banner.jpg -------------------------------------------------------------------------------- /frontend/.nuxtignore: -------------------------------------------------------------------------------- 1 | pages/**/*.ts -------------------------------------------------------------------------------- /frontend/app.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "default", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "tailwind.config.js", 7 | "css": "assets/css/main.css", 8 | "baseColor": "slate", 9 | "cssVariables": true, 10 | "prefix": "" 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /frontend/components/Base/Container.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/Base/SectionHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /frontend/components/DetailAction.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 40 | -------------------------------------------------------------------------------- /frontend/components/Form/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | -------------------------------------------------------------------------------- /frontend/components/Item/View/Table.types.ts: -------------------------------------------------------------------------------- 1 | import type { ItemSummary } from "~~/lib/api/types/data-contracts"; 2 | 3 | export type TableHeaderType = { 4 | text: string; 5 | value: keyof ItemSummary; 6 | sortable?: boolean; 7 | align?: "left" | "center" | "right"; 8 | enabled: boolean; 9 | type?: "price" | "boolean" | "name" | "location" | "date"; 10 | }; 11 | 12 | export type TableData = Record; 13 | -------------------------------------------------------------------------------- /frontend/components/Location/Tree/Root.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/components/Location/Tree/tree-state.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from "vue"; 2 | 3 | type TreeState = Record; 4 | 5 | const store: Record> = {}; 6 | 7 | export function newTreeKey(): string { 8 | return Math.random().toString(36).substring(2); 9 | } 10 | 11 | export function useTreeState(key: string): Ref { 12 | if (!store[key]) { 13 | store[key] = ref({}); 14 | } 15 | 16 | return store[key]; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/components/ModalConfirm.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 38 | -------------------------------------------------------------------------------- /frontend/components/global/Currency.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 46 | -------------------------------------------------------------------------------- /frontend/components/global/DateTime.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | -------------------------------------------------------------------------------- /frontend/components/global/DropZone.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/components/global/PasswordScore.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/components/global/Spacer.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /frontend/components/global/StatCard/StatCard.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/components/global/StatCard/types.ts: -------------------------------------------------------------------------------- 1 | export type StatsFormat = "currency" | "number" | "percent"; 2 | -------------------------------------------------------------------------------- /frontend/components/global/Subtitle.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/components/global/Table.types.ts: -------------------------------------------------------------------------------- 1 | export type TableHeader = { 2 | text: string; 3 | value: string; 4 | sortable?: boolean; 5 | align?: "left" | "center" | "right"; 6 | }; 7 | 8 | export type TableData = Record; 9 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialog.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogAction.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogCancel.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogDescription.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/AlertDialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/alert-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AlertDialog } from './AlertDialog.vue' 2 | export { default as AlertDialogAction } from './AlertDialogAction.vue' 3 | export { default as AlertDialogCancel } from './AlertDialogCancel.vue' 4 | export { default as AlertDialogContent } from './AlertDialogContent.vue' 5 | export { default as AlertDialogDescription } from './AlertDialogDescription.vue' 6 | export { default as AlertDialogFooter } from './AlertDialogFooter.vue' 7 | export { default as AlertDialogHeader } from './AlertDialogHeader.vue' 8 | export { default as AlertDialogTitle } from './AlertDialogTitle.vue' 9 | export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue' 10 | -------------------------------------------------------------------------------- /frontend/components/ui/badge/Badge.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Badge } from './Badge.vue' 4 | 5 | export const badgeVariants = cva( 6 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | }, 23 | ) 24 | 25 | export type BadgeVariants = VariantProps 26 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbEllipsis.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbLink.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbPage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/BreadcrumbSeparator.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/breadcrumb/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Breadcrumb } from './Breadcrumb.vue' 2 | export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue' 3 | export { default as BreadcrumbItem } from './BreadcrumbItem.vue' 4 | export { default as BreadcrumbLink } from './BreadcrumbLink.vue' 5 | export { default as BreadcrumbList } from './BreadcrumbList.vue' 6 | export { default as BreadcrumbPage } from './BreadcrumbPage.vue' 7 | export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue' 8 | -------------------------------------------------------------------------------- /frontend/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | -------------------------------------------------------------------------------- /frontend/components/ui/button/ButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | -------------------------------------------------------------------------------- /frontend/components/ui/card/Card.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/card/CardContent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/card/CardDescription.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/card/CardFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/card/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/card/CardTitle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | -------------------------------------------------------------------------------- /frontend/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Card } from './Card.vue' 2 | export { default as CardContent } from './CardContent.vue' 3 | export { default as CardDescription } from './CardDescription.vue' 4 | export { default as CardFooter } from './CardFooter.vue' 5 | export { default as CardHeader } from './CardHeader.vue' 6 | export { default as CardTitle } from './CardTitle.vue' 7 | -------------------------------------------------------------------------------- /frontend/components/ui/checkbox/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Checkbox } from './Checkbox.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/command/Command.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 31 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandDialog.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandEmpty.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandGroup.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandInput.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 34 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandItem.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 27 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandSeparator.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | -------------------------------------------------------------------------------- /frontend/components/ui/command/CommandShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/command/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Command } from './Command.vue' 2 | export { default as CommandDialog } from './CommandDialog.vue' 3 | export { default as CommandEmpty } from './CommandEmpty.vue' 4 | export { default as CommandGroup } from './CommandGroup.vue' 5 | export { default as CommandInput } from './CommandInput.vue' 6 | export { default as CommandItem } from './CommandItem.vue' 7 | export { default as CommandList } from './CommandList.vue' 8 | export { default as CommandSeparator } from './CommandSeparator.vue' 9 | export { default as CommandShortcut } from './CommandShortcut.vue' 10 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog-provider/index.ts: -------------------------------------------------------------------------------- 1 | export { useDialog, useDialogHotkey } from "./utils"; 2 | export { default as DialogProvider } from "./DialogProvider.vue"; 3 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogClose.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogDescription.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/DialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Dialog } from './Dialog.vue' 2 | export { default as DialogClose } from './DialogClose.vue' 3 | export { default as DialogContent } from './DialogContent.vue' 4 | export { default as DialogDescription } from './DialogDescription.vue' 5 | export { default as DialogFooter } from './DialogFooter.vue' 6 | export { default as DialogHeader } from './DialogHeader.vue' 7 | export { default as DialogScrollContent } from './DialogScrollContent.vue' 8 | export { default as DialogTitle } from './DialogTitle.vue' 9 | export { default as DialogTrigger } from './DialogTrigger.vue' 10 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/Drawer.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerDescription.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerOverlay.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/DrawerTitle.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Drawer } from './Drawer.vue' 2 | export { default as DrawerContent } from './DrawerContent.vue' 3 | export { default as DrawerDescription } from './DrawerDescription.vue' 4 | export { default as DrawerFooter } from './DrawerFooter.vue' 5 | export { default as DrawerHeader } from './DrawerHeader.vue' 6 | export { default as DrawerOverlay } from './DrawerOverlay.vue' 7 | export { default as DrawerTitle } from './DrawerTitle.vue' 8 | export { DrawerClose, DrawerPortal, DrawerTrigger } from 'vaul-vue' 9 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuSub.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DropdownMenu } from "./DropdownMenu.vue"; 2 | 3 | export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"; 4 | export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"; 5 | export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"; 6 | export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"; 7 | export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"; 8 | export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"; 9 | export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"; 10 | export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"; 11 | export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"; 12 | export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"; 13 | export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"; 14 | export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"; 15 | export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"; 16 | export { DropdownMenuPortal } from "reka-ui"; 17 | -------------------------------------------------------------------------------- /frontend/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 33 | -------------------------------------------------------------------------------- /frontend/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Input } from "./Input.vue"; 2 | -------------------------------------------------------------------------------- /frontend/components/ui/label/Label.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /frontend/components/ui/label/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Label } from './Label.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/PaginationEllipsis.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/PaginationFirst.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/PaginationLast.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/PaginationNext.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/PaginationPrev.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PaginationEllipsis } from './PaginationEllipsis.vue' 2 | export { default as PaginationFirst } from './PaginationFirst.vue' 3 | export { default as PaginationLast } from './PaginationLast.vue' 4 | export { default as PaginationNext } from './PaginationNext.vue' 5 | export { default as PaginationPrev } from './PaginationPrev.vue' 6 | export { 7 | PaginationRoot as Pagination, 8 | PaginationList, 9 | PaginationListItem, 10 | } from 'reka-ui' 11 | -------------------------------------------------------------------------------- /frontend/components/ui/popover/Popover.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /frontend/components/ui/popover/PopoverTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/popover/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Popover } from './Popover.vue' 2 | export { default as PopoverContent } from './PopoverContent.vue' 3 | export { default as PopoverTrigger } from './PopoverTrigger.vue' 4 | -------------------------------------------------------------------------------- /frontend/components/ui/progress/Progress.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 37 | -------------------------------------------------------------------------------- /frontend/components/ui/progress/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Progress } from './Progress.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/select/Select.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectItemText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectScrollDownButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectScrollUpButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectSeparator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /frontend/components/ui/select/SelectValue.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue' 2 | export { default as SelectContent } from './SelectContent.vue' 3 | export { default as SelectGroup } from './SelectGroup.vue' 4 | export { default as SelectItem } from './SelectItem.vue' 5 | export { default as SelectItemText } from './SelectItemText.vue' 6 | export { default as SelectLabel } from './SelectLabel.vue' 7 | export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue' 8 | export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue' 9 | export { default as SelectSeparator } from './SelectSeparator.vue' 10 | export { default as SelectTrigger } from './SelectTrigger.vue' 11 | export { default as SelectValue } from './SelectValue.vue' 12 | -------------------------------------------------------------------------------- /frontend/components/ui/separator/Separator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | -------------------------------------------------------------------------------- /frontend/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Separator } from "./Separator.vue"; 2 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/Sheet.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetClose.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetDescription.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetHeader.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/sheet/SheetTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/shortcut/Shortcut.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | -------------------------------------------------------------------------------- /frontend/components/ui/shortcut/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Shortcut } from './Shortcut.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarContent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarGroup.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarGroupAction.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarGroupContent.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarGroupLabel.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarInput.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarInset.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenu.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuBadge.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 28 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuButtonChild.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 34 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuLink.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuSkeleton.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuSub.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarMenuSubItem.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarSeparator.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/SidebarTrigger.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/sidebar/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, Ref } from "vue"; 2 | import { createContext } from "reka-ui"; 3 | 4 | export const SIDEBAR_COOKIE_NAME = "sidebar:state"; 5 | export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; 6 | export const SIDEBAR_WIDTH = "16rem"; 7 | export const SIDEBAR_WIDTH_MOBILE = "18rem"; 8 | export const SIDEBAR_WIDTH_ICON = "3.5rem"; 9 | export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; 10 | 11 | export const [useSidebar, provideSidebarContext] = createContext<{ 12 | state: ComputedRef<"expanded" | "collapsed">; 13 | open: Ref; 14 | setOpen: (value: boolean) => void; 15 | isMobile: Ref; 16 | openMobile: Ref; 17 | setOpenMobile: (value: boolean) => void; 18 | toggleSidebar: () => void; 19 | }>("Sidebar"); 20 | -------------------------------------------------------------------------------- /frontend/components/ui/skeleton/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Skeleton } from "./Skeleton.vue"; 2 | -------------------------------------------------------------------------------- /frontend/components/ui/sonner/Sonner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from './Sonner.vue' 2 | export { toast } from './toast' -------------------------------------------------------------------------------- /frontend/components/ui/sonner/toast.ts: -------------------------------------------------------------------------------- 1 | import { toast as internalToast } from "vue-sonner"; 2 | 3 | // triggering too many toasts at once can cause the toaster to not render properly https://github.com/xiaoluoboding/vue-sonner/issues/98 4 | 5 | const wrapToast = any>(fn: T): ((...args: Parameters) => Promise>) => { 6 | return (...args: Parameters) => 7 | new Promise(resolve => { 8 | setTimeout(() => resolve(fn(...args)), 0); 9 | }); 10 | }; 11 | 12 | const toast = (...args: Parameters) => internalToast(...args); 13 | 14 | toast.success = wrapToast(internalToast.success); 15 | toast.info = wrapToast(internalToast.info); 16 | toast.warning = wrapToast(internalToast.warning); 17 | toast.error = wrapToast(internalToast.error); 18 | toast.message = wrapToast(internalToast.message); 19 | 20 | export { toast }; 21 | -------------------------------------------------------------------------------- /frontend/components/ui/switch/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Switch } from './Switch.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/table/Table.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableBody.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableCaption.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableCell.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableEmpty.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 38 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableHead.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/TableRow.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/table/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Table } from './Table.vue' 2 | export { default as TableBody } from './TableBody.vue' 3 | export { default as TableCaption } from './TableCaption.vue' 4 | export { default as TableCell } from './TableCell.vue' 5 | export { default as TableEmpty } from './TableEmpty.vue' 6 | export { default as TableFooter } from './TableFooter.vue' 7 | export { default as TableHead } from './TableHead.vue' 8 | export { default as TableHeader } from './TableHeader.vue' 9 | export { default as TableRow } from './TableRow.vue' 10 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/TagsInput.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/TagsInputInput.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/TagsInputItem.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/TagsInputItemDelete.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/TagsInputItemText.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /frontend/components/ui/tags-input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TagsInput } from './TagsInput.vue' 2 | export { default as TagsInputInput } from './TagsInputInput.vue' 3 | export { default as TagsInputItem } from './TagsInputItem.vue' 4 | export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue' 5 | export { default as TagsInputItemText } from './TagsInputItemText.vue' 6 | -------------------------------------------------------------------------------- /frontend/components/ui/textarea/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Textarea } from './Textarea.vue' 2 | -------------------------------------------------------------------------------- /frontend/components/ui/tooltip/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /frontend/components/ui/tooltip/TooltipProvider.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/tooltip/TooltipTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tooltip } from "./Tooltip.vue"; 2 | export { default as TooltipContent } from "./TooltipContent.vue"; 3 | export { default as TooltipProvider } from "./TooltipProvider.vue"; 4 | export { default as TooltipTrigger } from "./TooltipTrigger.vue"; 5 | -------------------------------------------------------------------------------- /frontend/composables/use-defer.ts: -------------------------------------------------------------------------------- 1 | type DeferFunction = (...args: TArgs) => TReturn; 2 | 3 | // useDefer is a function that takes a function and returns a function that 4 | // calls the original function and then calls the onComplete function. 5 | export function useDefer( 6 | onComplete: (...args: TArgs) => void, 7 | func: DeferFunction 8 | ): DeferFunction { 9 | return (...args: TArgs) => { 10 | let result: TReturn; 11 | try { 12 | result = func(...args); 13 | } finally { 14 | onComplete(...args); 15 | } 16 | 17 | return result; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/composables/use-item-search.ts: -------------------------------------------------------------------------------- 1 | import type { ItemSummary, LabelSummary, LocationSummary } from "~~/lib/api/types/data-contracts"; 2 | import type { UserClient } from "~~/lib/api/user"; 3 | 4 | type SearchOptions = { 5 | immediate?: boolean; 6 | }; 7 | 8 | export function useItemSearch(client: UserClient, opts?: SearchOptions) { 9 | const query = ref(""); 10 | const locations = ref([]); 11 | const labels = ref([]); 12 | const results = ref([]); 13 | const includeArchived = ref(false); 14 | 15 | watchDebounced(query, search, { debounce: 250, maxWait: 1000 }); 16 | async function search() { 17 | const locIds = locations.value.map(l => l.id); 18 | const labelIds = labels.value.map(l => l.id); 19 | 20 | const { data, error } = await client.items.getAll({ 21 | q: query.value, 22 | locations: locIds, 23 | labels: labelIds, 24 | includeArchived: includeArchived.value, 25 | }); 26 | if (error) { 27 | return; 28 | } 29 | results.value = data.items; 30 | } 31 | 32 | if (opts?.immediate) { 33 | search(); 34 | } 35 | 36 | return { 37 | query, 38 | results, 39 | locations, 40 | labels, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/composables/use-min-loader.ts: -------------------------------------------------------------------------------- 1 | import type { WritableComputedRef } from "vue"; 2 | 3 | export function useMinLoader(ms = 500): WritableComputedRef { 4 | const loading = ref(false); 5 | 6 | const locked = ref(false); 7 | 8 | const minLoading = computed({ 9 | get: () => loading.value, 10 | set: value => { 11 | if (value) { 12 | loading.value = true; 13 | 14 | if (!locked.value) { 15 | locked.value = true; 16 | setTimeout(() => { 17 | locked.value = false; 18 | }, ms); 19 | } 20 | } 21 | 22 | if (!value && !locked.value) { 23 | loading.value = false; 24 | } else if (!value && locked.value) { 25 | setTimeout(() => { 26 | loading.value = false; 27 | }, ms); 28 | } 29 | }, 30 | }); 31 | return minLoading; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/composables/use-password-score.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, Ref } from "vue"; 2 | import { scorePassword } from "~~/lib/passwords"; 3 | 4 | export interface PasswordScore { 5 | score: ComputedRef; 6 | message: ComputedRef; 7 | isValid: ComputedRef; 8 | } 9 | 10 | export function usePasswordScore(pw: Ref, min = 30): PasswordScore { 11 | const score = computed(() => { 12 | return scorePassword(pw.value) || 0; 13 | }); 14 | 15 | const message = computed(() => { 16 | if (score.value < 20) { 17 | return "Very weak"; 18 | } else if (score.value < 40) { 19 | return "Weak"; 20 | } else if (score.value < 60) { 21 | return "Good"; 22 | } else if (score.value < 80) { 23 | return "Strong"; 24 | } 25 | return "Very strong"; 26 | }); 27 | 28 | const isValid = computed(() => { 29 | return score.value >= min; 30 | }); 31 | 32 | return { 33 | score, 34 | isValid, 35 | message, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /frontend/composables/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { maybeUrl } from "./utils"; 3 | 4 | describe("maybeURL works as expected", () => { 5 | test("basic valid URL case", () => { 6 | const result = maybeUrl("https://example.com"); 7 | expect(result.isUrl).toBe(true); 8 | expect(result.url).toBe("https://example.com"); 9 | expect(result.text).toBe("https://example.com"); 10 | }); 11 | 12 | test("special URL syntax", () => { 13 | const result = maybeUrl("[My Text](http://example.com)"); 14 | expect(result.isUrl).toBe(true); 15 | expect(result.url).toBe("http://example.com"); 16 | expect(result.text).toBe("My Text"); 17 | }); 18 | 19 | test("not a url", () => { 20 | const result = maybeUrl("not a url"); 21 | expect(result.isUrl).toBe(false); 22 | expect(result.url).toBe(""); 23 | expect(result.text).toBe(""); 24 | }); 25 | 26 | test("malformed special syntax", () => { 27 | const result = maybeUrl("[My Text(http://example.com)"); 28 | expect(result.isUrl).toBe(false); 29 | expect(result.url).toBe(""); 30 | expect(result.text).toBe(""); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/error.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /frontend/global.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /frontend/layouts/empty.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /frontend/lib/api/__test__/public.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { factories } from "./factories"; 3 | 4 | describe("[GET] /api/v1/status", () => { 5 | test("server should respond", async () => { 6 | const api = factories.client.public(); 7 | const { response, data } = await api.status(); 8 | expect(response.status).toBe(200); 9 | expect(data.health).toBe(true); 10 | }); 11 | }); 12 | 13 | describe("first time user workflow (register, login, join group)", () => { 14 | const api = factories.client.public(); 15 | const userData = factories.user(); 16 | 17 | test("user should be able to register", async () => { 18 | const { response } = await api.register(userData); 19 | expect(response.status).toBe(204); 20 | }); 21 | 22 | test("user should be able to login", async () => { 23 | const { response, data } = await api.login(userData.email, userData.password); 24 | expect(response.status).toBe(200); 25 | expect(data.token).toBeTruthy(); 26 | 27 | // Cleanup 28 | const userApi = factories.client.user(data.token); 29 | { 30 | const { response } = await userApi.user.delete(); 31 | expect(response.status).toBe(204); 32 | } 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /frontend/lib/api/__test__/user/user.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { describe, expect, test } from "vitest"; 3 | import { factories } from "../factories"; 4 | 5 | describe("basic user workflows", () => { 6 | test("user should be able to change password", async () => { 7 | const { client, user } = await factories.client.singleUse(); 8 | const password = faker.internet.password(); 9 | 10 | // Change Password 11 | { 12 | const response = await client.user.changePassword(user.password, password); 13 | expect(response.error).toBeFalsy(); 14 | expect(response.status).toBe(204); 15 | } 16 | 17 | // Ensure New Login is Valid 18 | { 19 | const pub = factories.client.public(); 20 | const response = await pub.login(user.email, password); 21 | expect(response.error).toBeFalsy(); 22 | expect(response.status).toBe(200); 23 | } 24 | 25 | await client.user.delete(); 26 | }, 20000); 27 | }); 28 | -------------------------------------------------------------------------------- /frontend/lib/api/base/base-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { hasKey, parseDate } from "./base-api"; 3 | 4 | describe("hasKey works as expected", () => { 5 | test("hasKey returns true if the key exists", () => { 6 | const obj = { createdAt: "2021-01-01" }; 7 | expect(hasKey(obj, "createdAt")).toBe(true); 8 | }); 9 | 10 | test("hasKey returns false if the key does not exist", () => { 11 | const obj = { createdAt: "2021-01-01" }; 12 | expect(hasKey(obj, "updatedAt")).toBe(false); 13 | }); 14 | }); 15 | 16 | describe("parseDate should work as expected", () => { 17 | test("parseDate should set defaults", () => { 18 | const obj = { createdAt: "2021-01-01", updatedAt: "2021-01-01" }; 19 | const result = parseDate(obj); 20 | expect(result.createdAt).toBeInstanceOf(Date); 21 | expect(result.updatedAt).toBeInstanceOf(Date); 22 | }); 23 | 24 | test("parseDate should set passed in types", () => { 25 | const obj = { key1: "2021-01-01", key2: "2021-01-01" }; 26 | const result = parseDate(obj, ["key1", "key2"]); 27 | expect(result.key1).toBeInstanceOf(Date); 28 | expect(result.key2).toBeInstanceOf(Date); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/lib/api/base/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { route } from "."; 3 | 4 | describe("UrlBuilder", () => { 5 | it("basic query parameter", () => { 6 | const result = route("/test", { a: "b" }); 7 | expect(result).toBe("/api/v1/test?a=b"); 8 | }); 9 | 10 | it("multiple query parameters", () => { 11 | const result = route("/test", { a: "b", c: "d" }); 12 | expect(result).toBe("/api/v1/test?a=b&c=d"); 13 | }); 14 | 15 | it("no query parameters", () => { 16 | const result = route("/test"); 17 | expect(result).toBe("/api/v1/test"); 18 | }); 19 | 20 | it("list-like query parameters", () => { 21 | const result = route("/test", { a: ["b", "c"] }); 22 | expect(result).toBe("/api/v1/test?a=b&a=c"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /frontend/lib/api/base/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseAPI } from "./base-api"; 2 | export { route } from "./urls"; 3 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/actions.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { ActionAmountResult } from "../types/data-contracts"; 3 | 4 | export class ActionsAPI extends BaseAPI { 5 | ensureAssetIDs() { 6 | return this.http.post({ 7 | url: route("/actions/ensure-asset-ids"), 8 | }); 9 | } 10 | 11 | resetItemDateTimes() { 12 | return this.http.post({ 13 | url: route("/actions/zero-item-time-fields"), 14 | }); 15 | } 16 | 17 | ensureImportRefs() { 18 | return this.http.post({ 19 | url: route("/actions/ensure-import-refs"), 20 | }); 21 | } 22 | 23 | setPrimaryPhotos() { 24 | return this.http.post({ 25 | url: route("/actions/set-primary-photos"), 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/assets.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { ItemSummary } from "../types/data-contracts"; 3 | import type { PaginationResult } from "../types/non-generated"; 4 | 5 | export class AssetsApi extends BaseAPI { 6 | async get(id: string, page = 1, pageSize = 50) { 7 | return await this.http.get>({ 8 | url: route(`/assets/${id}`, { page, pageSize }), 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/group.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { 3 | CurrenciesCurrency, 4 | Group, 5 | GroupInvitation, 6 | GroupInvitationCreate, 7 | GroupUpdate, 8 | } from "../types/data-contracts"; 9 | 10 | export class GroupApi extends BaseAPI { 11 | createInvitation(data: GroupInvitationCreate) { 12 | return this.http.post({ 13 | url: route("/groups/invitations"), 14 | body: data, 15 | }); 16 | } 17 | 18 | update(data: GroupUpdate) { 19 | return this.http.put({ 20 | url: route("/groups"), 21 | body: data, 22 | }); 23 | } 24 | 25 | get() { 26 | return this.http.get({ 27 | url: route("/groups"), 28 | }); 29 | } 30 | 31 | currencies() { 32 | return this.http.get({ 33 | url: route("/currencies"), 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/labels.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { LabelCreate, LabelOut } from "../types/data-contracts"; 3 | 4 | export class LabelsApi extends BaseAPI { 5 | getAll() { 6 | return this.http.get({ url: route("/labels") }); 7 | } 8 | 9 | create(body: LabelCreate) { 10 | return this.http.post({ url: route("/labels"), body }); 11 | } 12 | 13 | get(id: string) { 14 | return this.http.get({ url: route(`/labels/${id}`) }); 15 | } 16 | 17 | delete(id: string) { 18 | return this.http.delete({ url: route(`/labels/${id}`) }); 19 | } 20 | 21 | update(id: string, body: LabelCreate) { 22 | return this.http.put({ url: route(`/labels/${id}`), body }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/locations.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { LocationOutCount, LocationCreate, LocationOut, LocationUpdate, TreeItem } from "../types/data-contracts"; 3 | 4 | export type LocationsQuery = { 5 | filterChildren: boolean; 6 | }; 7 | 8 | export type TreeQuery = { 9 | withItems: boolean; 10 | }; 11 | 12 | export class LocationsApi extends BaseAPI { 13 | getAll(q: LocationsQuery = { filterChildren: false }) { 14 | return this.http.get({ url: route("/locations", q) }); 15 | } 16 | 17 | getTree(tq = { withItems: false }) { 18 | return this.http.get({ url: route("/locations/tree", tq) }); 19 | } 20 | 21 | create(body: LocationCreate) { 22 | return this.http.post({ url: route("/locations"), body }); 23 | } 24 | 25 | get(id: string) { 26 | return this.http.get({ url: route(`/locations/${id}`) }); 27 | } 28 | 29 | delete(id: string) { 30 | return this.http.delete({ url: route(`/locations/${id}`) }); 31 | } 32 | 33 | update(id: string, body: LocationUpdate) { 34 | return this.http.put({ url: route(`/locations/${id}`), body }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/maintenance.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { 3 | MaintenanceEntry, 4 | MaintenanceEntryWithDetails, 5 | MaintenanceEntryUpdate, 6 | MaintenanceFilterStatus, 7 | } from "../types/data-contracts"; 8 | 9 | export interface MaintenanceFilters { 10 | status?: MaintenanceFilterStatus; 11 | } 12 | 13 | export class MaintenanceAPI extends BaseAPI { 14 | getAll(filters: MaintenanceFilters) { 15 | return this.http.get({ 16 | url: route(`/maintenance`, { status: filters.status?.toString() }), 17 | }); 18 | } 19 | 20 | delete(id: string) { 21 | return this.http.delete({ url: route(`/maintenance/${id}`) }); 22 | } 23 | 24 | update(id: string, data: MaintenanceEntryUpdate) { 25 | return this.http.put({ 26 | url: route(`/maintenance/${id}`), 27 | body: data, 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/notifiers.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { NotifierCreate, NotifierOut, NotifierUpdate } from "../types/data-contracts"; 3 | 4 | export class NotifiersAPI extends BaseAPI { 5 | getAll() { 6 | return this.http.get({ url: route("/notifiers") }); 7 | } 8 | 9 | create(body: NotifierCreate) { 10 | return this.http.post({ url: route("/notifiers"), body }); 11 | } 12 | 13 | update(id: string, body: NotifierUpdate) { 14 | if (body.url === "") { 15 | body.url = null; 16 | } 17 | 18 | return this.http.put({ url: route(`/notifiers/${id}`), body }); 19 | } 20 | 21 | delete(id: string) { 22 | return this.http.delete({ url: route(`/notifiers/${id}`) }); 23 | } 24 | 25 | test(url: string) { 26 | return this.http.post<{ url: string }, null>({ url: route(`/notifiers/test`), body: { url } }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/reports.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | 3 | export class ReportsAPI extends BaseAPI { 4 | billOfMaterialsURL(): string { 5 | return route("/reporting/bill-of-materials"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/lib/api/classes/users.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "../base"; 2 | import type { ChangePassword, UserOut } from "../types/data-contracts"; 3 | import type { Result } from "../types/non-generated"; 4 | 5 | export class UserApi extends BaseAPI { 6 | public self() { 7 | return this.http.get>({ url: route("/users/self") }); 8 | } 9 | 10 | public logout() { 11 | return this.http.post({ url: route("/users/logout") }); 12 | } 13 | 14 | public delete() { 15 | return this.http.delete({ url: route("/users/self") }); 16 | } 17 | 18 | public changePassword(current: string, newPassword: string) { 19 | return this.http.put({ 20 | url: route("/users/self/change-password"), 21 | body: { 22 | current, 23 | new: newPassword, 24 | }, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/lib/api/public.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPI, route } from "./base"; 2 | import type { APISummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts"; 3 | 4 | export type StatusResult = { 5 | health: boolean; 6 | versions: string[]; 7 | title: string; 8 | message: string; 9 | }; 10 | 11 | export class PublicApi extends BaseAPI { 12 | public status() { 13 | return this.http.get({ url: route("/status") }); 14 | } 15 | 16 | public login(username: string, password: string, stayLoggedIn = false) { 17 | return this.http.post({ 18 | url: route("/users/login"), 19 | body: { 20 | username, 21 | password, 22 | stayLoggedIn, 23 | }, 24 | }); 25 | } 26 | 27 | public register(body: UserRegistration) { 28 | return this.http.post({ url: route("/users/register"), body }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/lib/api/types/non-generated.ts: -------------------------------------------------------------------------------- 1 | export enum AttachmentTypes { 2 | Photo = "photo", 3 | Manual = "manual", 4 | Warranty = "warranty", 5 | Attachment = "attachment", 6 | Receipt = "receipt", 7 | } 8 | 9 | export type Result = { 10 | item: T; 11 | }; 12 | 13 | export interface PaginationResult { 14 | items: T[]; 15 | page: number; 16 | pageSize: number; 17 | total: number; 18 | } 19 | 20 | export interface ItemSummaryPaginationResult extends PaginationResult { 21 | totalPrice: number; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/lib/datelib/datelib.ts: -------------------------------------------------------------------------------- 1 | import { addDays } from "date-fns"; 2 | 3 | /* 4 | * Formats a date as a string 5 | * */ 6 | export function format(date: Date | string): string { 7 | if (typeof date === "string") { 8 | return date; 9 | } 10 | return date.toISOString().split("T")[0]; 11 | } 12 | 13 | export function zeroTime(date: Date): Date { 14 | return new Date( 15 | new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - date.getTimezoneOffset() * 60000 16 | ); 17 | } 18 | 19 | export function factorRange(offset: number = 7): [Date, Date] { 20 | const date = zeroTime(new Date()); 21 | 22 | return [date, addDays(date, offset)]; 23 | } 24 | 25 | export function factory(offset = 0): Date { 26 | if (offset) { 27 | return addDays(zeroTime(new Date()), offset); 28 | } 29 | 30 | return zeroTime(new Date()); 31 | } 32 | 33 | export function parse(yyyyMMdd: string): Date { 34 | const parts = yyyyMMdd.split("-"); 35 | return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/lib/passwords/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { scorePassword } from "."; 3 | 4 | describe("scorePassword tests", () => { 5 | test("flagged words should return negative number", () => { 6 | const flaggedWords = ["password", "homebox", "admin", "qwerty", "login"]; 7 | 8 | for (const word of flaggedWords) { 9 | expect(scorePassword(word)).toBe(0); 10 | } 11 | }); 12 | 13 | test("should return 0 for empty string", () => { 14 | expect(scorePassword("")).toBe(0); 15 | }); 16 | 17 | test("should return 0 for strings less than 6", () => { 18 | expect(scorePassword("12345")).toBe(0); 19 | }); 20 | 21 | test("should return positive number for long string", () => { 22 | const result = expect(scorePassword("123456")); 23 | result.toBeGreaterThan(0); 24 | result.toBeLessThan(31); 25 | }); 26 | 27 | test("should return max number for long string with all variations", () => { 28 | expect(scorePassword("3bYWcfYOwqxljqeOmQXTLlBwkrH6HV")).toBe(100); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /frontend/lib/requests/index.ts: -------------------------------------------------------------------------------- 1 | export { Requests, type TResponse } from "./requests"; 2 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/locales/lb-LU.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /frontend/locales/ta-IN.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "global": { 4 | "date_time": { 5 | "days": "நாட்கள்", 6 | "hour": "மணி", 7 | "hours": "மணிகள்", 8 | "in": "{0} இல்", 9 | "just-now": "இப்பொழுது", 10 | "last-month": "கடந்த மாதம்", 11 | "last-week": "கடந்த வாரம்" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | const ctx = useAuthContext(); 3 | const api = useUserApi(); 4 | const redirectTo = useState("authRedirect"); 5 | 6 | if (!ctx.isAuthorized()) { 7 | if (window.location.pathname !== "/") { 8 | console.debug("[middleware/auth] isAuthorized returned false, redirecting to /"); 9 | redirectTo.value = window.location.pathname; 10 | return navigateTo("/"); 11 | } 12 | } 13 | 14 | if (!ctx.user) { 15 | console.log("Fetching user data"); 16 | const { data, error } = await api.user.self(); 17 | if (error) { 18 | if (window.location.pathname !== "/") { 19 | console.debug("[middleware/user] user is null and fetch failed, redirecting to /"); 20 | redirectTo.value = window.location.pathname; 21 | return navigateTo("/"); 22 | } 23 | } 24 | 25 | ctx.user = data.item; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /frontend/pages/a/[id].vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /frontend/pages/home/statistics.ts: -------------------------------------------------------------------------------- 1 | import { useI18n } from "vue-i18n"; 2 | import type { UserClient } from "~~/lib/api/user"; 3 | 4 | type StatCard = { 5 | label: string; 6 | value: number; 7 | type: "currency" | "number"; 8 | }; 9 | 10 | export function statCardData(api: UserClient) { 11 | const { t } = useI18n(); 12 | 13 | const { data: statistics } = useAsyncData(async () => { 14 | const { data } = await api.stats.group(); 15 | return data; 16 | }); 17 | 18 | return computed(() => { 19 | return [ 20 | { 21 | label: t("home.total_value"), 22 | value: statistics.value?.totalItemPrice || 0, 23 | type: "currency", 24 | }, 25 | { 26 | label: t("home.total_items"), 27 | value: statistics.value?.totalItems || 0, 28 | type: "number", 29 | }, 30 | { 31 | label: t("home.total_locations"), 32 | value: statistics.value?.totalLocations || 0, 33 | type: "number", 34 | }, 35 | { 36 | label: t("home.total_labels"), 37 | value: statistics.value?.totalLabels || 0, 38 | type: "number", 39 | }, 40 | ] as StatCard[]; 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /frontend/pages/home/table.ts: -------------------------------------------------------------------------------- 1 | import type { UserClient } from "~~/lib/api/user"; 2 | 3 | export function itemsTable(api: UserClient) { 4 | const { data: items, refresh } = useAsyncData(async () => { 5 | const { data } = await api.items.getAll({ 6 | page: 1, 7 | pageSize: 5, 8 | orderBy: "createdAt", 9 | }); 10 | return data.items; 11 | }); 12 | 13 | onServerEvent(ServerEvent.ItemMutation, () => { 14 | console.log("item mutation"); 15 | refresh(); 16 | }); 17 | 18 | return computed(() => { 19 | return { 20 | items: items.value || [], 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/pages/item/[id]/index/maintenance.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /frontend/pages/maintenance.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /frontend/plugins/scroll.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(nuxtApp => { 2 | nuxtApp.hook("page:finish", () => { 3 | document.body.scrollTo({ top: 0 }); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /frontend/public/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/frontend/public/no-image.jpg -------------------------------------------------------------------------------- /frontend/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/frontend/public/pwa-192x192.png -------------------------------------------------------------------------------- /frontend/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sysadminsmedia/homebox/676b87d87e7d1ca1b6914af809b8fe7cec80f741/frontend/public/pwa-512x512.png -------------------------------------------------------------------------------- /frontend/public/set-theme.js: -------------------------------------------------------------------------------- 1 | try { 2 | console.log('Setting theme'); 3 | const theme = JSON.parse( 4 | localStorage.getItem('homebox/preferences/location') 5 | ).theme; 6 | if (theme) { 7 | document.documentElement.setAttribute('data-theme', theme); 8 | document.documentElement.classList.add('theme-' + theme); 9 | } 10 | } catch (e) { 11 | console.error('Failed to set theme', e); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/stores/labels.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import type { LabelOut } from "~~/lib/api/types/data-contracts"; 3 | 4 | export const useLabelStore = defineStore("labels", { 5 | state: () => ({ 6 | allLabels: null as LabelOut[] | null, 7 | client: useUserApi(), 8 | }), 9 | getters: { 10 | /** 11 | * labels represents the labels that are currently in the store. The store is 12 | * synched with the server by intercepting the API calls and updating on the 13 | * response. 14 | */ 15 | labels(state): LabelOut[] { 16 | if (state.allLabels === null) { 17 | this.client.labels.getAll().then(result => { 18 | if (result.error) { 19 | console.error(result.error); 20 | } 21 | 22 | this.allLabels = result.data; 23 | }); 24 | } 25 | return state.allLabels ?? []; 26 | }, 27 | }, 28 | actions: { 29 | async refresh() { 30 | const result = await this.client.labels.getAll(); 31 | if (result.error) { 32 | return result; 33 | } 34 | 35 | this.allLabels = result.data; 36 | return result; 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /frontend/test/config.ts: -------------------------------------------------------------------------------- 1 | export const PORT = "7745"; 2 | export const HOST = "http://127.0.0.1"; 3 | export const BASE_URL = HOST + ":" + PORT; 4 | -------------------------------------------------------------------------------- /frontend/test/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | testDir: "./e2e", 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 1, 8 | reporter: process.env.CI ? "blob" : "html", 9 | use: { 10 | baseURL: process.env.E2E_BASE_URL || "http://localhost:3000", 11 | trace: "on-all-retries", 12 | video: "retry-with-video", 13 | }, 14 | projects: [ 15 | { 16 | name: "chromium", 17 | use: { ...devices["Desktop Chrome"] }, 18 | }, 19 | { 20 | name: "firefox", 21 | use: { ...devices["Desktop Firefox"] }, 22 | }, 23 | { 24 | name: "webkit", 25 | use: { ...devices["Desktop Safari"] }, 26 | }, 27 | ], 28 | globalTeardown: require.resolve("./playwright.teardown"), 29 | }); 30 | -------------------------------------------------------------------------------- /frontend/test/playwright.teardown.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | function globalTeardown() { 4 | if (process.env.TEST_SHUTDOWN_API_SERVER) { 5 | const pc = exec("pkill -SIGTERM api"); // Kill background API process 6 | const fr = exec("pkill -SIGTERM task"); // Kill background Frontend process 7 | pc.stdout?.on("data", (data: void) => { 8 | console.log(`stdout: ${data}`); 9 | }); 10 | fr.stdout?.on("data", (data: void) => { 11 | console.log(`stdout: ${data}`); 12 | }); 13 | } 14 | } 15 | 16 | export default globalTeardown; 17 | -------------------------------------------------------------------------------- /frontend/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import * as config from "./config"; 3 | 4 | export const setup = () => { 5 | console.log("Starting Client Tests"); 6 | console.log({ 7 | PORT: config.PORT, 8 | HOST: config.HOST, 9 | BASE_URL: config.BASE_URL, 10 | }); 11 | }; 12 | 13 | export const teardown = () => { 14 | if (process.env.TEST_SHUTDOWN_API_SERVER) { 15 | const pc = exec("pkill -SIGTERM api"); // Kill background API process 16 | pc.stdout?.on("data", data => { 17 | console.log(`stdout: ${data}`); 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | // @ts-ignore 6 | test: { 7 | globalSetup: "./test/setup.ts", 8 | include: ["**/*.test.ts"], 9 | }, 10 | resolve: { 11 | alias: { 12 | "@": path.resolve(__dirname, ".."), 13 | "~~": path.resolve(__dirname, ".."), 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebox", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "docs:dev": "vitepress dev docs", 9 | "docs:build": "vitepress build docs", 10 | "docs:preview": "vitepress preview docs" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "AGPLv3", 15 | "devDependencies": { 16 | "vitepress": "^1.6.3" 17 | }, 18 | "packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0" 19 | } 20 | --------------------------------------------------------------------------------