diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 58550d5eea..275c9c5a70 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -236,6 +236,7 @@ "forgotPasswordLink": "Zapomenuté heslo?", "passwordResetHeading": "Resetovat heslo", "passwordResetDescription": "Zadejte své uživatelské jméno, a pošleme Vám e-mail pro resetování hesla.", + "passwordResetPageDescription": "Tímto ukončíte všechny své aktivní relace. Vydané API klíče zůstanou platné, dokud je nerotujete.", "resetPasswordButton": "Resetovat heslo", "resetPasswordEmailSentHeading": "Byl Vám zaslán e-mail k resetování hesla", "resetPasswordEmailSent1": "Vaše žádost o resetování hesla byla úspěšně zpracována.", @@ -952,6 +953,11 @@ "settingsChangeEmailAddressSuccess": "Na váš nový e-mail byla zaslána potvrzovací zpráva. Prosím, následujte odkaz uvnitř pro aktivaci.", "emailChangeErrorMessage": "Nepodařilo se změnit e-mail", "emailChangeSuccessMessage": "Vaše e-mailová adresa byla úspěšně změněna", + "socialAccountNoPasswordBanner": "Váš účet zatím nemá nastavené heslo. Nastavte si ho níže, abyste mohli změnit svůj e-mail nebo se přihlásit pomocí e-mailu a hesla.", + "noPasswordYet": "Zatím nemáte nastavené heslo.", + "sendSetPasswordEmail": "Nastavit heslo", + "sendSetPasswordEmailSuccess": "Odkaz pro nastavení hesla byl odeslán na váš e-mail.", + "setPasswordRequiredForEmailChange": "Před změnou e-mailu si prosím nejprve nastavte heslo.", "backToSettings": "Zpět do Nastavení", "choices": "Možnosti", "choicesLockedHelp": "Možnosti lze po zahájení prognózování měnit pouze v administrátorském panelu.", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 3ec55186d8..a44456f8df 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -362,6 +362,7 @@ "forgotPasswordLink": "Forgot Password?", "passwordResetHeading": "Password Reset", "passwordResetDescription": "Enter your username and we'll send you an email to reset your password.", + "passwordResetPageDescription": "This will end all your active sessions. Any issued API keys remain valid until you rotate them.", "resetPasswordButton": "Reset Password", "resetPasswordEmailSentHeading": "Password Reset Email Sent", "resetPasswordEmailSent1": "Your password reset request has been successfully processed.", @@ -906,6 +907,11 @@ "settingsChangeEmailAddressSuccess": "A confirmation email has been sent to your new address. Please follow the link inside to activate it.", "emailChangeErrorMessage": "Failed to change email", "emailChangeSuccessMessage": "Your email address has been changed successfully", + "socialAccountNoPasswordBanner": "Your account doesn't have a password yet. Set one below to change your email or log in with email and password.", + "noPasswordYet": "You don't have a password yet.", + "sendSetPasswordEmail": "Set password", + "sendSetPasswordEmailSuccess": "Password setup link has been sent to your email.", + "setPasswordRequiredForEmailChange": "Please set a password first before changing your email.", "backToSettings": "Back to Settings", "settingsMentionsInComments": "Mentions in comments", "settingsQuestionResolution": "Resolved Questions", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 0ee8eb2e5b..a6cc8b542a 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -243,6 +243,7 @@ "forgotPasswordLink": "¿Olvidaste tu contraseña?", "passwordResetHeading": "Restablecer Contraseña", "passwordResetDescription": "Ingresa tu nombre de usuario y te enviaremos un correo para restablecer tu contraseña.", + "passwordResetPageDescription": "Esto cerrará todas tus sesiones activas. Las claves API emitidas seguirán siendo válidas hasta que las rotas.", "resetPasswordButton": "Restablecer Contraseña", "resetPasswordEmailSentHeading": "Correo de Restablecimiento de Contraseña Enviado", "resetPasswordEmailSent1": "Tu solicitud de restablecimiento de contraseña ha sido procesada con éxito.", @@ -963,6 +964,11 @@ "settingsChangeEmailAddressSuccess": "Se ha enviado un correo electrónico de confirmación a tu nueva dirección. Por favor, sigue el enlace dentro para activarlo.", "emailChangeErrorMessage": "Error al cambiar el correo electrónico", "emailChangeSuccessMessage": "Tu dirección de correo electrónico ha sido cambiada exitosamente", + "socialAccountNoPasswordBanner": "Tu cuenta aún no tiene contraseña. Establece una a continuación para cambiar tu correo electrónico o iniciar sesión con correo y contraseña.", + "noPasswordYet": "Aún no tienes una contraseña.", + "sendSetPasswordEmail": "Establecer contraseña", + "sendSetPasswordEmailSuccess": "Se ha enviado un enlace para establecer tu contraseña a tu correo electrónico.", + "setPasswordRequiredForEmailChange": "Por favor, establece una contraseña primero antes de cambiar tu correo electrónico.", "backToSettings": "Volver a Configuración", "choices": "Opciones", "inCommunityReviewStatus1": "Esta pregunta necesita aprobación de un curador de la Comunidad como tú. También puedes hacer ediciones, etiquetar al autor en un comentario a continuación, enviar la pregunta de vuelta a Borradores para revisión o rechazar la pregunta. Algunas sugerencias para preguntas de alta calidad:", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 1e63791240..c4fed43a86 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -258,6 +258,7 @@ "forgotPasswordLink": "Esqueceu a Senha?", "passwordResetHeading": "Redefinição de Senha", "passwordResetDescription": "Insira seu nome de usuário e enviaremos um e-mail para redefinir sua senha.", + "passwordResetPageDescription": "Isso encerrará todas as suas sessões ativas. As chaves de API emitidas permanecerão válidas até que você as rotacione.", "resetPasswordButton": "Redefinir Senha", "resetPasswordEmailSentHeading": "E-mail de Redefinição de Senha Enviado", "resetPasswordEmailSent1": "Sua solicitação de redefinição de senha foi processada com sucesso.", @@ -725,6 +726,11 @@ "settingsChangeEmailAddressSuccess": "Um e-mail de confirmação foi enviado para seu novo endereço. Por favor, siga o link dentro para ativá-lo.", "emailChangeErrorMessage": "Falha ao alterar o e-mail", "emailChangeSuccessMessage": "Seu endereço de e-mail foi alterado com sucesso", + "socialAccountNoPasswordBanner": "Sua conta ainda não tem uma senha. Defina uma abaixo para alterar seu e-mail ou fazer login com e-mail e senha.", + "noPasswordYet": "Você ainda não tem uma senha.", + "sendSetPasswordEmail": "Definir senha", + "sendSetPasswordEmailSuccess": "Um link para definir sua senha foi enviado para seu e-mail.", + "setPasswordRequiredForEmailChange": "Por favor, defina uma senha primeiro antes de alterar seu e-mail.", "backToSettings": "Voltar para Configurações", "settingsMentionsInComments": "Menções em comentários", "settingsQuestionResolution": "Perguntas Resolvidas", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 5e5dde86d6..feeb8bc3a2 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -285,6 +285,7 @@ "forgotPasswordLink": "忘記密碼?", "passwordResetHeading": "重設密碼", "passwordResetDescription": "輸入您的用戶名,我們將向您發送電子郵件以重設密碼。", + "passwordResetPageDescription": "此操作將結束您所有的活躍工作階段。已發行的 API 金鑰在您輪換之前仍然有效。", "resetPasswordButton": "重設密碼", "resetPasswordEmailSentHeading": "重設密碼郵件已發送", "resetPasswordEmailSent1": "您的重設密碼請求已成功處理。", @@ -777,6 +778,11 @@ "settingsChangeEmailAddressSuccess": "確認信已發送到您的新地址。請循信內的鏈接以激活。", "emailChangeErrorMessage": "更改電子郵件失敗", "emailChangeSuccessMessage": "您的電子郵件地址已成功更改", + "socialAccountNoPasswordBanner": "您的帳號尚未設定密碼。請在下方設定密碼,以便更改電子郵件或使用電子郵件和密碼登入。", + "noPasswordYet": "您尚未設定密碼。", + "sendSetPasswordEmail": "設定密碼", + "sendSetPasswordEmailSuccess": "密碼設定連結已傳送到您的電子郵件。", + "setPasswordRequiredForEmailChange": "請先設定密碼,然後再更改電子郵件。", "backToSettings": "返回設定", "settingsMentionsInComments": "評論中的提及", "settingsQuestionResolution": "已解析的問題", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index cd3c48479f..1914504f97 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -242,6 +242,7 @@ "forgotPasswordLink": "忘記密碼?", "passwordResetHeading": "重置密碼", "passwordResetDescription": "輸入你的用戶名,我們會向的電子郵件發送一封重置密碼的郵件。", + "passwordResetPageDescription": "此操作将结束您所有的活跃会话。已发行的 API 密钥在您轮换之前仍然有效。", "resetPasswordButton": "重置密碼", "resetPasswordEmailSentHeading": "重置密碼的郵件已發送", "resetPasswordEmailSent1": "你的重置密碼請求已成功處理。", @@ -954,6 +955,11 @@ "settingsChangeEmailAddressSuccess": "确认邮件已发送到您的新地址。请按照邮件中的链接激活。", "emailChangeErrorMessage": "更改邮箱失败", "emailChangeSuccessMessage": "您的邮箱地址已成功更改", + "socialAccountNoPasswordBanner": "您的账号尚未设置密码。请在下方设置密码,以便更改邮箱或使用邮箱和密码登录。", + "noPasswordYet": "您尚未设置密码。", + "sendSetPasswordEmail": "设置密码", + "sendSetPasswordEmailSuccess": "密码设置链接已发送到您的邮箱。", + "setPasswordRequiredForEmailChange": "请先设置密码,然后再更改邮箱。", "backToSettings": "返回设置", "choices": "选择", "inCommunityReviewStatus1": "此问题需要您这样社区策展人的批准。您还可以进行编辑、在下面的评论中标记作者、将问题发送回草稿以供修改或驳回此问题。高质量问题的一些建议:", diff --git a/front_end/src/app/(main)/accounts/reset/actions.ts b/front_end/src/app/(main)/accounts/reset/actions.ts index 1da1d18868..2de6e333ae 100644 --- a/front_end/src/app/(main)/accounts/reset/actions.ts +++ b/front_end/src/app/(main)/accounts/reset/actions.ts @@ -1,5 +1,7 @@ "use server"; +import { redirect } from "next/navigation"; + import { passwordResetConfirmSchema, passwordResetRequestSchema, @@ -68,13 +70,11 @@ export async function passwordResetConfirmAction( const authManager = await getAuthCookieManager(); authManager.setAuthTokens(response.tokens); - - return { - data: response, - }; } catch (err) { return { errors: ApiError.isApiError(err) ? err.data : undefined, }; } + + redirect("/accounts/settings/account/"); } diff --git a/front_end/src/app/(main)/accounts/reset/components/password_reset.tsx b/front_end/src/app/(main)/accounts/reset/components/password_reset.tsx index 62e0b78c86..c91982d54e 100644 --- a/front_end/src/app/(main)/accounts/reset/components/password_reset.tsx +++ b/front_end/src/app/(main)/accounts/reset/components/password_reset.tsx @@ -15,6 +15,7 @@ import { } from "@/app/(main)/accounts/schemas"; import Button from "@/components/ui/button"; import { FormError, Input } from "@/components/ui/form_field"; +import LoadingSpinner from "@/components/ui/loading_spiner"; export type PasswordResetProps = { user_id: number; @@ -26,81 +27,47 @@ const PasswordReset: FC = ({ user_id, token }) => { const { register } = useForm({ resolver: zodResolver(passwordResetConfirmSchema), }); - const [state, formAction] = useActionState< + const [state, formAction, isPending] = useActionState< PasswordResetConfirmActionState, FormData >(passwordResetConfirmAction, null); return ( - <> -
-
-

{t("passwordResetHeading")}

-
-
-
- + + + +
+ - -
-
- - {t("newPasswordLabel")} - -
- -
-
-
- -
-
-
-
- - {t("verifyPasswordLabel")} - -
- -
-
-
- - {/* Global errors container */} - -
-
-
- -
- -
- + +
+
+ + + +
+
+ + {isPending && } +
+ + ); }; diff --git a/front_end/src/app/(main)/accounts/reset/page.tsx b/front_end/src/app/(main)/accounts/reset/page.tsx index f7dd5fa1bf..cd27b78d77 100644 --- a/front_end/src/app/(main)/accounts/reset/page.tsx +++ b/front_end/src/app/(main)/accounts/reset/page.tsx @@ -1,23 +1,18 @@ -import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import PasswordReset from "@/app/(main)/accounts/reset/components/password_reset"; import { GlobalErrorContainer } from "@/components/global_error_boundary"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { getAuthCookieManager } from "@/services/auth_tokens"; import { ApiError, logError } from "@/utils/core/errors"; export default async function ResetPassword(props: { searchParams: Promise<{ user_id: number; token: string }>; }) { const searchParams = await props.searchParams; + const t = await getTranslations(); const { user_id, token } = searchParams; - const authManager = await getAuthCookieManager(); - if (authManager.hasAuthSession()) { - return redirect("/"); - } - try { await ServerAuthApi.passwordResetVerifyToken(user_id, token); } catch (error) { @@ -27,7 +22,16 @@ export default async function ResetPassword(props: { } return ( -
+
+
+

+ {t("passwordResetHeading")} +

+
+ {t("passwordResetPageDescription")} +
+
+
); diff --git a/front_end/src/app/(main)/accounts/settings/account/components/change_password.tsx b/front_end/src/app/(main)/accounts/settings/account/components/change_password.tsx index cb83cfe253..019c0b2a66 100644 --- a/front_end/src/app/(main)/accounts/settings/account/components/change_password.tsx +++ b/front_end/src/app/(main)/accounts/settings/account/components/change_password.tsx @@ -7,7 +7,10 @@ import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { changePassword } from "@/app/(main)/accounts/settings/actions"; +import { + changePassword, + sendSetPasswordEmail, +} from "@/app/(main)/accounts/settings/actions"; import Button from "@/components/ui/button"; import { FormErrorMessage, Input } from "@/components/ui/form_field"; import LoadingSpinner from "@/components/ui/loading_spiner"; @@ -31,7 +34,11 @@ export const changePasswordSchema = z }); export type ChangePasswordSchema = z.infer; -const ChangePassword: FC = () => { +type Props = { + hasPassword: boolean; +}; + +const ChangePassword: FC = ({ hasPassword }) => { const t = useTranslations(); const [submitErrors, setSubmitErrors] = useState(); @@ -61,53 +68,88 @@ const ChangePassword: FC = () => { [reset, t] ); const [submit, isPending] = useServerAction(onSubmit); + + const [setPasswordEmailSent, setSetPasswordEmailSent] = useState(false); + const onSendSetPasswordEmail = useCallback(async () => { + const response = await sendSetPasswordEmail(); + if (response && "errors" in response && !!response.errors) { + setSubmitErrors(response.errors); + } else { + setSubmitErrors(undefined); + setSetPasswordEmailSent(true); + toast(t("sendSetPasswordEmailSuccess")); + } + }, [t]); + const [submitSetPassword, isSetPasswordPending] = useServerAction( + onSendSetPasswordEmail + ); + return (

{t("changePasswordButton")}
-
-
-
- -
+ {hasPassword ? ( +
+ +
+ +
-
- -
+
+ +
-
- -
- +
+ +
+ +
+ + {isPending && } +
+ +
+ ) : ( +
+

{t("noPasswordYet")}

+
- - {isPending && } + {isSetPasswordPending && ( + + )}
- -
+
+ )}
); }; diff --git a/front_end/src/app/(main)/accounts/settings/account/components/email_edit.tsx b/front_end/src/app/(main)/accounts/settings/account/components/email_edit.tsx index ecee85f291..bc87f4fbfb 100644 --- a/front_end/src/app/(main)/accounts/settings/account/components/email_edit.tsx +++ b/front_end/src/app/(main)/accounts/settings/account/components/email_edit.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import React, { FC, useState } from "react"; +import toast from "react-hot-toast"; import ChangeEmailModal from "@/app/(main)/accounts/settings/components/change_email"; import Button from "@/components/ui/button"; @@ -15,6 +16,14 @@ const EmailEdit: FC = ({ user }) => { const t = useTranslations(); const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false); + const handleEditClick = () => { + if (!user.has_password) { + toast.error(t("setPasswordRequiredForEmailChange")); + return; + } + setIsChangeEmailModalOpen(true); + }; + return (
@@ -25,7 +34,7 @@ const EmailEdit: FC = ({ user }) => { diff --git a/front_end/src/app/(main)/accounts/settings/account/components/no_password_banner.tsx b/front_end/src/app/(main)/accounts/settings/account/components/no_password_banner.tsx new file mode 100644 index 0000000000..f19b105a33 --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/account/components/no_password_banner.tsx @@ -0,0 +1,11 @@ +import { useTranslations } from "next-intl"; + +export default function NoPasswordBanner() { + const t = useTranslations(); + + return ( +
+ {t("socialAccountNoPasswordBanner")} +
+ ); +} diff --git a/front_end/src/app/(main)/accounts/settings/account/page.tsx b/front_end/src/app/(main)/accounts/settings/account/page.tsx index 2a165fcb09..0f1613db5d 100644 --- a/front_end/src/app/(main)/accounts/settings/account/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/account/page.tsx @@ -8,6 +8,7 @@ import ApiAccess from "./components/api_access"; import ChangePassword from "./components/change_password"; import EmailChangeToast from "./components/email_change_toast"; import EmailEdit from "./components/email_edit"; +import NoPasswordBanner from "./components/no_password_banner"; import PreferencesSection from "../components/preferences_section"; export const metadata = { @@ -21,12 +22,15 @@ export default async function Settings() { const { key: apiKey } = await ServerAuthApi.getApiKey(); return ( - - - - - - - +
+ {!currentUser.has_password && } + + + + + + + +
); } diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index 70408d6765..81ee9892e5 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -45,6 +45,22 @@ export async function changeEmail(email: string, password: string) { } } +export async function sendSetPasswordEmail() { + try { + await ServerProfileApi.sendSetPasswordEmail(); + + return {}; + } catch (err) { + if (!ApiError.isApiError(err)) { + throw err; + } + + return { + errors: err.data, + }; + } +} + export async function emailMeMyData() { try { return await ServerProfileApi.emailMeMyData(); diff --git a/front_end/src/services/api/profile/profile.server.ts b/front_end/src/services/api/profile/profile.server.ts index 0d898fa5af..4f46a2e94d 100644 --- a/front_end/src/services/api/profile/profile.server.ts +++ b/front_end/src/services/api/profile/profile.server.ts @@ -79,6 +79,10 @@ class ServerProfileApiClass extends ProfileApi { }); } + async sendSetPasswordEmail() { + return this.post("/users/me/request-set-password/", {}); + } + async emailMeMyData() { return this.post("/users/me/email_me_my_data/", {}); } diff --git a/front_end/src/types/users.ts b/front_end/src/types/users.ts index 3add0f2bb9..4f7c3ba1fb 100644 --- a/front_end/src/types/users.ts +++ b/front_end/src/types/users.ts @@ -62,6 +62,7 @@ export type CurrentUser = User & { registered_campaigns: { key: string; details: object }[]; should_suggest_keyfactors: boolean; prediction_expiration_percent: number | null; + has_password: boolean; app_theme?: AppTheme | null; interface_type: InterfaceType; language?: string | null; diff --git a/users/serializers.py b/users/serializers.py index 9a98df77dc..08810bdb59 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -88,6 +88,7 @@ class UserPrivateSerializer(UserPublicSerializer): metadata = serializers.JSONField(read_only=True) registered_campaigns = serializers.SerializerMethodField() should_suggest_keyfactors = serializers.SerializerMethodField() + has_password = serializers.SerializerMethodField() class Meta: model = User @@ -106,6 +107,7 @@ class Meta: "language", "api_access_tier", "is_primary_bot", + "has_password", ) def get_registered_campaigns(self, user: User): @@ -128,6 +130,9 @@ def get_should_suggest_keyfactors(self, user: User) -> bool: or LeaderboardEntry.objects.filter(user=user, medal__isnull=False).exists() ) + def get_has_password(self, user: User) -> bool: + return user.has_usable_password() + class UserUpdateProfileSerializer(serializers.ModelSerializer): website = serializers.URLField(allow_blank=True, max_length=100) diff --git a/users/urls.py b/users/urls.py index c592fd0713..5a73179e7f 100644 --- a/users/urls.py +++ b/users/urls.py @@ -22,6 +22,11 @@ views.password_change_api_view, name="user-change-password", ), + path( + "users/me/request-set-password/", + views.send_set_password_email_api_view, + name="user-request-set-password", + ), path("users/me/email/", views.email_change_api_view, name="user-change-email"), path( "users/me/email_me_my_data/", diff --git a/users/views.py b/users/views.py index 9c345afc9d..bb4f0bc4ba 100644 --- a/users/views.py +++ b/users/views.py @@ -3,8 +3,6 @@ from django.utils import timezone from rest_framework import serializers, status - -from authentication.models import ApiKey from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 @@ -12,7 +10,8 @@ from rest_framework.request import Request from rest_framework.response import Response -from authentication.services import get_tokens_for_user +from authentication.models import ApiKey +from authentication.services import get_tokens_for_user, send_password_reset_email from users.models import User, UserSpamActivity from users.serializers import ( UserPrivateSerializer, @@ -173,6 +172,18 @@ def password_change_api_view(request): return Response(tokens) +@api_view(["POST"]) +def send_set_password_email_api_view(request): + user = request.user + + if user.has_usable_password(): + raise ValidationError({"message": "User already has a password set"}) + + send_password_reset_email(user) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @api_view(["POST"]) def email_change_api_view(request): user = request.user