mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 14:31:55 +01:00
Compare commits
20 Commits
v0.22.0-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4f398b5a | ||
|
|
545993a8aa | ||
|
|
a1947dd09e | ||
|
|
018f1f5977 | ||
|
|
9a9e3d462e | ||
|
|
37890c2a22 | ||
|
|
096b682f0a | ||
|
|
e4d8bb2ada | ||
|
|
3becf046e6 | ||
|
|
a21b3257d4 | ||
|
|
5f9ab577bb | ||
|
|
0a969bb64d | ||
|
|
2d1d3d927b | ||
|
|
540028a22e | ||
|
|
14b0d51894 | ||
|
|
4334f926c0 | ||
|
|
1088972ff0 | ||
|
|
55e247ac71 | ||
|
|
05a2700718 | ||
|
|
06c11cdcd5 |
@@ -243,6 +243,6 @@ jobs:
|
||||
uses: actions/attest-build-provenance@v1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: ${{ env.DOCKERHUB_REPO }}
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -245,6 +245,6 @@ jobs:
|
||||
uses: actions/attest-build-provenance@v1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: ${{ env.DOCKERHUB_REPO }}
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
2
.github/workflows/docker-publish.yaml
vendored
2
.github/workflows/docker-publish.yaml
vendored
@@ -236,6 +236,6 @@ jobs:
|
||||
uses: actions/attest-build-provenance@v1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: ${{ env.DOCKERHUB_REPO }}
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
14
.scaffold/go.sum
Normal file
14
.scaffold/go.sum
Normal file
@@ -0,0 +1,14 @@
|
||||
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
|
||||
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251212183312-2d1d3d927bfd h1:QULUJSgHc4rSlTjb2qYT6FIgwDWFCqEpnYqc/ltsrkk=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251212183312-2d1d3d927bfd/go.mod h1:jB+tPmHtPDM1VnAjah0gvcRfP/s7c+rtQwpA8cvZD/U=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -124,7 +124,7 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
|
||||
return validate.NewUnauthorizedError()
|
||||
}
|
||||
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
||||
return server.JSON(w, http.StatusOK, TokenResponse{
|
||||
Token: "Bearer " + newToken.Raw,
|
||||
ExpiresAt: newToken.ExpiresAt,
|
||||
@@ -178,7 +178,7 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
|
||||
return validate.NewUnauthorizedError()
|
||||
}
|
||||
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false)
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false, newToken.AttachmentToken)
|
||||
return server.JSON(w, http.StatusOK, newToken)
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func noPort(host string) string {
|
||||
return strings.Split(host, ":")[0]
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) {
|
||||
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool, attachmentToken string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameRemember,
|
||||
Value: strconv.FormatBool(remember),
|
||||
@@ -219,6 +219,19 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string
|
||||
HttpOnly: false,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
// Set attachment token cookie (accessible to frontend, not HttpOnly)
|
||||
if attachmentToken != "" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "hb.auth.attachment_token",
|
||||
Value: attachmentToken,
|
||||
Expires: expires,
|
||||
Domain: domain,
|
||||
Secure: ctrl.cookieSecure,
|
||||
HttpOnly: false,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
||||
@@ -252,6 +265,17 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
||||
HttpOnly: false,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
// Unset attachment token cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "hb.auth.attachment_token",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
Domain: domain,
|
||||
Secure: ctrl.cookieSecure,
|
||||
HttpOnly: false,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleOIDCLogin godoc
|
||||
@@ -310,7 +334,7 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc {
|
||||
}
|
||||
|
||||
// Set cookies and redirect to home
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
|
||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
||||
http.Redirect(w, r, "/home", http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -325,8 +325,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -349,8 +347,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI=
|
||||
github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
@@ -393,10 +389,6 @@ github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAX
|
||||
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users ALTER COLUMN password DROP NOT NULL;
|
||||
@@ -97,12 +97,35 @@ func ToItemAttachment(attachment *ent.Attachment) ItemAttachment {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePath converts backslashes to forward slashes and trims slashes from both ends
|
||||
// This ensures consistent path separators for blob storage which expects forward slashes
|
||||
func normalizePath(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
return strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
func (r *AttachmentRepo) path(gid uuid.UUID, hash string) string {
|
||||
return filepath.Join(gid.String(), "documents", hash)
|
||||
// Always use forward slashes for consistency across platforms
|
||||
// This ensures paths are stored in the database with forward slashes
|
||||
return fmt.Sprintf("%s/documents/%s", gid.String(), hash)
|
||||
}
|
||||
|
||||
func (r *AttachmentRepo) fullPath(relativePath string) string {
|
||||
return filepath.Join(r.storage.PrefixPath, relativePath)
|
||||
// Normalize path separators to forward slashes for blob storage
|
||||
// The blob library expects forward slashes in keys regardless of OS
|
||||
normalizedRelativePath := normalizePath(relativePath)
|
||||
|
||||
// Always use forward slashes when joining paths for blob storage
|
||||
if r.storage.PrefixPath == "" {
|
||||
return normalizedRelativePath
|
||||
}
|
||||
normalizedPrefix := normalizePath(r.storage.PrefixPath)
|
||||
|
||||
if normalizedPrefix == "" {
|
||||
return normalizedRelativePath
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", normalizedPrefix, normalizedRelativePath)
|
||||
}
|
||||
|
||||
func (r *AttachmentRepo) GetFullPath(relativePath string) string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
)
|
||||
|
||||
func TestAttachmentRepo_Create(t *testing.T) {
|
||||
@@ -281,3 +282,58 @@ func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, photo1.Primary, "Photo 1 should no longer be primary after setting Photo 2 as primary")
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_PathNormalization(t *testing.T) {
|
||||
// Test that paths always use forward slashes
|
||||
repo := &AttachmentRepo{
|
||||
storage: config.Storage{
|
||||
PrefixPath: ".data",
|
||||
},
|
||||
}
|
||||
|
||||
testGUID := uuid.MustParse("eb6bf410-a1a8-478d-a803-ca3948368a0c")
|
||||
testHash := "f295eb01-18a9-4631-a797-70bd9623edd4.png"
|
||||
|
||||
// Test path() method - should always return forward slashes
|
||||
relativePath := repo.path(testGUID, testHash)
|
||||
assert.Equal(t, "eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", relativePath)
|
||||
assert.NotContains(t, relativePath, "\\", "path() should not contain backslashes")
|
||||
|
||||
// Test fullPath() with forward slash input (from database)
|
||||
fullPath := repo.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
|
||||
assert.Equal(t, ".data/eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPath)
|
||||
assert.NotContains(t, fullPath, "\\", "fullPath() should not contain backslashes")
|
||||
|
||||
// Test fullPath() with backslash input (legacy Windows paths from old database)
|
||||
fullPathWithBackslash := repo.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c\\documents\\f295eb01-18a9-4631-a797-70bd9623edd4.png")
|
||||
assert.Equal(t, ".data/eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPathWithBackslash)
|
||||
assert.NotContains(t, fullPathWithBackslash, "\\", "fullPath() should normalize backslashes to forward slashes")
|
||||
|
||||
// Test with Windows-style prefix path
|
||||
repoWindows := &AttachmentRepo{
|
||||
storage: config.Storage{
|
||||
PrefixPath: ".data",
|
||||
},
|
||||
}
|
||||
fullPathWindows := repoWindows.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
|
||||
assert.NotContains(t, fullPathWindows, "\\", "fullPath() should normalize Windows paths")
|
||||
|
||||
// Test empty prefix
|
||||
repoNoPrefix := &AttachmentRepo{
|
||||
storage: config.Storage{
|
||||
PrefixPath: "",
|
||||
},
|
||||
}
|
||||
fullPathNoPrefix := repoNoPrefix.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
|
||||
assert.Equal(t, "eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPathNoPrefix)
|
||||
|
||||
// Test with single slash prefix (like in tests)
|
||||
repoSlashPrefix := &AttachmentRepo{
|
||||
storage: config.Storage{
|
||||
PrefixPath: "/",
|
||||
},
|
||||
}
|
||||
fullPathSlashPrefix := repoSlashPrefix.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
|
||||
assert.Equal(t, "eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPathSlashPrefix)
|
||||
assert.NotContains(t, fullPathSlashPrefix, "//", "fullPath() should not have double slashes")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export default [
|
||||
{text: 'Installation', link: '/en/installation'},
|
||||
{text: 'Configure', link: '/en/configure'},
|
||||
{text: 'Storage', link: '/en/configure/storage'},
|
||||
{text: 'OIDC', link: '/en/configure/oidc'},
|
||||
{text: 'Upgrade Guide', link: '/en/upgrade'},
|
||||
{text: 'Migration Guide', link: '/en/migration'},
|
||||
]
|
||||
@@ -20,7 +21,8 @@ export default [
|
||||
{
|
||||
text: 'Advanced',
|
||||
items: [
|
||||
{text: 'Import CSV', link: '/en/import-csv'},
|
||||
{text: 'Import CSV', link: '/en/advanced/import-csv'},
|
||||
{text: 'External Label Service', link: '/en/advanced/external-label-service'},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
53
docs/en/advanced/external-label-service.md
Normal file
53
docs/en/advanced/external-label-service.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# External Label Service
|
||||
|
||||
You can use an external web service to generate asset and location labels in homebox. This is useful if you have custom requirements for your labels and are happy to spin up a web service that can accept incoming requests and return an image file for homebox to use.
|
||||
|
||||
::: info "Note"
|
||||
|
||||
This service is not called to generate sheets of labels accessed via the label generator function. It is used when creating labels from an item or location.
|
||||
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
The extenal service is configured using the `HBOX_LABEL_MAKER_LABEL_SERVICE_URL` enviroment variable.
|
||||
|
||||
## Request
|
||||
|
||||
The service is called using an **HTTP `GET` request**. All parameters are passed as part of the **query string**.
|
||||
|
||||
#### Headers
|
||||
|
||||
- **User-Agent**: Homebox-LabelMaker/1.0
|
||||
|
||||
- **Accept**: image/*
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Description | Value |
|
||||
| --------------------- | ------ | -------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| AdditionalInformation | string | Extra free text to include on the label. | `HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION` |
|
||||
| ComponentPadding | int | Padding around label components (pixels). | `HBOX_LABEL_MAKER_PADDING` |
|
||||
| DescriptionFontSize | float | Font size for the description text. | |
|
||||
| DescriptionText | string | Descriptive text, can be multi-line. | Item name or "Homebox Location" |
|
||||
| Dpi | float | Rendering resolution (dots per inch). | |
|
||||
| DynamicLength | bool | Whether the label length should auto-adjust. | `HBOX_LABEL_MAKER_DYNAMIC_LENGTH` |
|
||||
| Height | int | Label height in pixels. | `HBOX_LABEL_MAKER_HEIGHT` |
|
||||
| Margin | int | Margin around the label in pixels. | `HBOX_LABEL_MAKER_MARGIN` |
|
||||
| QrSize | int | Size of the QR code element in pixels. | |
|
||||
| TitleFontSize | float | Font size for the title text. | |
|
||||
| TitleText | string | Main label title (e.g. product code). | Asset ID or Location Name |
|
||||
| URL | string | URL to be encoded into the QR code. | Generated based on the configured homebox URL and Asset / Location ID |
|
||||
| Width | int | Label width in pixels. | `HBOX_LABEL_MAKER_WIDTH` |
|
||||
|
||||
## Response
|
||||
|
||||
The external service should respond with the following specifications;
|
||||
|
||||
- **Size:** Less than or equal to `HBOX_WEB_MAX_UPLOAD_SIZE` (Default: 10Mb)
|
||||
|
||||
- **Content-Type**: Specified in the response header should be of the type image/*
|
||||
|
||||
- **Time**: Within the time specified in `HBOX_LABEL_MAKER_LABEL_SERVICE_TIMEOUT` (Default 30s)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ aside: false
|
||||
| HBOX_WEB_IDLE_TIMEOUT | 30s | Idle timeout of HTTP server |
|
||||
| HBOX_STORAGE_CONN_STRING | file:///./ | path to the data directory, do not change this if you're using docker |
|
||||
| HBOX_STORAGE_PREFIX_PATH | .data | prefix path for the storage, if not set the storage will be used as is |
|
||||
| HBOX_LOG_LEVEL | `info` | log level to use, can be one of `trace`, `debug`, `info`, `warn`, `error`, `critical` |
|
||||
| HBOX_LOG_LEVEL | `info` | log level to use, can be one of `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic` |
|
||||
| HBOX_LOG_FORMAT | `text` | log format to use, can be one of: `text`, `json` |
|
||||
| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used |
|
||||
| HBOX_MAILER_PORT | 587 | email port to use |
|
||||
@@ -71,11 +71,89 @@ aside: false
|
||||
| HBOX_THUMBNAIL_ENABLED | true | enable thumbnail generation for images, supports PNG, JPEG, AVIF, WEBP, GIF file types |
|
||||
| HBOX_THUMBNAIL_WIDTH | 500 | width for generated thumbnails in pixels |
|
||||
| HBOX_THUMBNAIL_HEIGHT | 500 | height for generated thumbnails in pixels |
|
||||
| HBOX_BARCODE_TOKEN_BARCODESPIDER | | API token for BarcodeSpider.com service used for barcode product lookups. If not set, barcode product lookups will not be performed. |
|
||||
|
||||
```sh
|
||||
Options:
|
||||
--barcode-token-barcodespider <string>
|
||||
--database-database <string>
|
||||
--database-driver <string> (default: sqlite3)
|
||||
--database-host <string>
|
||||
--database-password <string>
|
||||
--database-port <string>
|
||||
--database-pub-sub-conn-string <string> (default: mem://{{ .Topic }})
|
||||
--database-sqlite-path <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
|
||||
--database-ssl-cert <string>
|
||||
--database-ssl-key <string>
|
||||
--database-ssl-mode <string> (default: require)
|
||||
--database-ssl-root-cert <string>
|
||||
--database-username <string>
|
||||
--debug-enabled <bool> (default: false)
|
||||
--debug-port <string> (default: 4000)
|
||||
--demo <bool>
|
||||
-h, --help display this help message
|
||||
--label-maker-additional-information <string>
|
||||
--label-maker-bold-font-path <string>
|
||||
--label-maker-dynamic-length <bool> (default: true)
|
||||
--label-maker-font-size <float> (default: 32.0)
|
||||
--label-maker-height <int> (default: 200)
|
||||
--label-maker-label-service-timeout <int>
|
||||
--label-maker-label-service-url <string>
|
||||
--label-maker-margin <int> (default: 32)
|
||||
--label-maker-padding <int> (default: 32)
|
||||
--label-maker-print-command <string>
|
||||
--label-maker-regular-font-path <string>
|
||||
--label-maker-width <int> (default: 526)
|
||||
--log-format <string> (default: text)
|
||||
--log-level <string> (default: info)
|
||||
--mailer-from <string>
|
||||
--mailer-host <string>
|
||||
--mailer-password <string>
|
||||
--mailer-port <int>
|
||||
--mailer-username <string>
|
||||
--mode <string> (default: development)
|
||||
--oidc-allowed-groups <string>
|
||||
--oidc-auto-redirect <bool> (default: false)
|
||||
--oidc-button-text <string> (default: Sign in with OIDC)
|
||||
--oidc-client-id <string>
|
||||
--oidc-client-secret <string>
|
||||
--oidc-email-claim <string> (default: email)
|
||||
--oidc-email-verified-claim <string> (default: email_verified)
|
||||
--oidc-enabled <bool> (default: false)
|
||||
--oidc-group-claim <string> (default: groups)
|
||||
--oidc-issuer-url <string>
|
||||
--oidc-name-claim <string> (default: name)
|
||||
--oidc-request-timeout <duration> (default: 30s)
|
||||
--oidc-scope <string> (default: openid profile email)
|
||||
--oidc-state-expiry <duration> (default: 10m)
|
||||
--oidc-verify-email <bool> (default: false)
|
||||
--options-allow-analytics <bool> (default: false)
|
||||
--options-allow-local-login <bool> (default: true)
|
||||
--options-allow-registration <bool> (default: true)
|
||||
--options-auto-increment-asset-id <bool> (default: true)
|
||||
--options-currency-config <string>
|
||||
--options-github-release-check <bool> (default: true)
|
||||
--options-hostname <string>
|
||||
--options-trust-proxy <bool> (default: false)
|
||||
--storage-conn-string <string> (default: file:///./)
|
||||
--storage-prefix-path <string> (default: .data)
|
||||
--thumbnail-enabled <bool> (default: true)
|
||||
--thumbnail-height <int> (default: 500)
|
||||
--thumbnail-width <int> (default: 500)
|
||||
-v, --version display version
|
||||
--web-host <string>
|
||||
--web-idle-timeout <duration> (default: 30s)
|
||||
--web-max-upload-size <int> (default: 10)
|
||||
--web-port <string> (default: 7745)
|
||||
--web-read-timeout <duration> (default: 10s)
|
||||
--web-write-timeout <duration> (default: 10s)
|
||||
```
|
||||
:::
|
||||
|
||||
### HBOX_WEB_HOST examples
|
||||
|
||||
| Value | Notes |
|
||||
|-----------------------------|------------------------------------------------------------|
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| 0.0.0.0 | Visible all interfaces (default behaviour) |
|
||||
| 127.0.0.1 | Only visible on same host |
|
||||
| 100.64.0.1 | Only visible on a specific interface (e.g., VPN in a VPS). |
|
||||
@@ -109,6 +187,7 @@ the webserver (Caddy) can access it. Other processes/containers on the host
|
||||
cannot connect to Homebox directly, bypassing the webserver.
|
||||
|
||||
File: homebox.socket
|
||||
|
||||
```systemd
|
||||
# /usr/local/lib/systemd/system/homebox.socket
|
||||
[Unit]
|
||||
@@ -124,6 +203,7 @@ WantedBy=sockets.target
|
||||
```
|
||||
|
||||
File: homebox.service
|
||||
|
||||
```systemd
|
||||
# /usr/local/lib/systemd/system/homebox.service
|
||||
[Unit]
|
||||
@@ -144,6 +224,7 @@ CapabilityBoundingSet=
|
||||
RestrictNamespaces=true
|
||||
SystemCallFilter=@system-service
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
@@ -169,105 +250,4 @@ For SQLite in production:
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
HomeBox supports OpenID Connect (OIDC) authentication, allowing users to login using external identity providers like Keycloak, Authentik, Google, Microsoft, etc.
|
||||
|
||||
### Basic OIDC Setup
|
||||
|
||||
1. **Enable OIDC**: Set `HBOX_OIDC_ENABLED=true`
|
||||
2. **Provider Configuration**: Set the required provider details:
|
||||
- `HBOX_OIDC_ISSUER_URL`: Your OIDC provider's issuer URL
|
||||
- `HBOX_OIDC_CLIENT_ID`: Client ID from your OIDC provider
|
||||
- `HBOX_OIDC_CLIENT_SECRET`: Client secret from your OIDC provider
|
||||
|
||||
3. **Configure Redirect URI**: In your OIDC provider, set the redirect URI to:
|
||||
`https://your-homebox-domain.com/api/v1/users/login/oidc/callback`
|
||||
|
||||
### Advanced OIDC Configuration
|
||||
|
||||
- **Group Authorization**: Use `HBOX_OIDC_ALLOWED_GROUPS` to restrict access to specific groups
|
||||
- **Custom Claims**: Configure `HBOX_OIDC_GROUP_CLAIM`, `HBOX_OIDC_EMAIL_CLAIM`, and `HBOX_OIDC_NAME_CLAIM` if your provider uses different claim names
|
||||
- **Auto Redirect to OIDC**: Set `HBOX_OIDC_AUTO_REDIRECT=true` to automatically redirect users directly to OIDC
|
||||
- **Local Login**: Set `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false` to completely disable username/password login
|
||||
- **Email Verification**: Set `HBOX_OIDC_VERIFY_EMAIL=true` to require email verification from the OIDC provider
|
||||
|
||||
### Security Considerations
|
||||
|
||||
::: warning OIDC Security
|
||||
- Store `HBOX_OIDC_CLIENT_SECRET` securely (use environment variables, not config files)
|
||||
- Use HTTPS for production deployments
|
||||
- Configure proper redirect URIs in your OIDC provider
|
||||
- Consider setting `HBOX_OIDC_ALLOWED_GROUPS` for group-based access control
|
||||
:::
|
||||
|
||||
::: tip CLI Arguments
|
||||
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help`
|
||||
for more information.
|
||||
|
||||
```sh
|
||||
Usage: api [options] [arguments]
|
||||
|
||||
OPTIONS
|
||||
--mode/$HBOX_MODE <string> (default: development)
|
||||
--web-port/$HBOX_WEB_PORT <string> (default: 7745)
|
||||
--web-host/$HBOX_WEB_HOST <string>
|
||||
--web-max-upload-size/$HBOX_WEB_MAX_UPLOAD_SIZE <int> (default: 10)
|
||||
--storage-conn-string/$HBOX_STORAGE_CONN_STRING <string> (default: file:///./)
|
||||
--storage-prefix-path/$HBOX_STORAGE_PREFIX_PATH <string> (default: .data)
|
||||
--log-level/$HBOX_LOG_LEVEL <string> (default: info)
|
||||
--log-format/$HBOX_LOG_FORMAT <string> (default: text)
|
||||
--mailer-host/$HBOX_MAILER_HOST <string>
|
||||
--mailer-port/$HBOX_MAILER_PORT <int>
|
||||
--mailer-username/$HBOX_MAILER_USERNAME <string>
|
||||
--mailer-password/$HBOX_MAILER_PASSWORD <string>
|
||||
--mailer-from/$HBOX_MAILER_FROM <string>
|
||||
--demo/$HBOX_DEMO <bool>
|
||||
--debug-enabled/$HBOX_DEBUG_ENABLED <bool> (default: false)
|
||||
--debug-port/$HBOX_DEBUG_PORT <string> (default: 4000)
|
||||
--database-driver/$HBOX_DATABASE_DRIVER <string> (default: sqlite3)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite)
|
||||
--database-host/$HBOX_DATABASE_HOST <string>
|
||||
--database-port/$HBOX_DATABASE_PORT <string>
|
||||
--database-username/$HBOX_DATABASE_USERNAME <string>
|
||||
--database-password/$HBOX_DATABASE_PASSWORD <string>
|
||||
--database-database/$HBOX_DATABASE_DATABASE <string>
|
||||
--database-ssl-mode/$HBOX_DATABASE_SSL_MODE <string> (default: prefer)
|
||||
--options-allow-registration/$HBOX_OPTIONS_ALLOW_REGISTRATION <bool> (default: true)
|
||||
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
|
||||
--options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG <string>
|
||||
--options-github-release-check/$HBOX_OPTIONS_GITHUB_RELEASE_CHECK <bool> (default: true)
|
||||
--options-allow-analytics/$HBOX_OPTIONS_ALLOW_ANALYTICS <bool> (default: false)
|
||||
--options-allow-local-login/$HBOX_OPTIONS_ALLOW_LOCAL_LOGIN <bool> (default: true)
|
||||
--options-trust-proxy/$HBOX_OPTIONS_TRUST_PROXY <bool> (default: false)
|
||||
--options-hostname/$HBOX_OPTIONS_HOSTNAME <string>
|
||||
--oidc-enabled/$HBOX_OIDC_ENABLED <bool> (default: false)
|
||||
--oidc-issuer-url/$HBOX_OIDC_ISSUER_URL <string>
|
||||
--oidc-client-id/$HBOX_OIDC_CLIENT_ID <string>
|
||||
--oidc-client-secret/$HBOX_OIDC_CLIENT_SECRET <string>
|
||||
--oidc-scope/$HBOX_OIDC_SCOPE <string> (default: openid profile email)
|
||||
--oidc-allowed-groups/$HBOX_OIDC_ALLOWED_GROUPS <string>
|
||||
--oidc-auto-redirect/$HBOX_OIDC_AUTO_REDIRECT <bool> (default: false)
|
||||
--oidc-verify-email/$HBOX_OIDC_VERIFY_EMAIL <bool> (default: false)
|
||||
--oidc-group-claim/$HBOX_OIDC_GROUP_CLAIM <string> (default: groups)
|
||||
--oidc-email-claim/$HBOX_OIDC_EMAIL_CLAIM <string> (default: email)
|
||||
--oidc-name-claim/$HBOX_OIDC_NAME_CLAIM <string> (default: name)
|
||||
--oidc-email-verified-claim/$HBOX_OIDC_EMAIL_VERIFIED_CLAIM <string> (default: email_verified)
|
||||
--oidc-button-text/$HBOX_OIDC_BUTTON_TEXT <string> (default: Sign in with OIDC)
|
||||
--oidc-state-expiry/$HBOX_OIDC_STATE_EXPIRY <duration> (default: 10m)
|
||||
--oidc-request-timeout/$HBOX_OIDC_REQUEST_TIMEOUT <duration> (default: 30s)
|
||||
--label-maker-width/$HBOX_LABEL_MAKER_WIDTH <int> (default: 526)
|
||||
--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT <int> (default: 200)
|
||||
--label-maker-padding/$HBOX_LABEL_MAKER_PADDING <int> (default: 32)
|
||||
--label-maker-margin/$HBOX_LABEL_MAKER_MARGIN <int> (default: 32)
|
||||
--label-maker-font-size/$HBOX_LABEL_MAKER_FONT_SIZE <float> (default: 32.0)
|
||||
--label-maker-print-command/$HBOX_LABEL_MAKER_PRINT_COMMAND <string>
|
||||
--label-maker-dynamic-length/$HBOX_LABEL_MAKER_DYNAMIC_LENGTH <bool> (default: true)
|
||||
--label-maker-additional-information/$HBOX_LABEL_MAKER_ADDITIONAL_INFORMATION <string>
|
||||
--label-maker-regular-font-path/$HBOX_LABEL_MAKER_REGULAR_FONT_PATH <string>
|
||||
--label-maker-bold-font-path/$HBOX_LABEL_MAKER_BOLD_FONT_PATH <string>
|
||||
--thumbnail-enabled/$HBOX_THUMBNAIL_ENABLED <bool> (default: true)
|
||||
--thumbnail-width/$HBOX_THUMBNAIL_WIDTH <int> (default: 500)
|
||||
--thumbnail-height/$HBOX_THUMBNAIL_HEIGHT <int> (default: 500)
|
||||
--help/-h display this help message
|
||||
```
|
||||
|
||||
:::
|
||||
For configuring OpenID Connect (OIDC) authentication, refer to the [OIDC Configuration Guide](/en/configure/oidc).
|
||||
|
||||
44
docs/en/configure/oidc.md
Normal file
44
docs/en/configure/oidc.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Configure OIDC
|
||||
|
||||
HomeBox supports OpenID Connect (OIDC) authentication, allowing users to login using external identity providers like Keycloak, Authentik, Authelia, Google, Microsoft, etc.
|
||||
|
||||
::: tip OIDC Provider Documentation
|
||||
When configuring OIDC, always refer to the documentation provided by your identity provider for specific details and requirements.
|
||||
:::
|
||||
|
||||
## Basic OIDC Setup
|
||||
|
||||
1. **Enable OIDC**: Set `HBOX_OIDC_ENABLED=true`.
|
||||
2. **Provider Configuration**: Set the required provider details:
|
||||
- `HBOX_OIDC_ISSUER_URL`: Your OIDC provider's issuer URL.
|
||||
- Generally this URL should not have a trailing slash, though it may be required for some providers.
|
||||
- `HBOX_OIDC_CLIENT_ID`: Client ID from your OIDC provider.
|
||||
- `HBOX_OIDC_CLIENT_SECRET`: Client secret from your OIDC provider.
|
||||
- If you are using a reverse proxy, it may be necessary to set `HBOX_OPTIONS_TRUST_PROXY=true` to ensure `https` is correctly detected.
|
||||
- If you have set `HBOX_OPTIONS_HOSTNAME` make sure it is just the hostname and does not include `https://` or `http://`.
|
||||
|
||||
3. **Configure Redirect URI**: In your OIDC provider, set the redirect URI to:
|
||||
`https://your-homebox-domain.example.com/api/v1/users/login/oidc/callback`.
|
||||
|
||||
## Advanced OIDC Configuration
|
||||
|
||||
- **Group Authorization**: Use `HBOX_OIDC_ALLOWED_GROUPS` to restrict access to specific groups, e.g. `HBOX_OIDC_ALLOWED_GROUPS=admin,homebox`.
|
||||
- Some providers require the `groups` scope to return group claims, include it in `HBOX_OIDC_SCOPE` (e.g. `openid profile email groups`) or configure the provider to release the claim.
|
||||
- **Custom Claims**: Configure `HBOX_OIDC_GROUP_CLAIM`, `HBOX_OIDC_EMAIL_CLAIM`, and `HBOX_OIDC_NAME_CLAIM` if your provider uses different claim names.
|
||||
- These default to `HBOX_OIDC_GROUP_CLAIM=groups`, `HBOX_OIDC_EMAIL_CLAIM=email` and `HBOX_OIDC_NAME_CLAIM=name`.
|
||||
- **Auto Redirect to OIDC**: Set `HBOX_OIDC_AUTO_REDIRECT=true` to automatically redirect users directly to OIDC.
|
||||
- **Local Login**: Set `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false` to completely disable username/password login.
|
||||
- **Email Verification**: Set `HBOX_OIDC_VERIFY_EMAIL=true` to require email verification from the OIDC provider.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
::: warning OIDC Security
|
||||
- Store `HBOX_OIDC_CLIENT_SECRET` securely (use environment variables, not config files).
|
||||
- Use HTTPS for production deployments.
|
||||
- Configure proper redirect URIs in your OIDC provider.
|
||||
- Consider setting `HBOX_OIDC_ALLOWED_GROUPS` for group-based access control.
|
||||
:::
|
||||
|
||||
::: tip CLI Arguments
|
||||
If you're deploying without docker you can use command line arguments to configure the application. Run `homebox --help` for more information.
|
||||
:::
|
||||
@@ -30,19 +30,19 @@ the bucket name in the connection string.
|
||||
### S3-Compatible Storage
|
||||
|
||||
You can also use S3-compatible storage by setting the `HBOX_STORAGE_CONN_STRING` to
|
||||
`s3://my-bucket?awssdk=v2&endpoint=http://my-s3-compatible-endpoint.tld&disableSSL=true&s3ForcePathStyle=true`.
|
||||
`s3://my-bucket?awssdk=v2&endpoint=http://my-s3-compatible-endpoint.tld&disable_https=true&s3ForcePathStyle=true`.
|
||||
|
||||
This allows you to connect to S3-compatible services like MinIO, DigitalOcean Spaces, or any other service that supports
|
||||
the S3 API. Configure the `disableSSL`, `s3ForcePathStyle`, and `endpoint` parameters as needed for your specific
|
||||
the S3 API. Configure the `disable_https`, `s3ForcePathStyle`, and `endpoint` parameters as needed for your specific
|
||||
service.
|
||||
|
||||
#### Tested S3-Compatible Storage
|
||||
|
||||
| Service | Working | Connection String |
|
||||
|---------------------|---------|--------------------------------------------------------------------------------------------------------------------------|
|
||||
| MinIO | Yes | `s3://my-bucket?awssdk=v2&endpoint=http://minio:9000&disableSSL=true&s3ForcePathStyle=true` |
|
||||
| Cloudflare R2 | Yes | `s3://my-bucket?awssdk=v2&endpoint=https://<account-id>.r2.cloudflarestorage.com&disableSSL=false&s3ForcePathStyle=true` |
|
||||
| Backblaze B2 | Yes | `s3://my-bucket?awssdk=v2&endpoint=https://s3.us-west-004.backblazeb2.com&disableSSL=false&s3ForcePathStyle=true` |
|
||||
| MinIO | Yes | `s3://my-bucket?awssdk=v2&endpoint=http://minio:9000&disable_https=true&s3ForcePathStyle=true` |
|
||||
| Cloudflare R2 | Yes | `s3://my-bucket?awssdk=v2&endpoint=https://<account-id>.r2.cloudflarestorage.com&disable_https=false&s3ForcePathStyle=true` |
|
||||
| Backblaze B2 | Yes | `s3://my-bucket?awssdk=v2&endpoint=https://s3.us-west-004.backblazeb2.com&disable_https=false&s3ForcePathStyle=true` |
|
||||
|
||||
::: info
|
||||
If you know of any other S3-compatible storage that works with Homebox, please let us know or create a pull request to update the table.
|
||||
@@ -57,7 +57,7 @@ Additionally, the parameters in the URL can be used to configure specific S3 set
|
||||
features.)
|
||||
- `endpoint`: The custom endpoint for S3-compatible storage services.
|
||||
- `s3ForcePathStyle`: Whether to force path-style access (set to `true` or `false`).
|
||||
- `disableSSL`: Whether to disable SSL (set to `true` or `false`).
|
||||
- `disable_https`: Whether to disable SSL (set to `true` or `false`).
|
||||
- `sseType`: The server-side encryption type (e.g., `AES256` or `aws:kms` or `aws:kms:dsse`).
|
||||
- `kmskeyid`: The KMS key ID for server-side encryption.
|
||||
- `fips`: Whether to use FIPS endpoints (set to `true` or `false`).
|
||||
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
environment:
|
||||
- HBOX_LOG_LEVEL=info
|
||||
- HBOX_LOG_FORMAT=text
|
||||
- HBOX_WEB_MAX_FILE_UPLOAD=10
|
||||
- HBOX_WEB_MAX_UPLOAD_SIZE=10
|
||||
# Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data)
|
||||
- HBOX_OPTIONS_ALLOW_ANALYTICS=false
|
||||
volumes:
|
||||
|
||||
@@ -81,17 +81,6 @@
|
||||
errorMessage.value = t("scanner.error");
|
||||
};
|
||||
|
||||
const checkPermissionsError = async () => {
|
||||
if (navigator.permissions) {
|
||||
const permissionStatus = await navigator.permissions.query({ name: "camera" as PermissionName });
|
||||
if (permissionStatus.state === "denied") {
|
||||
errorMessage.value = t("scanner.permission_denied");
|
||||
console.error("Camera permission denied");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
|
||||
};
|
||||
@@ -103,11 +92,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (await checkPermissionsError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request camera permission first
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "NotAllowedError") {
|
||||
errorMessage.value = t("scanner.permission_denied");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const devices = await codeReader.listVideoInputDevices();
|
||||
sources.value = devices;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
|
||||
<LocationSelector v-model="form.location" />
|
||||
|
||||
<!-- Template Info Display - Collapsible banner with distinct styling -->
|
||||
|
||||
@@ -6,12 +6,25 @@
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
<span>
|
||||
<span class="truncate text-left">
|
||||
<slot name="display" v-bind="{ item: value }">
|
||||
{{ displayValue(value) || localizedPlaceholder }}
|
||||
</slot>
|
||||
</span>
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
|
||||
<span class="ml-2 flex items-center">
|
||||
<button
|
||||
v-if="value"
|
||||
type="button"
|
||||
class="shrink-0 rounded p-1 hover:bg-primary/20"
|
||||
:aria-label="t('components.item.selector.clear')"
|
||||
@click.stop.prevent="clearSelection"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
||||
@@ -44,7 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -174,6 +187,12 @@
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value.value = null;
|
||||
search.value = "";
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
let baseItems = props.items;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateLabel" :title="$t('components.label.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
|
||||
<FormTextField
|
||||
v-model="form.name"
|
||||
:trigger-focus="focused"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateLocation" :title="$t('components.location.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
|
||||
<LocationSelector v-model="form.parent" />
|
||||
<FormTextField
|
||||
ref="locationNameRef"
|
||||
|
||||
@@ -7,8 +7,23 @@
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
<span class="min-w-0 flex-auto truncate text-left">
|
||||
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
|
||||
</span>
|
||||
|
||||
<span class="ml-2 flex items-center">
|
||||
<button
|
||||
v-if="value"
|
||||
type="button"
|
||||
class="shrink-0 rounded p-1 hover:bg-primary/20"
|
||||
:aria-label="$t('components.location.selector.clear')"
|
||||
@click.stop.prevent="clearSelection"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
||||
@@ -46,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
@@ -79,6 +94,12 @@
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value.value = null;
|
||||
search.value = "";
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const filteredLocations = computed(() => {
|
||||
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
|
||||
const state = useTreeState(props.treeId);
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
const sortedChildren = computed(() => {
|
||||
const children = props.item.children ?? [];
|
||||
return [...children].sort((a, b) => collator.compare(a.name, b.name));
|
||||
});
|
||||
|
||||
const openRef = computed({
|
||||
get() {
|
||||
return state.value[nodeHash.value] ?? false;
|
||||
@@ -66,7 +73,7 @@
|
||||
<NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink>
|
||||
</div>
|
||||
<div v-if="openRef" class="ml-4">
|
||||
<LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" />
|
||||
<LocationTreeNode v-for="child in sortedChildren" :key="child.id" :item="child" :tree-id="treeId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,14 +7,21 @@
|
||||
treeId: string;
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
const sortedLocs = computed(() => {
|
||||
const list = props.locs ?? [];
|
||||
return [...list].sort((a, b) => collator.compare(a.name, b.name));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="locs.length === 0" class="text-center text-sm">
|
||||
<p v-if="sortedLocs.length === 0" class="text-center text-sm">
|
||||
{{ $t("location.tree.no_locations") }}
|
||||
</p>
|
||||
<LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" />
|
||||
<LocationTreeNode v-for="item in sortedLocs" :key="item.id" :item="item" :tree-id="treeId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
|
||||
<FormTextField
|
||||
v-model="form.name"
|
||||
:autofocus="true"
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<Separator class="my-2" />
|
||||
<h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" />
|
||||
<FormTextArea
|
||||
v-model="form.defaultDescription"
|
||||
|
||||
@@ -38,6 +38,14 @@
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator v-if="value" />
|
||||
<CommandGroup v-if="value">
|
||||
<CommandItem v-if="value" value="clear-selection" @select="clearSelection">
|
||||
<div class="flex w-full">
|
||||
{{ $t("components.template.selector.clear") }}
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -79,6 +87,13 @@
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandItem v-if="value" value="clear-selection" @select="clearSelection">
|
||||
<X :class="cn('mr-2 h-4 w-4')" />
|
||||
<div class="flex w-full">
|
||||
<span class="text-destructive">{{ $t("components.template.selector.clear") }}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
@@ -87,10 +102,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "~/components/ui/command";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -132,6 +155,13 @@
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value.value = null;
|
||||
emit("template-selected", null);
|
||||
search.value = "";
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
if (!templates.value) return [];
|
||||
const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj);
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{{ btn.name.value }}
|
||||
<Shortcut
|
||||
v-if="btn.shortcut"
|
||||
class="ml-auto hidden group-hover:inline"
|
||||
class="invisible ml-auto group-hover:visible"
|
||||
:keys="btn.shortcut.replace('Shift', '⇧').split('+')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -136,7 +136,8 @@
|
||||
"no_results": "No Results Found",
|
||||
"placeholder": "Select…",
|
||||
"search_placeholder": "Type to search…",
|
||||
"searching": "Searching…"
|
||||
"searching": "Searching…",
|
||||
"clear": "Clear Item Selection"
|
||||
},
|
||||
"view": {
|
||||
"change_details": {
|
||||
@@ -221,7 +222,8 @@
|
||||
"no_location_found": "No location found",
|
||||
"parent_location": "Parent Location",
|
||||
"search_location": "Search Locations",
|
||||
"select_location": "Select a Location"
|
||||
"select_location": "Select a Location",
|
||||
"clear": "Clear Location Selection"
|
||||
},
|
||||
"tree": {
|
||||
"no_locations": "No locations available. Add new locations through the\n '<span class=\"link-primary\">'Create'</span>' button on the navigation bar."
|
||||
@@ -268,7 +270,8 @@
|
||||
"label": "Template (Optional)",
|
||||
"not_found": "No template found",
|
||||
"search": "Search templates...",
|
||||
"select": "Select template..."
|
||||
"select": "Select template...",
|
||||
"clear": "Clear Template Selection"
|
||||
},
|
||||
"toast": {
|
||||
"applied": "Template \"{name}\" applied",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
async function saveItem() {
|
||||
async function saveItem(redirect: boolean) {
|
||||
if (!item.value.location?.id) {
|
||||
toast.error(t("items.toast.failed_save_no_location"));
|
||||
return;
|
||||
@@ -139,7 +139,9 @@
|
||||
}
|
||||
|
||||
toast.success(t("items.toast.item_saved"));
|
||||
navigateTo("/item/" + itemId.value);
|
||||
if (redirect) {
|
||||
navigateTo("/item/" + itemId.value);
|
||||
}
|
||||
}
|
||||
|
||||
type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>;
|
||||
@@ -339,6 +341,8 @@
|
||||
|
||||
toast.success(t("items.toast.attachment_uploaded"));
|
||||
|
||||
await saveItem(false);
|
||||
|
||||
item.value.attachments = data.attachments;
|
||||
}
|
||||
|
||||
@@ -432,13 +436,13 @@
|
||||
// Cmd + S
|
||||
if (e.metaKey && e.key === "s") {
|
||||
e.preventDefault();
|
||||
await saveItem();
|
||||
await saveItem(false);
|
||||
}
|
||||
|
||||
// Ctrl + S
|
||||
if (e.ctrlKey && e.key === "s") {
|
||||
e.preventDefault();
|
||||
await saveItem();
|
||||
await saveItem(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +577,7 @@
|
||||
<TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button size="sm" :disabled="saving" @click="saveItem">
|
||||
<Button size="sm" :disabled="saving" @click="saveItem(true)">
|
||||
<MdiLoading v-if="saving" class="animate-spin" />
|
||||
<MdiContentSaveOutline v-else />
|
||||
{{ $t("global.save") }}
|
||||
|
||||
Reference in New Issue
Block a user