diff --git a/server/server.go b/server/server.go
index b474da7f..ce87f979 100644
--- a/server/server.go
+++ b/server/server.go
@@ -455,6 +455,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath {
 		return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v)
+	} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
+		return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberDelete))(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
 		return s.handleStats(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@@ -692,6 +694,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 	} else if call != "" && !vrate.CallAllowed() {
 		return nil, errHTTPTooManyRequestsLimitCalls.With(t)
 	}
+
+	// FIXME check allowed phone numbers
+	
 	if m.PollID != "" {
 		m = newPollRequestMessage(t.ID, m.PollID)
 	}
diff --git a/server/server_account.go b/server/server_account.go
index a323bfe0..c5517d66 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -146,13 +146,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 			return err
 		}
 		if len(phoneNumbers) > 0 {
-			response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0)
-			for _, p := range phoneNumbers {
-				response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{
-					Number:   p.Number,
-					Verified: p.Verified,
-				})
-			}
+			response.PhoneNumbers = phoneNumbers
 		}
 	} else {
 		response.Username = user.Everyone
@@ -542,19 +536,15 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
 	} else if u.IsUser() && u.Tier.CallLimit == 0 {
 		return errHTTPUnauthorized
 	}
-	// Actually add the unverified number, and send verification
-	logvr(v, r).
-		Tag(tagAccount).
-		Fields(log.Context{
-			"number": req.Number,
-		}).
-		Debug("Adding phone number, and sending verification")
-	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
-		if err == user.ErrPhoneNumberExists {
-			return errHTTPConflictPhoneNumberExists
-		}
+	// Check if phone number exists
+	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
+	if err != nil {
 		return err
+	} else if util.Contains(phoneNumbers, req.Number) {
+		return errHTTPConflictPhoneNumberExists
 	}
+	// Actually add the unverified number, and send verification
+	logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
 	if err := s.verifyPhone(v, r, req.Number); err != nil {
 		return err
 	}
@@ -570,31 +560,27 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
 	if !phoneNumberRegex.MatchString(req.Number) {
 		return errHTTPBadRequestPhoneNumberInvalid
 	}
-	// Get phone numbers, and check if it's in the list
-	phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
-	if err != nil {
-		return err
-	}
-	found := false
-	for _, phoneNumber := range phoneNumbers {
-		if phoneNumber.Number == req.Number && !phoneNumber.Verified {
-			found = true
-			break
-		}
-	}
-	if !found {
-		return errHTTPBadRequestPhoneNumberInvalid
-	}
 	if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil {
 		return err
 	}
-	logvr(v, r).
-		Tag(tagAccount).
-		Fields(log.Context{
-			"number": req.Number,
-		}).
-		Debug("Marking phone number as verified")
-	if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil {
+	logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
+	if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil {
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	u := v.User()
+	req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	if !phoneNumberRegex.MatchString(req.Number) {
+		return errHTTPBadRequestPhoneNumberInvalid
+	}
+	logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number")
+	if err := s.userManager.DeletePhoneNumber(u.ID, req.Number); err != nil {
 		return err
 	}
 	return s.writeJSON(w, newSuccessResponse())
diff --git a/server/types.go b/server/types.go
index 9015a006..d660e717 100644
--- a/server/types.go
+++ b/server/types.go
@@ -282,11 +282,6 @@ type apiAccountPhoneNumberRequest struct {
 	Code   string `json:"code,omitempty"` // Only supplied in "verify" call
 }
 
-type apiAccountPhoneNumberResponse struct {
-	Number   string `json:"number"`
-	Verified bool   `json:"verified"`
-}
-
 type apiAccountTier struct {
 	Code string `json:"code"`
 	Name string `json:"name"`
@@ -336,19 +331,19 @@ type apiAccountBilling struct {
 }
 
 type apiAccountResponse struct {
-	Username      string                           `json:"username"`
-	Role          string                           `json:"role,omitempty"`
-	SyncTopic     string                           `json:"sync_topic,omitempty"`
-	Language      string                           `json:"language,omitempty"`
-	Notification  *user.NotificationPrefs          `json:"notification,omitempty"`
-	Subscriptions []*user.Subscription             `json:"subscriptions,omitempty"`
-	Reservations  []*apiAccountReservation         `json:"reservations,omitempty"`
-	Tokens        []*apiAccountTokenResponse       `json:"tokens,omitempty"`
-	PhoneNumbers  []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"`
-	Tier          *apiAccountTier                  `json:"tier,omitempty"`
-	Limits        *apiAccountLimits                `json:"limits,omitempty"`
-	Stats         *apiAccountStats                 `json:"stats,omitempty"`
-	Billing       *apiAccountBilling               `json:"billing,omitempty"`
+	Username      string                     `json:"username"`
+	Role          string                     `json:"role,omitempty"`
+	SyncTopic     string                     `json:"sync_topic,omitempty"`
+	Language      string                     `json:"language,omitempty"`
+	Notification  *user.NotificationPrefs    `json:"notification,omitempty"`
+	Subscriptions []*user.Subscription       `json:"subscriptions,omitempty"`
+	Reservations  []*apiAccountReservation   `json:"reservations,omitempty"`
+	Tokens        []*apiAccountTokenResponse `json:"tokens,omitempty"`
+	PhoneNumbers  []string                   `json:"phone_numbers,omitempty"`
+	Tier          *apiAccountTier            `json:"tier,omitempty"`
+	Limits        *apiAccountLimits          `json:"limits,omitempty"`
+	Stats         *apiAccountStats           `json:"stats,omitempty"`
+	Billing       *apiAccountBilling         `json:"billing,omitempty"`
 }
 
 type apiAccountReservationRequest struct {
diff --git a/user/manager.go b/user/manager.go
index 7c179cf5..7a030951 100644
--- a/user/manager.go
+++ b/user/manager.go
@@ -115,7 +115,6 @@ const (
 		CREATE TABLE IF NOT EXISTS user_phone (
 			user_id TEXT NOT NULL,
 			phone_number TEXT NOT NULL,
-			verified INT NOT NULL,
 			PRIMARY KEY (user_id, phone_number),
 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
 		);
@@ -268,9 +267,9 @@ const (
 		)
 	`
 
-	selectPhoneNumbersQuery        = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?`
-	insertPhoneNumberQuery         = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)`
-	updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?`
+	selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?`
+	insertPhoneNumberQuery  = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
+	deletePhoneNumberQuery  = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
 
 	insertTierQuery = `
 		INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
@@ -414,7 +413,6 @@ const (
 		CREATE TABLE IF NOT EXISTS user_phone (
 			user_id TEXT NOT NULL,
 			phone_number TEXT NOT NULL,
-			verified INT NOT NULL,
 			PRIMARY KEY (user_id, phone_number),
 			FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
 		);
@@ -648,13 +646,14 @@ func (a *Manager) RemoveExpiredTokens() error {
 	return nil
 }
 
-func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) {
+// PhoneNumbers returns all phone numbers for the user with the given user ID
+func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
 	rows, err := a.db.Query(selectPhoneNumbersQuery, userID)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
-	phoneNumbers := make([]*PhoneNumber, 0)
+	phoneNumbers := make([]string, 0)
 	for {
 		phoneNumber, err := a.readPhoneNumber(rows)
 		if err == ErrPhoneNumberNotFound {
@@ -667,23 +666,20 @@ func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) {
 	return phoneNumbers, nil
 }
 
-func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) {
+func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
 	var phoneNumber string
-	var verified bool
 	if !rows.Next() {
-		return nil, ErrPhoneNumberNotFound
+		return "", ErrPhoneNumberNotFound
 	}
-	if err := rows.Scan(&phoneNumber, &verified); err != nil {
-		return nil, err
+	if err := rows.Scan(&phoneNumber); err != nil {
+		return "", err
 	} else if err := rows.Err(); err != nil {
-		return nil, err
+		return "", err
 	}
-	return &PhoneNumber{
-		Number:   phoneNumber,
-		Verified: verified,
-	}, nil
+	return phoneNumber, nil
 }
 
+// AddPhoneNumber adds a phone number to the user with the given user ID
 func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
 	if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil {
 		if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
@@ -694,11 +690,10 @@ func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error {
 	return nil
 }
 
-func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error {
-	if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil {
-		return err
-	}
-	return nil
+// DeletePhoneNumber deletes a phone number from the user with the given user ID
+func (a *Manager) DeletePhoneNumber(userID string, phoneNumber string) error {
+	_, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber)
+	return err
 }
 
 // RemoveDeletedUsers deletes all users that have been marked deleted for
diff --git a/user/types.go b/user/types.go
index 51a2b3f3..11895785 100644
--- a/user/types.go
+++ b/user/types.go
@@ -71,11 +71,6 @@ type TokenUpdate struct {
 	LastOrigin netip.Addr
 }
 
-type PhoneNumber struct {
-	Number   string
-	Verified bool
-}
-
 // Prefs represents a user's configuration settings
 type Prefs struct {
 	Language      *string            `json:"language,omitempty"`
diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json
index a3919ffd..0c9fcc7d 100644
--- a/web/public/static/langs/ar.json
+++ b/web/public/static/langs/ar.json
@@ -152,7 +152,7 @@
     "publish_dialog_chip_delay_label": "تأخير التسليم",
     "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
     "subscribe_dialog_subscribe_button_cancel": "إلغاء",
-    "subscribe_dialog_login_button_back": "العودة",
+    "common_back": "العودة",
     "prefs_notifications_sound_play": "تشغيل الصوت المحدد",
     "prefs_notifications_min_priority_title": "أولوية دنيا",
     "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
@@ -225,7 +225,7 @@
     "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
     "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
     "account_tokens_table_current_session": "جلسة المتصفح الحالية",
-    "account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
+    "common_copy_to_clipboard": "انسخ إلى الحافظة",
     "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
     "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
     "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json
index 8178c469..a040b015 100644
--- a/web/public/static/langs/bg.json
+++ b/web/public/static/langs/bg.json
@@ -104,7 +104,7 @@
     "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
     "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
     "subscribe_dialog_login_username_label": "Потребител, напр. phil",
-    "subscribe_dialog_login_button_back": "Назад",
+    "common_back": "Назад",
     "subscribe_dialog_subscribe_button_cancel": "Отказ",
     "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
     "subscribe_dialog_subscribe_button_subscribe": "Абониране",
diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json
index f8826584..aeff195b 100644
--- a/web/public/static/langs/cs.json
+++ b/web/public/static/langs/cs.json
@@ -91,7 +91,7 @@
     "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
     "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
     "subscribe_dialog_login_password_label": "Heslo",
-    "subscribe_dialog_login_button_back": "Zpět",
+    "common_back": "Zpět",
     "subscribe_dialog_login_button_login": "Přihlásit se",
     "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
     "subscribe_dialog_error_user_anonymous": "anonymně",
@@ -305,7 +305,7 @@
     "account_tokens_table_expires_header": "Vyprší",
     "account_tokens_table_never_expires": "Nikdy nevyprší",
     "account_tokens_table_current_session": "Současná relace prohlížeče",
-    "account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
+    "common_copy_to_clipboard": "Kopírování do schránky",
     "account_tokens_table_label_header": "Popisek",
     "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
     "account_tokens_table_create_token_button": "Vytvořit přístupový token",
diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json
index d60c56c2..c7477dfc 100644
--- a/web/public/static/langs/da.json
+++ b/web/public/static/langs/da.json
@@ -91,7 +91,7 @@
     "publish_dialog_delay_label": "Forsinkelse",
     "publish_dialog_button_send": "Send",
     "subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
-    "subscribe_dialog_login_button_back": "Tilbage",
+    "common_back": "Tilbage",
     "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
     "account_basics_title": "Konto",
     "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
@@ -209,7 +209,7 @@
     "subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
     "account_basics_tier_upgrade_button": "Opgrader til Pro",
     "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
-    "account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder",
+    "common_copy_to_clipboard": "Kopier til udklipsholder",
     "prefs_reservations_edit_button": "Rediger emneadgang",
     "account_upgrade_dialog_title": "Skift kontoniveau",
     "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json
index 88a5c14a..e3f55922 100644
--- a/web/public/static/langs/de.json
+++ b/web/public/static/langs/de.json
@@ -94,7 +94,7 @@
     "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
     "prefs_appearance_title": "Darstellung",
     "subscribe_dialog_login_password_label": "Kennwort",
-    "subscribe_dialog_login_button_back": "Zurück",
+    "common_back": "Zurück",
     "publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
     "publish_dialog_chip_delay_label": "Auslieferung verzögern",
     "publish_dialog_chip_topic_label": "Thema ändern",
@@ -284,7 +284,7 @@
     "account_tokens_table_expires_header": "Verfällt",
     "account_tokens_table_never_expires": "Verfällt nie",
     "account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
-    "account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
+    "common_copy_to_clipboard": "In die Zwischenablage kopieren",
     "account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
     "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
     "account_tokens_table_create_token_button": "Access-Token erzeugen",
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index 86330f14..7d8affc0 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -2,6 +2,8 @@
   "common_cancel": "Cancel",
   "common_save": "Save",
   "common_add": "Add",
+  "common_back": "Back",
+  "common_copy_to_clipboard": "Copy to clipboard",
   "signup_title": "Create a ntfy account",
   "signup_form_username": "Username",
   "signup_form_password": "Password",
@@ -169,7 +171,6 @@
   "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
   "subscribe_dialog_login_username_label": "Username, e.g. phil",
   "subscribe_dialog_login_password_label": "Password",
-  "subscribe_dialog_login_button_back": "Back",
   "subscribe_dialog_login_button_login": "Login",
   "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
   "subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
@@ -187,7 +188,17 @@
   "account_basics_password_dialog_button_submit": "Change password",
   "account_basics_password_dialog_current_password_incorrect": "Password incorrect",
   "account_basics_phone_numbers_title": "Phone numbers",
+  "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Adding it will send a verification SMS to your phone.",
   "account_basics_phone_numbers_description": "For phone call notifications",
+  "account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet",
+  "account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard",
+  "account_basics_phone_numbers_dialog_title": "Add phone number",
+  "account_basics_phone_numbers_dialog_number_label": "Phone number",
+  "account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444",
+  "account_basics_phone_numbers_dialog_send_verification_button": "Send verification",
+  "account_basics_phone_numbers_dialog_code_label": "Verification code",
+  "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456",
+  "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
   "account_usage_title": "Usage",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_unlimited": "Unlimited",
@@ -265,7 +276,6 @@
   "account_tokens_table_expires_header": "Expires",
   "account_tokens_table_never_expires": "Never expires",
   "account_tokens_table_current_session": "Current browser session",
-  "account_tokens_table_copy_to_clipboard": "Copy to clipboard",
   "account_tokens_table_copied_to_clipboard": "Access token copied",
   "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
   "account_tokens_table_create_token_button": "Create access token",
diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json
index 0fc7c3a4..3166a522 100644
--- a/web/public/static/langs/es.json
+++ b/web/public/static/langs/es.json
@@ -81,7 +81,7 @@
     "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
     "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
     "subscribe_dialog_login_password_label": "Contraseña",
-    "subscribe_dialog_login_button_back": "Volver",
+    "common_back": "Volver",
     "subscribe_dialog_login_button_login": "Iniciar sesión",
     "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
     "subscribe_dialog_error_user_anonymous": "anónimo",
@@ -257,7 +257,7 @@
     "account_tokens_table_expires_header": "Expira",
     "account_tokens_table_never_expires": "Nunca expira",
     "account_tokens_table_current_session": "Sesión del navegador actual",
-    "account_tokens_table_copy_to_clipboard": "Copiar al portapapeles",
+    "common_copy_to_clipboard": "Copiar al portapapeles",
     "account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
     "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
     "account_tokens_table_create_token_button": "Crear token de acceso",
diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json
index a24ece08..ba71eb4a 100644
--- a/web/public/static/langs/fr.json
+++ b/web/public/static/langs/fr.json
@@ -106,7 +106,7 @@
     "prefs_notifications_title": "Notifications",
     "prefs_notifications_delete_after_title": "Supprimer les notifications",
     "prefs_users_add_button": "Ajouter un utilisateur",
-    "subscribe_dialog_login_button_back": "Retour",
+    "common_back": "Retour",
     "subscribe_dialog_error_user_anonymous": "anonyme",
     "prefs_notifications_sound_no_sound": "Aucun son",
     "prefs_notifications_min_priority_title": "Priorité minimum",
@@ -293,7 +293,7 @@
     "account_tokens_table_expires_header": "Expire",
     "account_tokens_table_never_expires": "N'expire jamais",
     "account_tokens_table_current_session": "Session de navigation actuelle",
-    "account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
+    "common_copy_to_clipboard": "Copier dans le presse-papier",
     "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
     "account_tokens_table_create_token_button": "Créer un jeton d'accès",
     "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json
index 975d8d97..b52e3a48 100644
--- a/web/public/static/langs/hu.json
+++ b/web/public/static/langs/hu.json
@@ -84,7 +84,7 @@
     "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
     "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
     "subscribe_dialog_login_password_label": "Jelszó",
-    "subscribe_dialog_login_button_back": "Vissza",
+    "common_back": "Vissza",
     "subscribe_dialog_login_button_login": "Belépés",
     "subscribe_dialog_error_user_anonymous": "névtelen",
     "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json
index 027653bd..51e6a98a 100644
--- a/web/public/static/langs/id.json
+++ b/web/public/static/langs/id.json
@@ -116,7 +116,7 @@
     "common_save": "Simpan",
     "prefs_appearance_title": "Tampilan",
     "subscribe_dialog_login_password_label": "Kata sandi",
-    "subscribe_dialog_login_button_back": "Kembali",
+    "common_back": "Kembali",
     "prefs_notifications_sound_title": "Suara notifikasi",
     "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
     "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
@@ -278,7 +278,7 @@
     "account_tokens_table_expires_header": "Kedaluwarsa",
     "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa",
     "account_tokens_table_current_session": "Sesi peramban saat ini",
-    "account_tokens_table_copy_to_clipboard": "Salin ke papan klip",
+    "common_copy_to_clipboard": "Salin ke papan klip",
     "account_tokens_table_copied_to_clipboard": "Token akses disalin",
     "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini",
     "account_tokens_table_create_token_button": "Buat token akses",
diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json
index 87ea04a4..a62d31fe 100644
--- a/web/public/static/langs/it.json
+++ b/web/public/static/langs/it.json
@@ -178,7 +178,7 @@
     "prefs_notifications_sound_play": "Riproduci il suono selezionato",
     "prefs_notifications_min_priority_title": "Priorità minima",
     "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.",
-    "subscribe_dialog_login_button_back": "Indietro",
+    "common_back": "Indietro",
     "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato",
     "prefs_notifications_title": "Notifiche",
     "prefs_notifications_delete_after_title": "Elimina le notifiche",
diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json
index 1b24ec0d..7eb1c7d4 100644
--- a/web/public/static/langs/ja.json
+++ b/web/public/static/langs/ja.json
@@ -20,7 +20,7 @@
     "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
     "subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
     "subscribe_dialog_login_password_label": "パスワード",
-    "subscribe_dialog_login_button_back": "戻る",
+    "common_back": "戻る",
     "subscribe_dialog_login_button_login": "ログイン",
     "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
     "prefs_notifications_min_priority_max_only": "優先度最高のみ",
@@ -258,7 +258,7 @@
     "account_tokens_table_expires_header": "期限",
     "account_tokens_table_never_expires": "無期限",
     "account_tokens_table_current_session": "現在のブラウザセッション",
-    "account_tokens_table_copy_to_clipboard": "クリップボードにコピー",
+    "common_copy_to_clipboard": "クリップボードにコピー",
     "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
     "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
     "account_tokens_table_create_token_button": "アクセストークンを生成",
diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json
index 67c31280..2e46c7a1 100644
--- a/web/public/static/langs/ko.json
+++ b/web/public/static/langs/ko.json
@@ -93,7 +93,7 @@
     "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
     "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
     "subscribe_dialog_login_password_label": "비밀번호",
-    "subscribe_dialog_login_button_back": "뒤로가기",
+    "common_back": "뒤로가기",
     "subscribe_dialog_login_button_login": "로그인",
     "prefs_notifications_title": "알림",
     "prefs_notifications_sound_title": "알림 효과음",
diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json
index 312791da..0dd9571b 100644
--- a/web/public/static/langs/nb_NO.json
+++ b/web/public/static/langs/nb_NO.json
@@ -113,7 +113,7 @@
     "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
     "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
     "priority_min": "min.",
-    "subscribe_dialog_login_button_back": "Tilbake",
+    "common_back": "Tilbake",
     "prefs_notifications_delete_after_three_hours": "Etter tre timer",
     "prefs_users_table_base_url_header": "Tjeneste-nettadresse",
     "common_cancel": "Avbryt",
diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json
index b9ac8e17..ca7a2a13 100644
--- a/web/public/static/langs/nl.json
+++ b/web/public/static/langs/nl.json
@@ -140,7 +140,7 @@
     "subscribe_dialog_subscribe_title": "Onderwerp abonneren",
     "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
     "subscribe_dialog_login_password_label": "Wachtwoord",
-    "subscribe_dialog_login_button_back": "Terug",
+    "common_back": "Terug",
     "subscribe_dialog_login_button_login": "Aanmelden",
     "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
     "subscribe_dialog_error_user_anonymous": "anoniem",
@@ -331,7 +331,7 @@
     "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen",
     "account_tokens_table_last_access_header": "Laatste toegang",
     "account_tokens_table_expires_header": "Verloopt op",
-    "account_tokens_table_copy_to_clipboard": "Kopieer naar klembord",
+    "common_copy_to_clipboard": "Kopieer naar klembord",
     "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd",
     "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen",
     "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.",
diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json
index 5e6bcbe5..9dea2b8a 100644
--- a/web/public/static/langs/pl.json
+++ b/web/public/static/langs/pl.json
@@ -107,7 +107,7 @@
     "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
     "subscribe_dialog_login_password_label": "Hasło",
     "publish_dialog_button_cancel": "Anuluj",
-    "subscribe_dialog_login_button_back": "Powrót",
+    "common_back": "Powrót",
     "subscribe_dialog_login_button_login": "Zaloguj się",
     "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
     "subscribe_dialog_error_user_anonymous": "anonim",
@@ -253,7 +253,7 @@
     "account_tokens_table_expires_header": "Termin ważności",
     "account_tokens_table_never_expires": "Bezterminowy",
     "account_tokens_table_current_session": "Aktualna sesja przeglądarki",
-    "account_tokens_table_copy_to_clipboard": "Kopiuj do schowka",
+    "common_copy_to_clipboard": "Kopiuj do schowka",
     "account_tokens_table_copied_to_clipboard": "Token został skopiowany",
     "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
     "account_tokens_table_create_token_button": "Utwórz token dostępowy",
diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json
index 196baf4f..bf753c9a 100644
--- a/web/public/static/langs/pt.json
+++ b/web/public/static/langs/pt.json
@@ -144,7 +144,7 @@
     "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
     "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
     "subscribe_dialog_login_password_label": "Palavra-passe",
-    "subscribe_dialog_login_button_back": "Voltar",
+    "common_back": "Voltar",
     "subscribe_dialog_login_button_login": "Autenticar",
     "subscribe_dialog_error_user_anonymous": "anónimo",
     "prefs_notifications_title": "Notificações",
diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json
index 79622be3..acf5bca0 100644
--- a/web/public/static/langs/pt_BR.json
+++ b/web/public/static/langs/pt_BR.json
@@ -93,7 +93,7 @@
     "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
     "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
     "subscribe_dialog_login_password_label": "Senha",
-    "subscribe_dialog_login_button_back": "Voltar",
+    "common_back": "Voltar",
     "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
     "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
     "prefs_notifications_delete_after_title": "Apagar notificações",
diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json
index 42025e43..9633d97d 100644
--- a/web/public/static/langs/ru.json
+++ b/web/public/static/langs/ru.json
@@ -98,7 +98,7 @@
     "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
     "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
     "subscribe_dialog_login_password_label": "Пароль",
-    "subscribe_dialog_login_button_back": "Назад",
+    "common_back": "Назад",
     "subscribe_dialog_login_button_login": "Войти",
     "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
     "subscribe_dialog_error_user_anonymous": "анонимный пользователь",
@@ -206,7 +206,7 @@
     "account_basics_tier_free": "Бесплатный",
     "account_tokens_dialog_title_create": "Создать токен доступа",
     "account_tokens_dialog_title_delete": "Удалить токен доступа",
-    "account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена",
+    "common_copy_to_clipboard": "Скопировать в буфер обмена",
     "account_tokens_dialog_button_cancel": "Отмена",
     "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
     "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json
index 9e9dfc20..31e809c5 100644
--- a/web/public/static/langs/sv.json
+++ b/web/public/static/langs/sv.json
@@ -95,14 +95,14 @@
     "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com",
     "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i <docsLink>dokumentationen</docsLink> .",
     "publish_dialog_button_send": "Skicka",
-    "subscribe_dialog_login_button_back": "Tillbaka",
+    "common_back": "Tillbaka",
     "account_basics_tier_free": "Gratis",
     "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne",
     "account_delete_title": "Ta bort konto",
     "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden",
     "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
     "account_upgrade_dialog_button_cancel": "Avbryt",
-    "account_tokens_table_copy_to_clipboard": "Kopiera till urklipp",
+    "common_copy_to_clipboard": "Kopiera till urklipp",
     "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
     "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
     "account_tokens_table_create_token_button": "Skapa åtkomsttoken",
diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json
index 8bdb88d3..3eccda88 100644
--- a/web/public/static/langs/tr.json
+++ b/web/public/static/langs/tr.json
@@ -34,7 +34,7 @@
     "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
     "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
     "subscribe_dialog_login_password_label": "Parola",
-    "subscribe_dialog_login_button_back": "Geri",
+    "common_back": "Geri",
     "subscribe_dialog_login_button_login": "Oturum aç",
     "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
     "subscribe_dialog_error_user_anonymous": "anonim",
@@ -268,7 +268,7 @@
     "account_tokens_table_token_header": "Belirteç",
     "account_tokens_table_label_header": "Etiket",
     "account_tokens_table_current_session": "Geçerli tarayıcı oturumu",
-    "account_tokens_table_copy_to_clipboard": "Panoya kopyala",
+    "common_copy_to_clipboard": "Panoya kopyala",
     "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı",
     "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez",
     "account_tokens_table_create_token_button": "Erişim belirteci oluştur",
diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json
index 686a3d3e..8683769e 100644
--- a/web/public/static/langs/uk.json
+++ b/web/public/static/langs/uk.json
@@ -53,7 +53,7 @@
     "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
     "subscribe_dialog_subscribe_base_url_label": "URL служби",
     "subscribe_dialog_login_password_label": "Пароль",
-    "subscribe_dialog_login_button_back": "Назад",
+    "common_back": "Назад",
     "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
     "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
     "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json
index 4da4328c..2db95f56 100644
--- a/web/public/static/langs/zh_Hans.json
+++ b/web/public/static/langs/zh_Hans.json
@@ -103,7 +103,7 @@
     "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
     "subscribe_dialog_login_username_label": "用户名,例如 phil",
     "subscribe_dialog_login_password_label": "密码",
-    "subscribe_dialog_login_button_back": "返回",
+    "common_back": "返回",
     "subscribe_dialog_login_button_login": "登录",
     "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
     "subscribe_dialog_error_user_anonymous": "匿名",
@@ -333,7 +333,7 @@
     "account_tokens_table_expires_header": "过期",
     "account_tokens_table_never_expires": "永不过期",
     "account_tokens_table_current_session": "当前浏览器会话",
-    "account_tokens_table_copy_to_clipboard": "复制到剪贴板",
+    "common_copy_to_clipboard": "复制到剪贴板",
     "account_tokens_table_copied_to_clipboard": "已复制访问令牌",
     "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌",
     "account_tokens_table_create_token_button": "创建访问令牌",
diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json
index c1b4de81..aafc28e0 100644
--- a/web/public/static/langs/zh_Hant.json
+++ b/web/public/static/langs/zh_Hant.json
@@ -70,7 +70,7 @@
     "subscribe_dialog_subscribe_button_subscribe": "訂閱",
     "emoji_picker_search_clear": "清除",
     "subscribe_dialog_login_password_label": "密碼",
-    "subscribe_dialog_login_button_back": "返回",
+    "common_back": "返回",
     "subscribe_dialog_login_button_login": "登入",
     "prefs_notifications_delete_after_never": "從不",
     "prefs_users_add_button": "新增使用者",
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js
index 243286b4..21b3f810 100644
--- a/web/src/app/AccountApi.js
+++ b/web/src/app/AccountApi.js
@@ -1,7 +1,7 @@
 import {
     accountBillingPortalUrl,
     accountBillingSubscriptionUrl,
-    accountPasswordUrl,
+    accountPasswordUrl, accountPhoneUrl,
     accountReservationSingleUrl,
     accountReservationUrl,
     accountSettingsUrl,
@@ -299,6 +299,43 @@ class AccountApi {
         return await response.json(); // May throw SyntaxError
     }
 
+    async verifyPhone(phoneNumber) {
+        const url = accountPhoneUrl(config.base_url);
+        console.log(`[AccountApi] Sending phone verification ${url}`);
+        await fetchOrThrow(url, {
+            method: "PUT",
+            headers: withBearerAuth({}, session.token()),
+            body: JSON.stringify({
+                number: phoneNumber
+            })
+        });
+    }
+
+    async checkVerifyPhone(phoneNumber, code) {
+        const url = accountPhoneUrl(config.base_url);
+        console.log(`[AccountApi] Checking phone verification code ${url}`);
+        await fetchOrThrow(url, {
+            method: "POST",
+            headers: withBearerAuth({}, session.token()),
+            body: JSON.stringify({
+                number: phoneNumber,
+                code: code
+            })
+        });
+    }
+
+    async deletePhoneNumber(phoneNumber, code) {
+        const url = accountPhoneUrl(config.base_url);
+        console.log(`[AccountApi] Deleting phone number ${url}`);
+        await fetchOrThrow(url, {
+            method: "DELETE",
+            headers: withBearerAuth({}, session.token()),
+            body: JSON.stringify({
+                number: phoneNumber
+            })
+        });
+    }
+
     async sync() {
         try {
             if (!session.token()) {
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 25b4a459..6e044913 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -27,6 +27,7 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva
 export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
 export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
 export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
+export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
 export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
diff --git a/web/src/components/Account.js b/web/src/components/Account.js
index b5294cd5..4c19a291 100644
--- a/web/src/components/Account.js
+++ b/web/src/components/Account.js
@@ -325,37 +325,183 @@ const AccountType = () => {
 const PhoneNumbers = () => {
     const { t } = useTranslation();
     const { account } = useContext(AccountContext);
+    const [dialogKey, setDialogKey] = useState(0);
+    const [dialogOpen, setDialogOpen] = useState(false);
+    const [snackOpen, setSnackOpen] = useState(false);
     const labelId = "prefPhoneNumbers";
 
-    const handleAdd = () => {
-
+    const handleDialogOpen = () => {
+        setDialogKey(prev => prev+1);
+        setDialogOpen(true);
     };
 
-    const handleClick = () => {
-
+    const handleDialogClose = () => {
+        setDialogOpen(false);
     };
 
-    const handleDelete = () => {
-
+    const handleCopy = (phoneNumber) => {
+        navigator.clipboard.writeText(phoneNumber);
+        setSnackOpen(true);
     };
 
+    const handleDelete = async (phoneNumber) => {
+        try {
+            await accountApi.deletePhoneNumber(phoneNumber);
+        } catch (e) {
+            console.log(`[Account] Error deleting phone number`, e);
+            if (e instanceof UnauthorizedError) {
+                session.resetAndRedirect(routes.login);
+            }
+        }
+    };
+
+    if (!config.enable_calls) {
+        return null;
+    }
+
     return (
         <Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
             <div aria-labelledby={labelId}>
-                {account?.phone_numbers.map(p =>
-                    <Chip
-                        label={p.number}
-                        variant="outlined"
-                        onClick={() => navigator.clipboard.writeText(p.number)}
-                        onDelete={() => handleDelete(p.number)}
-                    />
+                {account?.phone_numbers?.map(phoneNumber =>
+                        <Chip
+                            label={
+                                <Tooltip title={t("common_copy_to_clipboard")}>
+                                   <span>{phoneNumber}</span>
+                                </Tooltip>
+                            }
+                            variant="outlined"
+                            onClick={() => handleCopy(phoneNumber)}
+                            onDelete={() => handleDelete(phoneNumber)}
+                        />
                 )}
-                <IconButton onClick={() => handleAdd()}><AddIcon/></IconButton>
+                {!account?.phone_numbers &&
+                    <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>
+                }
+                <IconButton onClick={handleDialogOpen}><AddIcon/></IconButton>
             </div>
+            <AddPhoneNumberDialog
+                key={`addPhoneNumberDialog${dialogKey}`}
+                open={dialogOpen}
+                onClose={handleDialogClose}
+            />
+            <Portal>
+                <Snackbar
+                    open={snackOpen}
+                    autoHideDuration={3000}
+                    onClose={() => setSnackOpen(false)}
+                    message={t("account_basics_phone_numbers_copied_to_clipboard")}
+                />
+            </Portal>
         </Pref>
     )
 };
 
+const AddPhoneNumberDialog = (props) => {
+    const { t } = useTranslation();
+    const [error, setError] = useState("");
+    const [phoneNumber, setPhoneNumber] = useState("");
+    const [code, setCode] = useState("");
+    const [sending, setSending] = useState(false);
+    const [verificationCodeSent, setVerificationCodeSent] = useState(false);
+    const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+
+    const handleDialogSubmit = async () => {
+        if (!verificationCodeSent) {
+            await verifyPhone();
+        } else {
+            await checkVerifyPhone();
+        }
+    };
+
+    const handleCancel = () => {
+        if (verificationCodeSent) {
+            setVerificationCodeSent(false);
+        } else {
+            props.onClose();
+        }
+    };
+
+    const verifyPhone = async () => {
+        try {
+            setSending(true);
+            await accountApi.verifyPhone(phoneNumber);
+            setVerificationCodeSent(true);
+        } catch (e) {
+            console.log(`[Account] Error sending verification`, e);
+            if (e instanceof UnauthorizedError) {
+                session.resetAndRedirect(routes.login);
+            } else {
+                setError(e.message);
+            }
+        } finally {
+            setSending(false);
+        }
+    };
+
+    const checkVerifyPhone = async () => {
+        try {
+            setSending(true);
+            await accountApi.checkVerifyPhone(phoneNumber, code);
+            props.onClose();
+        } catch (e) {
+            console.log(`[Account] Error confirming verification`, e);
+            if (e instanceof UnauthorizedError) {
+                session.resetAndRedirect(routes.login);
+            } else {
+                setError(e.message);
+            }
+        } finally {
+            setSending(false);
+        }
+    };
+
+    return (
+        <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
+            <DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
+            <DialogContent>
+                <DialogContentText>
+                    {t("account_basics_phone_numbers_dialog_description")}
+                </DialogContentText>
+                {!verificationCodeSent &&
+                    <TextField
+                        margin="dense"
+                        label={t("account_basics_phone_numbers_dialog_number_label")}
+                        aria-label={t("account_basics_phone_numbers_dialog_number_label")}
+                        placeholder={t("account_basics_phone_numbers_dialog_number_placeholder")}
+                        type="tel"
+                        value={phoneNumber}
+                        onChange={ev => setPhoneNumber(ev.target.value)}
+                        fullWidth
+                        inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }}
+                        variant="standard"
+                    />
+                }
+                {verificationCodeSent &&
+                    <TextField
+                        margin="dense"
+                        label={t("account_basics_phone_numbers_dialog_code_label")}
+                        aria-label={t("account_basics_phone_numbers_dialog_code_label")}
+                        placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")}
+                        type="text"
+                        value={code}
+                        onChange={ev => setCode(ev.target.value)}
+                        fullWidth
+                        inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
+                        variant="standard"
+                    />
+                }
+            </DialogContent>
+            <DialogFooter status={error}>
+                <Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
+                <Button onClick={handleDialogSubmit} disabled={sending || !/^\+\d+$/.test(phoneNumber)}>
+                    {verificationCodeSent ?t("account_basics_phone_numbers_dialog_check_verification_button")  : t("account_basics_phone_numbers_dialog_send_verification_button")}
+                </Button>
+            </DialogFooter>
+        </Dialog>
+    );
+};
+
+
 const Stats = () => {
     const { t } = useTranslation();
     const { account } = useContext(AccountContext);
@@ -594,7 +740,7 @@ const TokensTable = (props) => {
                             <span>
                                 <span style={{fontFamily: "Monospace", fontSize: "0.9rem"}}>{token.token.slice(0, 12)}</span>
                                 ...
-                                <Tooltip title={t("account_tokens_table_copy_to_clipboard")} placement="right">
+                                <Tooltip title={t("common_copy_to_clipboard")} placement="right">
                                     <IconButton onClick={() => handleCopy(token.token)}><ContentCopy/></IconButton>
                                 </Tooltip>
                             </span>
diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js
index 4fd4f8c4..95f1c473 100644
--- a/web/src/components/SubscribeDialog.js
+++ b/web/src/components/SubscribeDialog.js
@@ -288,7 +288,7 @@ const LoginPage = (props) => {
                 />
             </DialogContent>
             <DialogFooter status={error}>
-                <Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
+                <Button onClick={props.onBack}>{t("common_back")}</Button>
                 <Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
             </DialogFooter>
         </>
diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js
index c4d665e0..0b91b1b1 100644
--- a/web/src/components/UpgradeDialog.js
+++ b/web/src/components/UpgradeDialog.js
@@ -300,11 +300,9 @@ const TierCard = (props) => {
                             {tier.limits.reservations > 0 && <Feature>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}</Feature>}
                             <Feature>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })}</Feature>
                             <Feature>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })}</Feature>
-                            {tier.limits.sms > 0 && <Feature>{t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}</Feature>}
                             {tier.limits.calls > 0 && <Feature>{t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}</Feature>}
                             <Feature>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</Feature>
                             {tier.limits.reservations === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_reservations")}</NoFeature>}
-                            {tier.limits.sms === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_sms")}</NoFeature>}
                             {tier.limits.calls === 0 && <NoFeature>{t("account_upgrade_dialog_tier_features_no_calls")}</NoFeature>}
                         </List>
                         {tier.prices && props.interval === SubscriptionInterval.MONTH &&