mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 06:28:34 +01:00
Compare commits
13 Commits
copilot/im
...
tonya/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b479bb842e | ||
|
|
0f4f398b5a | ||
|
|
545993a8aa | ||
|
|
a1947dd09e | ||
|
|
018f1f5977 | ||
|
|
9a9e3d462e | ||
|
|
37890c2a22 | ||
|
|
096b682f0a | ||
|
|
e4d8bb2ada | ||
|
|
3becf046e6 | ||
|
|
a21b3257d4 | ||
|
|
5f9ab577bb | ||
|
|
0a969bb64d |
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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ type (
|
||||
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
|
||||
|
||||
// Default location and labels
|
||||
DefaultLocationID *uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
|
||||
DefaultLocationID uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
|
||||
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
|
||||
|
||||
// Metadata flags
|
||||
@@ -82,7 +82,7 @@ type (
|
||||
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
|
||||
|
||||
// Default location and labels
|
||||
DefaultLocationID *uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
|
||||
DefaultLocationID uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
|
||||
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
|
||||
|
||||
// Metadata flags
|
||||
@@ -262,6 +262,7 @@ func (r *ItemTemplatesRepository) GetOne(ctx context.Context, gid uuid.UUID, id
|
||||
|
||||
// Create creates a new template
|
||||
func (r *ItemTemplatesRepository) Create(ctx context.Context, gid uuid.UUID, data ItemTemplateCreate) (ItemTemplateOut, error) {
|
||||
// Set up create builder
|
||||
q := r.db.ItemTemplate.Create().
|
||||
SetName(data.Name).
|
||||
SetDescription(data.Description).
|
||||
@@ -277,9 +278,12 @@ func (r *ItemTemplatesRepository) Create(ctx context.Context, gid uuid.UUID, dat
|
||||
SetIncludeWarrantyFields(data.IncludeWarrantyFields).
|
||||
SetIncludePurchaseFields(data.IncludePurchaseFields).
|
||||
SetIncludeSoldFields(data.IncludeSoldFields).
|
||||
SetGroupID(gid).
|
||||
SetNillableLocationID(data.DefaultLocationID)
|
||||
SetGroupID(gid)
|
||||
|
||||
// If a default location was provided (uuid != Nil) set it, otherwise leave empty
|
||||
if data.DefaultLocationID != uuid.Nil {
|
||||
q.SetLocationID(data.DefaultLocationID)
|
||||
}
|
||||
// Set default label IDs (stored as JSON)
|
||||
if data.DefaultLabelIDs != nil && len(*data.DefaultLabelIDs) > 0 {
|
||||
q.SetDefaultLabelIds(*data.DefaultLabelIDs)
|
||||
@@ -340,9 +344,9 @@ func (r *ItemTemplatesRepository) Update(ctx context.Context, gid uuid.UUID, dat
|
||||
SetIncludePurchaseFields(data.IncludePurchaseFields).
|
||||
SetIncludeSoldFields(data.IncludeSoldFields)
|
||||
|
||||
// Update location
|
||||
if data.DefaultLocationID != nil {
|
||||
updateQ.SetLocationID(*data.DefaultLocationID)
|
||||
// Update location: set when provided (not uuid.Nil), otherwise clear
|
||||
if data.DefaultLocationID != uuid.Nil {
|
||||
updateQ.SetLocationID(data.DefaultLocationID)
|
||||
} else {
|
||||
updateQ.ClearLocation()
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ func TestItemTemplatesRepository_CreateWithLocation(t *testing.T) {
|
||||
|
||||
// Create template with location
|
||||
data := templateFactory()
|
||||
data.DefaultLocationID = &loc.ID
|
||||
data.DefaultLocationID = loc.ID
|
||||
|
||||
template, err := tRepos.ItemTemplates.Create(context.Background(), tGroup.ID, data)
|
||||
require.NoError(t, err)
|
||||
@@ -311,7 +311,7 @@ func TestItemTemplatesRepository_UpdateRemoveLocation(t *testing.T) {
|
||||
|
||||
// Create template with location
|
||||
data := templateFactory()
|
||||
data.DefaultLocationID = &loc.ID
|
||||
data.DefaultLocationID = loc.ID
|
||||
|
||||
template, err := tRepos.ItemTemplates.Create(context.Background(), tGroup.ID, data)
|
||||
require.NoError(t, err)
|
||||
@@ -323,7 +323,7 @@ func TestItemTemplatesRepository_UpdateRemoveLocation(t *testing.T) {
|
||||
ID: template.ID,
|
||||
Name: template.Name,
|
||||
DefaultQuantity: &qty,
|
||||
DefaultLocationID: nil, // Remove location
|
||||
DefaultLocationID: uuid.Nil, // Remove location
|
||||
}
|
||||
|
||||
updated, err := tRepos.ItemTemplates.Update(context.Background(), tGroup.ID, updateData)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -73,137 +73,6 @@ aside: false
|
||||
| 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_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). |
|
||||
| unix?path=/run/homebox.sock | Listen on unix socket at specified path |
|
||||
| sysd?name=homebox.socket | Listen on systemd socket |
|
||||
|
||||
For unix and systemd socket address syntax and available options, see the [anyhttp address-syntax documentation](https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax).
|
||||
|
||||
#### Private network example
|
||||
|
||||
Below example starts homebox in an isolated network. The process cannot make
|
||||
any external requests (including check for newer release) and thus more secure.
|
||||
|
||||
```bash
|
||||
❯ sudo systemd-run --property=PrivateNetwork=yes --uid $UID --pty --same-dir --wait --collect homebox --web-host "unix?path=/run/user/$UID/homebox.sock"
|
||||
Running as unit: run-p74482-i74483.service
|
||||
Press ^] three times within 1s to disconnect TTY.
|
||||
2025/07/11 22:33:29 goose: no migrations to run. current version: 20250706190000
|
||||
10:33PM INF ../../../go/src/app/app/api/handlers/v1/v1_ctrl_auth.go:98 > registering auth provider name=local
|
||||
10:33PM INF ../../../go/src/app/app/api/main.go:275 > Server is running on unix?path=/run/user/1000/homebox.sock
|
||||
10:33PM ERR ../../../go/src/app/app/api/main.go:403 > failed to get latest github release error="failed to make latest version request: Get \"https://api.github.com/repos/sysadminsmedia/homebox/releases/l
|
||||
atest\": dial tcp: lookup api.github.com on [::1]:53: read udp [::1]:50951->[::1]:53: read: connection refused"
|
||||
10:33PM INF ../../../go/src/app/internal/web/mid/logger.go:36 > request received method=GET path=/ rid=hname/PoXyRgt6ol-000001
|
||||
10:33PM INF ../../../go/src/app/internal/web/mid/logger.go:41 > request finished method=GET path=/ rid=hname/PoXyRgt6ol-000001 status=0
|
||||
```
|
||||
|
||||
#### Systemd socket example
|
||||
|
||||
In the example below, Homebox listens on a systemd socket securely so that only
|
||||
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]
|
||||
Description=Homebox socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/homebox.sock
|
||||
SocketGroup=caddy
|
||||
SocketMode=0660
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
```
|
||||
|
||||
File: homebox.service
|
||||
```systemd
|
||||
# /usr/local/lib/systemd/system/homebox.service
|
||||
[Unit]
|
||||
Description=Homebox
|
||||
After=network.target
|
||||
Documentation=https://homebox.software
|
||||
|
||||
[Service]
|
||||
DynamicUser=yes
|
||||
StateDirectory=homebox
|
||||
Environment=HBOX_WEB_HOST=sysd?name=homebox.socket
|
||||
WorkingDirectory=/var/lib/homebox
|
||||
|
||||
ExecStart=/usr/local/bin/homebox
|
||||
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
RestrictNamespaces=true
|
||||
SystemCallFilter=@system-service
|
||||
```
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
systemctl start homebox.socket
|
||||
```
|
||||
|
||||
::: warning Security Considerations
|
||||
For postgreSQL in production:
|
||||
|
||||
- Do not use the default `postgres` user
|
||||
- Do not use the default `postgres` database
|
||||
- Always use a strong unique password
|
||||
- Always use SSL (`sslmode=require` or `sslmode=verify-full`)
|
||||
- Consider using a connection pooler like `pgbouncer`
|
||||
|
||||
For SQLite in production:
|
||||
|
||||
- Secure file permissions for the database file (e.g. `chmod 600`)
|
||||
- Use a secure directory for the database file
|
||||
- Use a secure backup strategy
|
||||
- Monitor the file size and consider using a different database for large installations
|
||||
:::
|
||||
|
||||
## 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
|
||||
Options:
|
||||
--barcode-token-barcodespider <string>
|
||||
@@ -279,5 +148,106 @@ Options:
|
||||
--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). |
|
||||
| unix?path=/run/homebox.sock | Listen on unix socket at specified path |
|
||||
| sysd?name=homebox.socket | Listen on systemd socket |
|
||||
|
||||
For unix and systemd socket address syntax and available options, see the [anyhttp address-syntax documentation](https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax).
|
||||
|
||||
#### Private network example
|
||||
|
||||
Below example starts homebox in an isolated network. The process cannot make
|
||||
any external requests (including check for newer release) and thus more secure.
|
||||
|
||||
```bash
|
||||
❯ sudo systemd-run --property=PrivateNetwork=yes --uid $UID --pty --same-dir --wait --collect homebox --web-host "unix?path=/run/user/$UID/homebox.sock"
|
||||
Running as unit: run-p74482-i74483.service
|
||||
Press ^] three times within 1s to disconnect TTY.
|
||||
2025/07/11 22:33:29 goose: no migrations to run. current version: 20250706190000
|
||||
10:33PM INF ../../../go/src/app/app/api/handlers/v1/v1_ctrl_auth.go:98 > registering auth provider name=local
|
||||
10:33PM INF ../../../go/src/app/app/api/main.go:275 > Server is running on unix?path=/run/user/1000/homebox.sock
|
||||
10:33PM ERR ../../../go/src/app/app/api/main.go:403 > failed to get latest github release error="failed to make latest version request: Get \"https://api.github.com/repos/sysadminsmedia/homebox/releases/l
|
||||
atest\": dial tcp: lookup api.github.com on [::1]:53: read udp [::1]:50951->[::1]:53: read: connection refused"
|
||||
10:33PM INF ../../../go/src/app/internal/web/mid/logger.go:36 > request received method=GET path=/ rid=hname/PoXyRgt6ol-000001
|
||||
10:33PM INF ../../../go/src/app/internal/web/mid/logger.go:41 > request finished method=GET path=/ rid=hname/PoXyRgt6ol-000001 status=0
|
||||
```
|
||||
|
||||
#### Systemd socket example
|
||||
|
||||
In the example below, Homebox listens on a systemd socket securely so that only
|
||||
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]
|
||||
Description=Homebox socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/homebox.sock
|
||||
SocketGroup=caddy
|
||||
SocketMode=0660
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
```
|
||||
|
||||
File: homebox.service
|
||||
|
||||
```systemd
|
||||
# /usr/local/lib/systemd/system/homebox.service
|
||||
[Unit]
|
||||
Description=Homebox
|
||||
After=network.target
|
||||
Documentation=https://homebox.software
|
||||
|
||||
[Service]
|
||||
DynamicUser=yes
|
||||
StateDirectory=homebox
|
||||
Environment=HBOX_WEB_HOST=sysd?name=homebox.socket
|
||||
WorkingDirectory=/var/lib/homebox
|
||||
|
||||
ExecStart=/usr/local/bin/homebox
|
||||
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
RestrictNamespaces=true
|
||||
SystemCallFilter=@system-service
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
systemctl start homebox.socket
|
||||
```
|
||||
|
||||
::: warning Security Considerations
|
||||
For postgreSQL in production:
|
||||
|
||||
- Do not use the default `postgres` user
|
||||
- Do not use the default `postgres` database
|
||||
- Always use a strong unique password
|
||||
- Always use SSL (`sslmode=require` or `sslmode=verify-full`)
|
||||
- Consider using a connection pooler like `pgbouncer`
|
||||
|
||||
For SQLite in production:
|
||||
|
||||
- Secure file permissions for the database file (e.g. `chmod 600`)
|
||||
- Use a secure directory for the database file
|
||||
- Use a secure backup strategy
|
||||
- Monitor the file size and consider using a different database for large installations
|
||||
:::
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
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.
|
||||
:::
|
||||
@@ -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>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
defaultModelNumber: fullTemplate.defaultModelNumber,
|
||||
defaultLifetimeWarranty: fullTemplate.defaultLifetimeWarranty,
|
||||
defaultWarrantyDetails: fullTemplate.defaultWarrantyDetails,
|
||||
defaultLocationId: fullTemplate.defaultLocation?.id ?? "",
|
||||
defaultLocationId: fullTemplate.defaultLocation?.id ?? null,
|
||||
defaultLabelIds: fullTemplate.defaultLabels?.map(l => l.id) || [],
|
||||
includeWarrantyFields: fullTemplate.includeWarrantyFields,
|
||||
includePurchaseFields: fullTemplate.includePurchaseFields,
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
// Prepare the data with proper format for API
|
||||
const payload = {
|
||||
...updateData,
|
||||
defaultLocationId: updateData.defaultLocation?.id ?? "",
|
||||
defaultLocationId: updateData.defaultLocation?.id ?? null,
|
||||
};
|
||||
|
||||
const { error, data } = await api.templates.update(templateId.value, payload);
|
||||
|
||||
Reference in New Issue
Block a user