-
-
Notifications
You must be signed in to change notification settings - Fork 321
Add purchase price per month to item details #1206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add purchase price per month to item details #1206
Conversation
|
Important Review skippedToo many files! This PR contains 158 files, which is 8 over the limit of 150. You can disable this status message by setting the
WalkthroughAdds tenant/collection (group) support across backend and frontend, changes attachment APIs and upload returns, introduces PurchasePricePerMonth on ItemOut, expands group management endpoints/UI (invitations, members, settings), adjusts middleware/context to support tenant scoping, and updates many frontend components, types, and locales to surface collections. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant V1Controller
participant GroupService
participant GroupRepo
participant DB
Client->>V1Controller: POST /groups/invitations/accept { token }
V1Controller->>GroupService: AcceptInvitation(ctx, token)
GroupService->>GroupRepo: InvitationAccept(ctx, hashedToken, ctx.UID)
GroupRepo->>DB: SELECT invitation, validate expiry & uses
DB-->>GroupRepo: invitation row
GroupRepo->>DB: INSERT user_groups(user_id, group_id)
GroupRepo->>DB: UPDATE invitations SET uses = uses - 1
DB-->>GroupRepo: commit
GroupRepo-->>GroupService: group
GroupService-->>V1Controller: group
V1Controller-->>Client: 200 { group }
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Security Recommendations
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
backend/internal/data/ent/item.gois excluded by!backend/internal/data/ent/**
📒 Files selected for processing (4)
backend/internal/data/repo/repo_items.gofrontend/lib/api/types/data-contracts.tsfrontend/locales/en.jsonfrontend/pages/item/[id]/index.vue
🧰 Additional context used
📓 Path-based instructions (10)
frontend/**/*.{ts,tsx,vue,js,jsx}
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Frontend TypeScript/Vue code must pass ESLint with a maximum of 1 warning in CI (
pnpm run lint:ci)
Files:
frontend/pages/item/[id]/index.vuefrontend/lib/api/types/data-contracts.ts
frontend/**/*.{ts,tsx,vue}
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Frontend code must pass TypeScript type checking with
task ui:checkbefore PR submission
Files:
frontend/pages/item/[id]/index.vuefrontend/lib/api/types/data-contracts.ts
frontend/pages/**/*.vue
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Page components must use file-based routing conventions in
frontend/pages/directoryPages in
pages/automatically become routes via file-based routing. Use square brackets for dynamic segments (e.g.,[id].vuefor:idroute parameter)
Files:
frontend/pages/item/[id]/index.vue
frontend/**/*.{vue,ts,tsx}
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Use Tailwind CSS utility classes for styling in Vue components instead of custom CSS
Files:
frontend/pages/item/[id]/index.vuefrontend/lib/api/types/data-contracts.ts
frontend/{components,pages,layouts}/**/*.vue
📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)
frontend/{components,pages,layouts}/**/*.vue: Use<script setup lang="ts">for all Vue component files with TypeScript
Use Tailwind CSS for styling Vue components
Files:
frontend/pages/item/[id]/index.vue
frontend/{components,pages,composables}/**/*.{vue,ts}
📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)
frontend/{components,pages,composables}/**/*.{vue,ts}: UseuseUserApi()composable for all API calls - provides typed, authenticated API client access
Use TypeScript types fromlib/api/types/data-contracts.tsfor API request/response objects (e.g.,ItemCreate,ItemOut)
Files:
frontend/pages/item/[id]/index.vue
frontend/{components,pages}/**/*.vue
📄 CodeRabbit inference engine (.github/instructions/frontend.instructions.md)
frontend/{components,pages}/**/*.vue: Use Shadcn-vue UI components fromcomponents/ui/for common UI elements
Use Pinia stores fromstores/for global state management (auth, preferences, etc.)
Files:
frontend/pages/item/[id]/index.vue
**/*.{ts,vue}
⚙️ CodeRabbit configuration file
**/*.{ts,vue}: Check for hardcoded strings in UI components that should be translatable.
Look for:
- String literals in Vue components (e.g. Click me)
- Alert messages, error messages, and user-facing text
- Placeholder text and labels
Files:
frontend/pages/item/[id]/index.vuefrontend/lib/api/types/data-contracts.ts
frontend/lib/api/types/**/*.ts
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Never manually edit generated TypeScript API types in
frontend/lib/api/types/; these are auto-generated from the backend and should be regenerated usingtask generateNever edit generated types in
lib/api/types/- they are auto-generated from backend Swagger docs. Runtask generateafter backend API changes to regenerate
Files:
frontend/lib/api/types/data-contracts.ts
backend/**/*.go
📄 CodeRabbit inference engine (.github/instructions/code.instructions.md)
Backend Go code must pass golangci-lint validation with no errors (6-minute timeout in CI)
Files:
backend/internal/data/repo/repo_items.go
🧠 Learnings (2)
📚 Learning: 2025-12-29T16:30:10.230Z
Learnt from: CR
Repo: sysadminsmedia/homebox PR: 0
File: .github/instructions/frontend.instructions.md:0-0
Timestamp: 2025-12-29T16:30:10.230Z
Learning: Applies to frontend/{components,pages,composables}/**/*.{vue,ts} : Use TypeScript types from `lib/api/types/data-contracts.ts` for API request/response objects (e.g., `ItemCreate`, `ItemOut`)
Applied to files:
frontend/lib/api/types/data-contracts.ts
📚 Learning: 2025-12-29T16:29:27.462Z
Learnt from: CR
Repo: sysadminsmedia/homebox PR: 0
File: .github/instructions/backend-internal-data.instructions.md:0-0
Timestamp: 2025-12-29T16:29:27.462Z
Learning: Use the Repository pattern for data access in `repo/` implementations with structured repository struct, input types (e.g., `ItemCreate`), output types (e.g., `ItemOut`), query types (e.g., `ItemQuery`), and mapper functions (e.g., `mapToItemOut`)
Applied to files:
backend/internal/data/repo/repo_items.go
🧬 Code graph analysis (1)
backend/internal/data/repo/repo_items.go (1)
backend/internal/data/types/date.go (1)
Date(19-19)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (15)
- GitHub Check: End-to-End Playwright Tests / E2E Playwright Testing 4/4
- GitHub Check: Frontend Tests / Integration Tests PGSQL 17
- GitHub Check: End-to-End Playwright Tests / E2E Playwright Testing 1/4
- GitHub Check: Frontend Tests / Integration Tests PGSQL 15
- GitHub Check: End-to-End Playwright Tests / E2E Playwright Testing 3/4
- GitHub Check: Frontend Tests / Integration Tests PGSQL 16
- GitHub Check: End-to-End Playwright Tests / E2E Playwright Testing 2/4
- GitHub Check: Backend Server Tests / Go
- GitHub Check: Frontend Tests / Integration Tests
- GitHub Check: build (linux/arm64, ubuntu-24.04-arm)
- GitHub Check: build (linux/amd64, ubuntu-latest)
- GitHub Check: build (linux/amd64, ubuntu-latest)
- GitHub Check: build (linux/arm64, ubuntu-24.04-arm)
- GitHub Check: build (linux/arm64, ubuntu-24.04-arm)
- GitHub Check: build (linux/amd64, ubuntu-latest)
🔇 Additional comments (5)
frontend/locales/en.json (1)
460-460: LGTM! Translation key properly placed.The new translation key follows naming conventions and is logically positioned adjacent to the existing
purchase_priceentry.frontend/pages/item/[id]/index.vue (1)
384-388: Implementation looks correct.The new
purchasePricePerMonthdetail entry follows the same pattern as existing currency fields and properly integrates with the translation system and empty-value filtering.Note: If
purchasePricePerMonthis legitimately0(e.g., for an item purchased at no cost), it will be filtered out whenpreferences.showEmptyis false due to thefilterZeroValuescall at line 398. This behavior is consistent with other numeric fields but may hide valid zero values.backend/internal/data/repo/repo_items.go (2)
173-175: LGTM! ItemOut struct properly extended.The new
PurchasePricePerMonthfield is correctly typed asfloat64and properly placed within the Purchase section with the appropriate JSON tag.
324-324: Assignment correct, but depends on fixing the calculation.The assignment of
pppmtoPurchasePricePerMonthis correct. However, this depends on fixing the division by zero issue in the calculation logic at lines 298-305.frontend/lib/api/types/data-contracts.ts (1)
654-654: This file was auto-generated; confirm regeneration occurred viatask generate.The
purchasePricePerMonth: numberfield is correctly added to the response type as a read-only, calculated field (derived aspurchasePrice / monthsSincePurchase). It appears only in the ItemOut response contract and is not present in ItemCreate/ItemUpdate input contracts, which is the correct structure. The file header confirms auto-generation via swagger-typescript-api, and the field aligns properly with the backend implementation where it's calculated at query time, not persisted. Ensuretask generatewas run after backend schema modifications to regenerate this contract.
|
What is this trying to solve?? Sorry but I'm a bit confused on what exact situation this is trying to solve in the real world? Is it deprecating assets? Some weird subscription based hardware? I'm just not entirely sure what this solution is trying to solve. |
Valid question. The intention is indeed similar to deprecating assets. It's simply serving the curiosity on how much an item actually costs per month, especially when viewed over time. A clear value add would be in calculating on TOC, i.e. including any maintenance & costs of supply parts. |
|
Sorry for the delay on getting to this. I still don't really get the point of this, but why the need for storing PurchasePricePerMonth, surely it could just be calculated on the frontend? I also think this should only be shown if both purchase price and purchase date are set and maybe if a checkbox is ticked. It does give me the idea of making custom fields that can be dyanmic, similar to excel. |
The intention is not to store / persist this value but to calculate it on the fly while reading an item's purchase data.
Valid. My motivation was on "if one is interested in this, adding a purchase date makes sense". Your logic kind of supports this.
I'm out on this (for now?). |
Oh yea, I get that now, was not reading that code correctly, I would still lean towards just calculating it on the frontend. |
Understandable. However, does that still hold, when this would potentially be extend to "TCO per month", i.e. via calculating a TOC as sum of all costs that an item accumulated over time (#1212)? IMHO in that case, both values could be calculated on the fly without being persisted. That calculation sounds more plausible to be done in the backend not frontend. |
I still think thats probably gonna be easier to calc on frontend, i just dont see a benefit on doing it on backend really. |
…sysadminsmedia#1221) Bumps the npm_and_yarn group with 1 update in the / directory: [preact](https://github.com/preactjs/preact). Updates `preact` from 10.28.1 to 10.28.2 - [Release notes](https://github.com/preactjs/preact/releases) - [Commits](preactjs/preact@10.28.1...10.28.2) --- updated-dependencies: - dependency-name: preact dependency-version: 10.28.2 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Add MQTT to Docker * Reduce layers
…nsmedia#1150) * feat(label-generator): Allow hiding "Homebox" text on labels * fix(label-generator): Use single const for blank lines * fix(label-generator): Use translated string instead of static text for logic * fix(label-generator): Updating replacement text changes labels dynamically * fix(label-generator): Blank line as static text/string instead of ref * fix(label-generator): Revert, standardize capitalization of `HomeBox` * fix(label-generator): Use more descriptive option labels * fix(label-generator): Use const for option values, fix naming * fix(label-generator): Match blank underscore line style when replacing `HomeBox` text
* Add default group handling and user-groups relationship - Introduced `default_group_id` field in the User model to manage user group defaults. - Updated user creation and update logic to utilize the new default group ID. - Implemented a many-to-many relationship between users and groups via a new `user_groups` junction table. - Refactored relevant queries and middleware to support tenant-based access using the default group. * Fix Sqlite migration (or at least make sure it doesn't wipe things * The basics of the app are working again now * Forgot to update the API stuff and data-contracts.ts * Fix golang test * New API endpoints for basic group management * Add member management routes * Fix some tests * Go lint things * Fix front-end tests for groups * fix: ensure all groups only returns groups user is part of * Add the swagger/openapi stuff * feat: begin adding frontend for collection * fix: collection id being wrong causes big problems * Lets try this for the new tenant switching and events. * Adds invitation acceptance and deletion endpoints * Return information about group after accepting invitation * Forgot the swag stuff * feat: join collection * feat: customize group name during creation * feat: refactor collection management to use new composable and remove old store * chore: make sure users can't join group they already exist in * weird merge issue or something? * feat: begin adding collection pages * feat: implement collection options pages * Limit user information returned on all user endpoint * feat: improvements on collection ui * feat: finish cleaning up frontend * fix: missing things * Fix notifiers for user showing across all groups instead of just group their in * fix: various things coderabbit had issues with * fix: code rabbit complaints, fixed some routes * fix: code rabbit issues BREAKING-CHANGE: routes for groups changed significantly * chore: forgot the openAPI stuff * fix: backend tests * feat: update backend for frontend changes * fix: incorrect url * fix: attachments on non-primary tenant and code rabbit fixes * [skip ci] fix: front-end lint * fix: remove random console.log of auth --------- Co-authored-by: tonyaellie <tonya@tokia.dev>
…adminsmedia#1243) * Initial plan * Optimize attachment upload and thumbnail processing to reduce RAM usage - Changed UploadFile to return UploadResult struct with contentType - Removed redundant file reading in Create method (was reading 128KB after upload) - Optimized CreateThumbnail to read orientation metadata once instead of per image type - Reduced number of bytes.NewReader creations from 8+ to 6 per thumbnail - All existing tests pass Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com> * Add additional RAM usage optimizations - Optimize CSV import to read only first 4KB for separator detection instead of entire file - Add LimitReader safety check in CreateThumbnail to cap reads at 100MB - Improve memory efficiency in CSV parsing using MultiReader - All tests pass Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com> * Optimize UploadFile to use streaming hash computation - Use io.MultiWriter to hash content (blake3 + md5) while buffering - Single pass through data instead of separate hash computations - More efficient memory usage pattern - All tests pass Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com> * Address code review feedback - Clarify comment about blake3.DeriveKey requiring full buffer - Add separatorDetectionBufferSize constant for CSV import - Improve code documentation and maintainability - All tests pass Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com> Co-authored-by: Matt <tankerkiller125@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 19
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (11)
frontend/components/global/CopyText.vue (1)
53-53: Hardcoded string should be translatable.The "Continue" text is hardcoded but should use the i18n translation function like the other strings in this dialog.
🌐 Suggested fix
- <AlertDialogAction>Continue</AlertDialogAction> + <AlertDialogAction>{{ $t("components.global.copy_text.continue") }}</AlertDialogAction>You'll also need to add the corresponding translation key to your locale files.
frontend/pages/profile.vue (2)
85-89: Minor: Loading state not reset on early return.If
passwordChange.isValidis false, the function returns early butpasswordChange.loadingremainstrue. While the button's:disabledcondition also checks!passwordChange.isValid, this could leave the UI in an inconsistent state.🔧 Suggested fix
async function changePassword() { passwordChange.loading = true; if (!passwordChange.isValid) { + passwordChange.loading = false; return; }
60-76: Security note: Account deletion flow is properly protected.The account deletion flow includes appropriate safeguards:
- Confirmation dialog before deletion
- Authenticated API call via
useUserApi()- User is logged out and redirected after successful deletion
One minor observation: the error toast at line 75 executes even after a successful deletion (lines 69-73 don't return after success). This could show a spurious error message.
🔧 Suggested fix
if (response?.status === 204) { toast.success(t("profile.toast.account_deleted")); auth.logout(api); navigateTo("/"); + return; } toast.error(t("profile.toast.failed_delete_account"));backend/app/api/handlers/v1/v1_ctrl_actions.go (1)
117-158: Security implementation is solid, but missing event publication for always-deleted items.Security practices are properly implemented:
- Demo mode restriction (line 119-120)
- Owner-only authorization check (line 126-127) aligns with RBAC requirements
- Tenant-scoped operation via
ctx.GIDHowever, there's a real inconsistency: Items are always wiped (unconditionally) in the repository layer, but no
EventItemMutationevent is published in the handler. In contrast, labels and locations are only deleted when their respective flags are true—and only then do they publish mutation events. Since items are unconditionally deleted, they should also publish anEventItemMutationevent to maintain consistency with other deletion flows and ensure UI subscribers receive notifications for the wiped items.Add an
EventItemMutationevent publication in the handler after the repository call succeeds, similar to the label and location event logic.backend/internal/core/services/reporting/import.go (1)
32-39: Logic bug in separator detection when both delimiters are present.The condition
tab > commareturns the wrong separator when both characters exist in the header. If comma appears at index 3 and tab at index 5,tab > commais true, so it returns tab—but comma appears first and should be chosen.The correct logic should pick the delimiter with the smaller non-negative index.
🐛 Proposed fix
switch { case comma == -1 && tab == -1: return 0, errors.New("could not determine separator") - case tab > comma: - return '\t', nil - default: + case comma == -1: + return '\t', nil + case tab == -1: return ',', nil + case tab < comma: + return '\t', nil + default: + return ',', nil }backend/app/api/handlers/v1/v1_ctrl_notifiers.go (1)
94-105: 🔒 Security: Missing URL validation in notifier test endpoint.The
HandlerNotifierTesthandler accepts arbitrary URLs without validating against the allowlist of supported notification schemes. The body struct only includesvalidate:"required", omitting the"shoutrrr"tag used elsewhere in the codebase. This allows authenticated users to:
- Probe internal services (
http://localhost:*,http://internal-service/)- Perform SSRF attacks against private IP ranges
The codebase already defines a
shoutrrrvalidator (inbackend/internal/sys/validate/validate.go) that restricts URLs to legitimate notification schemes (bark, discord, smtp, gotify, etc.). Apply this same validation to the test endpoint:Suggested fix
type body struct { URL string `json:"url" validate:"required,shoutrrr"` }backend/app/api/handlers/v1/v1_ctrl_items.go (1)
340-346: Missing tenant validation before CsvImport call.Unlike
HandleBillOfMaterialsExport(inv1_ctrl_reporting.goLines 25-27), this handler does not validate thattenant != uuid.Nilbefore proceeding. If the tenant context is missing, this could lead to data being imported without proper group scoping.Proposed fix
tenant := services.UseTenantCtx(r.Context()) + if tenant == uuid.Nil { + return validate.NewRequestError(errors.New("tenant required"), http.StatusBadRequest) + } + _, err = ctrl.svc.Items.CsvImport(r.Context(), tenant, file)You'll also need to add these imports if not already present:
import "errors"backend/app/api/handlers/v1/v1_ctrl_items_attachments.go (1)
164-168: TheIDvariable is extracted but unused in the DELETE path; consider validating it or adjusting the endpoint.Line 165 extracts the item ID, but it's not used in the DELETE case (lines 214-221). The
AttachmentDeletecall passes only the context, GID, and attachment ID—no item ID validation occurs. This contrasts with the PUT handler (line 233), which validates the item ID viaAttachmentUpdate.While the repo-level authorization (
attachment.HasItemWith(item.HasGroupWith(group.ID(gid)))) correctly prevents cross-group deletion, it does not validate that the attachment belongs to the specific item ID in the request path. This means a request to/v1/items/{wrong_id}/attachments/{attachment_id}would still succeed if the attachment exists in the same group under a different item.Security recommendation: Either validate that the attachment belongs to the specified item ID in the DELETE handler (consistent with PUT), or update the route to remove the unused item ID parameter (
DELETE /v1/attachments/{attachment_id}).backend/internal/data/repo/repo_item_attachments.go (3)
457-460: Bug: CreateThumbnail returns nil instead of error on transaction failure.When
r.db.Tx(ctx)fails, the function returnsnilinstead of the actual error. This silently swallows the error and makes debugging difficult.🐛 Suggested fix
func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmentId uuid.UUID, title string, path string) error { log.Debug().Msg("starting thumbnail creation") tx, err := r.db.Tx(ctx) if err != nil { - return nil + return err }
636-643: Copy/paste error in log message: says "avif" for HEIC decode.The error log message incorrectly references "avif" when decoding HEIC images.
🔧 Suggested fix
case contentType == "image/heic" || contentType == "image/heif": log.Debug().Msg("creating thumbnail for heic file") img, err := heic.Decode(bytes.NewReader(contentBytes)) if err != nil { - log.Err(err).Msg("failed to decode avif image") + log.Err(err).Msg("failed to decode heic image")
653-663: Copy/paste error in log message: says "avif" for JXL decode.The error log message incorrectly references "avif" when decoding JPEG XL images.
🔧 Suggested fix
case contentType == "image/jxl": log.Debug().Msg("creating thumbnail for jpegxl file") img, err := jpegxl.Decode(bytes.NewReader(contentBytes)) if err != nil { - log.Err(err).Msg("failed to decode avif image") + log.Err(err).Msg("failed to decode jpegxl image")
🤖 Fix all issues with AI agents
In `@backend/app/api/handlers/v1/v1_ctrl_group.go`:
- Around line 322-325: The errors.Is check is comparing against errors.New("user
already a member of this group") which creates a new instance and will never
match; define a sentinel error (e.g., var ErrAlreadyMember = errors.New("user
already a member of this group")) in the repo package (repo_group.go) and have
AcceptInvitation return that sentinel on that condition, then replace the
handler check errors.Is(err, errors.New(...)) with errors.Is(err,
repo.ErrAlreadyMember) in the GroupAcceptInvitation handler so the comparison
works correctly.
- Around line 293-301: HandleGroupInvitationsDelete currently lets any group
member delete an invitation; update it to enforce owner-only deletion by:
retrieve the request context (services.NewContext) as before, load the
invitation using its id (e.g., call ctrl.svc.Group.GetInvitation or equivalent)
to obtain the associated group ID, verify the caller is an owner of that group
(e.g., use a service method like ctrl.svc.Group.IsOwner(auth, groupID) or fetch
membership via ctrl.svc.Group.GetMembership and check role == Owner), and return
a forbidden error if not an owner; only call
ctrl.svc.Group.DeleteInvitation(auth, id) when the ownership check passes and
keep using adapters.CommandID("id", fn, http.StatusNoContent).
- Around line 259-282: HandleGroupMemberRemove lacks an authorization check
allowing any authenticated member to remove others; before calling
ctrl.svc.Group.RemoveMember, verify the caller (auth from services.NewContext)
is authorized to manage members of auth.GID (e.g., is group owner or has
admin/manager role) by checking the caller’s role/membership via the repo or an
existing service method, and if unauthorized return a
validate.NewRequestError/errors.New(...) with http.StatusForbidden; add this
check after loading members (or via a dedicated
ctrl.svc.Group.AuthorizeManageMembers(auth, auth.GID)) and only call
ctrl.svc.Group.RemoveMember when the authorization succeeds.
In `@backend/app/api/handlers/v1/v1_ctrl_user.go`:
- Around line 16-24: The Swagger comment block for HandleUserRegistration has
inconsistent annotation indentation (notably the `@Failure` line); edit the godoc
comment above the HandleUserRegistration function to use consistent tabs/spaces
for all annotations (e.g., align each annotation with a single tab after the
leading comment and use the same spacing pattern for `@Summary`, `@Tags`, `@Produce`,
`@Param`, `@Success`, `@Failure` and `@Router`), changing the current "@Failure 403
..." line to match the others (for example "@Failure\t403 {string} string
\"Local login is not enabled\"") so all annotations have uniform formatting.
In `@backend/internal/core/services/service_group.go`:
- Around line 75-77: DeleteInvitation currently forwards the call without
validating the invitation id; add the same uuid.Nil check used in
AddMember/RemoveMember: if id == uuid.Nil return a validation/argument error
(consistent with other methods) before calling
svc.repos.Groups.InvitationDelete(ctx.Context, ctx.GID, id), so you validate the
id and avoid passing a nil UUID to InvitationDelete.
- Around line 40-42: DeleteGroup in GroupService lacks validation for ctx.GID;
before calling repos.Groups.GroupDelete, add a check at the top of DeleteGroup
to ensure ctx.GID != uuid.Nil and return a clear validation error (use the
project's existing validation/error type if available, otherwise return a
descriptive error like "invalid GID"); mirror the pattern used in CreateGroup's
ctx.UID validation so callers get a consistent, early error instead of passing
uuid.Nil into repos.Groups.GroupDelete.
In `@backend/internal/data/repo/repo_group.go`:
- Around line 22-27: The GroupRepository.attachments field is never initialized
causing a nil panic in GroupDelete when calling r.attachments.Delete; update
NewGroupRepository to construct and assign an AttachmentRepo instance to the
attachments field (e.g., create the AttachmentRepo using the same ent.Client or
relevant params) so GroupRepository{..., attachments: <new AttachmentRepo>(...)}
is returned; ensure the constructor uses the correct AttachmentRepo initializer
and imports so r.attachments is non-nil before GroupDelete is invoked.
- Around line 299-301: The error branch that returns when the item query fails
currently returns err without rolling back the transaction; update the err !=
nil block to explicitly roll back the transaction (call tx.Rollback() or
tx.Rollback(ctx) as used elsewhere in this function) before returning the error,
and handle/ignore any rollback error consistently with the other rollback calls
in this function; target the err != nil branch around the item query and the tx
variable to implement this change.
- Around line 288-342: GroupDelete is deleting attachment files via
r.attachments.Delete(ctx, id, att.ID) outside the transaction, which can leave
files removed while DB rows later roll back; fix by making attachment DB
deletions part of the same transaction and deferring file-system deletion until
after a successful tx.Commit (or by providing a transaction-aware attachment
deletion API). Concretely: in GroupDelete collect attachment IDs from
it.Edges.Attachments, perform attachment row deletes using the transaction
(e.g., tx.Attachment.Delete().Where(attachment.IDIn(collectedIDs)).Exec(ctx))
inside the tx, then after tx.Commit() iterate the collected IDs and call the
file-only removal helper (or r.attachments.DeleteFile(ctx, attID));
alternatively implement and call a new attachments.DeleteWithTx(ctx, tx,
groupID, attID) so both DB and file removals are coordinated, and remove the
current r.attachments.Delete calls inside the transaction block.
In `@backend/internal/sys/config/conf.go`:
- Around line 83-89: The OIDCConf contains sensitive fields (ClientSecret) that
are currently marshaled in Print() and exposed; implement a custom MarshalJSON()
method on the OIDCConf type that creates a shallow copy of the struct, replaces
sensitive fields (at minimum ClientSecret, and any other secrets like ClientID
if desired) with a redacted value (e.g. "<redacted>"), and returns json.Marshal
on that copy so any code (including Print()) that marshals OIDCConf will not
emit secrets. Ensure the method signature func (c OIDCConf) MarshalJSON()
([]byte, error) is used and preserves all other fields unchanged.
In `@docs/en/configure/storage.md`:
- Line 25: The example removed the awssdk URL parameter which is still
supported; restore the `?awssdk=v2` parameter in the HBOX_STORAGE_CONN_STRING
example (e.g., `s3://my-bucket?region=region-name&awssdk=v2`) or, if you
intentionally removed it, add a short note next to the
`HBOX_STORAGE_CONN_STRING` example explaining that the `awssdk` parameter
(`awssdk=v1`/`awssdk=v2`) is still supported by Go CDK's s3blob and when users
should specify it (compatibility reasons), referencing the
`HBOX_STORAGE_CONN_STRING` and `awssdk` parameter names so readers can find the
config and decide which option to use.
In `@frontend/components/Collection/JoinModal.vue`:
- Around line 80-129: Reviewer notes server already enforces expiration,
single-use and hashing, but asks for rate limiting and invitation-specific audit
logging; update the invitation acceptance handling by implementing server-side
rate limiting (per-user and per-IP) on the POST /groups/invitations/{id}
endpoint and add audit logs that record attempt metadata (invitation id/token
hash, acting user id, source IP, timestamp, outcome/success/failure and reason
such as expired/used/invalid) so every call from api.group.acceptInvitation is
audited; additionally keep the client-side join() logic as-is but ensure it does
not mask server rate-limit responses (propagate error details from
api.group.acceptInvitation so UI can surface rate-limit / audit-related
messages).
In `@frontend/components/ui/dialog-provider/utils.ts`:
- Around line 4-10: There are two duplicate TypeScript interfaces named
GroupInvitation in the generated types; remove the unused/incorrect one (the
variant with fields { expiresAt, group, id, uses }) or rename it (e.g.,
GroupInvitationDetail) so the actual API shape ({ expiresAt, token, uses })
remains the canonical GroupInvitation; update any references to the
removed/renamed symbol to use the correct interface name and run type-checks to
ensure no remaining usages point to the old definition.
In `@frontend/composables/use-server-events.ts`:
- Around line 30-35: The tenant ID is appended to the WebSocket URL without
encoding, allowing injection via currentTenantId; update the URL construction in
use-server-events (where url is built and WebSocket is created) to URL-encode
the tenant value (e.g., use encodeURIComponent or build query via
URLSearchParams) before appending so the query string cannot be manipulated,
then pass the safe url into the WebSocket constructor.
In `@frontend/lib/api/base/base-api.ts`:
- Around line 78-84: The authURL method currently always passes tenant derived
from useCollections().selectedId.value which can be null and will serialize to
"null"; update authURL (when this.attachmentToken is set) to only include the
tenant parameter if selectedId.value !== null (build the params object
conditionally before calling route), e.g. include access_token always but add
tenant only when not null, and keep using route to generate the URL; ensure you
reference authURL, attachmentToken, useCollections, selectedId.value and route
when making the change.
In `@frontend/lib/api/types/data-contracts.ts`:
- Around line 575-580: The TypeScript types are colliding because two distinct
backend structs both serialize to GroupInvitation; update the backend so the
handler-level POST response uses a distinct type name (e.g.,
GroupInvitationToken) or make both endpoints return the same unified shape, then
regenerate Swagger/TS clients; specifically change the handler response type in
app/api/handlers/v1 (the POST that returns Token/ExpiresAt/Uses) to a new struct
name like GroupInvitationToken (or align it with internal/data/repo's
GroupInvitation that includes ID/Group/ExpiresAt/Uses), update any references to
getInvitations() and the generated client, and re-run the OpenAPI/Swagger
generation so the frontend no longer merges the two shapes.
In `@frontend/pages/collection/index/invites.vue`:
- Around line 182-184: The TableCell currently does unguarded new
Date(inv.expiresAt).toLocaleString(), which can render "Invalid Date" or throw
if expiresAt is null/undefined; update the expression in the TableCell to check
inv.expiresAt (and validate the resulting Date) and render a safe fallback
(e.g., '—' or empty string) when missing or invalid; specifically, replace the
direct call in the TableCell with a conditional that first ensures inv.expiresAt
exists (and that new Date(inv.expiresAt).getTime() is a valid number) before
calling toLocaleString(), otherwise output the fallback.
🧹 Nitpick comments (29)
frontend/pages/reports/label-generator.vue (2)
265-288: Logic is sound, but consider simplifying the computed structure.The computed helper correctly handles all behavior modes. However, returning a function from a computed is slightly unusual—a plain function would suffice since you're not caching per-item results.
Also, the final
return nullat line 286 is only reached when behavior is one of the conditional modes but the condition doesn't match. This means the HomeBox line won't render at all in that case, which appears intentional but could be clarified with a comment.♻️ Optional: Simplify to a regular function
- const getHomeBoxLineText = computed(() => { - return (item: LabelData): string | null => { + function getHomeBoxLineText(item: LabelData): string | null { if (replaceHomeboxBehavior.value === BEHAVIOR_SHOW) { return "HomeBox"; } if (replaceHomeboxBehavior.value === BEHAVIOR_ALWAYS_REPLACE) { return replaceHomeboxText.value; } if ( replaceHomeboxBehavior.value === BEHAVIOR_ITEM_NO_NAME_NO_LOCATION && item.name === labelBlankLine && item.location === labelBlankLine ) { return replaceHomeboxText.value; } if (replaceHomeboxBehavior.value === BEHAVIOR_ITEM_NO_NAME && item.name === labelBlankLine) { return replaceHomeboxText.value; } if (replaceHomeboxBehavior.value === BEHAVIOR_ITEM_NO_LOCATION && item.location === labelBlankLine) { return replaceHomeboxText.value; } + // Conditional behavior selected but item doesn't match criteria - hide line return null; - }; - }); + }
518-524: Performance: Avoid triple invocation ofgetHomeBoxLineTextin the render loop.
getHomeBoxLineText(item)is called three times per label card (lines 519, 521, 523). For large label batches (up to 999 items × multiple per page), this adds unnecessary overhead. Cache the result in a local variable or use a scoped slot pattern.♻️ Cache the computed value to avoid repeated calls
You can use a template variable via destructuring or wrap the content:
<div class="ml-2 flex flex-col justify-center"> <div class="font-bold">{{ item.assetID }}</div> - <div - v-if="getHomeBoxLineText(item)" - class="text-xs" - :class="{ 'font-light italic': getHomeBoxLineText(item) !== labelBlankLine }" - > - {{ getHomeBoxLineText(item) }} - </div> + <template v-for="homeBoxLine in [getHomeBoxLineText(item)]" :key="'hb'"> + <div + v-if="homeBoxLine" + class="text-xs" + :class="{ 'font-light italic': homeBoxLine !== labelBlankLine }" + > + {{ homeBoxLine }} + </div> + </template> <div class="overflow-hidden text-wrap text-xs">{{ item.name }}</div> <div class="text-xs">{{ item.location }}</div> </div>frontend/composables/use-server-events.ts (3)
52-56: Typo:thorttled→throttled.Minor spelling mistake in the variable name.
✏️ Proposed fix
- const thorttled = new Map<ServerEvent, (m: EventMessage) => void>(); + const throttled = new Map<ServerEvent, (m: EventMessage) => void>(); - thorttled.set(ServerEvent.LocationMutation, useThrottleFn(onmessage, 1000)); - thorttled.set(ServerEvent.ItemMutation, useThrottleFn(onmessage, 1000)); - thorttled.set(ServerEvent.LabelMutation, useThrottleFn(onmessage, 1000)); + throttled.set(ServerEvent.LocationMutation, useThrottleFn(onmessage, 1000)); + throttled.set(ServerEvent.ItemMutation, useThrottleFn(onmessage, 1000)); + throttled.set(ServerEvent.LabelMutation, useThrottleFn(onmessage, 1000)); ws.onmessage = msg => { const pm = JSON.parse(msg.data); - const fn = thorttled.get(pm.event); + const fn = throttled.get(pm.event); if (fn) { fn(pm); } };
69-90: Module-level watcher is never stopped anduseViewPreferences()is called per invocation.A few observations on this pattern:
Repeated composable call:
useViewPreferences()is invoked every timeonServerEventis called (Line 70), but only the first call's watcher is retained. Subsequent calls still create a newprefsref that isn't watched.Watcher cleanup: The
watch()is created at module scope and never stopped, even when all components unmount. This is likely acceptable for a singleton WebSocket manager, but worth noting.Race on rapid tenant switches: If
collectionIdchanges multiple times in quick succession, multiple reconnect attempts could overlap sinceconnect()is async.Consider hoisting
prefsand the watcher setup outside ofonServerEventif you want a true singleton pattern, or document that this composable intentionally shares a single WebSocket across the app.♻️ Suggested refactor for cleaner singleton pattern
+const prefs = useViewPreferences(); + +watch( + () => prefs.value.collectionId, + newId => { + currentTenantId = newId || null; + if (socket) { + socket.onclose = null; + socket.close(); + socket = null; + connect(e => { + console.debug("received event", e); + listeners.get(e.event)?.forEach(c => c()); + }); + } + } +); + export function onServerEvent(event: ServerEvent, callback: () => void) { - const prefs = useViewPreferences(); - currentTenantId = prefs.value.collectionId || null; - - if (!watcherSetup) { - watch( - () => prefs.value.collectionId, - newId => { - currentTenantId = newId || null; - if (socket) { - socket.onclose = null; - socket.close(); - socket = null; - connect(e => { - console.debug("received event", e); - listeners.get(e.event)?.forEach(c => c()); - }); - } - } - ); - watcherSetup = true; - } + currentTenantId = prefs.value.collectionId || null;Note: If the refactor above causes issues with Nuxt's composable auto-import timing, the current guarded approach is acceptable—just be aware of the caveats.
58-64: Validate incoming WebSocket message structure before accessing properties.The
msg.datais parsed and immediately accessed without validation. A malformed message could cause runtime errors or, in edge cases, unexpected behavior if the server sends an unexpected payload.🛡️ Defensive parsing
ws.onmessage = msg => { - const pm = JSON.parse(msg.data); - const fn = thorttled.get(pm.event); - if (fn) { - fn(pm); + try { + const pm = JSON.parse(msg.data); + if (pm && typeof pm.event === "string") { + const fn = throttled.get(pm.event); + if (fn) { + fn(pm); + } + } + } catch (err) { + console.error("Failed to parse WebSocket message", err); } };backend/internal/data/repo/repo_notifier.go (1)
62-70: Good security improvement with tenant scoping on GetByUser.The addition of
tenantIDparameter and theGroupIDfilter correctly enforces tenant isolation, ensuring users can only retrieve notifiers belonging to their current tenant context. This prevents potential cross-tenant data leakage.Security recommendation: For consistency and defense-in-depth, consider adding tenant scoping to
Update(line 103) andDelete(line 118) methods as well. Currently they only validateuserID, which could theoretically allow operations on notifiers if a user somehow obtains a notifier ID from a different group context.🔒 Proposed security hardening for Delete
-func (r *NotifierRepository) Delete(ctx context.Context, userID uuid.UUID, id uuid.UUID) error { - _, err := r.db.Notifier.Delete().Where(notifier.UserID(userID), notifier.ID(id)).Exec(ctx) +func (r *NotifierRepository) Delete(ctx context.Context, userID uuid.UUID, tenantID uuid.UUID, id uuid.UUID) error { + _, err := r.db.Notifier.Delete().Where(notifier.UserID(userID), notifier.GroupID(tenantID), notifier.ID(id)).Exec(ctx) return err }#!/bin/bash # Check how Update and Delete are called to understand if tenant validation happens at handler level rg -n "NotifierRepository.*Update|NotifierRepository.*Delete|\.Notifiers\.Update|\.Notifiers\.Delete" --type go -B 3 -A 3frontend/composables/use-api.ts (1)
56-62: Hardcoded error message string is fragile.The exact string
"user does not have access to the requested tenant"couples this logic to the backend's error wording. If the message changes, this check silently stops working.Consider defining a constant for this string or, ideally, having the backend return a structured error code (e.g.,
{ code: "TENANT_ACCESS_DENIED", error: "..." }) that's more stable than message text.backend/app/api/handlers/v1/v1_ctrl_notifiers.go (1)
23-27: Inconsistent context extraction pattern.This handler uses
UseUserCtxandUseTenantCtxseparately, while other handlers in this file (lines 43, 60, 78) useservices.NewContext(r.Context())which bundles user and tenant context together.For consistency and to align with the learnings that handlers should use
NewContextfor auth and multi-tenancy, consider refactoring:♻️ Suggested refactor
func (ctrl *V1Controller) HandleGetUserNotifiers() errchain.HandlerFunc { fn := func(r *http.Request, _ struct{}) ([]repo.NotifierOut, error) { - user := services.UseUserCtx(r.Context()) - tenant := services.UseTenantCtx(r.Context()) - return ctrl.repo.Notifiers.GetByUser(r.Context(), user.ID, tenant) + auth := services.NewContext(r.Context()) + return ctrl.repo.Notifiers.GetByUser(auth, auth.UID, auth.GID) } return adapters.Query(fn, http.StatusOK) }frontend/components/Location/Tree/Root.vue (1)
27-52: Good accessibility implementation.The empty state has proper
role="status"andaria-live="polite"for screen reader announcements. The tree structure uses semanticrole="tree"androle="treeitem"attributes. The button includes an appropriatearia-label.Minor note: The
<ul role="tree">(lines 48-52) renders even whensortedLocsis empty, resulting in an empty tree element in the DOM alongside the empty state message. Consider wrapping it withv-if="sortedLocs.length > 0"or usingv-elseto avoid rendering an empty tree.✨ Optional: Hide empty tree element
- <ul role="tree" :aria-labelledby="treeId" class="space-y-1"> + <ul v-if="sortedLocs.length > 0" role="tree" :aria-labelledby="treeId" class="space-y-1"> <li v-for="item in sortedLocs" :key="item.id" role="treeitem"> <LocationTreeNode :item="item" :tree-id="treeId" /> </li> </ul>frontend/components/Collection/InviteCreateModal.vue (1)
76-81: Consider validating thatusesis an integer.The current validation allows decimal values like
1.5to pass sinceNumber.isFinite(1.5)is true and it's within range. Consider adding an integer check.🔧 Proposed fix
const parsedUses = Number(form.uses ?? 0); - if (!Number.isFinite(parsedUses) || parsedUses < 1 || parsedUses > 100) { + if (!Number.isInteger(parsedUses) || parsedUses < 1 || parsedUses > 100) { toast.error(t("components.collection.invite_create_modal.toast.invalid_uses")); return; }frontend/lib/api/classes/group.ts (1)
137-150: Consider using theCreateRequesttype.The
createmethod defines the request body type inline, butCreateRequestis available indata-contracts.tswith the same shape. Using the imported type would maintain consistency with the generated API types.🔧 Proposed fix
+import type { + // ... existing imports + CreateRequest, +} from "../types/data-contracts"; // ... create(name: string) { - return this.http.post< - { - name: string; - }, - Group - >({ + return this.http.post<CreateRequest, Group>({ url: route("/groups"), body: { name }, }); }backend/internal/data/repo/main_test.go (1)
3-9: Consider grouping imports per Go conventions.The
uuidimport should be grouped with other external packages, separated from standard library imports by a blank line.📦 Suggested import organization
import ( "context" - "github.com/google/uuid" - "github.com/sysadminsmedia/homebox/backend/internal/sys/config" "log" "os" "testing" + "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" "github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus" "github.com/sysadminsmedia/homebox/backend/internal/data/ent" + "github.com/sysadminsmedia/homebox/backend/internal/sys/config" "github.com/sysadminsmedia/homebox/pkgs/faker" )backend/app/api/middleware.go (1)
174-177: Note: Query parameter may appear in server logs.The
tenantquery parameter fallback is useful for URL-based tenant switching, but be aware that query parameters typically appear in web server access logs. If tenant IDs are considered sensitive, consider:
- Log sanitization
- Preferring the
X-Tenantheader for sensitive contextsThis is informational only - the authorization check still applies, so there's no security bypass.
frontend/pages/collection/index/settings.vue (1)
21-23: Consider displaying theerrorstate in the UI.The
errorref is set during failure scenarios but never rendered in the template. Currently, errors are only surfaced via toast notifications. Consider adding an inline error message for better UX, especially for users who might miss toast popups.💡 Optional: Add error display in template
<div v-else class="space-y-4 rounded-md border bg-card p-4"> + <div v-if="error" class="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> + {{ error }} + </div> <FormTextField v-model="name" :label="$t('global.name')" />backend/internal/core/services/service_items_attachments.go (1)
60-68: Simplify the return statement.The current return logic is slightly redundant. After the error check, you can directly return the result of
Delete.♻️ Simplified version
func (svc *ItemService) AttachmentDelete(ctx context.Context, gid uuid.UUID, attachmentID uuid.UUID) error { - // Delete the attachment - err := svc.repo.Attachments.Delete(ctx, gid, attachmentID) - if err != nil { - return err - } - - return err + return svc.repo.Attachments.Delete(ctx, gid, attachmentID) }frontend/pages/collection/index/members.vue (1)
28-50: Error handling looks good, minor string concatenation note.The error handling is comprehensive with both API error responses and exceptions covered.
Consider using template literals or a dedicated error formatting function for the concatenation at lines 35 and 43 for better readability:
- const msg = t("errors.api_failure") + String(res.error); + const msg = `${t("errors.api_failure")}${res.error}`;This is purely stylistic and optional.
backend/internal/data/migrations/postgres/20251226000000_user_group_m2m.sql (1)
15-26: Consider adding an index onuser_groups.group_id.The composite primary key provides an index on
(user_id, group_id), which is efficient for lookups by user. However, if you frequently query "all users in a group," consider adding a separate index ongroup_id:CREATE INDEX IF NOT EXISTS "idx_user_groups_group_id" ON "user_groups" ("group_id");The
ON DELETE SET NULLfordefault_group_idis appropriate - it allows users to remain in the system even if their default group is deleted.frontend/layouts/default.vue (1)
236-239: Explicit imports are acceptable but not required.Per Nuxt conventions, components in
components/are auto-imported. These explicit imports work fine but could be removed for consistency with the auto-import pattern used elsewhere.frontend/components/Collection/Selector.vue (2)
10-11: Hardcoded strings should use i18n.The
aria-labelandtitleattributes contain hardcoded "Collections" strings. These should use the translation system for consistency and accessibility across locales.Suggested fix
- aria-label="Collections" - title="Collections" + :aria-label="t('components.collection.selector.collections')" + :title="t('components.collection.selector.collections')"
118-124: Full page reload on collection switch may cause data loss.
window.location.reload()discards any in-progress work or unsaved state. Consider whether a softer navigation approach (e.g.,navigateTowith state refresh) would preserve user experience while still updating the collection context.If the full reload is intentional to ensure clean state across the app, adding a brief comment explaining this choice would help future maintainers.
frontend/composables/use-collections.ts (1)
65-73: Silent error swallowing inset()function.The empty catch block discards any errors from
useViewPreferences(). While preference persistence failures may not be critical, consider at minimum logging the error for debugging purposes.Suggested improvement
const set = (id: string | null) => { selectedId.value = id; try { const prefs = useViewPreferences(); prefs.value.collectionId = id; } catch (e) { - // ignore + console.warn("Failed to persist collection preference:", e); } };frontend/components/Collection/CreateModal.vue (1)
101-116: Consider using Vue Router navigation instead of full page reload.Using
window.location.reload()andwindow.location.hrefforces a full page reload, which loses any client-side state and provides a less smooth UX. Consider usingnavigateTo()(Nuxt 3) or updating reactive state to reflect the new collection without a hard refresh.That said, if the intention is to ensure a complete tenant context reset, this approach is defensible.
♻️ Optional: Use Vue Router navigation
+ const router = useRouter(); + if (close) { closeDialog(DialogID.CreateCollection); if (data) { const createdId = data.id; collections.set(createdId); if (redirectTo.value === "") { - // reload page to reflect new collection - window.location.reload(); + // Refresh collections and navigate + await collections.load(); + router.push('/'); } else { - window.location.href = redirectTo.value; + await navigateTo(redirectTo.value); } } }frontend/pages/collection/index/notifiers.vue (1)
97-110: Delete flow has proper confirmation and error handling.The confirmation dialog prevents accidental deletions, and the early return on cancel is correct. Consider adding a success toast after deletion for consistency with other operations.
♻️ Optional: Add success toast on delete
const deleteNotifier = async (id: string) => { const result = await confirm.open(t("profile.delete_notifier_confirm")); if (result.isCanceled) return; const { error } = await api.notifiers.delete(id); if (error) { toast.error(t("profile.toast.failed_delete_notifier")); return; } + toast.success(t("profile.toast.notifier_deleted")); await notifiers.refresh(); };frontend/components/Collection/JoinModal.vue (2)
91-99: URL parsing could be more robust for edge cases.The current logic handles
?token=CODEbut could behave unexpectedly with malformed URLs (e.g., multiple?characters, or URLs with other query parameters).♻️ Optional: Use URL API for more robust parsing
- // remove everything before the first '?' character - const code = form.inviteCode.includes("?") - ? form.inviteCode.split("?")[1]?.replace("token=", "").trim() - : form.inviteCode.trim(); + // Extract code from URL or use raw input + let code = form.inviteCode.trim(); + try { + if (form.inviteCode.includes("?")) { + const url = new URL(form.inviteCode); + code = url.searchParams.get("token")?.trim() || ""; + } + } catch { + // Not a valid URL, treat as raw code + }
96-99: Hardcoded invite code length may be fragile.The validation
code.length !== 26assumes invite codes will always be exactly 26 characters. If the backend changes the code format, this validation will reject valid codes.Consider either:
- Making the length configurable
- Removing client-side length validation entirely (let the server validate)
- Adding a minimum length check instead of exact match
♻️ Suggested: Use minimum length instead of exact match
- if (!code || code.length !== 26) { + if (!code || code.length < 20) { toast.error(t("components.collection.join_modal.toast.please_enter_valid_join_code")); return; }backend/internal/data/migrations/sqlite3/20251227000000_user_group_m2m.sql (1)
64-68: Consider adding foreign key integrity check before re-enabling constraints.After recreating the table and before re-enabling foreign keys, consider running
PRAGMA foreign_key_check;to verify no orphaned references exist. If there are constraint violations, re-enabling foreign keys won't retroactively flag them, which could lead to silent data integrity issues.🔧 Suggested addition before re-enabling foreign keys
CREATE UNIQUE INDEX IF NOT EXISTS users_email_key ON users(email); CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_issuer_subject_key ON users(oidc_issuer, oidc_subject); +-- Verify no foreign key violations exist before re-enabling +PRAGMA foreign_key_check; + PRAGMA foreign_keys=ON;frontend/pages/collection/index.vue (1)
143-145: Consider using Vue Router navigation instead of full page reload.Using
window.location.reload()works but loses any client-side state. Consider usingnavigateTo()orrouter.push()to redirect to a collection selector or home page after leaving/deleting a collection for a smoother UX.♻️ Alternative approach
toast.success(t("collection.left_collection")); await reloadCollections(); - window.location.reload(); + await navigateTo("/");backend/internal/data/repo/repo_group.go (1)
453-457: Variable shadowing in rollback error handling reduces clarity.Throughout
InvitationAccept, the rollback error is captured aserrwhich shadows the outererrvariable. While this works, it can be confusing and could mask the original error in logs.♻️ Suggested improvement: Use distinct variable name for rollback errors
if err != nil { - if err := tx.Rollback(); err != nil { - log.Warn().Err(err).Msg("failed to rollback transaction") + if rbErr := tx.Rollback(); rbErr != nil { + log.Warn().Err(rbErr).Msg("failed to rollback transaction") } return Group{}, err }Also applies to: 461-464, 467-471, 478-481, 484-488, 493-497, 508-512, 514-518
backend/internal/data/repo/repo_users.go (1)
67-70: Consider nil slice handling for empty groups.When
user.Edges.Groupsis nil (edge not loaded), this creates an empty slice which is fine. However, if there's ever a code path whereWithGroups()isn't called before mapping, this could silently return an emptyGroupIDsslice instead of signaling an error.This is a defensive suggestion - the current code paths appear to always load the edge, but documenting or adding a check could prevent future bugs:
// Consider adding a comment or assertion: // Requires: user queried WithGroups() groupIDs := make([]uuid.UUID, len(user.Edges.Groups))
| func (ctrl *V1Controller) HandleGroupMemberAdd() errchain.HandlerFunc { | ||
| fn := func(r *http.Request, body GroupMemberAdd) (any, error) { | ||
| auth := services.NewContext(r.Context()) | ||
| err := ctrl.svc.Group.AddMember(auth, body.UserID) | ||
| return nil, err | ||
| } | ||
|
|
||
| return adapters.Action(fn, http.StatusNoContent) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security: Missing authorization check for adding members.
HandleGroupMemberAdd allows any authenticated user to add members to a group. This should be restricted to group owners or administrators.
🔒 Proposed fix: Add authorization check
func (ctrl *V1Controller) HandleGroupMemberAdd() errchain.HandlerFunc {
fn := func(r *http.Request, body GroupMemberAdd) (any, error) {
auth := services.NewContext(r.Context())
+
+ // Authorization check
+ currentUser, err := ctrl.repo.Users.GetOneID(auth, auth.UID)
+ if err != nil {
+ return nil, err
+ }
+ if !currentUser.IsOwner {
+ return nil, validate.NewRequestError(errors.New("only group owners can add members"), http.StatusForbidden)
+ }
+
err := ctrl.svc.Group.AddMember(auth, body.UserID)
return nil, err
}| func (ctrl *V1Controller) HandleGroupMemberRemove() errchain.HandlerFunc { | ||
| fn := func(r *http.Request, userID uuid.UUID) (any, error) { | ||
| auth := services.NewContext(r.Context()) | ||
|
|
||
| // Safeguard: prevent user from removing themselves | ||
| if userID == auth.UID { | ||
| return nil, validate.NewRequestError(errors.New("cannot remove yourself from the group"), http.StatusBadRequest) | ||
| } | ||
|
|
||
| // Safeguard: prevent removing the last member | ||
| members, err := ctrl.repo.Users.GetUsersByGroupID(auth, auth.GID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if len(members) <= 1 { | ||
| return nil, validate.NewRequestError(errors.New("cannot remove the last member from the group"), http.StatusBadRequest) | ||
| } | ||
|
|
||
| err = ctrl.svc.Group.RemoveMember(auth, userID) | ||
| return nil, err | ||
| } | ||
|
|
||
| return adapters.CommandID("user_id", fn, http.StatusNoContent) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security: Missing authorization check for removing members.
While there are safeguards against self-removal and removing the last member, there's no check to ensure the current user has permission to remove other members from the group.
🔒 Proposed fix: Add authorization check
func (ctrl *V1Controller) HandleGroupMemberRemove() errchain.HandlerFunc {
fn := func(r *http.Request, userID uuid.UUID) (any, error) {
auth := services.NewContext(r.Context())
+ // Authorization check
+ currentUser, err := ctrl.repo.Users.GetOneID(auth, auth.UID)
+ if err != nil {
+ return nil, err
+ }
+ if !currentUser.IsOwner {
+ return nil, validate.NewRequestError(errors.New("only group owners can remove members"), http.StatusForbidden)
+ }
+
// Safeguard: prevent user from removing themselves
if userID == auth.UID {🤖 Prompt for AI Agents
In `@backend/app/api/handlers/v1/v1_ctrl_group.go` around lines 259 - 282,
HandleGroupMemberRemove lacks an authorization check allowing any authenticated
member to remove others; before calling ctrl.svc.Group.RemoveMember, verify the
caller (auth from services.NewContext) is authorized to manage members of
auth.GID (e.g., is group owner or has admin/manager role) by checking the
caller’s role/membership via the repo or an existing service method, and if
unauthorized return a validate.NewRequestError/errors.New(...) with
http.StatusForbidden; add this check after loading members (or via a dedicated
ctrl.svc.Group.AuthorizeManageMembers(auth, auth.GID)) and only call
ctrl.svc.Group.RemoveMember when the authorization succeeds.
| func (ctrl *V1Controller) HandleGroupInvitationsDelete() errchain.HandlerFunc { | ||
| fn := func(r *http.Request, id uuid.UUID) (any, error) { | ||
| auth := services.NewContext(r.Context()) | ||
| err := ctrl.svc.Group.DeleteInvitation(auth, id) | ||
| return nil, err | ||
| } | ||
|
|
||
| return adapters.CommandID("id", fn, http.StatusNoContent) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security: Missing authorization check for deleting invitations.
Any group member can currently delete invitations. This should be restricted to owners.
🔒 Proposed fix
func (ctrl *V1Controller) HandleGroupInvitationsDelete() errchain.HandlerFunc {
fn := func(r *http.Request, id uuid.UUID) (any, error) {
auth := services.NewContext(r.Context())
+
+ currentUser, err := ctrl.repo.Users.GetOneID(auth, auth.UID)
+ if err != nil {
+ return nil, err
+ }
+ if !currentUser.IsOwner {
+ return nil, validate.NewRequestError(errors.New("only group owners can delete invitations"), http.StatusForbidden)
+ }
+
err := ctrl.svc.Group.DeleteInvitation(auth, id)🤖 Prompt for AI Agents
In `@backend/app/api/handlers/v1/v1_ctrl_group.go` around lines 293 - 301,
HandleGroupInvitationsDelete currently lets any group member delete an
invitation; update it to enforce owner-only deletion by: retrieve the request
context (services.NewContext) as before, load the invitation using its id (e.g.,
call ctrl.svc.Group.GetInvitation or equivalent) to obtain the associated group
ID, verify the caller is an owner of that group (e.g., use a service method like
ctrl.svc.Group.IsOwner(auth, groupID) or fetch membership via
ctrl.svc.Group.GetMembership and check role == Owner), and return a forbidden
error if not an owner; only call ctrl.svc.Group.DeleteInvitation(auth, id) when
the ownership check passes and keep using adapters.CommandID("id", fn,
http.StatusNoContent).
| if errors.Is(err, errors.New("user already a member of this group")) { | ||
| return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest) | ||
| } | ||
| return GroupAcceptInvitationResponse{}, err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: errors.Is comparison with errors.New will never match.
errors.Is(err, errors.New("user already a member of this group")) creates a new error instance on each call, so it will never match the error returned from AcceptInvitation. This condition is effectively dead code.
🐛 Proposed fix: Use string comparison or define a sentinel error
Option 1: Check error message (simple but fragile)
if err != nil {
- if errors.Is(err, errors.New("user already a member of this group")) {
+ if err.Error() == "user already a member of this group" {
return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest)
}
return GroupAcceptInvitationResponse{}, err
}Option 2: Define sentinel error in repo package (recommended)
In repo_group.go:
var ErrAlreadyMember = errors.New("user already a member of this group")Then use errors.Is(err, repo.ErrAlreadyMember) in the handler.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if errors.Is(err, errors.New("user already a member of this group")) { | |
| return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest) | |
| } | |
| return GroupAcceptInvitationResponse{}, err | |
| if err.Error() == "user already a member of this group" { | |
| return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest) | |
| } | |
| return GroupAcceptInvitationResponse{}, err |
🤖 Prompt for AI Agents
In `@backend/app/api/handlers/v1/v1_ctrl_group.go` around lines 322 - 325, The
errors.Is check is comparing against errors.New("user already a member of this
group") which creates a new instance and will never match; define a sentinel
error (e.g., var ErrAlreadyMember = errors.New("user already a member of this
group")) in the repo package (repo_group.go) and have AcceptInvitation return
that sentinel on that condition, then replace the handler check errors.Is(err,
errors.New(...)) with errors.Is(err, repo.ErrAlreadyMember) in the
GroupAcceptInvitation handler so the comparison works correctly.
| // HandleUserRegistration godoc | ||
| // | ||
| // @Summary Register New User | ||
| // @Tags User | ||
| // @Produce json | ||
| // @Param payload body services.UserRegistration true "User Data" | ||
| // @Success 204 | ||
| // @Failure 403 {string} string "Local login is not enabled" | ||
| // @Router /v1/users/register [Post] | ||
| // @Summary Register New User | ||
| // @Tags User | ||
| // @Produce json | ||
| // @Param payload body services.UserRegistration true "User Data" | ||
| // @Success 204 | ||
| // @Failure 403 {string} string "Local login is not enabled" | ||
| // @Router /v1/users/register [Post] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Swagger documentation formatting issue.
The Swagger annotations have inconsistent indentation. Line 23 uses a different pattern (@Failure with extra space) compared to other annotations. This may cause issues with swagger doc generation.
🔧 Proposed fix for consistent formatting
// HandleUserRegistration godoc
//
-// `@Summary` Register New User
-// `@Tags` User
-// `@Produce` json
-// `@Param` payload body services.UserRegistration true "User Data"
-// `@Success` 204
-// `@Failure` 403 {string} string "Local login is not enabled"
-// `@Router` /v1/users/register [Post]
+// `@Summary` Register New User
+// `@Tags` User
+// `@Produce` json
+// `@Param` payload body services.UserRegistration true "User Data"
+// `@Success` 204
+// `@Failure` 403 {string} string "Local login is not enabled"
+// `@Router` /v1/users/register [Post]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // HandleUserRegistration godoc | |
| // | |
| // @Summary Register New User | |
| // @Tags User | |
| // @Produce json | |
| // @Param payload body services.UserRegistration true "User Data" | |
| // @Success 204 | |
| // @Failure 403 {string} string "Local login is not enabled" | |
| // @Router /v1/users/register [Post] | |
| // @Summary Register New User | |
| // @Tags User | |
| // @Produce json | |
| // @Param payload body services.UserRegistration true "User Data" | |
| // @Success 204 | |
| // @Failure 403 {string} string "Local login is not enabled" | |
| // @Router /v1/users/register [Post] | |
| // HandleUserRegistration godoc | |
| // | |
| // `@Summary` Register New User | |
| // `@Tags` User | |
| // `@Produce` json | |
| // `@Param` payload body services.UserRegistration true "User Data" | |
| // `@Success` 204 | |
| // `@Failure` 403 {string} string "Local login is not enabled" | |
| // `@Router` /v1/users/register [Post] |
🤖 Prompt for AI Agents
In `@backend/app/api/handlers/v1/v1_ctrl_user.go` around lines 16 - 24, The
Swagger comment block for HandleUserRegistration has inconsistent annotation
indentation (notably the `@Failure` line); edit the godoc comment above the
HandleUserRegistration function to use consistent tabs/spaces for all
annotations (e.g., align each annotation with a single tab after the leading
comment and use the same spacing pattern for `@Summary`, `@Tags`, `@Produce`, `@Param`,
`@Success`, `@Failure` and `@Router`), changing the current "@Failure 403 ..." line
to match the others (for example "@Failure\t403 {string} string \"Local login is
not enabled\"") so all annotations have uniform formatting.
| import type { | ||
| BarcodeProduct, | ||
| GroupInvitation, | ||
| ItemSummary, | ||
| MaintenanceEntry, | ||
| MaintenanceEntryWithDetails, | ||
| } from "~~/lib/api/types/data-contracts"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Verification inconclusive
Script executed:
#!/bin/bash
# Find all GroupInvitation interface definitions in data-contracts.ts
echo "=== GroupInvitation definitions in data-contracts.ts ==="
rg -n "interface GroupInvitation" frontend/lib/api/types/data-contracts.ts -A 6
# Check the context around the import in utils.ts
echo ""
echo "=== Import statement in utils.ts ==="
sed -n '1,15p' frontend/components/ui/dialog-provider/utils.ts
# Check how CreateGroupInvite dialog result is used
echo ""
echo "=== Usage of CreateGroupInvite result ==="
rg -n "CreateGroupInvite" frontend --type=ts -C 3Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 0
stdout:
=== GroupInvitation definitions in data-contracts.ts ===
575:export interface GroupInvitation {
576- expiresAt: Date | string;
577- group: Group;
578- id: string;
579- uses: number;
580-}
581-
--
1126:export interface GroupInvitation {
1127- expiresAt: Date | string;
1128- token: string;
1129- uses: number;
1130-}
1131-
1132:export interface GroupInvitationCreate {
1133- expiresAt: Date | string;
1134- /**
1135- * `@min` 1
1136- * `@max` 100
1137- */
1138- uses: number;
=== Import statement in utils.ts ===
import { computed, type ComputedRef } from "vue";
import { createContext } from "reka-ui";
import { useMagicKeys, useActiveElement } from "@vueuse/core";
import type {
BarcodeProduct,
GroupInvitation,
ItemSummary,
MaintenanceEntry,
MaintenanceEntryWithDetails,
} from "~~/lib/api/types/data-contracts";
export enum DialogID {
AttachmentEdit = "attachment-edit",
ChangePassword = "changePassword",
CreateItem = "create-item",
=== Usage of CreateGroupInvite result ===
frontend/components/ui/dialog-provider/utils.ts-16- CreateLocation = "create-location",
frontend/components/ui/dialog-provider/utils.ts-17- CreateLabel = "create-label",
frontend/components/ui/dialog-provider/utils.ts-18- CreateCollection = "create-collection",
frontend/components/ui/dialog-provider/utils.ts:19: CreateGroupInvite = "create-group-invite",
frontend/components/ui/dialog-provider/utils.ts-20- JoinCollection = "join-collection",
frontend/components/ui/dialog-provider/utils.ts-21- CreateNotifier = "create-notifier",
frontend/components/ui/dialog-provider/utils.ts-22- CreateTemplate = "create-template",
--
frontend/components/ui/dialog-provider/utils.ts-84- [DialogID.EditMaintenance]?: boolean;
frontend/components/ui/dialog-provider/utils.ts-85- [DialogID.ItemChangeDetails]?: boolean;
frontend/components/ui/dialog-provider/utils.ts-86- [DialogID.WipeInventory]?: { wipeLabels: boolean; wipeLocations: boolean; wipeMaintenance: boolean };
frontend/components/ui/dialog-provider/utils.ts:87: [DialogID.CreateGroupInvite]?: GroupInvitation;
frontend/components/ui/dialog-provider/utils.ts-88-};
frontend/components/ui/dialog-provider/utils.ts-89-
frontend/components/ui/dialog-provider/utils.ts-90-/** Helpers to split IDs by requirement */
Script executed:
#!/bin/bash
# Find where CreateGroupInvite dialog result is actually used
echo "=== Where CreateGroupInvite dialog is opened/handled ==="
rg -n "CreateGroupInvite" frontend --type=ts --type=vue -B 2 -A 5 | head -100
# Look for group invitation API calls and responses
echo ""
echo "=== Group invitation API endpoints ==="
rg -n "group.*invitation|GroupInvitation" frontend --type=ts --type=vue | grep -i "api\|endpoint\|fetch\|request" | head -20Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 0
stdout:
=== Where CreateGroupInvite dialog is opened/handled ===
=== Group invitation API endpoints ===
stderr:
unrecognized file type: vue
unrecognized file type: vue
Script executed:
#!/bin/bash
# Search for CreateGroupInvite without type restrictions
echo "=== CreateGroupInvite usage (all files) ==="
rg "CreateGroupInvite" frontend
# Check backend implementation
echo ""
echo "=== Backend group invitation definitions ==="
fd "group" backend --type f -name "*.go" | head -10
# Check if this is new enum value
echo ""
echo "=== DialogID enum full definition ==="
sed -n '12,30p' frontend/components/ui/dialog-provider/utils.ts
# Look for group invitation response handling
echo ""
echo "=== Group related API responses/types ==="
rg -n "group" frontend/lib/api/types/data-contracts.ts | grep -i "invitation\|response" | head -10Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 0
stdout:
=== CreateGroupInvite usage (all files) ===
frontend/pages/collection/index/invites.vue: openDialog(DialogID.CreateGroupInvite, {
frontend/components/Collection/InviteCreateModal.vue: <BaseModal :dialog-id="DialogID.CreateGroupInvite" :title="$t('collection.create_invite')" :hide-footer="true">
frontend/components/Collection/InviteCreateModal.vue: if (active && active === DialogID.CreateGroupInvite) {
frontend/components/Collection/InviteCreateModal.vue: closeDialog(DialogID.CreateGroupInvite, data);
frontend/components/ui/dialog-provider/utils.ts: CreateGroupInvite = "create-group-invite",
frontend/components/ui/dialog-provider/utils.ts: [DialogID.CreateGroupInvite]?: GroupInvitation;
=== Backend group invitation definitions ===
=== DialogID enum full definition ===
export enum DialogID {
AttachmentEdit = "attachment-edit",
ChangePassword = "changePassword",
CreateItem = "create-item",
CreateLocation = "create-location",
CreateLabel = "create-label",
CreateCollection = "create-collection",
CreateGroupInvite = "create-group-invite",
JoinCollection = "join-collection",
CreateNotifier = "create-notifier",
CreateTemplate = "create-template",
DuplicateSettings = "duplicate-settings",
DuplicateTemporarySettings = "duplicate-temporary-settings",
EditMaintenance = "edit-maintenance",
Import = "import",
ItemImage = "item-image",
ItemTableSettings = "item-table-settings",
PrintLabel = "print-label",
ProductImport = "product-import",
=== Group related API responses/types ===
stderr:
error: Found argument '-n' which wasn't expected, or isn't valid in this context
If you tried to supply '-n' as a value rather than a flag, use '-- -n'
Usage: fd [OPTIONS] [pattern] [path]...
For more information try '--help'
Script executed:
#!/bin/bash
# Check what data InviteCreateModal returns
echo "=== InviteCreateModal.vue - relevant sections ==="
rg -n "closeDialog" frontend/components/Collection/InviteCreateModal.vue -B 10 -A 2
# Also check the API call to understand response shape
echo ""
echo "=== API calls in InviteCreateModal ==="
rg -n "useUserApi\|api\|POST\|request" frontend/components/Collection/InviteCreateModal.vue -C 2Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 1
stdout:
=== InviteCreateModal.vue - relevant sections ===
33- import { useDialog } from "~/components/ui/dialog-provider";
34- import BaseModal from "@/components/App/CreateModal.vue";
35- import FormTextField from "~/components/Form/TextField.vue";
36- import { Button, ButtonGroup } from "~/components/ui/button";
37- import { Label } from "~/components/ui/label";
38- import { toast } from "@/components/ui/sonner";
39- import { useUserApi } from "~/composables/use-api";
40- import { darkThemes } from "~/lib/data/themes";
41-
42- const { t } = useI18n();
43: const { activeDialog, closeDialog } = useDialog();
44- const api = useUserApi();
45-
--
103- });
104-
105- if (res.error) {
106- const msg = t("errors.api_failure") + String(res.error);
107- toast.error(msg);
108- loading.value = false;
109- return;
110- }
111-
112- const data = res.data ?? undefined;
113: closeDialog(DialogID.CreateGroupInvite, data);
114- } catch (e) {
115- const msg = (e as Error).message ?? String(e);
=== API calls in InviteCreateModal ===
Script executed:
#!/bin/bash
# Read the InviteCreateModal.vue file to see full context
echo "=== InviteCreateModal.vue content ==="
cat frontend/components/Collection/InviteCreateModal.vue | head -120
# Also search for group invitation creation endpoint
echo ""
echo "=== Group invitation creation endpoint ==="
rg "group.*invite" frontend/lib/api --type ts -i -C 3Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 1
stdout:
=== InviteCreateModal.vue content ===
<template>
<BaseModal :dialog-id="DialogID.CreateGroupInvite" :title="$t('collection.create_invite')" :hide-footer="true">
<form class="flex min-w-0 flex-col gap-4" `@submit.prevent`="create">
<FormTextField v-model="form.uses" :label="$t('collection.uses')" type="number" :required="true" />
<div class="flex w-full flex-col gap-1.5">
<Label class="cursor-pointer">{{ $t("collection.expires_at") }}</Label>
<VueDatePicker
v-model="form.expiresAt"
:enable-time-picker="true"
clearable
:dark="isDark"
:format="formatDateTime"
/>
</div>
<div class="mt-4 flex flex-row-reverse">
<ButtonGroup>
<Button :disabled="loading" type="submit">
{{ $t("global.create") }}
</Button>
</ButtonGroup>
</div>
</form>
</BaseModal>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import VueDatePicker from "@vuepic/vue-datepicker";
import "@vuepic/vue-datepicker/dist/main.css";
import { DialogID } from "@/components/ui/dialog-provider/utils";
import { useDialog } from "~/components/ui/dialog-provider";
import BaseModal from "@/components/App/CreateModal.vue";
import FormTextField from "~/components/Form/TextField.vue";
import { Button, ButtonGroup } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { toast } from "@/components/ui/sonner";
import { useUserApi } from "~/composables/use-api";
import { darkThemes } from "~/lib/data/themes";
const { t } = useI18n();
const { activeDialog, closeDialog } = useDialog();
const api = useUserApi();
const loading = ref(false);
const form = reactive<{ uses: number; expiresAt: Date | null }>({
uses: 1,
expiresAt: defaultExpiry(),
});
const isDark = useIsThemeInList(darkThemes);
const formatDateTime = (date: Date | string | number) => fmtDate(date, "human", "datetime");
function defaultExpiry(): Date {
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
}
watch(
() => activeDialog.value,
active => {
if (active && active === DialogID.CreateGroupInvite) {
form.uses = 1;
form.expiresAt = defaultExpiry();
loading.value = false;
}
}
);
async function create() {
if (loading.value) {
return;
}
const parsedUses = Number(form.uses ?? 0);
if (!Number.isFinite(parsedUses) || parsedUses < 1 || parsedUses > 100) {
toast.error(t("components.collection.invite_create_modal.toast.invalid_uses"));
return;
}
const uses = parsedUses;
if (!form.expiresAt) {
toast.error(t("components.collection.invite_create_modal.toast.invalid_expiry_missing"));
return;
}
const now = new Date();
const exp = new Date(form.expiresAt);
if (exp.getTime() <= now.getTime()) {
toast.error(t("components.collection.invite_create_modal.toast.invalid_expiry_past"));
return;
}
const expiresAtToSend: Date = exp;
loading.value = true;
try {
const res = await api.group.createInvitation({
expiresAt: expiresAtToSend,
uses,
});
if (res.error) {
const msg = t("errors.api_failure") + String(res.error);
toast.error(msg);
loading.value = false;
return;
}
const data = res.data ?? undefined;
closeDialog(DialogID.CreateGroupInvite, data);
} catch (e) {
const msg = (e as Error).message ?? String(e);
toast.error(msg);
} finally {
loading.value = false;
}
}
=== Group invitation creation endpoint ===
Script executed:
#!/bin/bash
# Find useUserApi implementation
echo "=== useUserApi composable ==="
find frontend -name "*use*api*" -type f
# Search for createInvitation method
echo ""
echo "=== createInvitation method definition ==="
rg "createInvitation" frontend --type ts -B 2 -A 10
# Also check the API types/methods generated from OpenAPI
echo ""
echo "=== Group API definition ==="
fd "group" frontend/lib/api --type fRepository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 0
stdout:
=== useUserApi composable ===
frontend/composables/use-api.ts
=== createInvitation method definition ===
frontend/lib/api/classes/group.ts- * Create a new invitation for the current group.
frontend/lib/api/classes/group.ts- */
frontend/lib/api/classes/group.ts: createInvitation(data: GroupInvitationCreate) {
frontend/lib/api/classes/group.ts- return this.http.post<GroupInvitationCreate, GroupInvitation>({
frontend/lib/api/classes/group.ts- url: route("/groups/invitations"),
frontend/lib/api/classes/group.ts- body: data,
frontend/lib/api/classes/group.ts- });
frontend/lib/api/classes/group.ts- }
frontend/lib/api/classes/group.ts-
frontend/lib/api/classes/group.ts- /**
frontend/lib/api/classes/group.ts- * Accept an invitation.
frontend/lib/api/classes/group.ts- */
frontend/lib/api/classes/group.ts- acceptInvitation(id: string) {
--
frontend/lib/api/__test__/user/group.test.ts- const { data: user1 } = await client.user.self();
frontend/lib/api/__test__/user/group.test.ts-
frontend/lib/api/__test__/user/group.test.ts: const { response, data } = await client.group.createInvitation({
frontend/lib/api/__test__/user/group.test.ts- expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
frontend/lib/api/__test__/user/group.test.ts- uses: 1,
frontend/lib/api/__test__/user/group.test.ts- });
frontend/lib/api/__test__/user/group.test.ts-
frontend/lib/api/__test__/user/group.test.ts- expect(response.status).toBe(201);
frontend/lib/api/__test__/user/group.test.ts- expect(data.token).toBeTruthy();
frontend/lib/api/__test__/user/group.test.ts-
frontend/lib/api/__test__/user/group.test.ts- // Create User 2 with token
frontend/lib/api/__test__/user/group.test.ts- const duplicateUser = factories.user();
frontend/lib/api/__test__/user/group.test.ts- duplicateUser.token = data.token;
=== Group API definition ===
frontend/lib/api/__test__/user/group.test.ts
frontend/lib/api/classes/group.ts
Script executed:
#!/bin/bash
# Check which GroupInvitation definition is used
echo "=== First GroupInvitation usage (with group/id fields) ==="
rg "GroupInvitation" frontend --type ts | grep -v "data-contracts" | grep -v "types"
# Check backend implementation to see which shape is actually returned
echo ""
echo "=== Backend group invitation endpoints ==="
find backend -name "*.go" -type f | xargs grep -l "GroupInvitation" 2>/dev/null | head -5
# Verify test expectations
echo ""
echo "=== Full group.test.ts expectations ==="
rg "data\." frontend/lib/api/__test__/user/group.test.ts | grep -i invitation -A 2 -B 2Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 1
stdout:
=== First GroupInvitation usage (with group/id fields) ===
frontend/components/ui/dialog-provider/utils.ts: GroupInvitation,
frontend/components/ui/dialog-provider/utils.ts: [DialogID.CreateGroupInvite]?: GroupInvitation;
frontend/lib/api/classes/group.ts: GroupInvitation,
frontend/lib/api/classes/group.ts: GroupInvitationCreate,
frontend/lib/api/classes/group.ts: createInvitation(data: GroupInvitationCreate) {
frontend/lib/api/classes/group.ts: return this.http.post<GroupInvitationCreate, GroupInvitation>({
frontend/lib/api/classes/group.ts: return this.http.get<GroupInvitation[]>({
=== Backend group invitation endpoints ===
backend/app/api/handlers/v1/v1_ctrl_group.go
backend/app/api/routes.go
backend/app/api/static/docs/docs.go
backend/internal/core/services/service_group.go
backend/internal/core/services/service_user.go
=== Full group.test.ts expectations ===
Script executed:
#!/bin/bash
# Check backend GroupInvitation structure
echo "=== Backend GroupInvitation definitions ==="
rg "type GroupInvitation" backend --type go -A 10
# Verify actual API response
echo ""
echo "=== Test verification of API response ==="
sed -n '1,50p' frontend/lib/api/__test__/user/group.test.ts | tail -20Repository: sysadminsmedia/homebox
Repository: sysadminsmedia/homebox
Exit code: 0
stdout:
=== Backend GroupInvitation definitions ===
backend/internal/data/ent/groupinvitationtoken_create.go:type GroupInvitationTokenCreate struct {
backend/internal/data/ent/groupinvitationtoken_create.go- config
backend/internal/data/ent/groupinvitationtoken_create.go- mutation *GroupInvitationTokenMutation
backend/internal/data/ent/groupinvitationtoken_create.go- hooks []Hook
backend/internal/data/ent/groupinvitationtoken_create.go-}
backend/internal/data/ent/groupinvitationtoken_create.go-
backend/internal/data/ent/groupinvitationtoken_create.go-// SetCreatedAt sets the "created_at" field.
backend/internal/data/ent/groupinvitationtoken_create.go-func (_c *GroupInvitationTokenCreate) SetCreatedAt(v time.Time) *GroupInvitationTokenCreate {
backend/internal/data/ent/groupinvitationtoken_create.go- _c.mutation.SetCreatedAt(v)
backend/internal/data/ent/groupinvitationtoken_create.go- return _c
backend/internal/data/ent/groupinvitationtoken_create.go-}
--
backend/internal/data/ent/groupinvitationtoken_create.go:type GroupInvitationTokenCreateBulk struct {
backend/internal/data/ent/groupinvitationtoken_create.go- config
backend/internal/data/ent/groupinvitationtoken_create.go- err error
backend/internal/data/ent/groupinvitationtoken_create.go- builders []*GroupInvitationTokenCreate
backend/internal/data/ent/groupinvitationtoken_create.go-}
backend/internal/data/ent/groupinvitationtoken_create.go-
backend/internal/data/ent/groupinvitationtoken_create.go-// Save creates the GroupInvitationToken entities in the database.
backend/internal/data/ent/groupinvitationtoken_create.go-func (_c *GroupInvitationTokenCreateBulk) Save(ctx context.Context) ([]*GroupInvitationToken, error) {
backend/internal/data/ent/groupinvitationtoken_create.go- if _c.err != nil {
backend/internal/data/ent/groupinvitationtoken_create.go- return nil, _c.err
backend/internal/data/ent/groupinvitationtoken_create.go- }
--
backend/internal/data/ent/predicate/predicate.go:type GroupInvitationToken func(*sql.Selector)
backend/internal/data/ent/predicate/predicate.go-
backend/internal/data/ent/predicate/predicate.go-// Item is the predicate function for item builders.
backend/internal/data/ent/predicate/predicate.go-type Item func(*sql.Selector)
backend/internal/data/ent/predicate/predicate.go-
backend/internal/data/ent/predicate/predicate.go-// ItemField is the predicate function for itemfield builders.
backend/internal/data/ent/predicate/predicate.go-type ItemField func(*sql.Selector)
backend/internal/data/ent/predicate/predicate.go-
backend/internal/data/ent/predicate/predicate.go-// ItemTemplate is the predicate function for itemtemplate builders.
backend/internal/data/ent/predicate/predicate.go-type ItemTemplate func(*sql.Selector)
backend/internal/data/ent/predicate/predicate.go-
--
backend/internal/data/ent/schema/group_invitation_token.go:type GroupInvitationToken struct {
backend/internal/data/ent/schema/group_invitation_token.go- ent.Schema
backend/internal/data/ent/schema/group_invitation_token.go-}
backend/internal/data/ent/schema/group_invitation_token.go-
backend/internal/data/ent/schema/group_invitation_token.go-func (GroupInvitationToken) Mixin() []ent.Mixin {
backend/internal/data/ent/schema/group_invitation_token.go- return []ent.Mixin{
backend/internal/data/ent/schema/group_invitation_token.go- mixins.BaseMixin{},
backend/internal/data/ent/schema/group_invitation_token.go- }
backend/internal/data/ent/schema/group_invitation_token.go-}
backend/internal/data/ent/schema/group_invitation_token.go-
backend/internal/data/ent/schema/group_invitation_token.go-// Fields of the GroupInvitationToken.
--
backend/internal/data/ent/mutation.go:type GroupInvitationTokenMutation struct {
backend/internal/data/ent/mutation.go- config
backend/internal/data/ent/mutation.go- op Op
backend/internal/data/ent/mutation.go- typ string
backend/internal/data/ent/mutation.go- id *uuid.UUID
backend/internal/data/ent/mutation.go- created_at *time.Time
backend/internal/data/ent/mutation.go- updated_at *time.Time
backend/internal/data/ent/mutation.go- token *[]byte
backend/internal/data/ent/mutation.go- expires_at *time.Time
backend/internal/data/ent/mutation.go- uses *int
backend/internal/data/ent/mutation.go- adduses *int
--
backend/internal/data/ent/groupinvitationtoken_query.go:type GroupInvitationTokenQuery struct {
backend/internal/data/ent/groupinvitationtoken_query.go- config
backend/internal/data/ent/groupinvitationtoken_query.go- ctx *QueryContext
backend/internal/data/ent/groupinvitationtoken_query.go- order []groupinvitationtoken.OrderOption
backend/internal/data/ent/groupinvitationtoken_query.go- inters []Interceptor
backend/internal/data/ent/groupinvitationtoken_query.go- predicates []predicate.GroupInvitationToken
backend/internal/data/ent/groupinvitationtoken_query.go- withGroup *GroupQuery
backend/internal/data/ent/groupinvitationtoken_query.go- withFKs bool
backend/internal/data/ent/groupinvitationtoken_query.go- // intermediate query (i.e. traversal path).
backend/internal/data/ent/groupinvitationtoken_query.go- sql *sql.Selector
backend/internal/data/ent/groupinvitationtoken_query.go- path func(context.Context) (*sql.Selector, error)
--
backend/internal/data/ent/groupinvitationtoken_query.go:type GroupInvitationTokenGroupBy struct {
backend/internal/data/ent/groupinvitationtoken_query.go- selector
backend/internal/data/ent/groupinvitationtoken_query.go- build *GroupInvitationTokenQuery
backend/internal/data/ent/groupinvitationtoken_query.go-}
backend/internal/data/ent/groupinvitationtoken_query.go-
backend/internal/data/ent/groupinvitationtoken_query.go-// Aggregate adds the given aggregation functions to the group-by query.
backend/internal/data/ent/groupinvitationtoken_query.go-func (_g *GroupInvitationTokenGroupBy) Aggregate(fns ...AggregateFunc) *GroupInvitationTokenGroupBy {
backend/internal/data/ent/groupinvitationtoken_query.go- _g.fns = append(_g.fns, fns...)
backend/internal/data/ent/groupinvitationtoken_query.go- return _g
backend/internal/data/ent/groupinvitationtoken_query.go-}
backend/internal/data/ent/groupinvitationtoken_query.go-
--
backend/internal/data/ent/groupinvitationtoken_query.go:type GroupInvitationTokenSelect struct {
backend/internal/data/ent/groupinvitationtoken_query.go- *GroupInvitationTokenQuery
backend/internal/data/ent/groupinvitationtoken_query.go- selector
backend/internal/data/ent/groupinvitationtoken_query.go-}
backend/internal/data/ent/groupinvitationtoken_query.go-
backend/internal/data/ent/groupinvitationtoken_query.go-// Aggregate adds the given aggregation functions to the selector query.
backend/internal/data/ent/groupinvitationtoken_query.go-func (_s *GroupInvitationTokenSelect) Aggregate(fns ...AggregateFunc) *GroupInvitationTokenSelect {
backend/internal/data/ent/groupinvitationtoken_query.go- _s.fns = append(_s.fns, fns...)
backend/internal/data/ent/groupinvitationtoken_query.go- return _s
backend/internal/data/ent/groupinvitationtoken_query.go-}
backend/internal/data/ent/groupinvitationtoken_query.go-
--
backend/internal/data/ent/groupinvitationtoken_update.go:type GroupInvitationTokenUpdate struct {
backend/internal/data/ent/groupinvitationtoken_update.go- config
backend/internal/data/ent/groupinvitationtoken_update.go- hooks []Hook
backend/internal/data/ent/groupinvitationtoken_update.go- mutation *GroupInvitationTokenMutation
backend/internal/data/ent/groupinvitationtoken_update.go-}
backend/internal/data/ent/groupinvitationtoken_update.go-
backend/internal/data/ent/groupinvitationtoken_update.go-// Where appends a list predicates to the GroupInvitationTokenUpdate builder.
backend/internal/data/ent/groupinvitationtoken_update.go-func (_u *GroupInvitationTokenUpdate) Where(ps ...predicate.GroupInvitationToken) *GroupInvitationTokenUpdate {
backend/internal/data/ent/groupinvitationtoken_update.go- _u.mutation.Where(ps...)
backend/internal/data/ent/groupinvitationtoken_update.go- return _u
backend/internal/data/ent/groupinvitationtoken_update.go-}
--
backend/internal/data/ent/groupinvitationtoken_update.go:type GroupInvitationTokenUpdateOne struct {
backend/internal/data/ent/groupinvitationtoken_update.go- config
backend/internal/data/ent/groupinvitationtoken_update.go- fields []string
backend/internal/data/ent/groupinvitationtoken_update.go- hooks []Hook
backend/internal/data/ent/groupinvitationtoken_update.go- mutation *GroupInvitationTokenMutation
backend/internal/data/ent/groupinvitationtoken_update.go-}
backend/internal/data/ent/groupinvitationtoken_update.go-
backend/internal/data/ent/groupinvitationtoken_update.go-// SetUpdatedAt sets the "updated_at" field.
backend/internal/data/ent/groupinvitationtoken_update.go-func (_u *GroupInvitationTokenUpdateOne) SetUpdatedAt(v time.Time) *GroupInvitationTokenUpdateOne {
backend/internal/data/ent/groupinvitationtoken_update.go- _u.mutation.SetUpdatedAt(v)
backend/internal/data/ent/groupinvitationtoken_update.go- return _u
--
backend/internal/data/ent/groupinvitationtoken_delete.go:type GroupInvitationTokenDelete struct {
backend/internal/data/ent/groupinvitationtoken_delete.go- config
backend/internal/data/ent/groupinvitationtoken_delete.go- hooks []Hook
backend/internal/data/ent/groupinvitationtoken_delete.go- mutation *GroupInvitationTokenMutation
backend/internal/data/ent/groupinvitationtoken_delete.go-}
backend/internal/data/ent/groupinvitationtoken_delete.go-
backend/internal/data/ent/groupinvitationtoken_delete.go-// Where appends a list predicates to the GroupInvitationTokenDelete builder.
backend/internal/data/ent/groupinvitationtoken_delete.go-func (_d *GroupInvitationTokenDelete) Where(ps ...predicate.GroupInvitationToken) *GroupInvitationTokenDelete {
backend/internal/data/ent/groupinvitationtoken_delete.go- _d.mutation.Where(ps...)
backend/internal/data/ent/groupinvitationtoken_delete.go- return _d
backend/internal/data/ent/groupinvitationtoken_delete.go-}
--
backend/internal/data/ent/groupinvitationtoken_delete.go:type GroupInvitationTokenDeleteOne struct {
backend/internal/data/ent/groupinvitationtoken_delete.go- _d *GroupInvitationTokenDelete
backend/internal/data/ent/groupinvitationtoken_delete.go-}
backend/internal/data/ent/groupinvitationtoken_delete.go-
backend/internal/data/ent/groupinvitationtoken_delete.go-// Where appends a list predicates to the GroupInvitationTokenDelete builder.
backend/internal/data/ent/groupinvitationtoken_delete.go-func (_d *GroupInvitationTokenDeleteOne) Where(ps ...predicate.GroupInvitationToken) *GroupInvitationTokenDeleteOne {
backend/internal/data/ent/groupinvitationtoken_delete.go- _d._d.mutation.Where(ps...)
backend/internal/data/ent/groupinvitationtoken_delete.go- return _d
backend/internal/data/ent/groupinvitationtoken_delete.go-}
backend/internal/data/ent/groupinvitationtoken_delete.go-
backend/internal/data/ent/groupinvitationtoken_delete.go-// Exec executes the deletion query.
--
backend/internal/data/ent/groupinvitationtoken.go:type GroupInvitationToken struct {
backend/internal/data/ent/groupinvitationtoken.go- config `json:"-"`
backend/internal/data/ent/groupinvitationtoken.go- // ID of the ent.
backend/internal/data/ent/groupinvitationtoken.go- ID uuid.UUID `json:"id,omitempty"`
backend/internal/data/ent/groupinvitationtoken.go- // CreatedAt holds the value of the "created_at" field.
backend/internal/data/ent/groupinvitationtoken.go- CreatedAt time.Time `json:"created_at,omitempty"`
backend/internal/data/ent/groupinvitationtoken.go- // UpdatedAt holds the value of the "updated_at" field.
backend/internal/data/ent/groupinvitationtoken.go- UpdatedAt time.Time `json:"updated_at,omitempty"`
backend/internal/data/ent/groupinvitationtoken.go- // Token holds the value of the "token" field.
backend/internal/data/ent/groupinvitationtoken.go- Token []byte `json:"token,omitempty"`
backend/internal/data/ent/groupinvitationtoken.go- // ExpiresAt holds the value of the "expires_at" field.
--
backend/internal/data/ent/groupinvitationtoken.go:type GroupInvitationTokenEdges struct {
backend/internal/data/ent/groupinvitationtoken.go- // Group holds the value of the group edge.
backend/internal/data/ent/groupinvitationtoken.go- Group *Group `json:"group,omitempty"`
backend/internal/data/ent/groupinvitationtoken.go- // loadedTypes holds the information for reporting if a
backend/internal/data/ent/groupinvitationtoken.go- // type was loaded (or requested) in eager-loading or not.
backend/internal/data/ent/groupinvitationtoken.go- loadedTypes [1]bool
backend/internal/data/ent/groupinvitationtoken.go-}
backend/internal/data/ent/groupinvitationtoken.go-
backend/internal/data/ent/groupinvitationtoken.go-// GroupOrErr returns the Group value or an error if the edge
backend/internal/data/ent/groupinvitationtoken.go-// was not loaded in eager-loading, or loaded but was not found.
backend/internal/data/ent/groupinvitationtoken.go-func (e GroupInvitationTokenEdges) GroupOrErr() (*Group, error) {
--
backend/internal/data/ent/groupinvitationtoken.go:type GroupInvitationTokens []*GroupInvitationToken
--
backend/internal/data/ent/hook/hook.go:type GroupInvitationTokenFunc func(context.Context, *ent.GroupInvitationTokenMutation) (ent.Value, error)
backend/internal/data/ent/hook/hook.go-
backend/internal/data/ent/hook/hook.go-// Mutate calls f(ctx, m).
backend/internal/data/ent/hook/hook.go-func (f GroupInvitationTokenFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
backend/internal/data/ent/hook/hook.go- if mv, ok := m.(*ent.GroupInvitationTokenMutation); ok {
backend/internal/data/ent/hook/hook.go- return f(ctx, mv)
backend/internal/data/ent/hook/hook.go- }
backend/internal/data/ent/hook/hook.go- return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.GroupInvitationTokenMutation", m)
backend/internal/data/ent/hook/hook.go-}
backend/internal/data/ent/hook/hook.go-
backend/internal/data/ent/hook/hook.go-// The ItemFunc type is an adapter to allow the use of ordinary
--
backend/internal/data/ent/client.go:type GroupInvitationTokenClient struct {
backend/internal/data/ent/client.go- config
backend/internal/data/ent/client.go-}
backend/internal/data/ent/client.go-
backend/internal/data/ent/client.go-// NewGroupInvitationTokenClient returns a client for the GroupInvitationToken from the given config.
backend/internal/data/ent/client.go-func NewGroupInvitationTokenClient(c config) *GroupInvitationTokenClient {
backend/internal/data/ent/client.go- return &GroupInvitationTokenClient{config: c}
backend/internal/data/ent/client.go-}
backend/internal/data/ent/client.go-
backend/internal/data/ent/client.go-// Use adds a list of mutation hooks to the hooks stack.
backend/internal/data/ent/client.go-// A call to `Use(f, g, h)` equals to `groupinvitationtoken.Hooks(f(g(h())))`.
=== Test verification of API response ===
expect(response.status).toBe(200);
expect(group.name).toBeTruthy();
expect(group.currency).toBe("USD");
});
test("user should be able to join create join token and have user signup", async () => {
const api = factories.client.public();
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
Remove or consolidate duplicate GroupInvitation interface definitions.
The file data-contracts.ts contains two interfaces with the same name at lines 575-580 and 1126-1130:
- Lines 575-580:
{ expiresAt, group, id, uses }(unused) - Lines 1126-1130:
{ expiresAt, token, uses }(actual API response)
Currently, the code works because TypeScript uses the second definition, which correctly matches the API response. However, this duplication is a maintainability risk—the first definition appears to be dead code, and relying on declaration order is fragile. Rename one interface (e.g., GroupInvitationDetail) or remove unused definitions to clarify intent.
🤖 Prompt for AI Agents
In `@frontend/components/ui/dialog-provider/utils.ts` around lines 4 - 10, There
are two duplicate TypeScript interfaces named GroupInvitation in the generated
types; remove the unused/incorrect one (the variant with fields { expiresAt,
group, id, uses }) or rename it (e.g., GroupInvitationDetail) so the actual API
shape ({ expiresAt, token, uses }) remains the canonical GroupInvitation; update
any references to the removed/renamed symbol to use the correct interface name
and run type-checks to ensure no remaining usages point to the old definition.
| let url = `${protocol}://${host}/api/v1/ws/events`; | ||
| if (currentTenantId) { | ||
| url += `?tenant=${currentTenantId}`; | ||
| } | ||
|
|
||
| const ws = new WebSocket(url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security: URL-encode the tenant ID to prevent query string injection.
The currentTenantId value originates from localStorage (collectionId) and is appended directly to the WebSocket URL. A malicious or corrupted value could inject additional query parameters or break the URL structure. Always encode user-controlled values when constructing URLs.
🔒 Proposed fix
let url = `${protocol}://${host}/api/v1/ws/events`;
if (currentTenantId) {
- url += `?tenant=${currentTenantId}`;
+ url += `?tenant=${encodeURIComponent(currentTenantId)}`;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let url = `${protocol}://${host}/api/v1/ws/events`; | |
| if (currentTenantId) { | |
| url += `?tenant=${currentTenantId}`; | |
| } | |
| const ws = new WebSocket(url); | |
| let url = `${protocol}://${host}/api/v1/ws/events`; | |
| if (currentTenantId) { | |
| url += `?tenant=${encodeURIComponent(currentTenantId)}`; | |
| } | |
| const ws = new WebSocket(url); |
🤖 Prompt for AI Agents
In `@frontend/composables/use-server-events.ts` around lines 30 - 35, The tenant
ID is appended to the WebSocket URL without encoding, allowing injection via
currentTenantId; update the URL construction in use-server-events (where url is
built and WebSocket is created) to URL-encode the tenant value (e.g., use
encodeURIComponent or build query via URLSearchParams) before appending so the
query string cannot be manipulated, then pass the safe url into the WebSocket
constructor.
| authURL(url: string): string { | ||
| if (this.attachmentToken) { | ||
| return route(url, { access_token: this.attachmentToken }); | ||
| const { selectedId } = useCollections(); | ||
| return route(url, { access_token: this.attachmentToken, tenant: selectedId.value }); | ||
| } | ||
| return url; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find and examine use-collections.ts to check selectedId.value initialization
find . -type f -name "use-collections.ts" | head -5Repository: sysadminsmedia/homebox
Length of output: 107
🏁 Script executed:
# Check authURL method and its context
rg -n "authURL" frontend/lib/api/base/base-api.ts -A 8 -B 2Repository: sysadminsmedia/homebox
Length of output: 471
🏁 Script executed:
# Find the route() function to understand parameter handling
rg -n "export.*function.*route|export const route" --type ts --type tsx -A 10Repository: sysadminsmedia/homebox
Length of output: 93
🏁 Script executed:
# Check usages of authURL to understand its impact
rg -n "authURL" --type ts --type tsx -B 2 -A 2 | head -50Repository: sysadminsmedia/homebox
Length of output: 93
🏁 Script executed:
# Read use-collections.ts to check selectedId.value initialization
cat -n ./frontend/composables/use-collections.tsRepository: sysadminsmedia/homebox
Length of output: 3208
🏁 Script executed:
# Search for route function more broadly
rg -n "^\s*(export\s+)?(const|function)\s+route" --type tsRepository: sysadminsmedia/homebox
Length of output: 408
🏁 Script executed:
# Check if route is imported in base-api.ts and from where
rg -n "import.*route|from.*route" frontend/lib/api/base/base-api.tsRepository: sysadminsmedia/homebox
Length of output: 94
🏁 Script executed:
# Examine the route() function implementation
cat -n frontend/lib/api/base/urls.tsRepository: sysadminsmedia/homebox
Length of output: 1657
🏁 Script executed:
# Check authURL usage patterns - when and how is it called
rg -n "authURL" --type ts -B 3 -A 3Repository: sysadminsmedia/homebox
Length of output: 744
🏁 Script executed:
# Check if there are any tests that show the expected behavior
find . -name "*.test.ts" -o -name "*.spec.ts" | xargs grep -l "authURL" 2>/dev/null | head -5Repository: sysadminsmedia/homebox
Length of output: 48
Potential issue: selectedId.value may be null, resulting in tenant=null string in URL.
From useCollections() (use-collections.ts line 12 and 57), selectedId.value is typed as string | null and can be explicitly set to null when no collections are available. The route() function (urls.ts line 31) converts all parameters via String(value), which converts null to the string "null". This could cause backend parsing issues or unintended behavior.
Guard against null by conditionally including the tenant parameter:
authURL(url: string): string {
if (this.attachmentToken) {
const { selectedId } = useCollections();
- return route(url, { access_token: this.attachmentToken, tenant: selectedId.value });
+ const params: Record<string, string> = { access_token: this.attachmentToken };
+ if (selectedId.value) {
+ params.tenant = selectedId.value;
+ }
+ return route(url, params);
}
return url;
}Security note: Ensure the backend validates the tenant parameter against the user's authorized tenants to prevent unauthorized cross-tenant access via URL manipulation.
🤖 Prompt for AI Agents
In `@frontend/lib/api/base/base-api.ts` around lines 78 - 84, The authURL method
currently always passes tenant derived from useCollections().selectedId.value
which can be null and will serialize to "null"; update authURL (when
this.attachmentToken is set) to only include the tenant parameter if
selectedId.value !== null (build the params object conditionally before calling
route), e.g. include access_token always but add tenant only when not null, and
keep using route to generate the URL; ensure you reference authURL,
attachmentToken, useCollections, selectedId.value and route when making the
change.
| export interface GroupInvitation { | ||
| expiresAt: Date | string; | ||
| group: Group; | ||
| id: string; | ||
| uses: number; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's verify the GroupInvitation interfaces in the data-contracts.ts file
cd frontend
head -c 500 lib/api/types/data-contracts.ts | head -20
echo "---"
# Check for auto-generation markers
rg -n "auto.generated|DO NOT EDIT|regenerate" lib/api/types/data-contracts.ts | head -5
echo "---"
# Find all GroupInvitation interface definitions
rg -n "interface GroupInvitation" lib/api/types/data-contracts.tsRepository: sysadminsmedia/homebox
Length of output: 698
🏁 Script executed:
# Check the exact structure of both GroupInvitation interfaces
cd frontend
# Lines around 575-580
sed -n '570,585p' lib/api/types/data-contracts.ts
echo "---SEPARATOR---"
# Lines around 1126-1130
sed -n '1120,1135p' lib/api/types/data-contracts.tsRepository: sysadminsmedia/homebox
Length of output: 629
🏁 Script executed:
# Search backend for GroupInvitation in Swagger/API definitions
find . -path ./node_modules -prune -o -type f \( -name "*.go" -o -name "*.swagger.json" -o -name "*.yaml" -o -name "*.yml" \) -print | head -20
echo "---"
rg -l "GroupInvitation" --type goRepository: sysadminsmedia/homebox
Length of output: 2148
🏁 Script executed:
# Check backend for GroupInvitation struct definitions (likely the one with group)
rg -n "type GroupInvitation struct" backend/ --type go
echo "---"
# Check for group invitation token responses
rg -n "type.*Invitation.*struct" backend/ --type go | head -20Repository: sysadminsmedia/homebox
Length of output: 1435
🏁 Script executed:
# Find the API handlers that return GroupInvitation to understand the context
rg -B5 -A10 "GroupInvitation" backend/app/api/handlers/v1/v1_ctrl_group.go | head -60Repository: sysadminsmedia/homebox
Length of output: 1535
🏁 Script executed:
# Check if GroupInvitation is used differently in frontend
cd frontend
rg -n "GroupInvitation" src/ lib/ --type ts --type tsxRepository: sysadminsmedia/homebox
Length of output: 93
🏁 Script executed:
# Check if the first GroupInvitation (with group field) is actually used anywhere
cd frontend
rg -n "GroupInvitation" src/ lib/ --type ts | grep -v node_modulesRepository: sysadminsmedia/homebox
Length of output: 1148
🏁 Script executed:
# Check the Swagger generation or backend Swagger docs
rg -n "GroupInvitation" backend/ --type go -B2 -A8 | grep -i swagger -A8 -B8 | head -80Repository: sysadminsmedia/homebox
Length of output: 48
🏁 Script executed:
# Look for the actual endpoint definitions and their responses
rg -n "@Success.*GroupInvitation" backend/ --type go -B5 -A2Repository: sysadminsmedia/homebox
Length of output: 1305
🏁 Script executed:
# Check if the first GroupInvitation (with group field) is actually referenced anywhere in frontend
cd frontend
rg -n "\.group\s*\?" lib/api/classes/group.ts
echo "---"
# Check what the getInvitations endpoint is supposed to return
cat -n lib/api/classes/group.ts | sed -n '35,45p'Repository: sysadminsmedia/homebox
Length of output: 351
🏁 Script executed:
# Find the endpoint definition that would populate the first GroupInvitation
rg -n "repo.GroupInvitation" backend/ --type go -B3 -A3Repository: sysadminsmedia/homebox
Length of output: 3207
🏁 Script executed:
# Check the repo.GroupInvitation definition in the backend
rg -n "type GroupInvitation struct" backend/internal/data/repo/ --type go -A10Repository: sysadminsmedia/homebox
Length of output: 48
🏁 Script executed:
# Check if there's a GetAll endpoint that returns GroupInvitation with group field
rg -n "InvitationGetAll" backend/ --type go -A15 | head -50Repository: sysadminsmedia/homebox
Length of output: 2615
🏁 Script executed:
# Look at the swagger docs to see what repo.GroupInvitation is defined as
rg -n '"repo.GroupInvitation"' backend/app/api/static/docs/docs.go -A20 | head -40Repository: sysadminsmedia/homebox
Length of output: 765
🏁 Script executed:
# Check the backend repo.go to find the GroupInvitation struct definition
rg -n "type GroupInvitation struct" backend/internal/data/repo/ --type go -B2 -A8Repository: sysadminsmedia/homebox
Length of output: 48
🏁 Script executed:
# Verify the two different response types in the API handler
cd backend
cat -n app/api/handlers/v1/v1_ctrl_group.go | sed -n '45,120p'Repository: sysadminsmedia/homebox
Length of output: 2679
🏁 Script executed:
# Let's check if there's a GroupInvitation type in the repo package
rg -n "GroupInvitation" backend/internal/data/repo/ --type go | head -20Repository: sysadminsmedia/homebox
Length of output: 1958
🏁 Script executed:
# Check the frontend group API class to see how these types are used
cd frontend
cat -n lib/api/classes/group.ts | head -50Repository: sysadminsmedia/homebox
Length of output: 1508
🏁 Script executed:
# Let's see the exact backend repo.GroupInvitation struct definition
cd backend
rg -n "^type GroupInvitation struct" internal/data/repo/repo_group.go -A8Repository: sysadminsmedia/homebox
Length of output: 48
🏁 Script executed:
# And the handler-level GroupInvitation in the API handler
rg -n "^type GroupInvitation struct" app/api/handlers/v1/v1_ctrl_group.go -A5Repository: sysadminsmedia/homebox
Length of output: 142
🏁 Script executed:
# Get the exact GroupInvitation struct from repo_group.go
cd backend
sed -n '70,80p' internal/data/repo/repo_group.go
echo "---"
sed -n '40,50p' internal/data/repo/repo_group.goRepository: sysadminsmedia/homebox
Length of output: 638
🏁 Script executed:
# Find where the handler-level GroupInvitation is defined (in v1_ctrl_group.go)
cd backend
rg -n "GroupInvitation struct" app/api/handlers/v1/ --type go -B1 -A6Repository: sysadminsmedia/homebox
Length of output: 555
🏁 Script executed:
# Let's check what's happening in the mapper function at line 40-50
cd backend
sed -n '35,55p' internal/data/repo/repo_group.goRepository: sysadminsmedia/homebox
Length of output: 450
Duplicate GroupInvitation interface names: backend API returns different response shapes with identical names.
The backend defines two separate GroupInvitation structs with conflicting shapes:
- Handler-level (app/api/handlers/v1):
Token,ExpiresAt,Uses(POST endpoint) - Repo-level (internal/data/repo):
ID,ExpiresAt,Uses,Group(GET endpoint)
When Swagger generates TypeScript types, both get serialized as GroupInvitation, causing duplicate interface names that merge in TypeScript. This leads to type confusion: frontend code calling getInvitations() expects Group and ID fields, but TypeScript merges with the token-only shape.
Fix in backend: Distinguish these API response types by either:
- Renaming the handler-level response (e.g.,
GroupInvitationToken) - Having both endpoints return the same consistent shape
Security note: Type confusion can lead to tokens being unexpectedly serialized, missing group validation, or fields being accessed on wrong objects. Ensure frontend code doesn't accidentally expose or rely on merged fields.
Also applies to: 1126-1130
🤖 Prompt for AI Agents
In `@frontend/lib/api/types/data-contracts.ts` around lines 575 - 580, The
TypeScript types are colliding because two distinct backend structs both
serialize to GroupInvitation; update the backend so the handler-level POST
response uses a distinct type name (e.g., GroupInvitationToken) or make both
endpoints return the same unified shape, then regenerate Swagger/TS clients;
specifically change the handler response type in app/api/handlers/v1 (the POST
that returns Token/ExpiresAt/Uses) to a new struct name like
GroupInvitationToken (or align it with internal/data/repo's GroupInvitation that
includes ID/Group/ExpiresAt/Uses), update any references to getInvitations() and
the generated client, and re-run the OpenAPI/Swagger generation so the frontend
no longer merges the two shapes.
| <TableCell> | ||
| {{ new Date(inv.expiresAt).toLocaleString() }} | ||
| </TableCell> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential runtime error if expiresAt is undefined.
new Date(inv.expiresAt).toLocaleString() will produce "Invalid Date" if expiresAt is undefined or null. Consider adding a fallback.
Suggested fix
<TableCell>
- {{ new Date(inv.expiresAt).toLocaleString() }}
+ {{ inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : $t("global.never") }}
</TableCell>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <TableCell> | |
| {{ new Date(inv.expiresAt).toLocaleString() }} | |
| </TableCell> | |
| <TableCell> | |
| {{ inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : $t("global.never") }} | |
| </TableCell> |
🤖 Prompt for AI Agents
In `@frontend/pages/collection/index/invites.vue` around lines 182 - 184, The
TableCell currently does unguarded new Date(inv.expiresAt).toLocaleString(),
which can render "Invalid Date" or throw if expiresAt is null/undefined; update
the expression in the TableCell to check inv.expiresAt (and validate the
resulting Date) and render a safe fallback (e.g., '—' or empty string) when
missing or invalid; specifically, replace the direct call in the TableCell with
a conditional that first ensures inv.expiresAt exists (and that new
Date(inv.expiresAt).getTime() is a valid number) before calling
toLocaleString(), otherwise output the fallback.
* this is a test. add item fucntionality added * feat: collapse text on mobile --------- Co-authored-by: Ell <tonya@tokia.dev>
…ocking issues, really need to switch to a bulk api
Currently translated at 92.5% (644 of 696 strings) Translated using Weblate (Slovak) Currently translated at 92.5% (644 of 696 strings) Translated using Weblate (Slovak) Currently translated at 92.5% (644 of 696 strings) Translated using Weblate (French) Currently translated at 90.9% (633 of 696 strings) Translated using Weblate (French) Currently translated at 90.9% (633 of 696 strings) Translated using Weblate (French) Currently translated at 90.9% (633 of 696 strings) Translated using Weblate (French) Currently translated at 90.9% (633 of 696 strings) Translated using Weblate (German) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Spanish) Currently translated at 97.7% (680 of 696 strings) Translated using Weblate (Hungarian) Currently translated at 73.7% (513 of 696 strings) Translated using Weblate (Hungarian) Currently translated at 70.5% (491 of 696 strings) Translated using Weblate (Hungarian) Currently translated at 69.3% (483 of 696 strings) Deleted translation using Weblate (Tamil) Deleted translation using Weblate (Telugu) Deleted translation using Weblate (Greek) Deleted translation using Weblate (Arabic) Deleted translation using Weblate (Afrikaans) Update translation files Updated by "Remove blank strings" add-on in Weblate. Translated using Weblate (French) Currently translated at 90.0% (627 of 696 strings) Translated using Weblate (Romanian) Currently translated at 43.2% (301 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Czech) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Russian) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (French) Currently translated at 88.2% (614 of 696 strings) Translated using Weblate (French) Currently translated at 88.2% (614 of 696 strings) Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 96.2% (670 of 696 strings) Translated using Weblate (Italian) Currently translated at 86.7% (604 of 696 strings) Translated using Weblate (Czech) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Czech) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Czech) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Czech) Currently translated at 91.0% (634 of 696 strings) Translated using Weblate (Czech) Currently translated at 91.0% (634 of 696 strings) Translated using Weblate (Czech) Currently translated at 91.0% (634 of 696 strings) Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translated using Weblate (Slovak) Currently translated at 93.2% (649 of 696 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Spanish) Currently translated at 93.1% (648 of 696 strings) Translated using Weblate (Spanish) Currently translated at 91.3% (636 of 696 strings) Translated using Weblate (Spanish) Currently translated at 90.8% (632 of 696 strings) Translated using Weblate (Slovak) Currently translated at 88.9% (619 of 696 strings) Translated using Weblate (German) Currently translated at 94.5% (658 of 696 strings) Translated using Weblate (German) Currently translated at 94.5% (658 of 696 strings) Translated using Weblate (German) Currently translated at 94.5% (658 of 696 strings) Translated using Weblate (German) Currently translated at 94.5% (658 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (696 of 696 strings) Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translated using Weblate (German) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (German) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Polish) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 10.0% (63 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 6.1% (39 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 6.1% (39 of 630 strings) Translated using Weblate (Luxembourgish) Currently translated at 5.8% (37 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (German) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (German) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Czech) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Czech) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (630 of 630 strings) Translated using Weblate (Russian) Currently translated at 97.6% (615 of 630 strings) Translated using Weblate (Russian) Currently translated at 96.8% (610 of 630 strings) Translated using Weblate (Russian) Currently translated at 96.6% (609 of 630 strings) Translated using Weblate (Russian) Currently translated at 96.5% (608 of 630 strings) Translated using Weblate (Russian) Currently translated at 96.3% (607 of 630 strings) Translated using Weblate (Italian) Currently translated at 95.9% (598 of 623 strings) Translated using Weblate (Czech) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Luxembourgish) Currently translated at 5.7% (36 of 623 strings) Translated using Weblate (German) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.5% (608 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.5% (608 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.5% (608 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.5% (608 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.5% (608 of 623 strings) Translated using Weblate (Russian) Currently translated at 97.4% (607 of 623 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Afrikaans) Currently translated at 0.4% (3 of 623 strings) Added translation using Weblate (Afrikaans) Translated using Weblate (Dutch) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (German) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (German) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (German) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (German) Currently translated at 99.8% (622 of 623 strings) Translated using Weblate (German) Currently translated at 99.1% (618 of 623 strings) Translated using Weblate (French) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (French) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (French) Currently translated at 99.0% (617 of 623 strings) Translated using Weblate (French) Currently translated at 99.0% (617 of 623 strings) Translated using Weblate (French) Currently translated at 99.0% (617 of 623 strings) Translated using Weblate (French) Currently translated at 99.0% (617 of 623 strings) Translated using Weblate (Luxembourgish) Currently translated at 0.9% (6 of 623 strings) Translated using Weblate (Czech) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 81.7% (509 of 623 strings) Translated using Weblate (Finnish) Currently translated at 67.7% (422 of 623 strings) Translated using Weblate (Finnish) Currently translated at 66.4% (414 of 623 strings) Translated using Weblate (English) Currently translated at 97.7% (609 of 623 strings) Translated using Weblate (Finnish) Currently translated at 66.6% (415 of 623 strings) Translated using Weblate (Finnish) Currently translated at 66.6% (415 of 623 strings) Translated using Weblate (Finnish) Currently translated at 66.6% (415 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 63.2% (394 of 623 strings) Translated using Weblate (Finnish) Currently translated at 56.9% (355 of 623 strings) Translated using Weblate (Finnish) Currently translated at 55.8% (348 of 623 strings) Translated using Weblate (Finnish) Currently translated at 55.8% (348 of 623 strings) Translated using Weblate (Finnish) Currently translated at 55.8% (348 of 623 strings) Translated using Weblate (Finnish) Currently translated at 55.8% (348 of 623 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 98.7% (615 of 623 strings) Translated using Weblate (French) Currently translated at 95.9% (598 of 623 strings) Translated using Weblate (French) Currently translated at 95.9% (598 of 623 strings) Translated using Weblate (French) Currently translated at 95.9% (598 of 623 strings) Translated using Weblate (French) Currently translated at 95.9% (598 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 94.5% (589 of 623 strings) Translated using Weblate (French) Currently translated at 91.0% (567 of 623 strings) Translated using Weblate (French) Currently translated at 91.0% (567 of 623 strings) Translated using Weblate (French) Currently translated at 91.0% (567 of 623 strings) Translated using Weblate (French) Currently translated at 91.0% (567 of 623 strings) Translated using Weblate (French) Currently translated at 91.0% (567 of 623 strings) Translated using Weblate (French) Currently translated at 88.6% (552 of 623 strings) Translated using Weblate (French) Currently translated at 88.6% (552 of 623 strings) Translated using Weblate (French) Currently translated at 87.9% (548 of 623 strings) Translated using Weblate (French) Currently translated at 87.9% (548 of 623 strings) Translated using Weblate (French) Currently translated at 87.8% (547 of 623 strings) Translated using Weblate (French) Currently translated at 87.6% (546 of 623 strings) Translated using Weblate (French) Currently translated at 87.6% (546 of 623 strings) Translated using Weblate (French) Currently translated at 87.6% (546 of 623 strings) Translated using Weblate (French) Currently translated at 87.6% (546 of 623 strings) Translated using Weblate (German) Currently translated at 98.8% (616 of 623 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Czech) Currently translated at 100.0% (623 of 623 strings) Translated using Weblate (Dutch) Currently translated at 98.8% (616 of 623 strings) Translated using Weblate (Dutch) Currently translated at 98.8% (616 of 623 strings) Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translated using Weblate (German) Currently translated at 100.0% (612 of 612 strings) Translated using Weblate (German) Currently translated at 99.3% (608 of 612 strings) Translated using Weblate (German) Currently translated at 99.3% (608 of 612 strings) Translated using Weblate (German) Currently translated at 99.3% (608 of 612 strings) Translated using Weblate (German) Currently translated at 99.3% (608 of 612 strings) Co-authored-by: 101br03k <warmerdamm03@gmail.com> Co-authored-by: Adam Havránek <adamhavra@seznam.cz> Co-authored-by: Alberto Laurella <albertolaurella94@gmail.com> Co-authored-by: BL <chkta@tuta.io> Co-authored-by: Cheng Gu <guchengf@gmail.com> Co-authored-by: Clément Phan <phanou74370@gmail.com> Co-authored-by: Hannes Salen <hannes.salen@gmail.com> Co-authored-by: Henry Langenhoven <henry.langenhoven.dev@gmail.com> Co-authored-by: Jose Riha <jose1711@gmail.com> Co-authored-by: Krzysztof G. <mordret@o2.pl> Co-authored-by: Matthew Kilgore <matthew@kilgore.dev> Co-authored-by: MyMemory <noreply-mt-mymemory@weblate.org> Co-authored-by: Nacu George <nacu_george@hotmail.com> Co-authored-by: Pol Wagner <pol@plop.lu> Co-authored-by: Robert Eggl <robert@eggl.dev> Co-authored-by: Simon \"Tenry\" Burchert <s.burchert@yahoo.de> Co-authored-by: Simon \"Tenry\" Burchert <tenry@users.noreply.translate.sysadminsmedia.com> Co-authored-by: Simone Girardi <s.girardi92@gmail.com> Co-authored-by: Slydite4 <39199098+Slydite4@users.noreply.github.com> Co-authored-by: Supertriton <tristan.marie@laposte.net> Co-authored-by: Weblate <noreply-mt-weblate@weblate.org> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org> Co-authored-by: askolock <askolock@gmail.com> Co-authored-by: buzz <buzz.eclair@gmail.com> Co-authored-by: dennse78 <github@dennse.de> Co-authored-by: hzolt <hegyi.zoltan.01@gmail.com> Co-authored-by: j4nv <jan@mail-vogelsang.de> Co-authored-by: kaiwester <kai.westerschwiensterdt@gmail.com> Co-authored-by: mco1969 <mco1969@gmx.de> Co-authored-by: pannupanu <pannupanu@users.noreply.translate.sysadminsmedia.com> Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/af/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/cs/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/de/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/en/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/es/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/fi/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/fr/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/hu/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/it/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/lb/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/nl/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/pl/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ro/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ru/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/sk/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/zh_Hans/ Translation: Homebox/Frontend
Currently translated at 68.2% (475 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Dutch) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (German) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (German) Currently translated at 97.7% (680 of 696 strings) Translated using Weblate (German) Currently translated at 97.4% (678 of 696 strings) Translated using Weblate (German) Currently translated at 96.8% (674 of 696 strings) Translated using Weblate (German) Currently translated at 96.5% (672 of 696 strings) Translated using Weblate (Serbian) Currently translated at 1.2% (9 of 696 strings) Translated using Weblate (Serbian) Currently translated at 0.2% (2 of 696 strings) Added translation using Weblate (Serbian) Translated using Weblate (French) Currently translated at 93.9% (654 of 696 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Czech) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Danish) Currently translated at 99.4% (692 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Danish) Currently translated at 99.4% (692 of 696 strings) Translated using Weblate (Danish) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Danish) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Danish) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Danish) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 97.9% (682 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.8% (667 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 95.5% (665 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 89.0% (620 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 88.9% (619 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 84.3% (587 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 83.0% (578 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 83.0% (578 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 82.9% (577 of 696 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 82.7% (576 of 696 strings) Translated using Weblate (Danish) Currently translated at 96.1% (669 of 696 strings) Translated using Weblate (Danish) Currently translated at 92.5% (644 of 696 strings) Translated using Weblate (Danish) Currently translated at 90.9% (633 of 696 strings) Translated using Weblate (Danish) Currently translated at 90.3% (629 of 696 strings) Translated using Weblate (Danish) Currently translated at 86.7% (604 of 696 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Danish) Currently translated at 83.9% (584 of 696 strings) Translated using Weblate (Danish) Currently translated at 74.2% (517 of 696 strings) Translated using Weblate (Catalan) Currently translated at 39.9% (278 of 696 strings) Translated using Weblate (Catalan) Currently translated at 39.9% (278 of 696 strings) Translated using Weblate (Catalan) Currently translated at 39.9% (278 of 696 strings) Translated using Weblate (German) Currently translated at 96.4% (671 of 696 strings) Translated using Weblate (Italian) Currently translated at 86.3% (601 of 696 strings) Translated using Weblate (Japanese) Currently translated at 66.8% (465 of 696 strings) Translated using Weblate (English) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Slovak) Currently translated at 100.0% (696 of 696 strings) Translated using Weblate (Slovak) Currently translated at 93.3% (650 of 696 strings) Co-authored-by: Adam Havránek <adamhavra@seznam.cz> Co-authored-by: Christian Pedersen <chripede@gmail.com> Co-authored-by: Hannes Salen <hannes.salen@gmail.com> Co-authored-by: Jose Riha <jose1711@gmail.com> Co-authored-by: Krzysztof G. <mordret@o2.pl> Co-authored-by: Luiz <lfgiolo+weblatehb@gmail.com> Co-authored-by: Matthew Kilgore <matthew@kilgore.dev> Co-authored-by: MyMemory <noreply-mt-mymemory@weblate.org> Co-authored-by: Sergi Soler Arrufat <fraret@fraret.eu> Co-authored-by: SinisaGrujicAcn <sinisa.grujic.6@gmail.com> Co-authored-by: Slydite4 <39199098+Slydite4@users.noreply.github.com> Co-authored-by: Weblate <noreply-mt-weblate@weblate.org> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org> Co-authored-by: Webysther Sperandio <webysther@gmail.com> Co-authored-by: buzz <buzz.eclair@gmail.com> Co-authored-by: christianstrauch <christian+github@strauch.us> Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ca/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/cs/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/da/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/de/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/en/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/es/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/fr/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/it/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/ja/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/nl/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/pl/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/pt_BR/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/sk/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/sr/ Translate-URL: https://translate.sysadminsmedia.com/projects/homebox/frontend/tr/ Translation: Homebox/Frontend
…sysadminsmedia#1297) * Added note required for reverse proxy setup * Clarified reverse proxy instructions, added another helpful hint
Add 'Purchase Price per Month' to an item's details.
What type of PR is this?
What this PR does / why we need it:
Which issue(s) this PR fixes:
None. (Can surely create one if so required.)
Special notes for your reviewer:
Testing
Summary by CodeRabbit
New Features
API
Localization
✏️ Tip: You can customize this high-level summary in your review settings.