mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(backup): offer an account when an upload's owner is gone
An upload refused with backup.owner.missing no longer ends in an error toast. The backups console keeps the file and opens a dialog: a new account by default, which cannot hold anything yet, or an existing one, which the server checks as soon as it is picked and which the admin confirms by typing its username. Managed profiles and accounts pending deletion are not offered. The file is then sent again with that target, and a refusal stays in the dialog beside the choice it refuses. The new-account path says the account has no password yet and where to set its first one. Copy in every app language.
This commit is contained in:
1 parent
8014fd4aae
commit
a40bd53d45
14 files changed
+936
-35
No files matched your search
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet",
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Eine Kopie, deren Konto es auf diesem Server nicht mehr gibt, kann in ein anderes Konto gehen. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
|
||||
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
|
||||
},
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück.",
|
||||
"target": {
|
||||
"title": "In ein anderes Konto wiederherstellen",
|
||||
"description": "{file} gehört zu einem Konto, das es auf diesem Server nicht mehr gibt. Wähle das Konto, in das die Datensätze gehen. Eine Wiederherstellung ersetzt die Datensätze eines Kontos, deshalb kann sie nur ein Konto ohne Datensätze aufnehmen. Die Datei wird dafür noch einmal hochgeladen.",
|
||||
"choiceLabel": "Wohin die Datensätze gehen",
|
||||
"newAccount": "Neues Konto",
|
||||
"existingAccount": "Bestehendes Konto",
|
||||
"usernameLabel": "Benutzername für das neue Konto",
|
||||
"usernameInvalid": "3 bis 30 Buchstaben, Ziffern, _ oder -, nicht mit „managed-“ am Anfang.",
|
||||
"newAccountHint": "Das Konto wird ohne Passwort und ohne E-Mail angelegt. Bevor du es übergibst, setze sein erstes Passwort unter Nutzer mit „Passwort zurücksetzen“.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Konto wählen",
|
||||
"checking": "Konto wird geprüft …",
|
||||
"eligible": "Dieses Konto hat noch keine Datensätze und kann die Kopie aufnehmen.",
|
||||
"confirmLabel": "Tippe {username} zum Bestätigen",
|
||||
"submit": "In dieses Konto hochladen",
|
||||
"submitting": "Wird hochgeladen …",
|
||||
"success": "Backup für {username} gespeichert ({count} Datensätze). Stelle es über seine Zeile wieder her.",
|
||||
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
|
||||
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
|
||||
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
|
||||
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
|
||||
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
|
||||
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
|
||||
"failureOwnerExists": "Das Konto, zu dem diese Datei gehört, ist auf diesem Server, und die Datei wird in dieses Konto wiederhergestellt. Lade sie noch einmal hoch, ohne ein Konto zu wählen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. A copy whose account is no longer on this server can go into another account. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "The newest dump in the bucket is from {when}.",
|
||||
"offhostNeverUploaded": "No database dump has reached the bucket yet."
|
||||
},
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back."
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back.",
|
||||
"target": {
|
||||
"title": "Restore into another account",
|
||||
"description": "{file} belongs to an account that is no longer on this server. Choose the account its records go into. A restore replaces an account's records, so only an account without records can take them. The file is uploaded once more.",
|
||||
"choiceLabel": "Where the records go",
|
||||
"newAccount": "New account",
|
||||
"existingAccount": "Existing account",
|
||||
"usernameLabel": "Username for the new account",
|
||||
"usernameInvalid": "3 to 30 letters, digits, _ or -, not starting with \"managed-\".",
|
||||
"newAccountHint": "The account is created without a password or email. Before you hand it over, set its first password with Reset password under Users.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Choose an account",
|
||||
"checking": "Checking the account…",
|
||||
"eligible": "This account holds no records yet and can take the copy.",
|
||||
"confirmLabel": "Type {username} to confirm",
|
||||
"submit": "Upload into this account",
|
||||
"submitting": "Uploading…",
|
||||
"success": "Backup stored for {username} ({count} records). Restore it from its row.",
|
||||
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
|
||||
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
|
||||
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
"failureNotFound": "This account no longer exists.",
|
||||
"failureUnconfirmed": "Type the account's username exactly to confirm.",
|
||||
"failureUsernameTaken": "This username is already taken.",
|
||||
"failureOwnerExists": "The account this file belongs to is on this server, and the file restores into that account. Upload it again without choosing an account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger zone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "La descarga falló",
|
||||
"downloadStarted": "Descarga iniciada",
|
||||
"uploadTitle": "Subir copia de seguridad",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. Una copia cuya cuenta ya no está en este servidor puede ir a otra cuenta. Restaurar es un paso aparte.",
|
||||
"uploadButton": "Subir archivo",
|
||||
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
|
||||
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "El volcado más reciente del bucket es del {when}.",
|
||||
"offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven."
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven.",
|
||||
"target": {
|
||||
"title": "Restaurar en otra cuenta",
|
||||
"description": "{file} pertenece a una cuenta que ya no está en este servidor. Elige la cuenta a la que van sus registros. Una restauración reemplaza los registros de una cuenta, así que solo una cuenta sin registros puede recibirlos. El archivo se sube una vez más.",
|
||||
"choiceLabel": "Adónde van los registros",
|
||||
"newAccount": "Cuenta nueva",
|
||||
"existingAccount": "Cuenta existente",
|
||||
"usernameLabel": "Nombre de usuario de la cuenta nueva",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ o -, sin empezar por «managed-».",
|
||||
"newAccountHint": "La cuenta se crea sin contraseña ni correo. Antes de entregarla, pon su primera contraseña con Restablecer contraseña en Usuarios.",
|
||||
"accountLabel": "Cuenta",
|
||||
"accountPlaceholder": "Elige una cuenta",
|
||||
"checking": "Comprobando la cuenta…",
|
||||
"eligible": "Esta cuenta aún no tiene registros y puede recibir la copia.",
|
||||
"confirmLabel": "Escribe {username} para confirmar",
|
||||
"submit": "Subir a esta cuenta",
|
||||
"submitting": "Subiendo…",
|
||||
"success": "Copia guardada para {username} ({count} registros). Restáurala desde su fila.",
|
||||
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
|
||||
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
|
||||
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
|
||||
"failureNotFound": "Esta cuenta ya no existe.",
|
||||
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
|
||||
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
|
||||
"failureOwnerExists": "La cuenta a la que pertenece este archivo está en este servidor, y el archivo se restaura en esa cuenta. Súbelo de nuevo sin elegir una cuenta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"uploadTitle": "Téléverser une sauvegarde",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Une copie dont le compte n'existe plus sur ce serveur peut aller dans un autre compte. La restauration est une étape distincte.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
|
||||
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Le dump le plus récent du bucket date du {when}.",
|
||||
"offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas."
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas.",
|
||||
"target": {
|
||||
"title": "Restaurer dans un autre compte",
|
||||
"description": "{file} appartient à un compte qui n'existe plus sur ce serveur. Choisissez le compte qui recevra ses enregistrements. Une restauration remplace les enregistrements d'un compte : seul un compte sans enregistrements peut donc les recevoir. Le fichier est téléversé une nouvelle fois.",
|
||||
"choiceLabel": "Destination des enregistrements",
|
||||
"newAccount": "Nouveau compte",
|
||||
"existingAccount": "Compte existant",
|
||||
"usernameLabel": "Nom d'utilisateur du nouveau compte",
|
||||
"usernameInvalid": "De 3 à 30 lettres, chiffres, _ ou -, sans commencer par « managed- ».",
|
||||
"newAccountHint": "Le compte est créé sans mot de passe ni e-mail. Avant de le remettre, définissez son premier mot de passe avec Réinitialiser le mot de passe, sous Utilisateurs.",
|
||||
"accountLabel": "Compte",
|
||||
"accountPlaceholder": "Choisissez un compte",
|
||||
"checking": "Vérification du compte…",
|
||||
"eligible": "Ce compte n'a encore aucun enregistrement et peut recevoir la copie.",
|
||||
"confirmLabel": "Tapez {username} pour confirmer",
|
||||
"submit": "Téléverser dans ce compte",
|
||||
"submitting": "Téléversement…",
|
||||
"success": "Sauvegarde enregistrée pour {username} ({count} enregistrements). Restaurez-la depuis sa ligne.",
|
||||
"failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.",
|
||||
"failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.",
|
||||
"failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.",
|
||||
"failureNotFound": "Ce compte n'existe plus.",
|
||||
"failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.",
|
||||
"failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.",
|
||||
"failureOwnerExists": "Le compte auquel appartient ce fichier est sur ce serveur, et le fichier se restaure dans ce compte. Téléversez-le à nouveau sans choisir de compte."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download non riuscito",
|
||||
"downloadStarted": "Download avviato",
|
||||
"uploadTitle": "Carica backup",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. Una copia il cui account non è più su questo server può andare in un altro account. Il ripristino è un passaggio a parte.",
|
||||
"uploadButton": "Carica file",
|
||||
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
|
||||
"uploadSuccess": "Backup caricato ({count} record)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Il dump più recente nel bucket è del {when}.",
|
||||
"offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano."
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano.",
|
||||
"target": {
|
||||
"title": "Ripristina in un altro account",
|
||||
"description": "{file} appartiene a un account che non è più su questo server. Scegli l’account in cui vanno i suoi record. Un ripristino sostituisce i record di un account, quindi solo un account senza record può accoglierli. Il file viene caricato un’altra volta.",
|
||||
"choiceLabel": "Dove vanno i record",
|
||||
"newAccount": "Nuovo account",
|
||||
"existingAccount": "Account esistente",
|
||||
"usernameLabel": "Nome utente del nuovo account",
|
||||
"usernameInvalid": "Da 3 a 30 lettere, cifre, _ o -, senza iniziare con “managed-”.",
|
||||
"newAccountHint": "L’account viene creato senza password né email. Prima di consegnarlo, imposta la sua prima password con Reimposta password in Utenti.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Scegli un account",
|
||||
"checking": "Controllo dell’account…",
|
||||
"eligible": "Questo account non ha ancora record e può accogliere la copia.",
|
||||
"confirmLabel": "Digita {username} per confermare",
|
||||
"submit": "Carica in questo account",
|
||||
"submitting": "Caricamento…",
|
||||
"success": "Backup salvato per {username} ({count} record). Ripristinalo dalla sua riga.",
|
||||
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
|
||||
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
|
||||
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
|
||||
"failureNotFound": "Questo account non esiste più.",
|
||||
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
|
||||
"failureUsernameTaken": "Questo nome utente è già in uso.",
|
||||
"failureOwnerExists": "L’account a cui appartiene questo file è su questo server, e il file si ripristina in quell’account. Caricalo di nuovo senza scegliere un account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
|
||||
+27
-2
@@ -8192,7 +8192,7 @@
|
||||
"downloadFailed": "다운로드하지 못했어요",
|
||||
"downloadStarted": "다운로드를 시작했어요",
|
||||
"uploadTitle": "백업 업로드",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 계정이 이 서버에 더 이상 없는 사본은 다른 계정으로 보낼 수 있어요. 복원은 별도 단계예요.",
|
||||
"uploadButton": "파일 업로드",
|
||||
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
|
||||
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
|
||||
@@ -8351,7 +8351,32 @@
|
||||
"offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.",
|
||||
"offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다."
|
||||
},
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요."
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요.",
|
||||
"target": {
|
||||
"title": "다른 계정으로 복원",
|
||||
"description": "{file}은(는) 이 서버에 더 이상 없는 계정의 파일이에요. 기록을 넣을 계정을 골라 주세요. 복원은 계정의 기록을 바꿔 쓰기 때문에 기록이 없는 계정만 받을 수 있어요. 파일은 한 번 더 업로드돼요.",
|
||||
"choiceLabel": "기록을 넣을 곳",
|
||||
"newAccount": "새 계정",
|
||||
"existingAccount": "기존 계정",
|
||||
"usernameLabel": "새 계정의 사용자 이름",
|
||||
"usernameInvalid": "영문자, 숫자, _ 또는 - 3~30자이고, \"managed-\"로 시작하면 안 돼요.",
|
||||
"newAccountHint": "계정은 비밀번호와 이메일 없이 만들어져요. 넘겨주기 전에 사용자에서 비밀번호 재설정으로 첫 비밀번호를 정해 주세요.",
|
||||
"accountLabel": "계정",
|
||||
"accountPlaceholder": "계정을 골라 주세요",
|
||||
"checking": "계정을 확인하는 중…",
|
||||
"eligible": "이 계정에는 아직 기록이 없어서 사본을 받을 수 있어요.",
|
||||
"confirmLabel": "확인하려면 {username}을(를) 입력해 주세요",
|
||||
"submit": "이 계정으로 업로드",
|
||||
"submitting": "업로드 중…",
|
||||
"success": "{username}의 백업을 저장했어요 (기록 {count}개). 해당 행에서 복원해 주세요.",
|
||||
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
|
||||
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
|
||||
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
|
||||
"failureNotFound": "이 계정은 더 이상 없어요.",
|
||||
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
|
||||
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
|
||||
"failureOwnerExists": "이 파일의 계정이 이 서버에 있어서 파일은 그 계정으로 복원돼요. 계정을 고르지 말고 다시 업로드해 주세요."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Downloaden mislukt",
|
||||
"downloadStarted": "Download gestart",
|
||||
"uploadTitle": "Back-up uploaden",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Een kopie waarvan het account niet meer op deze server staat, kan naar een ander account. Terugzetten is een aparte stap.",
|
||||
"uploadButton": "Bestand uploaden",
|
||||
"uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.",
|
||||
"uploadSuccess": "Back-up geüpload ({count} records)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "De nieuwste dump in de bucket is van {when}.",
|
||||
"offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen."
|
||||
},
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug."
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug.",
|
||||
"target": {
|
||||
"title": "In een ander account terugzetten",
|
||||
"description": "{file} hoort bij een account dat niet meer op deze server staat. Kies het account waar de records naartoe gaan. Terugzetten vervangt de records van een account, dus alleen een account zonder records kan ze opnemen. Het bestand wordt nog een keer geüpload.",
|
||||
"choiceLabel": "Waar de records naartoe gaan",
|
||||
"newAccount": "Nieuw account",
|
||||
"existingAccount": "Bestaand account",
|
||||
"usernameLabel": "Gebruikersnaam voor het nieuwe account",
|
||||
"usernameInvalid": "3 tot 30 letters, cijfers, _ of -, niet beginnend met ‘managed-’.",
|
||||
"newAccountHint": "Het account wordt zonder wachtwoord en zonder e-mail aangemaakt. Stel het eerste wachtwoord in met Wachtwoord resetten onder Gebruikers voordat je het overdraagt.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Kies een account",
|
||||
"checking": "Account wordt gecontroleerd…",
|
||||
"eligible": "Dit account heeft nog geen records en kan de kopie opnemen.",
|
||||
"confirmLabel": "Typ {username} om te bevestigen",
|
||||
"submit": "Naar dit account uploaden",
|
||||
"submitting": "Uploaden…",
|
||||
"success": "Back-up opgeslagen voor {username} ({count} records). Zet hem terug vanuit zijn rij.",
|
||||
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
|
||||
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
|
||||
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
|
||||
"failureNotFound": "Dit account bestaat niet meer.",
|
||||
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
|
||||
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
|
||||
"failureOwnerExists": "Het account waar dit bestand bij hoort, staat op deze server, en het bestand wordt in dat account teruggezet. Upload het opnieuw zonder een account te kiezen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gevarenzone",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Pobieranie nie powiodło się",
|
||||
"downloadStarted": "Rozpoczęto pobieranie",
|
||||
"uploadTitle": "Prześlij kopię zapasową",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Kopia, której konta nie ma już na tym serwerze, może trafić do innego konta. Przywracanie to osobny krok.",
|
||||
"uploadButton": "Prześlij plik",
|
||||
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
|
||||
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.",
|
||||
"offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych."
|
||||
},
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają."
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają.",
|
||||
"target": {
|
||||
"title": "Przywróć do innego konta",
|
||||
"description": "{file} należy do konta, którego nie ma już na tym serwerze. Wybierz konto, do którego trafią jego rekordy. Przywracanie zastępuje rekordy konta, więc przyjąć je może tylko konto bez rekordów. Plik zostanie przesłany jeszcze raz.",
|
||||
"choiceLabel": "Dokąd trafią rekordy",
|
||||
"newAccount": "Nowe konto",
|
||||
"existingAccount": "Istniejące konto",
|
||||
"usernameLabel": "Nazwa użytkownika nowego konta",
|
||||
"usernameInvalid": "Od 3 do 30 liter, cyfr, _ lub -, bez „managed-” na początku.",
|
||||
"newAccountHint": "Konto zostanie utworzone bez hasła i bez adresu e-mail. Zanim je przekażesz, ustaw jego pierwsze hasło przyciskiem Zresetuj hasło w sekcji Użytkownicy.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Wybierz konto",
|
||||
"checking": "Sprawdzanie konta…",
|
||||
"eligible": "To konto nie ma jeszcze rekordów i może przyjąć kopię.",
|
||||
"confirmLabel": "Wpisz {username}, aby potwierdzić",
|
||||
"submit": "Prześlij do tego konta",
|
||||
"submitting": "Przesyłanie…",
|
||||
"success": "Kopia zapisana dla {username} ({count} rekordów). Przywróć ją z jej wiersza.",
|
||||
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
|
||||
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
|
||||
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
|
||||
"failureNotFound": "To konto już nie istnieje.",
|
||||
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
|
||||
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
|
||||
"failureOwnerExists": "Konto, do którego należy ten plik, jest na tym serwerze, a plik przywraca się do tego konta. Prześlij go ponownie bez wybierania konta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
|
||||
+27
-2
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Falha no download",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"uploadTitle": "Enviar backup",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. Uma cópia cuja conta não está mais neste servidor pode ir para outra conta. Restaurar é uma etapa separada.",
|
||||
"uploadButton": "Enviar arquivo",
|
||||
"uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.",
|
||||
"uploadSuccess": "Backup enviado ({count} registros)",
|
||||
@@ -8376,7 +8376,32 @@
|
||||
"offhostLastUpload": "O dump mais recente no bucket é de {when}.",
|
||||
"offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam."
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam.",
|
||||
"target": {
|
||||
"title": "Restaurar em outra conta",
|
||||
"description": "{file} pertence a uma conta que não está mais neste servidor. Escolha a conta para onde vão os registros. Uma restauração substitui os registros de uma conta, então só uma conta sem registros pode recebê-los. O arquivo é enviado mais uma vez.",
|
||||
"choiceLabel": "Para onde vão os registros",
|
||||
"newAccount": "Conta nova",
|
||||
"existingAccount": "Conta existente",
|
||||
"usernameLabel": "Nome de usuário da conta nova",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ ou -, sem começar com “managed-”.",
|
||||
"newAccountHint": "A conta é criada sem senha e sem e-mail. Antes de entregá-la, defina a primeira senha com Redefinir senha em Usuários.",
|
||||
"accountLabel": "Conta",
|
||||
"accountPlaceholder": "Escolha uma conta",
|
||||
"checking": "Verificando a conta…",
|
||||
"eligible": "Esta conta ainda não tem registros e pode receber a cópia.",
|
||||
"confirmLabel": "Digite {username} para confirmar",
|
||||
"submit": "Enviar para esta conta",
|
||||
"submitting": "Enviando…",
|
||||
"success": "Backup guardado para {username} ({count} registros). Restaure-o pela linha dele.",
|
||||
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
|
||||
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
|
||||
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
|
||||
"failureNotFound": "Esta conta não existe mais.",
|
||||
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
|
||||
"failureUsernameTaken": "Este nome de usuário já está em uso.",
|
||||
"failureOwnerExists": "A conta a que este arquivo pertence está neste servidor, e o arquivo é restaurado nessa conta. Envie-o de novo sem escolher uma conta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de perigo",
|
||||
|
||||
@@ -15,7 +15,10 @@ import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react";
|
||||
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,6 +32,7 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
|
||||
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
|
||||
import { DatabaseDumpRow } from "./backups/database-dump-row";
|
||||
import { BackupTargetDialog } from "./backups/backup-target-dialog";
|
||||
import { BackupUploadCard } from "./backups/backup-upload-card";
|
||||
import {
|
||||
RestoreRefusalNotice,
|
||||
@@ -55,8 +59,14 @@ export function BackupsSection() {
|
||||
// a fresh proof unless this session signed in or re-proved within minutes.
|
||||
const recentProof = useRecentProof();
|
||||
const { downloadingId, handleDownload } = useBackupDownload(recentProof);
|
||||
const { upload, fileInputRef, handleFileChange } =
|
||||
useBackupUpload(recentProof);
|
||||
const {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
} = useBackupUpload(recentProof);
|
||||
const {
|
||||
restoreJobs,
|
||||
dismissedRestores,
|
||||
@@ -127,10 +137,24 @@ export function BackupsSection() {
|
||||
) : null}
|
||||
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending}
|
||||
pending={upload.isPending || uploadInto.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
{/* Mounted per refused file, so every file starts from a clean form. */}
|
||||
{orphan ? (
|
||||
<BackupTargetDialog
|
||||
file={orphan}
|
||||
pending={uploadInto.isPending}
|
||||
error={
|
||||
uploadInto.error instanceof ReproofCancelledError
|
||||
? null
|
||||
: uploadInto.error
|
||||
}
|
||||
onSubmit={(target) => uploadInto.mutate(target)}
|
||||
onClose={closeOrphan}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The choice offered when an uploaded file's account is not on this server.
|
||||
*
|
||||
* SSR markup per project convention, plus the pure rules the dialog submits
|
||||
* by: a new account needs a valid, free username; an existing one needs the
|
||||
* server's yes for exactly that account and its username typed; managed
|
||||
* profiles and accounts pending deletion are never offered; and each refusal
|
||||
* the upload can answer has its own sentence.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
BackupTargetForm,
|
||||
EMPTY_TARGET_DRAFT,
|
||||
pickableAccounts,
|
||||
readyTarget,
|
||||
targetUploadFailureKey,
|
||||
type BackupTargetCheck,
|
||||
type BackupTargetDraft,
|
||||
} from "../backup-target-dialog";
|
||||
|
||||
function user(overrides: Partial<AdminUser>): AdminUser {
|
||||
return {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
mfaEnforced: false,
|
||||
documentQuotaBytes: null,
|
||||
passkeyCount: 0,
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
user({ id: "u1", username: "returning-person", email: "r@example.test" }),
|
||||
user({ id: "u2", username: "managed-cafe01", managedProfile: true }),
|
||||
user({
|
||||
id: "u3",
|
||||
username: "leaving",
|
||||
deletion: { requestedAt: "x", purgeAt: null, requestedBy: "self" },
|
||||
}),
|
||||
user({ id: "u4", username: "operator", role: "ADMIN" }),
|
||||
];
|
||||
|
||||
const ELIGIBLE: BackupTargetCheck = {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
function render(
|
||||
draft: BackupTargetDraft,
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown } = {
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={() => {}}
|
||||
disabled={false}
|
||||
users={USERS}
|
||||
check={check}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pickableAccounts", () => {
|
||||
it("offers neither managed profiles nor accounts pending deletion", () => {
|
||||
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
|
||||
"returning-person",
|
||||
"operator",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyTarget", () => {
|
||||
it("takes a new account with a valid username nobody has", () => {
|
||||
expect(
|
||||
readyTarget(
|
||||
{ ...EMPTY_TARGET_DRAFT, username: " back-again " },
|
||||
USERS,
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ into: "new", username: "back-again" });
|
||||
});
|
||||
|
||||
it("refuses a new username that is taken, malformed or reads as a managed profile", () => {
|
||||
for (const username of ["operator", "no spaces", "ab", "managed-x1"]) {
|
||||
expect(
|
||||
readyTarget({ ...EMPTY_TARGET_DRAFT, username }, USERS, undefined),
|
||||
username,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an existing account only with the server's yes and its username typed", () => {
|
||||
const draft: BackupTargetDraft = {
|
||||
into: "existing",
|
||||
username: "",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
};
|
||||
expect(readyTarget(draft, USERS, ELIGIBLE)).toEqual({
|
||||
into: "existing",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(readyTarget(draft, USERS, undefined)).toBeNull();
|
||||
expect(
|
||||
readyTarget(draft, USERS, {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
}),
|
||||
).toBeNull();
|
||||
// A yes for another account does not carry over.
|
||||
expect(readyTarget(draft, USERS, { ...ELIGIBLE, id: "u4" })).toBeNull();
|
||||
expect(
|
||||
readyTarget({ ...draft, confirm: "Returning-Person" }, USERS, ELIGIBLE),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetUploadFailureKey", () => {
|
||||
it("names each refusal the upload answers with, and falls back for the rest", () => {
|
||||
const key = (errorCode?: string) =>
|
||||
targetUploadFailureKey(
|
||||
new ApiError("x", 409, errorCode ? { errorCode } : undefined),
|
||||
);
|
||||
expect(key("backup.target.holds_records")).toBe(
|
||||
"admin.section.backups.target.failureHoldsRecords",
|
||||
);
|
||||
expect(key("backup.target.managed_profile")).toBe(
|
||||
"admin.section.backups.target.failureManagedProfile",
|
||||
);
|
||||
expect(key("backup.target.pending_deletion")).toBe(
|
||||
"admin.section.backups.target.failurePendingDeletion",
|
||||
);
|
||||
expect(key("backup.target.not_found")).toBe(
|
||||
"admin.section.backups.target.failureNotFound",
|
||||
);
|
||||
expect(key("backup.target.unconfirmed")).toBe(
|
||||
"admin.section.backups.target.failureUnconfirmed",
|
||||
);
|
||||
expect(key("backup.target.username_taken")).toBe(
|
||||
"admin.section.backups.target.failureUsernameTaken",
|
||||
);
|
||||
expect(key("backup.owner.exists")).toBe(
|
||||
"admin.section.backups.target.failureOwnerExists",
|
||||
);
|
||||
expect(key()).toBe("admin.section.backups.uploadFailed");
|
||||
expect(targetUploadFailureKey(new Error("offline"))).toBe(
|
||||
"admin.section.backups.uploadFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BackupTargetForm>", () => {
|
||||
it("starts on a new account, with the sign-in note", () => {
|
||||
const html = render(EMPTY_TARGET_DRAFT);
|
||||
expect(html).toContain('data-testid="backup-target-username"');
|
||||
expect(html).toContain("created without a password or email");
|
||||
expect(html).not.toContain('data-testid="backup-target-account"');
|
||||
});
|
||||
|
||||
it("says a taken username is taken", () => {
|
||||
const html = render({ ...EMPTY_TARGET_DRAFT, username: "operator" });
|
||||
expect(html).toContain("This username is already taken.");
|
||||
});
|
||||
|
||||
it("offers only pickable accounts, and asks for the username once the server says yes", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{ data: ELIGIBLE, loading: false, error: null },
|
||||
);
|
||||
expect(html).toContain("returning-person (r@example.test)");
|
||||
expect(html).toContain(">operator<");
|
||||
expect(html).not.toContain("managed-cafe01");
|
||||
expect(html).not.toContain(">leaving<");
|
||||
expect(html).toContain('data-slot="backup-target-eligible"');
|
||||
expect(html).toContain("Type returning-person to confirm");
|
||||
expect(html).toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
|
||||
it("says why an account that holds records cannot take the copy, and asks nothing more", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{
|
||||
data: {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="backup-target-refused"');
|
||||
expect(html).toContain("This account already holds records.");
|
||||
expect(html).not.toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
import type { BackupUploadTarget } from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/** What the admin has filled in so far. */
|
||||
export interface BackupTargetDraft {
|
||||
into: "new" | "existing";
|
||||
username: string;
|
||||
userId: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
export const EMPTY_TARGET_DRAFT: BackupTargetDraft = {
|
||||
into: "new",
|
||||
username: "",
|
||||
userId: "",
|
||||
confirm: "",
|
||||
};
|
||||
|
||||
/** The server's answer to "may this account take the copy?". */
|
||||
export interface BackupTargetCheck {
|
||||
id: string;
|
||||
username: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts the picker offers: not a managed profile (its Guardians run
|
||||
* it) and not pending deletion. Whether one holds records is the server's to
|
||||
* say, per account, once it is picked.
|
||||
*/
|
||||
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
|
||||
return users.filter((user) => !user.managedProfile && user.deletion === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target the draft describes, or null while it is not one the upload
|
||||
* would take: a new account needs a valid username nobody has, an existing
|
||||
* one needs the server's yes and its username typed exactly.
|
||||
*/
|
||||
export function readyTarget(
|
||||
draft: BackupTargetDraft,
|
||||
users: readonly AdminUser[],
|
||||
check: BackupTargetCheck | undefined,
|
||||
): BackupUploadTarget | null {
|
||||
if (draft.into === "new") {
|
||||
const username = draft.username.trim();
|
||||
if (!accountUsernameSchema.safeParse(username).success) return null;
|
||||
if (users.some((user) => user.username === username)) return null;
|
||||
return { into: "new", username };
|
||||
}
|
||||
const user = users.find((candidate) => candidate.id === draft.userId);
|
||||
if (!user || !check || check.id !== user.id || !check.eligible) return null;
|
||||
if (draft.confirm.trim() !== user.username) return null;
|
||||
return { into: "existing", userId: user.id, confirm: user.username };
|
||||
}
|
||||
|
||||
/** The sentence for a refusal, by the code the server answered with. */
|
||||
export function targetFailureKey(code: string): string {
|
||||
switch (code) {
|
||||
case "backup.target.holds_records":
|
||||
return "admin.section.backups.target.failureHoldsRecords";
|
||||
case "backup.target.managed_profile":
|
||||
return "admin.section.backups.target.failureManagedProfile";
|
||||
case "backup.target.pending_deletion":
|
||||
return "admin.section.backups.target.failurePendingDeletion";
|
||||
case "backup.target.not_found":
|
||||
return "admin.section.backups.target.failureNotFound";
|
||||
case "backup.target.unconfirmed":
|
||||
return "admin.section.backups.target.failureUnconfirmed";
|
||||
case "backup.target.username_taken":
|
||||
return "admin.section.backups.target.failureUsernameTaken";
|
||||
case "backup.owner.exists":
|
||||
return "admin.section.backups.target.failureOwnerExists";
|
||||
default:
|
||||
return "admin.section.backups.uploadFailed";
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence for a failed upload or check. */
|
||||
export function targetUploadFailureKey(error: unknown): string {
|
||||
return targetFailureKey(
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form inside the dialog, apart from it so it renders on its own: where
|
||||
* the copy of an account that is no longer on this server goes. A new account
|
||||
* by default, since it cannot hold anything yet; an existing one when the
|
||||
* person has signed up again, checked by the server as soon as it is picked.
|
||||
*/
|
||||
export function BackupTargetForm({
|
||||
draft,
|
||||
onChange,
|
||||
disabled,
|
||||
users,
|
||||
check,
|
||||
}: {
|
||||
draft: BackupTargetDraft;
|
||||
onChange: (next: BackupTargetDraft) => void;
|
||||
disabled: boolean;
|
||||
/** Every account; null while the list loads. */
|
||||
users: readonly AdminUser[] | null;
|
||||
/** The server's check of the picked account. */
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown };
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const accounts = pickableAccounts(users ?? []);
|
||||
const chosen = accounts.find((user) => user.id === draft.userId);
|
||||
const username = draft.username.trim();
|
||||
const usernameTaken = (users ?? []).some(
|
||||
(user) => user.username === username,
|
||||
);
|
||||
const usernameInvalid =
|
||||
username !== "" && !accountUsernameSchema.safeParse(username).success;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="backup-target-form">
|
||||
<Segmented
|
||||
label={t("admin.section.backups.target.choiceLabel")}
|
||||
slot="backup-target-choice"
|
||||
stretch
|
||||
disabled={disabled}
|
||||
value={draft.into}
|
||||
onChange={(into) => onChange({ ...draft, into })}
|
||||
options={[
|
||||
{ value: "new", label: t("admin.section.backups.target.newAccount") },
|
||||
{
|
||||
value: "existing",
|
||||
label: t("admin.section.backups.target.existingAccount"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.into === "new" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-username">
|
||||
{t("admin.section.backups.target.usernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-username"
|
||||
data-testid="backup-target-username"
|
||||
value={draft.username}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...draft, username: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={usernameInvalid || usernameTaken}
|
||||
/>
|
||||
{usernameTaken ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.failureUsernameTaken")}
|
||||
</p>
|
||||
) : usernameInvalid ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.usernameInvalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.newAccountHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-account">
|
||||
{t("admin.section.backups.target.accountLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="backup-target-account"
|
||||
data-testid="backup-target-account"
|
||||
value={draft.userId}
|
||||
disabled={disabled || users === null}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, userId: e.target.value, confirm: "" })
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.section.backups.target.accountPlaceholder")}
|
||||
</option>
|
||||
{accounts.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email
|
||||
? `${user.username} (${user.email})`
|
||||
: user.username}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{chosen && check.loading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.checking")}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t(targetUploadFailureKey(check.error))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data && !check.data.eligible ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-slot="backup-target-refused"
|
||||
>
|
||||
{t(targetFailureKey(`backup.target.${check.data.reason}`))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data?.eligible ? (
|
||||
<>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="backup-target-eligible"
|
||||
>
|
||||
{t("admin.section.backups.target.eligible")}
|
||||
</p>
|
||||
<Label htmlFor="backup-target-confirm">
|
||||
{t("admin.section.backups.target.confirmLabel", {
|
||||
username: chosen.username,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-confirm"
|
||||
data-testid="backup-target-confirm"
|
||||
value={draft.confirm}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, confirm: e.target.value })
|
||||
}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={chosen.username}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when an upload was refused because the account the file belongs to
|
||||
* is not on this server. Choosing a target sends the file again with it; the
|
||||
* server rewrites the file's owner to that account as it stores it.
|
||||
*/
|
||||
export function BackupTargetDialog({
|
||||
file,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
/** The refused file; the dialog is open while there is one. */
|
||||
file: File | null;
|
||||
pending: boolean;
|
||||
/** The last refusal of an upload into a target, shown in the dialog. */
|
||||
error: unknown;
|
||||
onSubmit: (target: BackupUploadTarget) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState<BackupTargetDraft>(EMPTY_TARGET_DRAFT);
|
||||
const users = useQuery({
|
||||
queryKey: queryKeys.adminUsers(),
|
||||
queryFn: () => apiGet<AdminUser[]>("/api/admin/users"),
|
||||
enabled: file !== null,
|
||||
});
|
||||
const check = useQuery({
|
||||
queryKey: queryKeys.adminBackupTarget(draft.userId),
|
||||
queryFn: () =>
|
||||
apiGet<BackupTargetCheck>(
|
||||
`/api/admin/backups/targets/${encodeURIComponent(draft.userId)}`,
|
||||
),
|
||||
enabled: file !== null && draft.into === "existing" && draft.userId !== "",
|
||||
retry: false,
|
||||
});
|
||||
const target = readyTarget(draft, users.data ?? [], check.data);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={file !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (next || pending) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-slot="backup-target-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.backups.target.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.target.description", {
|
||||
file: file?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{file ? (
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
disabled={pending}
|
||||
users={users.isError ? [] : (users.data ?? null)}
|
||||
check={{
|
||||
data: check.data,
|
||||
loading: check.isLoading,
|
||||
error: check.error,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{error && !pending ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(targetUploadFailureKey(error))}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
data-testid="backup-target-submit"
|
||||
disabled={!target || pending}
|
||||
onClick={() => {
|
||||
if (target) onSubmit(target);
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending
|
||||
? t("admin.section.backups.target.submitting")
|
||||
: t("admin.section.backups.target.submit")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/**
|
||||
* The upload body for a backup file: the file itself when it is already
|
||||
@@ -28,9 +33,11 @@ async function uploadBody(file: File): Promise<Blob> {
|
||||
* and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
|
||||
* timeout, because a file of that size takes longer to check and store than
|
||||
* the default allows. The body is built here, per call, so a retry after a
|
||||
* re-proof sends the file again rather than a spent stream.
|
||||
* re-proof sends the file again rather than a spent stream. With a target, the
|
||||
* copy goes into that account instead of the one the file names, which is
|
||||
* how the copy of an account no longer on this server comes back.
|
||||
*/
|
||||
async function uploadOnce(file: File) {
|
||||
async function uploadOnce(file: File, target?: BackupUploadTarget) {
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
@@ -65,12 +72,17 @@ async function uploadOnce(file: File) {
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
}>(
|
||||
target
|
||||
? `/api/admin/backups/upload?${backupUploadTargetQuery(target)}`
|
||||
: "/api/admin/backups/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type UploadSummary = Awaited<ReturnType<typeof uploadOnce>>["summary"];
|
||||
@@ -106,9 +118,19 @@ export function uploadedRecordCount(summary: UploadSummary): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an upload was refused because the file's account is not here. */
|
||||
export function isOwnerMissing(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === BACKUP_OWNER_MISSING_CODE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-file upload flow: a fresh proof first, then the file, and the
|
||||
* file input reset after either outcome.
|
||||
* file input reset after either outcome. A file whose account is not on this
|
||||
* server is kept (`orphan`) for the target dialog, which sends it again into
|
||||
* the account the admin picks (`uploadInto`).
|
||||
*/
|
||||
export function useBackupUpload(
|
||||
recentProof: ReturnType<typeof useRecentProof>,
|
||||
@@ -118,6 +140,7 @@ export function useBackupUpload(
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [orphan, setOrphan] = useState<File | null>(null);
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
// Ask first, so a large file is not sent once only to be refused.
|
||||
@@ -131,8 +154,13 @@ export function useBackupUpload(
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, file) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
// Not a dead end: the dialog offers an account to restore it into.
|
||||
if (isOwnerMissing(err)) {
|
||||
setOrphan(file);
|
||||
return;
|
||||
}
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -147,10 +175,43 @@ export function useBackupUpload(
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInto = useMutation({
|
||||
mutationFn: async (target: BackupUploadTarget) => {
|
||||
if (!orphan) throw new Error("No file to upload");
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return { data: await uploadOnce(orphan, target), target };
|
||||
},
|
||||
onSuccess: ({ data, target }) => {
|
||||
const total = uploadedRecordCount(data.summary);
|
||||
toast.success(
|
||||
t("admin.section.backups.target.success", {
|
||||
count: String(total),
|
||||
username: target.into === "new" ? target.username : target.confirm,
|
||||
}),
|
||||
);
|
||||
setOrphan(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
},
|
||||
// A refusal stays in the dialog, beside the choice it refuses.
|
||||
});
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
}
|
||||
|
||||
return { upload, fileInputRef, handleFileChange };
|
||||
function closeOrphan() {
|
||||
setOrphan(null);
|
||||
uploadInto.reset();
|
||||
}
|
||||
|
||||
return {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,12 @@ export const adminKeys = {
|
||||
/** v1.37.20 — restore preview: the counts a stored backup file carries. */
|
||||
adminBackupSummary: (id: string) =>
|
||||
["admin", "backups", id, "summary"] as const,
|
||||
/**
|
||||
* Whether an account can take the copy of an erased account. Under the
|
||||
* backup list's prefix, so a new copy refetches it.
|
||||
*/
|
||||
adminBackupTarget: (userId: string) =>
|
||||
["admin", "backups", "target", userId] as const,
|
||||
/**
|
||||
* v1.39.1 — the restore jobs the backups console shows. Its own prefix, so
|
||||
* invalidating the backup list does not refetch the poll and vice versa.
|
||||
|
||||
Reference in new issue
Block a user