From 4b5ec39b8efc231ebbc1e269912e7f66cd889d32 Mon Sep 17 00:00:00 2001 From: qwqcode <22412567+qwqcode@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:56:04 +0800 Subject: [PATCH] feat(auth): add user profile and password changing (#966) --- .vscode/launch.json | 2 +- docs/swagger/docs.go | 108 ++++++++++++- docs/swagger/swagger.json | 108 ++++++++++++- docs/swagger/swagger.yaml | 65 +++++++- server/handler/auth_email_register.go | 2 +- server/handler/user_info_update.go | 104 +++++++++++++ server/server.go | 1 + ui/artalk/src/api/v2.ts | 47 +++++- ui/artalk/src/i18n/en.ts | 6 +- ui/artalk/src/i18n/fr.ts | 6 +- ui/artalk/src/i18n/ja.ts | 6 +- ui/artalk/src/i18n/ko.ts | 6 +- ui/artalk/src/i18n/ru.ts | 6 +- ui/artalk/src/i18n/zh-CN.ts | 4 + ui/artalk/src/i18n/zh-TW.ts | 4 + ui/plugin-auth/DialogMain.tsx | 29 ++-- ui/plugin-auth/EditorUser.tsx | 10 +- ui/plugin-auth/UserProfileDialog.tsx | 213 ++++++++++++++++++++++++++ ui/plugin-auth/style.scss | 12 ++ 19 files changed, 716 insertions(+), 23 deletions(-) create mode 100644 server/handler/user_info_update.go create mode 100644 ui/plugin-auth/UserProfileDialog.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json index b3fe0fa4..9afea592 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "preLaunchTask": "Build for debugging", "env": { "ATK_SITE_DEFAULT": "ArtalkDocs", - "ATK_TRUSTED_DOMAINS": "http://localhost:5173 http://localhost:23367", + "ATK_TRUSTED_DOMAINS": "http://localhost:5173 http://localhost:23367" }, "args": ["server", "-c", "${workspaceFolder}/artalk.yml"] } diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 3d26a28c..624055e0 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -96,7 +96,7 @@ const docTemplate = `{ }, "/auth/email/register": { "post": { - "description": "Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first)", + "description": "Register by email and verify code (if user exists, will update user, like forget or change password. Need send email verify code first)", "consumes": [ "application/json" ], @@ -2866,6 +2866,80 @@ const docTemplate = `{ } } } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update user profile when user is logged in", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Update user profile", + "operationId": "UpdateProfile", + "parameters": [ + { + "description": "The profile data to update", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestUserInfoUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserInfoUpdate" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } } }, "/user/access_token": { @@ -4433,6 +4507,27 @@ const docTemplate = `{ } } }, + "handler.RequestUserInfoUpdate": { + "type": "object", + "required": [ + "email", + "name" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "handler.ResponseAdminUserList": { "type": "object", "required": [ @@ -5210,6 +5305,17 @@ const docTemplate = `{ } } }, + "handler.ResponseUserInfoUpdate": { + "type": "object", + "required": [ + "user" + ], + "properties": { + "user": { + "$ref": "#/definitions/entity.CookedUser" + } + } + }, "handler.ResponseUserLogin": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index cd79666a..58941f6a 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -89,7 +89,7 @@ }, "/auth/email/register": { "post": { - "description": "Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first)", + "description": "Register by email and verify code (if user exists, will update user, like forget or change password. Need send email verify code first)", "consumes": [ "application/json" ], @@ -2859,6 +2859,80 @@ } } } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update user profile when user is logged in", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Update user profile", + "operationId": "UpdateProfile", + "parameters": [ + { + "description": "The profile data to update", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RequestUserInfoUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ResponseUserInfoUpdate" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Map" + }, + { + "type": "object", + "properties": { + "msg": { + "type": "string" + } + } + } + ] + } + } + } } }, "/user/access_token": { @@ -4426,6 +4500,27 @@ } } }, + "handler.RequestUserInfoUpdate": { + "type": "object", + "required": [ + "email", + "name" + ], + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + }, + "link": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "handler.ResponseAdminUserList": { "type": "object", "required": [ @@ -5203,6 +5298,17 @@ } } }, + "handler.ResponseUserInfoUpdate": { + "type": "object", + "required": [ + "user" + ], + "properties": { + "user": { + "$ref": "#/definitions/entity.CookedUser" + } + } + }, "handler.ResponseUserLogin": { "type": "object", "required": [ diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 0fd27467..f1180203 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -625,6 +625,20 @@ definitions: required: - email type: object + handler.RequestUserInfoUpdate: + properties: + code: + type: string + email: + type: string + link: + type: string + name: + type: string + required: + - email + - name + type: object handler.ResponseAdminUserList: properties: count: @@ -1170,6 +1184,13 @@ definitions: - notifies_count - user type: object + handler.ResponseUserInfoUpdate: + properties: + user: + $ref: '#/definitions/entity.CookedUser' + required: + - user + type: object handler.ResponseUserLogin: properties: token: @@ -1299,7 +1320,7 @@ paths: consumes: - application/json description: Register by email and verify code (if user exists, will update - user, like forget password. Need send email verify code first) + user, like forget or change password. Need send email verify code first) operationId: RegisterByEmail parameters: - description: The data to register @@ -2905,6 +2926,48 @@ paths: summary: Get User Info tags: - Auth + post: + consumes: + - application/json + description: Update user profile when user is logged in + operationId: UpdateProfile + parameters: + - description: The profile data to update + in: body + name: data + required: true + schema: + $ref: '#/definitions/handler.RequestUserInfoUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ResponseUserInfoUpdate' + "400": + description: Bad Request + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + "500": + description: Internal Server Error + schema: + allOf: + - $ref: '#/definitions/handler.Map' + - properties: + msg: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: Update user profile + tags: + - Auth /user/access_token: post: consumes: diff --git a/server/handler/auth_email_register.go b/server/handler/auth_email_register.go index 5634db20..1415a6af 100644 --- a/server/handler/auth_email_register.go +++ b/server/handler/auth_email_register.go @@ -19,7 +19,7 @@ type RequestAuthEmailRegister struct { // @Id RegisterByEmail // @Summary Register by email -// @Description Register by email and verify code (if user exists, will update user, like forget password. Need send email verify code first) +// @Description Register by email and verify code (if user exists, will update user, like forget or change password. Need send email verify code first) // @Tags Auth // @Param data body RequestAuthEmailRegister true "The data to register" // @Success 200 {object} ResponseUserLogin diff --git a/server/handler/user_info_update.go b/server/handler/user_info_update.go new file mode 100644 index 00000000..b28497fb --- /dev/null +++ b/server/handler/user_info_update.go @@ -0,0 +1,104 @@ +package handler + +import ( + "strings" + + "github.com/ArtalkJS/Artalk/internal/core" + "github.com/ArtalkJS/Artalk/internal/entity" + "github.com/ArtalkJS/Artalk/internal/i18n" + "github.com/ArtalkJS/Artalk/internal/utils" + "github.com/ArtalkJS/Artalk/server/common" + "github.com/gofiber/fiber/v2" +) + +type RequestUserInfoUpdate struct { + Email string `json:"email" validate:"required"` + Name string `json:"name" validate:"required"` + Link string `json:"link" validate:"optional"` + Code string `json:"code" validate:"optional"` +} + +type ResponseUserInfoUpdate struct { + User entity.CookedUser `json:"user"` +} + +// @Id UpdateProfile +// @Summary Update user profile +// @Description Update user profile when user is logged in +// @Tags Auth +// @Security ApiKeyAuth +// @Param data body RequestUserInfoUpdate true "The profile data to update" +// @Success 200 {object} ResponseUserInfoUpdate +// @Failure 400 {object} Map{msg=string} +// @Failure 500 {object} Map{msg=string} +// @Accept json +// @Produce json +// @Router /user [post] +func UserInfoUpdate(app *core.App, router fiber.Router) { + router.Post("/user", common.LoginGuard(app, func(c *fiber.Ctx, user entity.User) error { + var p RequestUserInfoUpdate + if ok, resp := common.ParamsDecode(c, &p); !ok { + return resp + } + + // Trim form + p.Name = strings.TrimSpace(p.Name) + p.Email = strings.TrimSpace(p.Email) + p.Link = strings.TrimSpace(p.Link) + + // Modify name + if p.Name != user.Name { + // Check name exists + // (Allows the same name but different email) + findUserByName := app.Dao().FindUser(p.Name, p.Email) // Find by `name` AND `email` + if !findUserByName.IsEmpty() && findUserByName.ID != user.ID { + // If user name exists but not current user + // Allow current user to change the same name but case not same + return common.RespError(c, 400, i18n.T("{{name}} already exists", map[string]interface{}{"name": i18n.T("Username")})) + } + + user.Name = p.Name + } + + // Modify email + // (Verify sent email code first) + if p.Email != user.Email { + // Check email format + if !utils.ValidateEmail(p.Email) { + return common.RespError(c, 400, i18n.T("Invalid {{name}}", map[string]interface{}{"name": i18n.T("Email")})) + } + + // Check email exists + var findUserByEmail entity.User + app.Dao().DB().Where("LOWER(email) = LOWER(?)", p.Email).First(&findUserByEmail) + if !findUserByEmail.IsEmpty() { + return common.RespError(c, 400, i18n.T("{{name}} already exists", map[string]interface{}{"name": i18n.T("Email")})) + } + + // Check email verify code + if ok, resp := CheckEmailVerifyCode(app, c, p.Email, p.Code); !ok { + return resp + } + + user.Email = p.Email + } + + // Modify link + if p.Link != "" { + // Check link format + if !utils.ValidateURL(p.Link) { + user.Link = "https://" + p.Link + } + + user.Link = p.Link + } + + if err := app.Dao().UpdateUser(&user); err != nil { + return common.RespError(c, 500, "Failed to update user") + } + + return common.RespData(c, ResponseUserInfoUpdate{ + User: app.Dao().CookUser(&user), + }) + })) +} diff --git a/server/server.go b/server/server.go index b86c61c4..3467e325 100644 --- a/server/server.go +++ b/server/server.go @@ -97,6 +97,7 @@ func Serve(app *core.App) (*fiber.App, error) { // user h.UserInfo(app, api) + h.UserInfoUpdate(app, api) h.UserLogin(app, api) h.UserStatus(app, api) diff --git a/ui/artalk/src/api/v2.ts b/ui/artalk/src/api/v2.ts index d92feb5c..503c182e 100644 --- a/ui/artalk/src/api/v2.ts +++ b/ui/artalk/src/api/v2.ts @@ -327,6 +327,13 @@ export interface HandlerRequestAuthEmailSend { email: string } +export interface HandlerRequestUserInfoUpdate { + code?: string + email: string + link?: string + name: string +} + export interface HandlerResponseAdminUserList { count: number users: EntityCookedUserForAdmin[] @@ -553,6 +560,10 @@ export interface HandlerResponseUserInfo { user: EntityCookedUser } +export interface HandlerResponseUserInfoUpdate { + user: EntityCookedUser +} + export interface HandlerResponseUserLogin { token: string user: EntityCookedUser @@ -849,7 +860,7 @@ export class Api extends HttpClient extends HttpClient + this.request< + HandlerResponseUserInfoUpdate, + HandlerMap & { + msg?: string + } + >({ + path: `/user`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + format: 'json', + ...params, + }), + /** * @description Login user by name or email * diff --git a/ui/artalk/src/i18n/en.ts b/ui/artalk/src/i18n/en.ts index 43946cec..f460729d 100644 --- a/ui/artalk/src/i18n/en.ts +++ b/ui/artalk/src/i18n/en.ts @@ -70,12 +70,16 @@ const en = { ctrlCenter: 'Dashboard', /* Auth */ + userProfile: 'Profile', noAccountPrompt: "Don't have an account?", haveAccountPrompt: 'Already have an account?', forgetPassword: 'Forget Password', resetPassword: 'Reset Password', + changePassword: 'Change Password', + confirmPassword: 'Confirm Password', + passwordMismatch: 'Passwords do not match', verificationCode: 'Verification Code', - verifySend: 'Verify', + verifySend: 'Send Code', verifyResend: 'Resend', waitSeconds: 'Wait {seconds}s', emailVerified: 'Email has been verified', diff --git a/ui/artalk/src/i18n/fr.ts b/ui/artalk/src/i18n/fr.ts index 7cd4ca93..985be697 100644 --- a/ui/artalk/src/i18n/fr.ts +++ b/ui/artalk/src/i18n/fr.ts @@ -74,12 +74,16 @@ export default defineLocaleExternal( ctrlCenter: 'Tableau de bord', /* Auth */ + userProfile: 'Profil', noAccountPrompt: 'Vous n’avez pas de compte ?', haveAccountPrompt: 'Vous avez déjà un compte ?', forgetPassword: 'Mot de passe oublié', resetPassword: 'Réinitialiser le mot de passe', + changePassword: 'Changer le mot de passe', + confirmPassword: 'Confirmer le mot de passe', + passwordMismatch: 'Les mots de passe ne correspondent pas', verificationCode: 'Code de vérification', - verifySend: 'Vérifier', + verifySend: 'Envoyer le code', verifyResend: 'Renvoyer', waitSeconds: 'Attendez {seconds}s', emailVerified: 'Email vérifié', diff --git a/ui/artalk/src/i18n/ja.ts b/ui/artalk/src/i18n/ja.ts index 8e24bfd9..051c6b9f 100644 --- a/ui/artalk/src/i18n/ja.ts +++ b/ui/artalk/src/i18n/ja.ts @@ -74,12 +74,16 @@ export default defineLocaleExternal( ctrlCenter: 'ダッシュボード', /* Auth */ + userProfile: 'プロフィール', noAccountPrompt: 'アカウントがありませんか?', haveAccountPrompt: 'アカウントをお持ちですか?', forgetPassword: 'パスワードを忘れた', resetPassword: 'パスワードをリセット', + changePassword: 'パスワードを変更', + confirmPassword: 'パスワードを確認', + passwordMismatch: '入力されたパスワードが一致しません', verificationCode: '検証コード', - verifySend: '検証', + verifySend: 'コードを送信', verifyResend: '再送信', waitSeconds: '{seconds}秒待つ', emailVerified: 'メールアドレスが確認されました', diff --git a/ui/artalk/src/i18n/ko.ts b/ui/artalk/src/i18n/ko.ts index 3153b468..2ad65d1b 100644 --- a/ui/artalk/src/i18n/ko.ts +++ b/ui/artalk/src/i18n/ko.ts @@ -74,12 +74,16 @@ export default defineLocaleExternal( ctrlCenter: '대시보드', /* Auth */ + userProfile: '프로필', noAccountPrompt: '계정이 없으신가요?', haveAccountPrompt: '이미 계정이 있으신가요?', forgetPassword: '비밀번호 찾기', resetPassword: '비밀번호 재설정', + changePassword: '비밀번호 변경', + confirmPassword: '비밀번호 확인', + passwordMismatch: '비밀번호가 일치하지 않습니다', verificationCode: '인증 코드', - verifySend: '인증', + verifySend: '인증 코드 전송', verifyResend: '재전송', waitSeconds: '{seconds}초 대기', emailVerified: '이메일 인증 완료', diff --git a/ui/artalk/src/i18n/ru.ts b/ui/artalk/src/i18n/ru.ts index 7bce08d4..c8c5f51d 100644 --- a/ui/artalk/src/i18n/ru.ts +++ b/ui/artalk/src/i18n/ru.ts @@ -74,12 +74,16 @@ export default defineLocaleExternal( ctrlCenter: 'Панель управления', /* Auth */ + userProfile: 'Профиль', noAccountPrompt: 'Нет аккаунта?', haveAccountPrompt: 'Уже есть аккаунт?', forgetPassword: 'Забыли пароль', resetPassword: 'Сбросить пароль', + changePassword: 'Изменить пароль', + confirmPassword: 'Подтвердить пароль', + passwordMismatch: 'Пароли не совпадают', verificationCode: 'Код подтверждения', - verifySend: 'Подтвердить', + verifySend: 'Отправить код', verifyResend: 'Отправить снова', waitSeconds: 'Подождите {seconds}с', emailVerified: 'Электронная почта подтверждена', diff --git a/ui/artalk/src/i18n/zh-CN.ts b/ui/artalk/src/i18n/zh-CN.ts index 4dac4421..82023cd6 100644 --- a/ui/artalk/src/i18n/zh-CN.ts +++ b/ui/artalk/src/i18n/zh-CN.ts @@ -72,10 +72,14 @@ const zhCN: I18n = { ctrlCenter: '控制中心', /* Auth */ + userProfile: '个人资料', noAccountPrompt: '没有账号?', haveAccountPrompt: '已有账号?', forgetPassword: '忘记密码', resetPassword: '重置密码', + changePassword: '修改密码', + confirmPassword: '确认密码', + passwordMismatch: '两次输入的密码不一致', verificationCode: '验证码', verifySend: '发送验证码', verifyResend: '重新发送', diff --git a/ui/artalk/src/i18n/zh-TW.ts b/ui/artalk/src/i18n/zh-TW.ts index e1cc906c..80297d9b 100644 --- a/ui/artalk/src/i18n/zh-TW.ts +++ b/ui/artalk/src/i18n/zh-TW.ts @@ -72,10 +72,14 @@ export default defineLocaleExternal('zh-TW', { ctrlCenter: '控制中心', /* Auth */ + userProfile: '個人資料', noAccountPrompt: '沒有帳號?', haveAccountPrompt: '已有帳號?', forgetPassword: '忘記密碼', resetPassword: '重置密碼', + changePassword: '修改密碼', + confirmPassword: '確認密碼', + passwordMismatch: '兩次輸入的密碼不一致', verificationCode: '驗證碼', verifySend: '發送驗證碼', verifyResend: '重新發送', diff --git a/ui/plugin-auth/DialogMain.tsx b/ui/plugin-auth/DialogMain.tsx index 3e4dfd14..39032941 100644 --- a/ui/plugin-auth/DialogMain.tsx +++ b/ui/plugin-auth/DialogMain.tsx @@ -18,17 +18,24 @@ export const DialogMain = (props: DialogMainProps) => { const { ctx, onClose, onSkip, ...others } = props const [methods] = createResource(async () => { - return (await fetchMethods(ctx)).map((mm) => { - if (mm.name === 'email') mm.onClick = () => setPage('login') - if (mm.name === 'skip') { - mm.label = ctx.$t('skipVerify') - mm.onClick = () => { - onSkip() - onClose() - } - } - return mm - }) + return fetchMethods(ctx) + .then((m) => + m.map((mm) => { + if (mm.name === 'email') mm.onClick = () => setPage('login') + if (mm.name === 'skip') { + mm.label = ctx.$t('skipVerify') + mm.onClick = () => { + onSkip() + onClose() + } + } + return mm + }), + ) + .catch((e) => { + alert('Failed to fetch login methods: ' + e.message) + return [] + }) }) const [title, setTitle] = createSignal('Login') diff --git a/ui/plugin-auth/EditorUser.tsx b/ui/plugin-auth/EditorUser.tsx index 59260917..786783ec 100644 --- a/ui/plugin-auth/EditorUser.tsx +++ b/ui/plugin-auth/EditorUser.tsx @@ -1,6 +1,8 @@ import type { ContextApi } from 'artalk' import { Show, onCleanup, createSignal } from 'solid-js' import { render } from 'solid-js/web' +import { createLayer } from './lib/layer' +import { UserProfileDialog } from './UserProfileDialog' const EditorUser = ({ ctx }: { ctx: ContextApi }) => { const logoutHandler = () => { @@ -27,11 +29,17 @@ const EditorUser = ({ ctx }: { ctx: ContextApi }) => { ctx.off('user-changed', userChangedHandler) }) + const openUserProfileDialog = () => { + createLayer(ctx).show((layer) => ( + layer.destroy()} /> + )) + } + return (
-