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 dokumentationen .", "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 dokumentationen.", "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 (
- {account?.phone_numbers.map(p => - navigator.clipboard.writeText(p.number)} - onDelete={() => handleDelete(p.number)} - /> + {account?.phone_numbers?.map(phoneNumber => + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> )} - handleAdd()}> + {!account?.phone_numbers && + {t("account_basics_phone_numbers_no_phone_numbers_yet")} + } +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> +
) }; +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 ( + + {t("account_basics_phone_numbers_dialog_title")} + + + {t("account_basics_phone_numbers_dialog_description")} + + {!verificationCodeSent && + setPhoneNumber(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + /> + } + {verificationCodeSent && + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} + variant="standard" + /> + } + + + + + + + ); +}; + + const Stats = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); @@ -594,7 +740,7 @@ const TokensTable = (props) => { {token.token.slice(0, 12)} ... - + handleCopy(token.token)}> 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) => { /> - + 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 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} - {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} - {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH &&