diff --git a/CHANGELOG.md b/CHANGELOG.md index 6109f37d8..d5babeb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **Connected apps: other applications can sign people in with their Bambuddy account** — Settings → API Keys → Connected Apps registers an app with one exact callback URL and gives it a client ID and a secret, shown once. The app sends the browser to `/connect/authorize`; the user confirms once ("Allow"), and after that sign-in is automatic, so an app opened from the sidebar needs no second login. The app's server then swaps a single-use code for the user's name, email, groups and permissions at `POST /api/v1/connect/token`. It is the OAuth 2.0 authorization-code flow with PKCE (S256 only), kept to what an app on the same network needs: a code lives 60 seconds, works once, is bound to its app, callback URL and PKCE challenge, and is redeemable only with the app's secret; codes and secrets are stored hashed, failed exchanges are rate-limited per app and per IP, and the page never redirects to a callback URL the backend has not matched against the registration. The app never receives the user's password or Bambuddy session, and an API key cannot sign its owner in to an app. Connected apps require authentication to be enabled; with it off, both endpoints answer `auth_disabled` so an app falls back to its own login instead of letting everyone in. See the wiki page *Connected Apps*. - **A batch can record the external order it fulfils, so an integration can't queue the same order twice** — `POST /queue/batches` accepts `external_source` (e.g. `shopify`) and `external_ref`, a reference to the record in that system; both are returned on every batch and can be filtered on with `GET /queue/batches?external_source=&external_ref=`. The pair is unique: a second create for the same record answers 409 instead of making a second batch, so a connector that retries after losing the response can't print an order twice. Both fields are optional and must be given together; batches created without them behave exactly as before. - **PDFs in the File Manager get their thumbnail the moment they arrive (#2976)** — A PDF's grid thumbnail used to exist only once somebody had opened its preview, because the browser rendered it and posted it back; until then the card was blank. Page one is now rendered on the server with PDFium (`pypdfium2`, a new dependency whose wheels carry the PDFium binary for every platform Bambuddy ships, so no system package is needed) on upload, inside an extracted ZIP and when an external folder is scanned. **Generate Thumbnails** covers PDFs too, so existing ones can be backfilled in one click; its tooltip and its nothing-to-do toast say so in all 15 languages. The thumbnail has the same shape as the browser's (a 256 px square, page centred on white), so old and new cards match. PDFium is not thread-safe, so renders are serialised behind a lock and run off the event loop; the render scale comes from the page's own size, so a PDF declaring a huge page cannot make it allocate a huge bitmap. A PDF it cannot read (damaged, encrypted) gets no thumbnail and still lands in the library, and the browser preview remains the fallback. - **Colour swatches for printer slots in the Print / Schedule dialog's filament mapping (#3159, requested by @frantiseklorenc)** — The dialog's filament mapping compares the colour the slice asked for against the colour actually in the machine, and until now only the left-hand side of that comparison had a swatch. The right-hand side — the slot, and every slot in its dropdown — was text, and the text is not reliable: a slot's colour name comes from the Color Catalog or, failing that, from hue, so a third-party beige is announced as "Orange". On a farm running twenty or thirty non-Bambu colours that are swapped between machines daily, a "Color mismatch" warning then gives no way to tell a real mismatch from two names for the same hex without opening the printer card in another tab. Each slot now carries its colour, in the picker and on the row, together with its hex; the slot whose colour is exactly the one the slice asked for is ticked. It works for a slot bound to an inventory spool and for one configured through Configure Slot or on the printer itself, which is the distinction that mattered — the second kind has no inventory row behind it and draws the colour the printer reports. A bound spool also contributes what a tray record cannot carry: its remaining colour stops and its effect, so a two-tone or glittery spool draws as itself rather than as its base colour. The same treatment is applied to the filament-override picker used for model-based assignment, which is the same choice on the other dispatch path and would otherwise have stayed text-only. Both controls stop being ` setName(e.target.value)} + placeholder={t('connectedApps.namePlaceholder')} + aria-label={t('connectedApps.name')} + className={inputClass} + /> + setRedirectUri(e.target.value)} + placeholder="http://orders.local:8090/auth/callback" + aria-label={t('connectedApps.callbackUrl')} + className={inputClass} + /> + + +

{t('connectedApps.callbackHint')}

+ + {isLoading ? null : apps.length === 0 ? ( +

{t('connectedApps.empty')}

+ ) : ( +
+ + + + + + + + + + + {apps.map((app) => ( + + + + + + + + ))} + +
{t('connectedApps.name')}{t('connectedApps.clientId')}{t('connectedApps.callbackUrl')}{t('connectedApps.lastUsed')} +
+ {app.name} + {!app.enabled && ( + + {t('connectedApps.disabledBadge')} + + )} + {app.client_id}{app.redirect_uri}{formatDate(app.last_used_at)} +
+ + + +
+
+
+ )} + + {shownSecret && setShownSecret(null)} />} + {confirm && ( + setConfirm(null)} + onConfirm={() => { + if (confirm.action === 'rotate') rotate.mutate(confirm.app); + else remove.mutate(confirm.app); + setConfirm(null); + }} + /> + )} + + ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 7d9ab7388..d22aad561 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -7477,6 +7477,63 @@ export default { runNow: 'Archive jetzt löschen', saveFailed: 'Einstellungen konnten nicht gespeichert werden.', }, + connectedApps: { + title: 'Verbundene Apps', + description: 'Apps, bei denen man sich mit dem Bambuddy-Konto anmeldet. Jede App bekommt eine Client-ID und ein Geheimnis, und Bambuddy schickt eine Anmeldung nur an die hier eingetragene Callback-URL.', + requiresAuth: 'Verbundene Apps brauchen die Bambuddy-Anmeldung. Aktiviere sie zuerst unter Benutzer.', + name: 'App-Name', + namePlaceholder: 'z. B. Auftragsverwaltung', + callbackUrl: 'Callback-URL', + callbackHint: 'Die Callback-URL steht in der Dokumentation der App. Sie muss exakt übereinstimmen.', + add: 'App hinzufügen', + empty: 'Noch keine verbundenen Apps.', + clientId: 'Client-ID', + clientSecret: 'Client-Geheimnis', + lastUsed: 'Letzte Anmeldung', + disabledBadge: 'Deaktiviert', + enable: 'Aktivieren', + disable: 'Deaktivieren', + rotate: 'Neues Geheimnis', + delete: 'Löschen', + cancel: 'Abbrechen', + copy: 'Kopieren', + secret: { + title: '{{name}}: Geheimnis jetzt kopieren', + warning: 'Trage Client-ID und Geheimnis in der App ein. Das Geheimnis wird nur dieses eine Mal angezeigt; geht es verloren, erzeuge ein neues.', + dismiss: 'Gespeichert', + }, + confirmRotate: { + title: 'Neues Geheimnis erzeugen?', + body: '"{{name}}" kann niemanden mehr anmelden, bis du das neue Geheimnis dort einträgst.', + }, + confirmDelete: { + title: 'App löschen?', + body: '"{{name}}" kann niemanden mehr anmelden. Wird sie wieder hinzugefügt, werden alle erneut um Erlaubnis gefragt.', + }, + toast: { + created: 'App hinzugefügt', + enabled: 'App aktiviert', + disabled: 'App deaktiviert', + rotated: 'Neues Geheimnis erzeugt', + deleted: 'App gelöscht', + copied: 'In die Zwischenablage kopiert', + copyFailed: 'Kopieren fehlgeschlagen. Markiere den Text und kopiere ihn von Hand.', + failed: 'Etwas ist schiefgelaufen', + }, + authorize: { + checking: 'Anmeldeanfrage wird geprüft…', + redirecting: 'Du wirst angemeldet…', + errorTitle: 'Anmeldung nicht möglich', + invalidRequest: 'Diese Anmeldeanfrage ist ungültig: Die App ist nicht registriert, deaktiviert oder nutzt eine andere Callback-URL. Bitte deinen Bambuddy-Admin, Einstellungen > API-Schlüssel > Verbundene Apps zu prüfen.', + authDisabled: 'Die Anmeldung mit Bambuddy ist nicht verfügbar, weil die Bambuddy-Anmeldung ausgeschaltet ist. Nutze stattdessen die Anmeldung der App.', + failed: 'Anmeldung fehlgeschlagen. Geh zurück zur App und versuche es erneut.', + title: '{{app}} möchte dich anmelden', + signedInAs: 'Bei Bambuddy angemeldet als {{username}}', + sharedData: '{{app}} sieht deinen Benutzernamen, deine E-Mail-Adresse, Gruppen und Berechtigungen. Dein Passwort und deine Bambuddy-Sitzung bekommt die App nicht.', + allow: 'Erlauben', + deny: 'Abbrechen', + }, + }, cameraTokens: { scope: { camera_stream: 'Kamera-Stream', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 535481b47..3c22a7ad5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -7528,6 +7528,63 @@ export default { runNow: 'Purge archives now', saveFailed: 'Could not save auto-purge settings.', }, + connectedApps: { + title: 'Connected Apps', + description: 'Apps that sign people in with their Bambuddy account. Each app gets a client ID and secret, and Bambuddy only ever sends a sign-in to the callback URL registered here.', + requiresAuth: 'Connected apps need Bambuddy authentication. Turn it on under Users first.', + name: 'App name', + namePlaceholder: 'e.g. Order manager', + callbackUrl: 'Callback URL', + callbackHint: 'The app\'s documentation gives its callback URL. It must match exactly.', + add: 'Add app', + empty: 'No connected apps yet.', + clientId: 'Client ID', + clientSecret: 'Client secret', + lastUsed: 'Last sign-in', + disabledBadge: 'Disabled', + enable: 'Enable', + disable: 'Disable', + rotate: 'New secret', + delete: 'Delete', + cancel: 'Cancel', + copy: 'Copy', + secret: { + title: '{{name}}: copy the secret now', + warning: 'Paste the client ID and secret into the app. The secret is shown only this once; if it\'s lost, create a new one.', + dismiss: 'I\'ve saved it', + }, + confirmRotate: { + title: 'Create a new secret?', + body: '"{{name}}" stops signing people in until you paste the new secret into it.', + }, + confirmDelete: { + title: 'Delete this app?', + body: '"{{name}}" can no longer sign anyone in. If it\'s added again, everyone is asked for permission again.', + }, + toast: { + created: 'App added', + enabled: 'App enabled', + disabled: 'App disabled', + rotated: 'New secret created', + deleted: 'App deleted', + copied: 'Copied to clipboard', + copyFailed: 'Copy failed. Select the text and copy it manually.', + failed: 'Something went wrong', + }, + authorize: { + checking: 'Checking the sign-in request…', + redirecting: 'Signing you in…', + errorTitle: 'Can\'t sign you in', + invalidRequest: 'This sign-in request isn\'t valid: the app isn\'t registered, is disabled, or uses a different callback URL. Ask your Bambuddy admin to check Settings > API Keys > Connected Apps.', + authDisabled: 'Sign-in with Bambuddy isn\'t available because Bambuddy authentication is turned off. Use the app\'s own login instead.', + failed: 'Sign-in failed. Go back to the app and try again.', + title: '{{app}} wants to sign you in', + signedInAs: 'Signed in to Bambuddy as {{username}}', + sharedData: '{{app}} will see your username, email address, groups and permissions. It doesn\'t get your password or your Bambuddy session.', + allow: 'Allow', + deny: 'Cancel', + }, + }, cameraTokens: { scope: { camera_stream: 'Camera stream', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index bc44743a3..b8dacb6cd 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -7485,6 +7485,63 @@ export default { runNow: 'Purgar los archivos ahora', saveFailed: 'No se pudieron guardar los ajustes de purga automática.', }, + connectedApps: { + title: 'Aplicaciones conectadas', + description: 'Aplicaciones en las que se inicia sesión con la cuenta de Bambuddy. Cada aplicación recibe un ID de cliente y un secreto, y Bambuddy solo envía el inicio de sesión a la URL de retorno registrada aquí.', + requiresAuth: 'Las aplicaciones conectadas necesitan la autenticación de Bambuddy. Actívala primero en Usuarios.', + name: 'Nombre de la aplicación', + namePlaceholder: 'p. ej. Gestor de pedidos', + callbackUrl: 'URL de retorno', + callbackHint: 'La documentación de la aplicación indica su URL de retorno. Debe coincidir exactamente.', + add: 'Añadir aplicación', + empty: 'Todavía no hay aplicaciones conectadas.', + clientId: 'ID de cliente', + clientSecret: 'Secreto de cliente', + lastUsed: 'Último inicio de sesión', + disabledBadge: 'Desactivada', + enable: 'Activar', + disable: 'Desactivar', + rotate: 'Nuevo secreto', + delete: 'Eliminar', + cancel: 'Cancelar', + copy: 'Copiar', + secret: { + title: '{{name}}: copia el secreto ahora', + warning: 'Pega el ID de cliente y el secreto en la aplicación. El secreto solo se muestra esta vez; si se pierde, crea uno nuevo.', + dismiss: 'Lo he guardado', + }, + confirmRotate: { + title: '¿Crear un secreto nuevo?', + body: '"{{name}}" dejará de iniciar sesiones hasta que pegues en ella el secreto nuevo.', + }, + confirmDelete: { + title: '¿Eliminar esta aplicación?', + body: '"{{name}}" ya no podrá iniciar sesión de nadie. Si se vuelve a añadir, se pedirá permiso de nuevo a todos.', + }, + toast: { + created: 'Aplicación añadida', + enabled: 'Aplicación activada', + disabled: 'Aplicación desactivada', + rotated: 'Secreto nuevo creado', + deleted: 'Aplicación eliminada', + copied: 'Copiado al portapapeles', + copyFailed: 'No se pudo copiar. Selecciona el texto y cópialo a mano.', + failed: 'Algo salió mal', + }, + authorize: { + checking: 'Comprobando la solicitud de inicio de sesión…', + redirecting: 'Iniciando sesión…', + errorTitle: 'No se puede iniciar sesión', + invalidRequest: 'Esta solicitud de inicio de sesión no es válida: la aplicación no está registrada, está desactivada o usa otra URL de retorno. Pide a tu administrador de Bambuddy que revise Ajustes > Claves API > Aplicaciones conectadas.', + authDisabled: 'El inicio de sesión con Bambuddy no está disponible porque la autenticación de Bambuddy está desactivada. Usa el inicio de sesión propio de la aplicación.', + failed: 'Error al iniciar sesión. Vuelve a la aplicación e inténtalo de nuevo.', + title: '{{app}} quiere iniciar tu sesión', + signedInAs: 'Sesión iniciada en Bambuddy como {{username}}', + sharedData: '{{app}} verá tu nombre de usuario, tu correo electrónico, tus grupos y tus permisos. No obtiene tu contraseña ni tu sesión de Bambuddy.', + allow: 'Permitir', + deny: 'Cancelar', + }, + }, cameraTokens: { scope: { camera_stream: 'Transmisión de cámara', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 96f60197b..adfab7bc9 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -7465,6 +7465,63 @@ export default { runNow: 'Purger les archives maintenant', saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.', }, + connectedApps: { + title: 'Applications connectées', + description: 'Applications auxquelles on se connecte avec son compte Bambuddy. Chaque application reçoit un identifiant client et un secret, et Bambuddy n\'envoie une connexion qu\'à l\'URL de retour enregistrée ici.', + requiresAuth: 'Les applications connectées nécessitent l\'authentification Bambuddy. Activez-la d\'abord dans Utilisateurs.', + name: 'Nom de l\'application', + namePlaceholder: 'p. ex. Gestion des commandes', + callbackUrl: 'URL de retour', + callbackHint: 'La documentation de l\'application indique son URL de retour. Elle doit correspondre exactement.', + add: 'Ajouter une application', + empty: 'Aucune application connectée pour l\'instant.', + clientId: 'Identifiant client', + clientSecret: 'Secret client', + lastUsed: 'Dernière connexion', + disabledBadge: 'Désactivée', + enable: 'Activer', + disable: 'Désactiver', + rotate: 'Nouveau secret', + delete: 'Supprimer', + cancel: 'Annuler', + copy: 'Copier', + secret: { + title: '{{name}} : copiez le secret maintenant', + warning: 'Collez l\'identifiant client et le secret dans l\'application. Le secret n\'est affiché qu\'une seule fois ; s\'il est perdu, créez-en un nouveau.', + dismiss: 'Je l\'ai enregistré', + }, + confirmRotate: { + title: 'Créer un nouveau secret ?', + body: '« {{name}} » ne pourra plus connecter personne tant que vous n\'y aurez pas collé le nouveau secret.', + }, + confirmDelete: { + title: 'Supprimer cette application ?', + body: '« {{name}} » ne pourra plus connecter personne. Si elle est ajoutée à nouveau, chacun devra redonner son autorisation.', + }, + toast: { + created: 'Application ajoutée', + enabled: 'Application activée', + disabled: 'Application désactivée', + rotated: 'Nouveau secret créé', + deleted: 'Application supprimée', + copied: 'Copié dans le presse-papiers', + copyFailed: 'Échec de la copie. Sélectionnez le texte et copiez-le manuellement.', + failed: 'Une erreur s\'est produite', + }, + authorize: { + checking: 'Vérification de la demande de connexion…', + redirecting: 'Connexion en cours…', + errorTitle: 'Connexion impossible', + invalidRequest: 'Cette demande de connexion n\'est pas valide : l\'application n\'est pas enregistrée, est désactivée ou utilise une autre URL de retour. Demandez à votre administrateur Bambuddy de vérifier Paramètres > Clés API > Applications connectées.', + authDisabled: 'La connexion avec Bambuddy n\'est pas disponible car l\'authentification Bambuddy est désactivée. Utilisez plutôt la connexion propre à l\'application.', + failed: 'La connexion a échoué. Revenez à l\'application et réessayez.', + title: '{{app}} souhaite vous connecter', + signedInAs: 'Connecté à Bambuddy en tant que {{username}}', + sharedData: '{{app}} verra votre nom d\'utilisateur, votre adresse e-mail, vos groupes et vos autorisations. Elle n\'obtient ni votre mot de passe ni votre session Bambuddy.', + allow: 'Autoriser', + deny: 'Annuler', + }, + }, cameraTokens: { scope: { camera_stream: 'Flux de caméra', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index c3ca4ab46..39bf9e9bf 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -7464,6 +7464,63 @@ export default { runNow: 'Elimina archivi ora', saveFailed: 'Impossibile salvare le impostazioni di pulizia automatica.', }, + connectedApps: { + title: 'App collegate', + description: 'App a cui si accede con il proprio account Bambuddy. Ogni app riceve un ID client e un segreto, e Bambuddy invia l\'accesso solo all\'URL di callback registrato qui.', + requiresAuth: 'Le app collegate richiedono l\'autenticazione di Bambuddy. Attivala prima in Utenti.', + name: 'Nome dell\'app', + namePlaceholder: 'es. Gestione ordini', + callbackUrl: 'URL di callback', + callbackHint: 'La documentazione dell\'app indica il suo URL di callback. Deve corrispondere esattamente.', + add: 'Aggiungi app', + empty: 'Ancora nessuna app collegata.', + clientId: 'ID client', + clientSecret: 'Segreto client', + lastUsed: 'Ultimo accesso', + disabledBadge: 'Disattivata', + enable: 'Attiva', + disable: 'Disattiva', + rotate: 'Nuovo segreto', + delete: 'Elimina', + cancel: 'Annulla', + copy: 'Copia', + secret: { + title: '{{name}}: copia subito il segreto', + warning: 'Incolla l\'ID client e il segreto nell\'app. Il segreto viene mostrato solo questa volta; se va perso, creane uno nuovo.', + dismiss: 'L\'ho salvato', + }, + confirmRotate: { + title: 'Creare un nuovo segreto?', + body: '"{{name}}" non farà più accedere nessuno finché non vi incolli il nuovo segreto.', + }, + confirmDelete: { + title: 'Eliminare questa app?', + body: '"{{name}}" non potrà più far accedere nessuno. Se viene aggiunta di nuovo, a tutti verrà richiesto di nuovo il permesso.', + }, + toast: { + created: 'App aggiunta', + enabled: 'App attivata', + disabled: 'App disattivata', + rotated: 'Nuovo segreto creato', + deleted: 'App eliminata', + copied: 'Copiato negli appunti', + copyFailed: 'Copia non riuscita. Seleziona il testo e copialo manualmente.', + failed: 'Qualcosa è andato storto', + }, + authorize: { + checking: 'Verifica della richiesta di accesso…', + redirecting: 'Accesso in corso…', + errorTitle: 'Impossibile accedere', + invalidRequest: 'Questa richiesta di accesso non è valida: l\'app non è registrata, è disattivata o usa un altro URL di callback. Chiedi all\'amministratore di Bambuddy di controllare Impostazioni > Chiavi API > App collegate.', + authDisabled: 'L\'accesso con Bambuddy non è disponibile perché l\'autenticazione di Bambuddy è disattivata. Usa invece l\'accesso dell\'app.', + failed: 'Accesso non riuscito. Torna all\'app e riprova.', + title: '{{app}} vuole farti accedere', + signedInAs: 'Accesso a Bambuddy effettuato come {{username}}', + sharedData: '{{app}} vedrà il tuo nome utente, l\'indirizzo email, i gruppi e i permessi. Non riceve la tua password né la tua sessione Bambuddy.', + allow: 'Consenti', + deny: 'Annulla', + }, + }, cameraTokens: { scope: { camera_stream: 'Flusso della telecamera', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index a2e75243c..e4992a3a8 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -7476,6 +7476,63 @@ export default { runNow: 'アーカイブを今すぐ削除', saveFailed: '自動削除設定を保存できませんでした。', }, + connectedApps: { + title: '連携アプリ', + description: 'Bambuddy アカウントでサインインするアプリです。各アプリにはクライアント ID とシークレットが発行され、Bambuddy はここに登録されたコールバック URL にだけサインインを送ります。', + requiresAuth: '連携アプリを使うには Bambuddy の認証が必要です。先に「ユーザー」で有効にしてください。', + name: 'アプリ名', + namePlaceholder: '例:注文管理', + callbackUrl: 'コールバック URL', + callbackHint: 'コールバック URL はアプリのドキュメントに記載されています。完全に一致する必要があります。', + add: 'アプリを追加', + empty: '連携アプリはまだありません。', + clientId: 'クライアント ID', + clientSecret: 'クライアントシークレット', + lastUsed: '最終サインイン', + disabledBadge: '無効', + enable: '有効にする', + disable: '無効にする', + rotate: 'シークレットを再発行', + delete: '削除', + cancel: 'キャンセル', + copy: 'コピー', + secret: { + title: '{{name}}:今すぐシークレットをコピーしてください', + warning: 'クライアント ID とシークレットをアプリに貼り付けてください。シークレットが表示されるのは今回だけです。紛失した場合は再発行してください。', + dismiss: '保存しました', + }, + confirmRotate: { + title: 'シークレットを再発行しますか?', + body: '新しいシークレットを貼り付けるまで、「{{name}}」ではサインインできなくなります。', + }, + confirmDelete: { + title: 'このアプリを削除しますか?', + body: '「{{name}}」では誰もサインインできなくなります。再度追加した場合、全員に改めて許可を求めます。', + }, + toast: { + created: 'アプリを追加しました', + enabled: 'アプリを有効にしました', + disabled: 'アプリを無効にしました', + rotated: '新しいシークレットを発行しました', + deleted: 'アプリを削除しました', + copied: 'クリップボードにコピーしました', + copyFailed: 'コピーできませんでした。テキストを選択して手動でコピーしてください。', + failed: '問題が発生しました', + }, + authorize: { + checking: 'サインイン要求を確認しています…', + redirecting: 'サインインしています…', + errorTitle: 'サインインできません', + invalidRequest: 'このサインイン要求は無効です。アプリが登録されていないか、無効になっているか、別のコールバック URL を使っています。Bambuddy の管理者に「設定 > API キー > 連携アプリ」を確認してもらってください。', + authDisabled: 'Bambuddy の認証がオフになっているため、Bambuddy でのサインインは使えません。アプリ独自のログインを使ってください。', + failed: 'サインインに失敗しました。アプリに戻ってもう一度お試しください。', + title: '{{app}} がサインインを求めています', + signedInAs: '{{username}} として Bambuddy にサインイン中', + sharedData: '{{app}} はあなたのユーザー名、メールアドレス、グループ、権限を参照します。パスワードや Bambuddy のセッションは渡されません。', + allow: '許可', + deny: 'キャンセル', + }, + }, cameraTokens: { scope: { camera_stream: 'カメラストリーム', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index eec847a2e..26251a38b 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -6921,6 +6921,63 @@ export default { purgeStatsLabel: '통계에서도 제거', purgeStatsDescription: '활성화되면 일일 정리 작업도 각 삭제된 아카이브를 빠른 통계(필라멘트, 시간, 비용, 에너지)에서 제거합니다. 기본값 비활성화 — 빠른 통계는 기여를 유지하고 파일만 디스크에서 제거됩니다.' }, + connectedApps: { + title: '연결된 앱', + description: 'Bambuddy 계정으로 로그인하는 앱입니다. 각 앱에는 클라이언트 ID와 시크릿이 발급되며, Bambuddy는 여기에 등록된 콜백 URL로만 로그인을 보냅니다.', + requiresAuth: '연결된 앱을 사용하려면 Bambuddy 인증이 필요합니다. 먼저 사용자 메뉴에서 켜세요.', + name: '앱 이름', + namePlaceholder: '예: 주문 관리', + callbackUrl: '콜백 URL', + callbackHint: '콜백 URL은 앱 문서에 나와 있습니다. 정확히 일치해야 합니다.', + add: '앱 추가', + empty: '아직 연결된 앱이 없습니다.', + clientId: '클라이언트 ID', + clientSecret: '클라이언트 시크릿', + lastUsed: '마지막 로그인', + disabledBadge: '비활성', + enable: '활성화', + disable: '비활성화', + rotate: '새 시크릿', + delete: '삭제', + cancel: '취소', + copy: '복사', + secret: { + title: '{{name}}: 지금 시크릿을 복사하세요', + warning: '클라이언트 ID와 시크릿을 앱에 붙여 넣으세요. 시크릿은 이번 한 번만 표시됩니다. 잃어버리면 새로 만드세요.', + dismiss: '저장했습니다', + }, + confirmRotate: { + title: '새 시크릿을 만들까요?', + body: '새 시크릿을 붙여 넣을 때까지 "{{name}}"(으)로 로그인할 수 없습니다.', + }, + confirmDelete: { + title: '이 앱을 삭제할까요?', + body: '"{{name}}"(으)로는 더 이상 아무도 로그인할 수 없습니다. 다시 추가하면 모두에게 다시 권한을 묻습니다.', + }, + toast: { + created: '앱을 추가했습니다', + enabled: '앱을 활성화했습니다', + disabled: '앱을 비활성화했습니다', + rotated: '새 시크릿을 만들었습니다', + deleted: '앱을 삭제했습니다', + copied: '클립보드에 복사했습니다', + copyFailed: '복사하지 못했습니다. 텍스트를 선택해 직접 복사하세요.', + failed: '문제가 발생했습니다', + }, + authorize: { + checking: '로그인 요청을 확인하는 중…', + redirecting: '로그인하는 중…', + errorTitle: '로그인할 수 없습니다', + invalidRequest: '유효하지 않은 로그인 요청입니다. 앱이 등록되지 않았거나, 비활성화되었거나, 다른 콜백 URL을 사용합니다. Bambuddy 관리자에게 설정 > API 키 > 연결된 앱을 확인해 달라고 요청하세요.', + authDisabled: 'Bambuddy 인증이 꺼져 있어 Bambuddy로 로그인할 수 없습니다. 앱 자체 로그인을 사용하세요.', + failed: '로그인에 실패했습니다. 앱으로 돌아가 다시 시도하세요.', + title: '{{app}}에서 로그인을 요청합니다', + signedInAs: '{{username}}(으)로 Bambuddy에 로그인됨', + sharedData: '{{app}}은(는) 사용자 이름, 이메일 주소, 그룹, 권한을 볼 수 있습니다. 비밀번호나 Bambuddy 세션은 전달되지 않습니다.', + allow: '허용', + deny: '취소', + }, + }, cameraTokens: { scope: { camera_stream: '카메라 스트림', diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index 2f52853de..249775bcd 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -7528,6 +7528,63 @@ export default { runNow: 'Archieven nu opschonen', saveFailed: 'Instellingen voor automatisch opschonen konden niet worden opgeslagen.', }, + connectedApps: { + title: 'Gekoppelde apps', + description: 'Apps waarin je inlogt met je Bambuddy-account. Elke app krijgt een client-ID en een geheim, en Bambuddy stuurt een aanmelding alleen naar de callback-URL die hier is geregistreerd.', + requiresAuth: 'Gekoppelde apps hebben Bambuddy-authenticatie nodig. Zet die eerst aan onder Gebruikers.', + name: 'Naam van de app', + namePlaceholder: 'bijv. Orderbeheer', + callbackUrl: 'Callback-URL', + callbackHint: 'De documentatie van de app vermeldt de callback-URL. Die moet exact overeenkomen.', + add: 'App toevoegen', + empty: 'Nog geen gekoppelde apps.', + clientId: 'Client-ID', + clientSecret: 'Clientgeheim', + lastUsed: 'Laatste aanmelding', + disabledBadge: 'Uitgeschakeld', + enable: 'Inschakelen', + disable: 'Uitschakelen', + rotate: 'Nieuw geheim', + delete: 'Verwijderen', + cancel: 'Annuleren', + copy: 'Kopiëren', + secret: { + title: '{{name}}: kopieer het geheim nu', + warning: 'Plak het client-ID en het geheim in de app. Het geheim wordt alleen deze ene keer getoond; ben je het kwijt, maak dan een nieuw aan.', + dismiss: 'Ik heb het bewaard', + }, + confirmRotate: { + title: 'Nieuw geheim aanmaken?', + body: '"{{name}}" kan niemand meer aanmelden totdat je het nieuwe geheim erin plakt.', + }, + confirmDelete: { + title: 'Deze app verwijderen?', + body: '"{{name}}" kan niemand meer aanmelden. Wordt de app opnieuw toegevoegd, dan wordt iedereen opnieuw om toestemming gevraagd.', + }, + toast: { + created: 'App toegevoegd', + enabled: 'App ingeschakeld', + disabled: 'App uitgeschakeld', + rotated: 'Nieuw geheim aangemaakt', + deleted: 'App verwijderd', + copied: 'Gekopieerd naar klembord', + copyFailed: 'Kopiëren mislukt. Selecteer de tekst en kopieer die handmatig.', + failed: 'Er ging iets mis', + }, + authorize: { + checking: 'Aanmeldverzoek controleren…', + redirecting: 'Je wordt aangemeld…', + errorTitle: 'Aanmelden niet mogelijk', + invalidRequest: 'Dit aanmeldverzoek is ongeldig: de app is niet geregistreerd, is uitgeschakeld of gebruikt een andere callback-URL. Vraag je Bambuddy-beheerder om Instellingen > API-sleutels > Gekoppelde apps te controleren.', + authDisabled: 'Aanmelden met Bambuddy is niet beschikbaar omdat Bambuddy-authenticatie is uitgeschakeld. Gebruik in plaats daarvan de eigen aanmelding van de app.', + failed: 'Aanmelden mislukt. Ga terug naar de app en probeer het opnieuw.', + title: '{{app}} wil je aanmelden', + signedInAs: 'Aangemeld bij Bambuddy als {{username}}', + sharedData: '{{app}} ziet je gebruikersnaam, e-mailadres, groepen en rechten. De app krijgt je wachtwoord en je Bambuddy-sessie niet.', + allow: 'Toestaan', + deny: 'Annuleren', + }, + }, cameraTokens: { scope: { camera_stream: 'Camerastream', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 9fd09a99c..8a953ca59 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -7464,6 +7464,63 @@ export default { runNow: 'Limpar arquivos agora', saveFailed: 'Não foi possível salvar as configurações de limpeza automática.', }, + connectedApps: { + title: 'Apps conectados', + description: 'Apps em que se entra com a conta do Bambuddy. Cada app recebe um ID de cliente e um segredo, e o Bambuddy só envia o login para a URL de retorno registrada aqui.', + requiresAuth: 'Apps conectados precisam da autenticação do Bambuddy. Ative-a primeiro em Usuários.', + name: 'Nome do app', + namePlaceholder: 'ex.: Gerenciador de pedidos', + callbackUrl: 'URL de retorno', + callbackHint: 'A documentação do app informa a URL de retorno. Ela precisa ser exatamente igual.', + add: 'Adicionar app', + empty: 'Nenhum app conectado ainda.', + clientId: 'ID do cliente', + clientSecret: 'Segredo do cliente', + lastUsed: 'Último login', + disabledBadge: 'Desativado', + enable: 'Ativar', + disable: 'Desativar', + rotate: 'Novo segredo', + delete: 'Excluir', + cancel: 'Cancelar', + copy: 'Copiar', + secret: { + title: '{{name}}: copie o segredo agora', + warning: 'Cole o ID do cliente e o segredo no app. O segredo só aparece desta vez; se ele se perder, crie um novo.', + dismiss: 'Já salvei', + }, + confirmRotate: { + title: 'Criar um novo segredo?', + body: '"{{name}}" deixa de fazer login até você colar o novo segredo nele.', + }, + confirmDelete: { + title: 'Excluir este app?', + body: '"{{name}}" não poderá mais fazer login de ninguém. Se for adicionado de novo, todos terão de dar permissão outra vez.', + }, + toast: { + created: 'App adicionado', + enabled: 'App ativado', + disabled: 'App desativado', + rotated: 'Novo segredo criado', + deleted: 'App excluído', + copied: 'Copiado para a área de transferência', + copyFailed: 'Não foi possível copiar. Selecione o texto e copie manualmente.', + failed: 'Algo deu errado', + }, + authorize: { + checking: 'Verificando a solicitação de login…', + redirecting: 'Fazendo seu login…', + errorTitle: 'Não foi possível entrar', + invalidRequest: 'Esta solicitação de login não é válida: o app não está registrado, está desativado ou usa outra URL de retorno. Peça ao administrador do Bambuddy para verificar Configurações > Chaves de API > Apps conectados.', + authDisabled: 'O login com o Bambuddy não está disponível porque a autenticação do Bambuddy está desligada. Use o login próprio do app.', + failed: 'Falha no login. Volte ao app e tente de novo.', + title: '{{app}} quer fazer seu login', + signedInAs: 'Conectado ao Bambuddy como {{username}}', + sharedData: '{{app}} verá seu nome de usuário, e-mail, grupos e permissões. Ele não recebe sua senha nem sua sessão do Bambuddy.', + allow: 'Permitir', + deny: 'Cancelar', + }, + }, cameraTokens: { scope: { camera_stream: 'Transmissão da câmera', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 239ae5032..01344561c 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -7102,6 +7102,63 @@ export default { runNow: "Очистить архив сейчас", saveFailed: "Не удалось сохранить настройки автоочистки.", }, + connectedApps: { + title: 'Подключённые приложения', + description: 'Приложения, в которые входят с учётной записью Bambuddy. Каждое приложение получает идентификатор клиента и секрет, а Bambuddy отправляет вход только на зарегистрированный здесь URL обратного вызова.', + requiresAuth: 'Для подключённых приложений нужна аутентификация Bambuddy. Сначала включите её в разделе «Пользователи».', + name: 'Название приложения', + namePlaceholder: 'например, Управление заказами', + callbackUrl: 'URL обратного вызова', + callbackHint: 'URL обратного вызова указан в документации приложения. Он должен совпадать в точности.', + add: 'Добавить приложение', + empty: 'Подключённых приложений пока нет.', + clientId: 'Идентификатор клиента', + clientSecret: 'Секрет клиента', + lastUsed: 'Последний вход', + disabledBadge: 'Отключено', + enable: 'Включить', + disable: 'Отключить', + rotate: 'Новый секрет', + delete: 'Удалить', + cancel: 'Отмена', + copy: 'Копировать', + secret: { + title: '{{name}}: скопируйте секрет сейчас', + warning: 'Вставьте идентификатор клиента и секрет в приложение. Секрет показывается только сейчас; если он потерян, создайте новый.', + dismiss: 'Я сохранил', + }, + confirmRotate: { + title: 'Создать новый секрет?', + body: '«{{name}}» не сможет выполнять вход, пока вы не вставите в него новый секрет.', + }, + confirmDelete: { + title: 'Удалить это приложение?', + body: 'Через «{{name}}» больше никто не сможет войти. Если добавить его снова, у всех снова спросят разрешение.', + }, + toast: { + created: 'Приложение добавлено', + enabled: 'Приложение включено', + disabled: 'Приложение отключено', + rotated: 'Новый секрет создан', + deleted: 'Приложение удалено', + copied: 'Скопировано в буфер обмена', + copyFailed: 'Не удалось скопировать. Выделите текст и скопируйте вручную.', + failed: 'Что-то пошло не так', + }, + authorize: { + checking: 'Проверка запроса на вход…', + redirecting: 'Выполняется вход…', + errorTitle: 'Не удаётся войти', + invalidRequest: 'Этот запрос на вход недействителен: приложение не зарегистрировано, отключено или использует другой URL обратного вызова. Попросите администратора Bambuddy проверить «Настройки > API-ключи > Подключённые приложения».', + authDisabled: 'Вход через Bambuddy недоступен, потому что аутентификация Bambuddy отключена. Используйте собственный вход приложения.', + failed: 'Не удалось войти. Вернитесь в приложение и попробуйте ещё раз.', + title: '{{app}} хочет выполнить ваш вход', + signedInAs: 'Вход в Bambuddy выполнен как {{username}}', + sharedData: '{{app}} увидит ваше имя пользователя, адрес электронной почты, группы и права. Ваш пароль и сеанс Bambuddy приложение не получит.', + allow: 'Разрешить', + deny: 'Отмена', + }, + }, cameraTokens: { scope: { camera_stream: "Поток камеры", diff --git a/frontend/src/i18n/locales/sv.ts b/frontend/src/i18n/locales/sv.ts index 4b820eda5..1b595c565 100644 --- a/frontend/src/i18n/locales/sv.ts +++ b/frontend/src/i18n/locales/sv.ts @@ -7528,6 +7528,63 @@ errors: { runNow: 'Rensa arkiv nu', saveFailed: 'Kunde inte spara inställningar för auto-rensning.', }, + connectedApps: { + title: 'Anslutna appar', + description: 'Appar där man loggar in med sitt Bambuddy-konto. Varje app får ett klient-ID och en hemlighet, och Bambuddy skickar bara en inloggning till den återanrops-URL som registrerats här.', + requiresAuth: 'Anslutna appar kräver Bambuddy-autentisering. Slå på den under Användare först.', + name: 'Appens namn', + namePlaceholder: 't.ex. Orderhantering', + callbackUrl: 'Återanrops-URL', + callbackHint: 'Appens dokumentation anger dess återanrops-URL. Den måste stämma exakt.', + add: 'Lägg till app', + empty: 'Inga anslutna appar ännu.', + clientId: 'Klient-ID', + clientSecret: 'Klienthemlighet', + lastUsed: 'Senaste inloggning', + disabledBadge: 'Inaktiverad', + enable: 'Aktivera', + disable: 'Inaktivera', + rotate: 'Ny hemlighet', + delete: 'Ta bort', + cancel: 'Avbryt', + copy: 'Kopiera', + secret: { + title: '{{name}}: kopiera hemligheten nu', + warning: 'Klistra in klient-ID och hemlighet i appen. Hemligheten visas bara den här gången; om den tappas bort, skapa en ny.', + dismiss: 'Jag har sparat den', + }, + confirmRotate: { + title: 'Skapa en ny hemlighet?', + body: '"{{name}}" kan inte logga in någon förrän du klistrar in den nya hemligheten i den.', + }, + confirmDelete: { + title: 'Ta bort den här appen?', + body: '"{{name}}" kan inte längre logga in någon. Om den läggs till igen tillfrågas alla om tillåtelse på nytt.', + }, + toast: { + created: 'App tillagd', + enabled: 'App aktiverad', + disabled: 'App inaktiverad', + rotated: 'Ny hemlighet skapad', + deleted: 'App borttagen', + copied: 'Kopierat till urklipp', + copyFailed: 'Kopieringen misslyckades. Markera texten och kopiera den manuellt.', + failed: 'Något gick fel', + }, + authorize: { + checking: 'Kontrollerar inloggningsbegäran…', + redirecting: 'Loggar in dig…', + errorTitle: 'Det går inte att logga in', + invalidRequest: 'Den här inloggningsbegäran är ogiltig: appen är inte registrerad, är inaktiverad eller använder en annan återanrops-URL. Be din Bambuddy-administratör kontrollera Inställningar > API-nycklar > Anslutna appar.', + authDisabled: 'Inloggning med Bambuddy är inte tillgänglig eftersom Bambuddy-autentiseringen är avstängd. Använd appens egen inloggning i stället.', + failed: 'Inloggningen misslyckades. Gå tillbaka till appen och försök igen.', + title: '{{app}} vill logga in dig', + signedInAs: 'Inloggad i Bambuddy som {{username}}', + sharedData: '{{app}} ser ditt användarnamn, din e-postadress, dina grupper och behörigheter. Appen får inte ditt lösenord eller din Bambuddy-session.', + allow: 'Tillåt', + deny: 'Avbryt', + }, + }, cameraTokens: { scope: { camera_stream: 'Kameraström', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b7be0f14a..42577b881 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -7416,6 +7416,63 @@ export default { runNow: 'Arşivleri şimdi temizle', saveFailed: 'Otomatik temizleme ayarları kaydedilemedi.', }, + connectedApps: { + title: 'Bağlı Uygulamalar', + description: 'Bambuddy hesabıyla oturum açılan uygulamalar. Her uygulamaya bir istemci kimliği ve gizli anahtar verilir; Bambuddy oturum açmayı yalnızca burada kayıtlı geri çağırma URL\'sine gönderir.', + requiresAuth: 'Bağlı uygulamalar Bambuddy kimlik doğrulaması gerektirir. Önce Kullanıcılar bölümünden açın.', + name: 'Uygulama adı', + namePlaceholder: 'ör. Sipariş yönetimi', + callbackUrl: 'Geri çağırma URL\'si', + callbackHint: 'Geri çağırma URL\'si uygulamanın belgelerinde yazar. Tam olarak eşleşmelidir.', + add: 'Uygulama ekle', + empty: 'Henüz bağlı uygulama yok.', + clientId: 'İstemci kimliği', + clientSecret: 'İstemci gizli anahtarı', + lastUsed: 'Son oturum açma', + disabledBadge: 'Devre dışı', + enable: 'Etkinleştir', + disable: 'Devre dışı bırak', + rotate: 'Yeni gizli anahtar', + delete: 'Sil', + cancel: 'İptal', + copy: 'Kopyala', + secret: { + title: '{{name}}: gizli anahtarı şimdi kopyalayın', + warning: 'İstemci kimliğini ve gizli anahtarı uygulamaya yapıştırın. Gizli anahtar yalnızca bu kez gösterilir; kaybolursa yenisini oluşturun.', + dismiss: 'Kaydettim', + }, + confirmRotate: { + title: 'Yeni gizli anahtar oluşturulsun mu?', + body: 'Yeni gizli anahtarı yapıştırana kadar "{{name}}" ile oturum açılamaz.', + }, + confirmDelete: { + title: 'Bu uygulama silinsin mi?', + body: '"{{name}}" artık kimsenin oturumunu açamaz. Yeniden eklenirse herkesten tekrar izin istenir.', + }, + toast: { + created: 'Uygulama eklendi', + enabled: 'Uygulama etkinleştirildi', + disabled: 'Uygulama devre dışı bırakıldı', + rotated: 'Yeni gizli anahtar oluşturuldu', + deleted: 'Uygulama silindi', + copied: 'Panoya kopyalandı', + copyFailed: 'Kopyalanamadı. Metni seçip elle kopyalayın.', + failed: 'Bir şeyler ters gitti', + }, + authorize: { + checking: 'Oturum açma isteği kontrol ediliyor…', + redirecting: 'Oturumunuz açılıyor…', + errorTitle: 'Oturum açılamıyor', + invalidRequest: 'Bu oturum açma isteği geçersiz: uygulama kayıtlı değil, devre dışı ya da farklı bir geri çağırma URL\'si kullanıyor. Bambuddy yöneticinizden Ayarlar > API Anahtarları > Bağlı Uygulamalar bölümünü kontrol etmesini isteyin.', + authDisabled: 'Bambuddy kimlik doğrulaması kapalı olduğu için Bambuddy ile oturum açma kullanılamıyor. Bunun yerine uygulamanın kendi girişini kullanın.', + failed: 'Oturum açılamadı. Uygulamaya dönüp tekrar deneyin.', + title: '{{app}} oturumunuzu açmak istiyor', + signedInAs: 'Bambuddy\'de {{username}} olarak oturum açıldı', + sharedData: '{{app}} kullanıcı adınızı, e-posta adresinizi, gruplarınızı ve izinlerinizi görecek. Parolanızı ve Bambuddy oturumunuzu almaz.', + allow: 'İzin ver', + deny: 'İptal', + }, + }, cameraTokens: { scope: { camera_stream: 'Kamera akışı', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 8f1517f0a..0ecdfe578 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -7520,6 +7520,63 @@ export default { runNow: "Очистити архіви зараз", saveFailed: "Не вдалося зберегти налаштування автоматичного очищення.", }, + connectedApps: { + title: 'Підключені застосунки', + description: 'Застосунки, у які входять з обліковим записом Bambuddy. Кожен застосунок отримує ідентифікатор клієнта й секрет, а Bambuddy надсилає вхід лише на зареєстровану тут URL-адресу зворотного виклику.', + requiresAuth: 'Для підключених застосунків потрібна автентифікація Bambuddy. Спершу увімкніть її в розділі «Користувачі».', + name: 'Назва застосунку', + namePlaceholder: 'напр., Керування замовленнями', + callbackUrl: 'URL зворотного виклику', + callbackHint: 'URL зворотного виклику вказано в документації застосунку. Він має збігатися точно.', + add: 'Додати застосунок', + empty: 'Підключених застосунків поки немає.', + clientId: 'Ідентифікатор клієнта', + clientSecret: 'Секрет клієнта', + lastUsed: 'Останній вхід', + disabledBadge: 'Вимкнено', + enable: 'Увімкнути', + disable: 'Вимкнути', + rotate: 'Новий секрет', + delete: 'Видалити', + cancel: 'Скасувати', + copy: 'Копіювати', + secret: { + title: '{{name}}: скопіюйте секрет зараз', + warning: 'Вставте ідентифікатор клієнта й секрет у застосунок. Секрет показується лише цього разу; якщо його втрачено, створіть новий.', + dismiss: 'Я зберіг', + }, + confirmRotate: { + title: 'Створити новий секрет?', + body: '«{{name}}» не зможе виконувати вхід, доки ви не вставите в нього новий секрет.', + }, + confirmDelete: { + title: 'Видалити цей застосунок?', + body: 'Через «{{name}}» більше ніхто не зможе увійти. Якщо додати його знову, у всіх знову запитають дозвіл.', + }, + toast: { + created: 'Застосунок додано', + enabled: 'Застосунок увімкнено', + disabled: 'Застосунок вимкнено', + rotated: 'Новий секрет створено', + deleted: 'Застосунок видалено', + copied: 'Скопійовано в буфер обміну', + copyFailed: 'Не вдалося скопіювати. Виділіть текст і скопіюйте вручну.', + failed: 'Щось пішло не так', + }, + authorize: { + checking: 'Перевірка запиту на вхід…', + redirecting: 'Виконується вхід…', + errorTitle: 'Не вдається увійти', + invalidRequest: 'Цей запит на вхід недійсний: застосунок не зареєстровано, вимкнено або він використовує іншу URL-адресу зворотного виклику. Попросіть адміністратора Bambuddy перевірити «Налаштування > API-ключі > Підключені застосунки».', + authDisabled: 'Вхід через Bambuddy недоступний, бо автентифікацію Bambuddy вимкнено. Використовуйте власний вхід застосунку.', + failed: 'Не вдалося увійти. Поверніться до застосунку й спробуйте ще раз.', + title: '{{app}} хоче виконати ваш вхід', + signedInAs: 'Вхід у Bambuddy виконано як {{username}}', + sharedData: '{{app}} бачитиме ваше ім\'я користувача, адресу електронної пошти, групи та дозволи. Ваш пароль і сеанс Bambuddy застосунок не отримає.', + allow: 'Дозволити', + deny: 'Скасувати', + }, + }, cameraTokens: { scope: { camera_stream: "Потік камери", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ec91bf53c..fef32698d 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -7462,6 +7462,63 @@ export default { runNow: '立即清除归档', saveFailed: '无法保存自动清除设置。', }, + connectedApps: { + title: '已连接的应用', + description: '使用 Bambuddy 账户登录的应用。每个应用会获得一个客户端 ID 和密钥,Bambuddy 只会把登录发送到此处登记的回调 URL。', + requiresAuth: '已连接的应用需要启用 Bambuddy 身份验证。请先在“用户”中开启。', + name: '应用名称', + namePlaceholder: '例如:订单管理', + callbackUrl: '回调 URL', + callbackHint: '回调 URL 见应用的文档,必须完全一致。', + add: '添加应用', + empty: '还没有已连接的应用。', + clientId: '客户端 ID', + clientSecret: '客户端密钥', + lastUsed: '最近登录', + disabledBadge: '已停用', + enable: '启用', + disable: '停用', + rotate: '新密钥', + delete: '删除', + cancel: '取消', + copy: '复制', + secret: { + title: '{{name}}:请立即复制密钥', + warning: '请把客户端 ID 和密钥粘贴到应用中。密钥只显示这一次;如果丢失,请重新生成。', + dismiss: '我已保存', + }, + confirmRotate: { + title: '生成新密钥?', + body: '在你把新密钥粘贴进去之前,“{{name}}”将无法让任何人登录。', + }, + confirmDelete: { + title: '删除此应用?', + body: '“{{name}}”将无法再让任何人登录。如果重新添加,所有人都需要再次授权。', + }, + toast: { + created: '已添加应用', + enabled: '已启用应用', + disabled: '已停用应用', + rotated: '已生成新密钥', + deleted: '已删除应用', + copied: '已复制到剪贴板', + copyFailed: '复制失败。请选中文字后手动复制。', + failed: '出错了', + }, + authorize: { + checking: '正在检查登录请求…', + redirecting: '正在为你登录…', + errorTitle: '无法登录', + invalidRequest: '此登录请求无效:应用未登记、已停用,或使用了不同的回调 URL。请联系 Bambuddy 管理员检查“设置 > API 密钥 > 已连接的应用”。', + authDisabled: '由于 Bambuddy 身份验证已关闭,无法使用 Bambuddy 登录。请改用应用自带的登录。', + failed: '登录失败。请返回应用重试。', + title: '{{app}} 请求为你登录', + signedInAs: '已以 {{username}} 身份登录 Bambuddy', + sharedData: '{{app}} 将看到你的用户名、电子邮件地址、用户组和权限。它不会获得你的密码或 Bambuddy 会话。', + allow: '允许', + deny: '取消', + }, + }, cameraTokens: { scope: { camera_stream: '摄像头视频流', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index e0704f2f2..8c86fa4c4 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -7462,6 +7462,63 @@ export default { runNow: '立即清除歸檔', saveFailed: '無法儲存自動清除設定。', }, + connectedApps: { + title: '已連結的應用程式', + description: '使用 Bambuddy 帳號登入的應用程式。每個應用程式會取得一組用戶端 ID 與密鑰,Bambuddy 只會把登入傳送到此處登記的回呼 URL。', + requiresAuth: '已連結的應用程式需要啟用 Bambuddy 驗證。請先在「使用者」中開啟。', + name: '應用程式名稱', + namePlaceholder: '例如:訂單管理', + callbackUrl: '回呼 URL', + callbackHint: '回呼 URL 請見應用程式的說明文件,必須完全相符。', + add: '新增應用程式', + empty: '尚無已連結的應用程式。', + clientId: '用戶端 ID', + clientSecret: '用戶端密鑰', + lastUsed: '最近登入', + disabledBadge: '已停用', + enable: '啟用', + disable: '停用', + rotate: '新密鑰', + delete: '刪除', + cancel: '取消', + copy: '複製', + secret: { + title: '{{name}}:請立即複製密鑰', + warning: '請把用戶端 ID 與密鑰貼到應用程式中。密鑰只會顯示這一次;若遺失,請重新產生。', + dismiss: '我已儲存', + }, + confirmRotate: { + title: '產生新密鑰?', + body: '在你把新密鑰貼進去之前,「{{name}}」將無法讓任何人登入。', + }, + confirmDelete: { + title: '刪除此應用程式?', + body: '「{{name}}」將無法再讓任何人登入。若重新新增,所有人都需要再次授權。', + }, + toast: { + created: '已新增應用程式', + enabled: '已啟用應用程式', + disabled: '已停用應用程式', + rotated: '已產生新密鑰', + deleted: '已刪除應用程式', + copied: '已複製到剪貼簿', + copyFailed: '複製失敗。請選取文字後手動複製。', + failed: '發生錯誤', + }, + authorize: { + checking: '正在檢查登入要求…', + redirecting: '正在為你登入…', + errorTitle: '無法登入', + invalidRequest: '此登入要求無效:應用程式未登記、已停用,或使用了不同的回呼 URL。請聯絡 Bambuddy 管理員檢查「設定 > API 金鑰 > 已連結的應用程式」。', + authDisabled: '由於 Bambuddy 驗證已關閉,無法使用 Bambuddy 登入。請改用應用程式本身的登入。', + failed: '登入失敗。請返回應用程式再試一次。', + title: '{{app}} 要求為你登入', + signedInAs: '已以 {{username}} 身分登入 Bambuddy', + sharedData: '{{app}} 將看到你的使用者名稱、電子郵件地址、群組與權限。它不會取得你的密碼或 Bambuddy 工作階段。', + allow: '允許', + deny: '取消', + }, + }, cameraTokens: { scope: { camera_stream: '攝影機串流', diff --git a/frontend/src/pages/ConnectAuthorizePage.tsx b/frontend/src/pages/ConnectAuthorizePage.tsx new file mode 100644 index 000000000..ac4154bfb --- /dev/null +++ b/frontend/src/pages/ConnectAuthorizePage.tsx @@ -0,0 +1,164 @@ +/** + * "Sign in with Bambuddy" for connected apps. + * + * An app sends the browser here with its client_id, callback URL, PKCE + * challenge and state. The page runs with the user's normal session: it asks + * the backend whether the request is valid, shows a one-time consent screen, + * and sends the browser back to the app with a single-use code. + * + * It never redirects anywhere until the backend has confirmed that the + * callback URL is the one registered for that app, so it can't be used as an + * open redirect. Every error before that point is shown here instead. + */ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { AlertTriangle, Link2, Loader2 } from 'lucide-react'; +import { api, ApiError, type ConnectAuthorizeInfo } from '../api/client'; +import { useAuth } from '../contexts/AuthContext'; + +type Phase = + | { kind: 'loading' } + | { kind: 'consent'; info: ConnectAuthorizeInfo } + | { kind: 'redirecting' } + | { kind: 'error'; message: string }; + +function withParams(base: string, params: Record): string { + const url = new URL(base); + for (const [key, value] of Object.entries(params)) { + if (value) url.searchParams.set(key, value); + } + return url.toString(); +} + +export function ConnectAuthorizePage() { + const { t } = useTranslation(); + const { authEnabled, loading } = useAuth(); + const [searchParams] = useSearchParams(); + const [phase, setPhase] = useState({ kind: 'loading' }); + const started = useRef(false); + + const clientId = searchParams.get('client_id') ?? ''; + const redirectUri = searchParams.get('redirect_uri') ?? ''; + const state = searchParams.get('state') ?? ''; + const codeChallenge = searchParams.get('code_challenge') ?? ''; + const codeChallengeMethod = searchParams.get('code_challenge_method') ?? ''; + + const approve = async () => { + setPhase({ kind: 'redirecting' }); + try { + const result = await api.connectAuthorize({ + client_id: clientId, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + // Use the callback the backend returned, not the query parameter. + window.location.replace(withParams(result.redirect_uri, { code: result.code, state })); + } catch (err) { + setPhase({ + kind: 'error', + message: err instanceof Error ? err.message : t('connectedApps.authorize.failed'), + }); + } + }; + + const deny = () => { + // Safe: we only reach consent after the backend matched redirectUri + // against the app's registration. + window.location.replace(withParams(redirectUri, { error: 'access_denied', state })); + }; + + useEffect(() => { + if (loading || started.current) return; + started.current = true; + + if (!authEnabled) { + setPhase({ kind: 'error', message: t('connectedApps.authorize.authDisabled') }); + return; + } + if (!clientId || !redirectUri || !codeChallenge || codeChallengeMethod !== 'S256') { + setPhase({ kind: 'error', message: t('connectedApps.authorize.invalidRequest') }); + return; + } + + api + .getConnectAuthorizeInfo(clientId, redirectUri) + .then((info) => { + if (info.already_granted) { + void approve(); + } else { + setPhase({ kind: 'consent', info }); + } + }) + .catch((err) => { + const disabled = err instanceof ApiError && err.status === 409; + setPhase({ + kind: 'error', + message: disabled ? t('connectedApps.authorize.authDisabled') : t('connectedApps.authorize.invalidRequest'), + }); + }); + // approve() reads the same query parameters; running this once is the point. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, authEnabled]); + + return ( +
+
+ {(phase.kind === 'loading' || phase.kind === 'redirecting') && ( +
+ +

+ {phase.kind === 'loading' + ? t('connectedApps.authorize.checking') + : t('connectedApps.authorize.redirecting')} +

+
+ )} + + {phase.kind === 'error' && ( +
+ +

{t('connectedApps.authorize.errorTitle')}

+

{phase.message}

+
+ )} + + {phase.kind === 'consent' && ( + <> +
+
+ +
+

+ {t('connectedApps.authorize.title', { app: phase.info.app_name })} +

+

+ {t('connectedApps.authorize.signedInAs', { username: phase.info.username })} +

+
+

+ {t('connectedApps.authorize.sharedData', { app: phase.info.app_name })} +

+
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d45b0baf0..ab358cf69 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -22,6 +22,7 @@ import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationH import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card'; import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel'; import { CameraTokensSection } from './CameraTokensPage'; +import { ConnectedAppsSection } from '../components/ConnectedAppsSection'; import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder'; import { Collapsible } from '../components/Collapsible'; import { CopyButton } from '../components/CopyButton'; @@ -59,7 +60,7 @@ import { availableLanguages } from '../i18n'; import { useToast } from '../contexts/ToastContext'; import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Gauge, Palette } from 'lucide-react'; +import { Gauge, Link2, Palette } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; import type { UsersSubTab } from '../lib/settingsSearch'; import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines'; @@ -102,6 +103,7 @@ registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network', registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' }); registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' }); registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' }); +registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' }); registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' }); registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' }); registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' }); @@ -4787,6 +4789,22 @@ export function SettingsPage() { } + {/* Connected apps: "Sign in with Bambuddy" for external applications. + Admin-only, like the settings it sits between. */} + {hasPermission('settings:update') && ( + + +

+ + {t('connectedApps.title')} +

+
+ + + +
+ )} + {/* Long-lived camera-stream tokens (#1108) */}