package handlers
import (
+ "crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
}
// Verify password
- if !utils.VerifyPassword(req.Password, hashedPassword, salt) {
+ if !utils.VerifyPassword(req.Password, hashedPassword) {
utils.Logger.Printf("Login failed. Password for user '%s' is incorrect", req.Username)
http.Error(w, "User/Password combination not found", http.StatusNotFound)
return
}
// Create new user data
- hashedPassword, salt, err := utils.HashPassword(password)
+ hashedPassword, err := utils.HashPassword(password)
if err != nil {
return false, fmt.Errorf("internal Server Error: %d", http.StatusInternalServerError)
}
+ // Generate a random salt
+ salt := make([]byte, 16)
+ if _, err := rand.Read(salt); err != nil {
+ return false, fmt.Errorf("internal Server Error: %d", http.StatusInternalServerError)
+ }
+ // Convert salt to base64
+ saltBase64 := base64.StdEncoding.EncodeToString(salt)
+
// Create encryption key
- derivedKey, err := utils.DeriveKeyFromPassword(password, salt)
+ derivedKey, err := utils.DeriveKeyFromPassword(password, saltBase64)
if err != nil {
return false, fmt.Errorf("internal Server Error: %d", http.StatusInternalServerError)
}
// Generate a new random encryption key
encryptionKey := make([]byte, 32)
- if _, err := utils.RandRead(encryptionKey); err != nil {
+ if _, err := rand.Read(encryptionKey); err != nil {
return false, fmt.Errorf("internal Server Error: %d", http.StatusInternalServerError)
}
}
nonce := make([]byte, aead.NonceSize())
- if _, err := utils.RandRead(nonce); err != nil {
+ if _, err := rand.Read(nonce); err != nil {
return false, fmt.Errorf("internal Server Error: %d", http.StatusInternalServerError)
}
"progress": progress,
})
}
+
+// ChangePasswordRequest represents the change password request body
+type ChangePasswordRequest struct {
+ OldPassword string `json:"old_password"`
+ NewPassword string `json:"new_password"`
+}
+
+// ChangePassword changes the user's password
+func ChangePassword(w http.ResponseWriter, r *http.Request) {
+ // Get user ID from context
+ userID, ok := r.Context().Value(utils.UserIDKey).(int)
+ if !ok {
+ utils.JSONResponse(w, http.StatusOK, map[string]any{
+ "success": false,
+ "message": "User not authenticated",
+ })
+ return
+ }
+
+ // Get derived key from context
+ derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string)
+ if !ok {
+ utils.JSONResponse(w, http.StatusOK, map[string]any{
+ "success": false,
+ "message": "User not authenticated",
+ })
+ return
+ }
+
+ // Parse request body
+ var req ChangePasswordRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.JSONResponse(w, http.StatusBadRequest, map[string]any{
+ "success": false,
+ "message": "Invalid request body",
+ })
+ return
+ }
+
+ // Get user data
+ users, err := utils.GetUsers()
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error retrieving users: %v", err),
+ })
+ return
+ }
+
+ usersList, ok := users["users"].([]any)
+ if !ok {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": "Users data is not in the correct format",
+ })
+ return
+ }
+
+ var user map[string]any
+ for _, u := range usersList {
+ uMap, ok := u.(map[string]any)
+ if !ok {
+ continue
+ }
+ if id, ok := uMap["user_id"].(float64); ok && int(id) == userID {
+ user = uMap
+ break
+ }
+ }
+
+ if user == nil {
+ utils.JSONResponse(w, http.StatusNotFound, map[string]any{
+ "success": false,
+ "message": "User not found",
+ })
+ return
+ }
+
+ currentPassword, ok := user["password"].(string)
+ if !ok {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": "Current hashed password not found for user",
+ })
+ return
+ }
+
+ if !utils.VerifyPassword(req.OldPassword, currentPassword) {
+ utils.JSONResponse(w, http.StatusOK, map[string]any{
+ "success": false,
+ "message": "Old password is incorrect",
+ "password_incorrect": true,
+ })
+ return
+ }
+
+ newHashedPassword, err := utils.HashPassword(req.NewPassword)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error hashing new password: %v", err),
+ })
+ return
+ }
+
+ // Update user data
+ user["password"] = newHashedPassword
+
+ // Decrypt the existing encryption key
+ // Get encryption key
+ encKey, err := utils.GetEncryptionKey(userID, derivedKey)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error getting encryption key: %v", err),
+ })
+ return
+ }
+ encKeyBytes, err := base64.URLEncoding.DecodeString(encKey)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error decoding encryption key: %v", err),
+ })
+ return
+ }
+
+ // Re-Encrypt the encryption key with the new password
+ // Generate a random salt
+ salt := make([]byte, 16)
+ _, err = rand.Read(salt)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error generating salt: %v", err),
+ })
+ return
+ }
+
+ saltBase64 := base64.StdEncoding.EncodeToString(salt)
+ newDerivedKey, err := utils.DeriveKeyFromPassword(req.NewPassword, saltBase64)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error deriving new key from password: %v", err),
+ })
+ return
+ }
+
+ // Encrypt the encryption key with the new derived key
+ aead, err := utils.CreateAEAD(newDerivedKey)
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error creating AEAD: %v", err),
+ })
+ return
+ }
+ nonce := make([]byte, aead.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error generating nonce: %v", err),
+ })
+ return
+ }
+ encryptedKey := aead.Seal(nonce, nonce, encKeyBytes, nil)
+ encEncKey := base64.StdEncoding.EncodeToString(encryptedKey)
+
+ // Update user data with new salt and encrypted key
+ user["salt"] = saltBase64
+ user["enc_enc_key"] = encEncKey
+
+ // Update users data
+ for i, u := range usersList {
+ if uMap, ok := u.(map[string]any); ok && uMap["user_id"] == userID {
+ usersList[i] = user
+ break
+ }
+ }
+ users["users"] = usersList
+ // Write updated users data to file
+ if err := utils.WriteUsers(users); err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error writing users data: %v", err),
+ })
+ return
+ }
+
+ // create new JWT token with updated derived key
+ token, err := utils.GenerateToken(userID, user["username"].(string), base64.StdEncoding.EncodeToString(newDerivedKey))
+ if err != nil {
+ utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+ "success": false,
+ "message": fmt.Sprintf("Error generating new token: %v", err),
+ })
+ return
+ }
+
+ // Set new token cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "token",
+ Value: token,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Path: "/",
+ Expires: time.Now().Add(time.Duration(utils.Settings.LogoutAfterDays) * 24 * time.Hour),
+ })
+
+ // Return success and return a new cookie
+ utils.JSONResponse(w, http.StatusOK, map[string]any{
+ "success": true,
+ "message": "Password changed successfully",
+ })
+}
mux.HandleFunc("GET /users/check", middleware.RequireAuth(handlers.CheckLogin))
mux.HandleFunc("GET /users/getUserSettings", middleware.RequireAuth(handlers.GetUserSettings))
mux.HandleFunc("POST /users/saveUserSettings", middleware.RequireAuth(handlers.SaveUserSettings))
+ mux.HandleFunc("POST /users/changePassword", middleware.RequireAuth(handlers.ChangePassword))
mux.HandleFunc("POST /logs/saveLog", middleware.RequireAuth(handlers.SaveLog))
mux.HandleFunc("GET /logs/getLog", middleware.RequireAuth(handlers.GetLog))
package utils
import (
- "crypto/rand"
"encoding/json"
"fmt"
"io"
return nil
}
-// RandRead is a helper function for reading random bytes
-func RandRead(b []byte) (int, error) {
- return rand.Read(b)
-}
-
// GetUserSettings retrieves the settings for a specific user
func GetUserSettings(userID int) (string, error) {
userSettingsMutex.RLock()
newUserID = int(id)
// Verify password
- if !VerifyPassword(password, u["password"].(string), u["salt"].(string)) {
+ if !VerifyPassword(password, u["password"].(string)) {
return handleError(fmt.Sprintf("Login failed. Password for user '%s' is incorrect", username), nil)
}
import (
"crypto/cipher"
"crypto/rand"
+ "crypto/subtle"
"encoding/base64"
"fmt"
"io"
+ "runtime"
+ "strings"
"time"
"github.com/golang-jwt/jwt/v5"
return claims, nil
}
+type Argon2Configuration struct {
+ HashRaw []byte
+ Salt []byte
+ TimeCost uint32
+ MemoryCost uint32
+ Threads uint8
+ KeyLength uint32
+}
+
// HashPassword hashes a password using Argon2
-func HashPassword(password string) (string, string, error) {
+func HashPassword(password string) (string, error) {
+ config := &Argon2Configuration{
+ TimeCost: 5,
+ MemoryCost: 64 * 1024,
+ Threads: uint8(runtime.NumCPU()),
+ KeyLength: 32,
+ }
+
// Generate a random salt
salt := make([]byte, 16)
- if _, err := io.ReadFull(rand.Reader, salt); err != nil {
- return "", "", err
+ _, err := rand.Read(salt)
+ if err != nil {
+ return "", err
}
+ config.Salt = salt
// Hash password
- hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
+ config.HashRaw = argon2.IDKey([]byte(password), salt, config.TimeCost, config.MemoryCost, config.Threads, config.KeyLength)
+
+ // Generate standardized hash format
+ encodedHash := fmt.Sprintf(
+ "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
+ argon2.Version,
+ config.MemoryCost,
+ config.TimeCost,
+ config.Threads,
+ base64.RawStdEncoding.EncodeToString(config.Salt),
+ base64.RawStdEncoding.EncodeToString(config.HashRaw),
+ )
+
+ return encodedHash, nil
+}
- // Encode salt and hash to base64
- saltBase64 := base64.StdEncoding.EncodeToString(salt)
- hashBase64 := base64.StdEncoding.EncodeToString(hash)
+func parseArgon2Hash(encodedHash string) (*Argon2Configuration, error) {
+ components := strings.Split(encodedHash, "$")
+ if len(components) != 6 {
+ return nil, fmt.Errorf("invalid hash format structure")
+ }
- return hashBase64, saltBase64, nil
-}
+ // Validate algorithm identifier
+ if !strings.HasPrefix(components[1], "argon2id") {
+ return nil, fmt.Errorf("unsupported algorithm variant")
+ }
-// VerifyPassword verifies if a password matches a hash
-func VerifyPassword(password, hashBase64, saltBase64 string) bool {
- // Decode salt and hash
- salt, err := base64.StdEncoding.DecodeString(saltBase64)
+ // Extract version information
+ var version int
+ fmt.Sscanf(components[2], "v=%d", &version)
+
+ // Parse configuration parameters
+ config := &Argon2Configuration{}
+ fmt.Sscanf(components[3], "m=%d,t=%d,p=%d",
+ &config.MemoryCost, &config.TimeCost, &config.Threads)
+
+ // Decode salt component
+ salt, err := base64.RawStdEncoding.DecodeString(components[4])
if err != nil {
- return false
+ return nil, fmt.Errorf("salt decoding failed: %w", err)
}
+ config.Salt = salt
- _, err = base64.StdEncoding.DecodeString(hashBase64)
+ // Decode hash component
+ hash, err := base64.RawStdEncoding.DecodeString(components[5])
if err != nil {
- return false
+ return nil, fmt.Errorf("hash decoding failed: %w", err)
}
+ config.HashRaw = hash
+ config.KeyLength = uint32(len(hash))
- // Hash the provided password with the same salt
- hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
+ return config, nil
+}
+
+// VerifyPassword verifies if a password matches a hash
+func VerifyPassword(password, hashBase64 string) bool {
+ // Parse stored hash parameters
+ config, err := parseArgon2Hash(hashBase64)
+ if err != nil {
+ return false
+ }
- // Compare hashes
- return base64.StdEncoding.EncodeToString(hash) == hashBase64
+ // Generate hash using identical parameters
+ computedHash := argon2.IDKey(
+ []byte(password),
+ config.Salt,
+ config.TimeCost,
+ config.MemoryCost,
+ config.Threads,
+ config.KeyLength,
+ )
+
+ // Perform constant-time comparison to prevent timing attacks
+ return subtle.ConstantTimeCompare(config.HashRaw, computedHash) == 1
}
// DeriveKeyFromPassword derives a key from a password and salt
settingsModal.hide();
} else {
- throw new Error('Error saving settings');
+ console.error('Error saving settings');
}
})
.catch((error) => {
localStorage.setItem('autoLoadImagesThisDevice', $autoLoadImagesThisDevice);
});
+
+ let currentPassword = $state('');
+ let newPassword = $state('');
+ let confirmNewPassword = $state('');
+ let changePasswordNotEqual = $state(false);
+ let isChangingPassword = $state(false);
+ let changingPasswordSuccess = $state(false);
+ let changingPasswordError = $state(false);
+ let changingPasswordIncorrect = $state(false);
+
+ function changePassword() {
+ changePasswordNotEqual = false;
+ changingPasswordSuccess = false;
+ changingPasswordError = false;
+ changingPasswordIncorrect = false;
+
+ if (newPassword !== confirmNewPassword) {
+ changePasswordNotEqual = true;
+ return;
+ }
+
+ if (isChangingPassword) return;
+ isChangingPassword = true;
+
+ axios
+ .post(API_URL + '/users/changePassword', {
+ old_password: currentPassword,
+ new_password: newPassword
+ })
+ .then((response) => {
+ if (response.data.success) {
+ changingPasswordSuccess = true;
+ } else {
+ changingPasswordError = true;
+ console.error('Error changing password');
+ if (response.data.password_incorrect) {
+ changingPasswordIncorrect = true;
+ }
+ }
+ })
+ .catch((error) => {
+ console.error(error);
+ console.log('Error on Changing password:', error.response.data.message);
+ changingPasswordError = true;
+ })
+ .finally(() => {
+ isChangingPassword = false;
+ });
+ }
</script>
<main class="d-flex flex-column">
<div id="security">
<h3 class="text-primary">🔒 Sicherheit</h3>
- <div id="password"><h5>Password ändern</h5></div>
+ <div id="password">
+ <h5>Password ändern</h5>
+ <form onsubmit={changePassword}>
+ <div class="form-floating mb-3">
+ <input
+ type="password"
+ class="form-control"
+ id="currentPassword"
+ placeholder="Aktuelles Passwort"
+ bind:value={currentPassword}
+ />
+ <label for="currentPassword">Aktuelles Passwort</label>
+ </div>
+ <div class="form-floating mb-3">
+ <input
+ type="password"
+ class="form-control"
+ id="newPassword"
+ placeholder="Neues Passwort"
+ bind:value={newPassword}
+ />
+ <label for="newPassword">Neues Passwort</label>
+ </div>
+ <div class="form-floating mb-3">
+ <input
+ type="password"
+ class="form-control"
+ id="confirmNewPassword"
+ placeholder="Neues Passwort bestätigen"
+ bind:value={confirmNewPassword}
+ />
+ <label for="confirmNewPassword">Neues Passwort bestätigen</label>
+ </div>
+ <button class="btn btn-primary" onclick={changePassword}>
+ {#if isChangingPassword}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ {/if}
+ Passwort ändern
+ </button>
+ </form>
+ {#if changePasswordNotEqual}
+ <div class="alert alert-danger mt-2" role="alert" transition:slide>
+ Die neuen Passwörter stimmen nicht überein!
+ </div>
+ {/if}
+ {#if changingPasswordSuccess}
+ <div class="alert alert-success mt-2" role="alert" transition:slide>
+ Das Passwort wurde erfolgreich geändert!<br />
+ Backup-Keys wurden ungültig gemacht (sofern vorhanden), und müssen neu erstellt
+ werden.
+ </div>
+ {/if}
+ {#if changingPasswordIncorrect}
+ <div class="alert alert-danger mt-2" role="alert" transition:slide>
+ Das aktuelle Passwort ist falsch!
+ </div>
+ {:else if changingPasswordError}
+ <div class="alert alert-danger mt-2" role="alert" transition:slide>
+ Fehler beim Ändern des Passworts!
+ </div>
+ {/if}
+ </div>
<div id="backupkeys"><h5>Backup-Keys</h5></div>
<div id="username"><h5>Username ändern</h5></div>
<div id="deleteaccount"><h5>Konto löschen</h5></div>
</button>
</div>
</div>
+ </div>
+ </div>
- <div class="toast-container position-fixed bottom-0 end-0 p-3">
- <div
- id="toastSuccessEditTag"
- class="toast align-items-center text-bg-success"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Änderungen wurden gespeichert!</div>
- </div>
- </div>
+ <div class="toast-container position-fixed bottom-0 end-0 p-3">
+ <div
+ id="toastSuccessEditTag"
+ class="toast align-items-center text-bg-success"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Änderungen wurden gespeichert!</div>
+ </div>
+ </div>
- <div
- id="toastErrorEditTag"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Fehler beim Speichern der Änderungen!</div>
- </div>
- </div>
+ <div
+ id="toastErrorEditTag"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Fehler beim Speichern der Änderungen!</div>
+ </div>
+ </div>
- <div
- id="toastErrorDeleteTag"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Fehler beim Löschen des Tags!</div>
- </div>
- </div>
+ <div
+ id="toastErrorDeleteTag"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Fehler beim Löschen des Tags!</div>
+ </div>
+ </div>
- <div
- id="toastSuccessSaveSettings"
- class="toast align-items-center text-bg-success"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Einstellungen gespeichert!</div>
- </div>
- </div>
+ <div
+ id="toastSuccessSaveSettings"
+ class="toast align-items-center text-bg-success"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Einstellungen gespeichert!</div>
+ </div>
+ </div>
- <div
- id="toastErrorSaveSettings"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Fehler beim Speichern der Einstellungen!</div>
- </div>
- </div>
+ <div
+ id="toastErrorSaveSettings"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Fehler beim Speichern der Einstellungen!</div>
+ </div>
+ </div>
- <div
- id="toastErrorInvalidTemplateEmpty"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Name oder Inhalt einer Vorlage dürfen nicht leer sein!</div>
- </div>
- </div>
+ <div
+ id="toastErrorInvalidTemplateEmpty"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Name oder Inhalt einer Vorlage dürfen nicht leer sein!</div>
+ </div>
+ </div>
- <div
- id="toastErrorInvalidTemplateDouble"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Name der Vorlage existiert bereits</div>
- </div>
- </div>
+ <div
+ id="toastErrorInvalidTemplateDouble"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Name der Vorlage existiert bereits</div>
+ </div>
+ </div>
- <div
- id="toastSuccessSaveTemplate"
- class="toast align-items-center text-bg-success"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Vorlage gespeichert</div>
- </div>
- </div>
+ <div
+ id="toastSuccessSaveTemplate"
+ class="toast align-items-center text-bg-success"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Vorlage gespeichert</div>
+ </div>
+ </div>
- <div
- id="toastErrorDeletingTemplate"
- class="toast align-items-center text-bg-danger"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Fehler beim Löschen der Vorlage</div>
- </div>
- </div>
+ <div
+ id="toastErrorDeletingTemplate"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Fehler beim Löschen der Vorlage</div>
+ </div>
+ </div>
- <div
- id="toastSuccessDeletingTemplate"
- class="toast align-items-center text-bg-success"
- role="alert"
- aria-live="assertive"
- aria-atomic="true"
- >
- <div class="d-flex">
- <div class="toast-body">Vorlage gelöscht</div>
- </div>
- </div>
+ <div
+ id="toastSuccessDeletingTemplate"
+ class="toast align-items-center text-bg-success"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">Vorlage gelöscht</div>
</div>
</div>
</div>