mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-23 06:03:49 +01:00
Compare commits
10 Commits
copilot/fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1947dd09e | ||
|
|
018f1f5977 | ||
|
|
9a9e3d462e | ||
|
|
37890c2a22 | ||
|
|
096b682f0a | ||
|
|
e4d8bb2ada | ||
|
|
3becf046e6 | ||
|
|
a21b3257d4 | ||
|
|
5f9ab577bb | ||
|
|
0a969bb64d |
@@ -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)
|
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
||||||
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)
|
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false, newToken.AttachmentToken)
|
||||||
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) {
|
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool, attachmentToken string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieNameRemember,
|
Name: cookieNameRemember,
|
||||||
Value: strconv.FormatBool(remember),
|
Value: strconv.FormatBool(remember),
|
||||||
@@ -219,6 +219,19 @@ 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) {
|
||||||
@@ -252,6 +265,17 @@ 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
|
||||||
@@ -310,7 +334,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)
|
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
||||||
http.Redirect(w, r, "/home", http.StatusFound)
|
http.Redirect(w, r, "/home", http.StatusFound)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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'},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -73,6 +73,83 @@ 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 |
|
||||||
@@ -170,114 +247,4 @@ For SQLite in production:
|
|||||||
|
|
||||||
## OIDC Configuration
|
## OIDC Configuration
|
||||||
|
|
||||||
HomeBox supports OpenID Connect (OIDC) authentication, allowing users to login using external identity providers like Keycloak, Authentik, Google, Microsoft, etc.
|
For configuring OpenID Connect (OIDC) authentication, refer to the [OIDC Configuration Guide](/en/configure/oidc).
|
||||||
|
|
||||||
### 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|||||||
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:
|
environment:
|
||||||
- HBOX_LOG_LEVEL=info
|
- HBOX_LOG_LEVEL=info
|
||||||
- HBOX_LOG_FORMAT=text
|
- 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)
|
# 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:
|
||||||
|
|||||||
@@ -81,17 +81,6 @@
|
|||||||
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 } });
|
||||||
};
|
};
|
||||||
@@ -103,11 +92,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await checkPermissionsError()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const devices = await codeReader.listVideoInputDevices();
|
const devices = await codeReader.listVideoInputDevices();
|
||||||
sources.value = devices;
|
sources.value = devices;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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" />
|
<LocationSelector v-model="form.location" />
|
||||||
|
|
||||||
<!-- Template Info Display - Collapsible banner with distinct styling -->
|
<!-- Template Info Display - Collapsible banner with distinct styling -->
|
||||||
|
|||||||
@@ -6,12 +6,25 @@
|
|||||||
<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>
|
<span class="truncate text-left">
|
||||||
<slot name="display" v-bind="{ item: value }">
|
<slot name="display" v-bind="{ item: value }">
|
||||||
{{ displayValue(value) || localizedPlaceholder }}
|
{{ displayValue(value) || localizedPlaceholder }}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</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.item.selector.clear')"
|
||||||
|
@click.stop.prevent="clearSelection"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
<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">
|
||||||
@@ -44,7 +57,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue";
|
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 fuzzysort from "fuzzysort";
|
||||||
import { useVModel } from "@vueuse/core";
|
import { useVModel } from "@vueuse/core";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
@@ -174,6 +187,12 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 flex-col gap-2" @submit.prevent="create()">
|
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="create()">
|
||||||
<FormTextField
|
<FormTextField
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:trigger-focus="focused"
|
:trigger-focus="focused"
|
||||||
|
|||||||
@@ -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 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" />
|
<LocationSelector v-model="form.parent" />
|
||||||
<FormTextField
|
<FormTextField
|
||||||
ref="locationNameRef"
|
ref="locationNameRef"
|
||||||
|
|||||||
@@ -7,8 +7,23 @@
|
|||||||
<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") }}
|
||||||
|
</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" />
|
<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">
|
||||||
@@ -46,7 +61,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
import { Check, ChevronsUpDown, X } 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";
|
||||||
@@ -79,6 +94,12 @@
|
|||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -66,7 +73,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 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,14 +7,21 @@
|
|||||||
treeId: string;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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") }}
|
{{ $t("location.tree.no_locations") }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 flex-col gap-2" @submit.prevent="create()">
|
<form class="flex min-w-0 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="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" />
|
<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"
|
||||||
|
|||||||
@@ -38,6 +38,14 @@
|
|||||||
</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>
|
||||||
@@ -79,6 +87,13 @@
|
|||||||
</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>
|
||||||
@@ -87,10 +102,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
import { Check, ChevronsUpDown, X } 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,
|
||||||
|
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";
|
||||||
@@ -132,6 +155,13 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
{{ btn.name.value }}
|
{{ btn.name.value }}
|
||||||
<Shortcut
|
<Shortcut
|
||||||
v-if="btn.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('+')"
|
:keys="btn.shortcut.replace('Shift', '⇧').split('+')"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -136,7 +136,8 @@
|
|||||||
"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": {
|
||||||
@@ -221,7 +222,8 @@
|
|||||||
"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."
|
||||||
@@ -268,7 +270,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
async function saveItem() {
|
async function saveItem(redirect: boolean) {
|
||||||
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,8 +139,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 }>;
|
||||||
type NonNullableNumberKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends number ? K : never]: any }>;
|
type NonNullableNumberKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends number ? K : never]: any }>;
|
||||||
@@ -339,6 +341,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,13 +436,13 @@
|
|||||||
// Cmd + S
|
// Cmd + S
|
||||||
if (e.metaKey && e.key === "s") {
|
if (e.metaKey && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await saveItem();
|
await saveItem(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl + S
|
// Ctrl + S
|
||||||
if (e.ctrlKey && e.key === "s") {
|
if (e.ctrlKey && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await saveItem();
|
await saveItem(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +577,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">
|
<Button size="sm" :disabled="saving" @click="saveItem(true)">
|
||||||
<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") }}
|
||||||
|
|||||||
Reference in New Issue
Block a user