Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ad87991619 Update progress - all checks complete
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-12-18 21:59:26 +00:00
copilot-swe-agent[bot]
7a7c10c052 Refactor path normalization into helper function per code review
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-12-18 21:57:13 +00:00
copilot-swe-agent[bot]
41c0ccf628 Fix Windows attachment path encoding issue by normalizing to forward slashes
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-12-18 21:54:39 +00:00
copilot-swe-agent[bot]
3d5545bf4d Initial plan for fixing Windows attachment path issue
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-12-18 21:49:02 +00:00
copilot-swe-agent[bot]
7bac55f50e Initial plan 2025-12-18 21:44:54 +00:00
22 changed files with 252 additions and 291 deletions

14
.scaffold/go.sum Normal file
View 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=

View File

@@ -124,7 +124,7 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
return validate.NewUnauthorizedError() return validate.NewUnauthorizedError()
} }
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
return server.JSON(w, http.StatusOK, TokenResponse{ return server.JSON(w, http.StatusOK, TokenResponse{
Token: "Bearer " + newToken.Raw, Token: "Bearer " + newToken.Raw,
ExpiresAt: newToken.ExpiresAt, ExpiresAt: newToken.ExpiresAt,
@@ -178,7 +178,7 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
return validate.NewUnauthorizedError() return validate.NewUnauthorizedError()
} }
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false, newToken.AttachmentToken) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false)
return server.JSON(w, http.StatusOK, newToken) return server.JSON(w, http.StatusOK, newToken)
} }
} }
@@ -187,7 +187,7 @@ func noPort(host string) string {
return strings.Split(host, ":")[0] return strings.Split(host, ":")[0]
} }
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool, attachmentToken string) { func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieNameRemember, Name: cookieNameRemember,
Value: strconv.FormatBool(remember), Value: strconv.FormatBool(remember),
@@ -219,19 +219,6 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string
HttpOnly: false, HttpOnly: false,
Path: "/", 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) { func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
@@ -265,17 +252,6 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
HttpOnly: false, HttpOnly: false,
Path: "/", 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 // HandleOIDCLogin godoc
@@ -334,7 +310,7 @@ func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc {
} }
// Set cookies and redirect to home // Set cookies and redirect to home
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken) ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
http.Redirect(w, r, "/home", http.StatusFound) http.Redirect(w, r, "/home", http.StatusFound)
return nil return nil
} }

View File

@@ -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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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= 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/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 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI=
github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= 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 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 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/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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= 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= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -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 { 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 { 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 { func (r *AttachmentRepo) GetFullPath(relativePath string) string {

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent" "github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
) )
func TestAttachmentRepo_Create(t *testing.T) { func TestAttachmentRepo_Create(t *testing.T) {
@@ -281,3 +282,58 @@ func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.False(t, photo1.Primary, "Photo 1 should no longer be primary after setting Photo 2 as primary") 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")
}

View File

@@ -6,7 +6,6 @@ export default [
{text: 'Installation', link: '/en/installation'}, {text: 'Installation', link: '/en/installation'},
{text: 'Configure', link: '/en/configure'}, {text: 'Configure', link: '/en/configure'},
{text: 'Storage', link: '/en/configure/storage'}, {text: 'Storage', link: '/en/configure/storage'},
{text: 'OIDC', link: '/en/configure/oidc'},
{text: 'Upgrade Guide', link: '/en/upgrade'}, {text: 'Upgrade Guide', link: '/en/upgrade'},
{text: 'Migration Guide', link: '/en/migration'}, {text: 'Migration Guide', link: '/en/migration'},
] ]

View File

@@ -73,83 +73,6 @@ aside: false
| HBOX_THUMBNAIL_HEIGHT | 500 | height 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. | | 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 ### HBOX_WEB_HOST examples
| Value | Notes | | Value | Notes |
@@ -247,4 +170,114 @@ For SQLite in production:
## OIDC Configuration ## OIDC Configuration
For configuring OpenID Connect (OIDC) authentication, refer to the [OIDC Configuration Guide](/en/configure/oidc). 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
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)
```
:::

View File

@@ -1,44 +0,0 @@
# 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.
:::

View File

@@ -52,7 +52,7 @@ services:
environment: environment:
- HBOX_LOG_LEVEL=info - HBOX_LOG_LEVEL=info
- HBOX_LOG_FORMAT=text - HBOX_LOG_FORMAT=text
- HBOX_WEB_MAX_UPLOAD_SIZE=10 - HBOX_WEB_MAX_FILE_UPLOAD=10
# Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data) # Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data)
- HBOX_OPTIONS_ALLOW_ANALYTICS=false - HBOX_OPTIONS_ALLOW_ANALYTICS=false
volumes: volumes:

View File

@@ -81,6 +81,17 @@
errorMessage.value = t("scanner.error"); 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 = () => { const handleButtonClick = () => {
openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } }); openDialog(DialogID.ProductImport, { params: { barcode: detectedBarcode.value } });
}; };
@@ -92,19 +103,11 @@
return; return;
} }
try { if (await checkPermissionsError()) {
// Request camera permission first return;
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;
}
try {
const devices = await codeReader.listVideoInputDevices(); const devices = await codeReader.listVideoInputDevices();
sources.value = devices; sources.value = devices;

View File

@@ -39,7 +39,7 @@
</div> </div>
</template> </template>
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()"> <form class="flex flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.location" /> <LocationSelector v-model="form.location" />
<!-- Template Info Display - Collapsible banner with distinct styling --> <!-- Template Info Display - Collapsible banner with distinct styling -->

View File

@@ -6,25 +6,12 @@
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between"> <Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
<span class="truncate text-left"> <span>
<slot name="display" v-bind="{ item: value }"> <slot name="display" v-bind="{ item: value }">
{{ displayValue(value) || localizedPlaceholder }} {{ displayValue(value) || localizedPlaceholder }}
</slot> </slot>
</span> </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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0"> <PopoverContent class="w-[--reka-popper-anchor-width] p-0">
@@ -57,7 +44,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { Check, ChevronsUpDown, X } from "lucide-vue-next"; import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { useVModel } from "@vueuse/core"; import { useVModel } from "@vueuse/core";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@@ -187,12 +174,6 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
search.value = "";
open.value = false;
}
const filtered = computed(() => { const filtered = computed(() => {
let baseItems = props.items; let baseItems = props.items;

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateLabel" :title="$t('components.label.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateLabel" :title="$t('components.label.create_modal.title')">
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()"> <form class="flex flex-col gap-2" @submit.prevent="create()">
<FormTextField <FormTextField
v-model="form.name" v-model="form.name"
:trigger-focus="focused" :trigger-focus="focused"

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateLocation" :title="$t('components.location.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateLocation" :title="$t('components.location.create_modal.title')">
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()"> <form class="flex flex-col gap-2" @submit.prevent="create()">
<LocationSelector v-model="form.parent" /> <LocationSelector v-model="form.parent" />
<FormTextField <FormTextField
ref="locationNameRef" ref="locationNameRef"

View File

@@ -7,23 +7,8 @@
<Popover v-model:open="open"> <Popover v-model:open="open">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between"> <Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
<span class="min-w-0 flex-auto truncate text-left"> {{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }} <ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0"> <PopoverContent class="w-[--reka-popper-anchor-width] p-0">
@@ -61,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronsUpDown, X } from "lucide-vue-next"; import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
@@ -94,12 +79,6 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
search.value = "";
open.value = false;
}
const filteredLocations = computed(() => { const filteredLocations = computed(() => {
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj); const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);

View File

@@ -22,13 +22,6 @@
const state = useTreeState(props.treeId); 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({ const openRef = computed({
get() { get() {
return state.value[nodeHash.value] ?? false; return state.value[nodeHash.value] ?? false;
@@ -73,7 +66,7 @@
<NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink> <NuxtLink class="text-lg hover:underline" :to="link" @click.stop>{{ item.name }} </NuxtLink>
</div> </div>
<div v-if="openRef" class="ml-4"> <div v-if="openRef" class="ml-4">
<LocationTreeNode v-for="child in sortedChildren" :key="child.id" :item="child" :tree-id="treeId" /> <LocationTreeNode v-for="child in item.children" :key="child.id" :item="child" :tree-id="treeId" />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -7,21 +7,14 @@
treeId: string; treeId: string;
}; };
const props = defineProps<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> </script>
<template> <template>
<div> <div>
<p v-if="sortedLocs.length === 0" class="text-center text-sm"> <p v-if="locs.length === 0" class="text-center text-sm">
{{ $t("location.tree.no_locations") }} {{ $t("location.tree.no_locations") }}
</p> </p>
<LocationTreeNode v-for="item in sortedLocs" :key="item.id" :item="item" :tree-id="treeId" /> <LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" />
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<template> <template>
<BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')"> <BaseModal :dialog-id="DialogID.CreateTemplate" :title="$t('components.template.create_modal.title')">
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()"> <form class="flex flex-col gap-2" @submit.prevent="create()">
<FormTextField <FormTextField
v-model="form.name" v-model="form.name"
:autofocus="true" :autofocus="true"
@@ -16,7 +16,7 @@
<Separator class="my-2" /> <Separator class="my-2" />
<h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3> <h3 class="text-sm font-medium">{{ $t("components.template.form.default_item_values") }}</h3>
<div class="flex min-w-0 flex-col gap-2"> <div class="grid gap-2">
<FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" /> <FormTextField v-model="form.defaultName" :label="$t('components.template.form.item_name')" :max-length="255" />
<FormTextArea <FormTextArea
v-model="form.defaultDescription" v-model="form.defaultDescription"

View File

@@ -38,14 +38,6 @@
</div> </div>
</CommandItem> </CommandItem>
</CommandGroup> </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> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -87,13 +79,6 @@
</div> </div>
</CommandItem> </CommandItem>
</CommandGroup> </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> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -102,18 +87,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, ChevronsUpDown, X } from "lucide-vue-next"; import { Check, ChevronsUpDown } from "lucide-vue-next";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "~/components/ui/command";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@@ -155,13 +132,6 @@
open.value = false; open.value = false;
} }
function clearSelection() {
value.value = null;
emit("template-selected", null);
search.value = "";
open.value = false;
}
const filteredTemplates = computed(() => { const filteredTemplates = computed(() => {
if (!templates.value) return []; if (!templates.value) return [];
const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj); const filtered = fuzzysort.go(search.value, templates.value, { key: "name", all: true }).map(i => i.obj);

View File

@@ -47,7 +47,7 @@
{{ btn.name.value }} {{ btn.name.value }}
<Shortcut <Shortcut
v-if="btn.shortcut" v-if="btn.shortcut"
class="invisible ml-auto group-hover:visible" class="ml-auto hidden group-hover:inline"
:keys="btn.shortcut.replace('Shift', '').split('+')" :keys="btn.shortcut.replace('Shift', '').split('+')"
/> />
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -136,8 +136,7 @@
"no_results": "No Results Found", "no_results": "No Results Found",
"placeholder": "Select…", "placeholder": "Select…",
"search_placeholder": "Type to search…", "search_placeholder": "Type to search…",
"searching": "Searching…", "searching": "Searching…"
"clear": "Clear Item Selection"
}, },
"view": { "view": {
"change_details": { "change_details": {
@@ -222,8 +221,7 @@
"no_location_found": "No location found", "no_location_found": "No location found",
"parent_location": "Parent Location", "parent_location": "Parent Location",
"search_location": "Search Locations", "search_location": "Search Locations",
"select_location": "Select a Location", "select_location": "Select a Location"
"clear": "Clear Location Selection"
}, },
"tree": { "tree": {
"no_locations": "No locations available. Add new locations through the\n '<span class=\"link-primary\">'Create'</span>' button on the navigation bar." "no_locations": "No locations available. Add new locations through the\n '<span class=\"link-primary\">'Create'</span>' button on the navigation bar."
@@ -270,8 +268,7 @@
"label": "Template (Optional)", "label": "Template (Optional)",
"not_found": "No template found", "not_found": "No template found",
"search": "Search templates...", "search": "Search templates...",
"select": "Select template...", "select": "Select template..."
"clear": "Clear Template Selection"
}, },
"toast": { "toast": {
"applied": "Template \"{name}\" applied", "applied": "Template \"{name}\" applied",

View File

@@ -98,7 +98,7 @@
const saving = ref(false); const saving = ref(false);
async function saveItem(redirect: boolean) { async function saveItem() {
if (!item.value.location?.id) { if (!item.value.location?.id) {
toast.error(t("items.toast.failed_save_no_location")); toast.error(t("items.toast.failed_save_no_location"));
return; return;
@@ -139,9 +139,7 @@
} }
toast.success(t("items.toast.item_saved")); toast.success(t("items.toast.item_saved"));
if (redirect) { navigateTo("/item/" + itemId.value);
navigateTo("/item/" + itemId.value);
}
} }
type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>; type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>;
@@ -341,8 +339,6 @@
toast.success(t("items.toast.attachment_uploaded")); toast.success(t("items.toast.attachment_uploaded"));
await saveItem(false);
item.value.attachments = data.attachments; item.value.attachments = data.attachments;
} }
@@ -436,13 +432,13 @@
// Cmd + S // Cmd + S
if (e.metaKey && e.key === "s") { if (e.metaKey && e.key === "s") {
e.preventDefault(); e.preventDefault();
await saveItem(false); await saveItem();
} }
// Ctrl + S // Ctrl + S
if (e.ctrlKey && e.key === "s") { if (e.ctrlKey && e.key === "s") {
e.preventDefault(); e.preventDefault();
await saveItem(false); await saveItem();
} }
} }
@@ -577,7 +573,7 @@
<TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent> <TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<Button size="sm" :disabled="saving" @click="saveItem(true)"> <Button size="sm" :disabled="saving" @click="saveItem">
<MdiLoading v-if="saving" class="animate-spin" /> <MdiLoading v-if="saving" class="animate-spin" />
<MdiContentSaveOutline v-else /> <MdiContentSaveOutline v-else />
{{ $t("global.save") }} {{ $t("global.save") }}