diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteLoginModal.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteLoginModal.vue index 7e8df94..6920efd 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteLoginModal.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteLoginModal.vue @@ -12,6 +12,8 @@ import { computed, ref } from 'vue' import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session' import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue' +import SkinSitePasswordResetModal from './SkinSitePasswordResetModal.vue' +import SkinSiteRegisterModal from './SkinSiteRegisterModal.vue' const { formatMessage } = useVIntl() const { handleError, addNotification } = injectNotificationManager() @@ -22,6 +24,8 @@ const messages = defineMessages({ password: { id: 'app.skin-site.login.password', defaultMessage: 'Password' }, signIn: { id: 'app.skin-site.login.sign-in', defaultMessage: 'Sign in' }, signingIn: { id: 'app.skin-site.login.signing-in', defaultMessage: 'Signing in…' }, + register: { id: 'app.skin-site.login.register', defaultMessage: 'Create account' }, + forgotPassword: { id: 'app.skin-site.login.forgot', defaultMessage: 'Forgot password?' }, usernameHint: { id: 'app.skin-site.login.username-hint', defaultMessage: '3–16 characters, or a registered email address.', @@ -37,6 +41,18 @@ const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/ const modal = ref>() const captchaModal = ref>() +const registerModal = ref>() +const passwordResetModal = ref>() + +function openRegister() { + modal.value?.hide() + registerModal.value?.show() +} + +function openPasswordReset() { + modal.value?.hide() + passwordResetModal.value?.show() +} const username = ref('') const password = ref('') const working = ref(false) @@ -98,6 +114,10 @@ function cancel() { emit('cancel') } +function onRegisterSignedIn() { + emit('signed-in') +} + defineExpose({ show }) @@ -115,6 +135,14 @@ defineExpose({ show }) {{ formatMessage(messages.passwordHint) }} +
+ + +
+ + + +import { + commonMessages, + defineMessages, + injectNotificationManager, + NewModal, + StyledInput, + useVIntl, +} from '@modrinth/ui' +import { computed, onUnmounted, ref } from 'vue' + +import { skinSitePasswordReset, skinSitePasswordResetEmailVerify } from '@/helpers/skin-site-api' + +import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue' + +const { formatMessage } = useVIntl() +const { handleError, addNotification } = injectNotificationManager() + +const messages = defineMessages({ + title: { id: 'app.skin-site.reset.title', defaultMessage: 'Reset password' }, + email: { id: 'app.skin-site.reset.email', defaultMessage: 'Email' }, + emailHint: { + id: 'app.skin-site.reset.email-hint', + defaultMessage: 'Use the address you registered with.', + }, + code: { id: 'app.skin-site.reset.code', defaultMessage: 'Verification code' }, + sendCode: { id: 'app.skin-site.reset.send-code', defaultMessage: 'Send code' }, + sending: { id: 'app.skin-site.reset.sending', defaultMessage: 'Sending…' }, + resendIn: { id: 'app.skin-site.reset.resend-in', defaultMessage: 'Sent ({seconds}s)' }, + newPassword: { id: 'app.skin-site.reset.new-password', defaultMessage: 'New password' }, + repeatPassword: { id: 'app.skin-site.reset.repeat-password', defaultMessage: 'Repeat password' }, + passwordHint: { + id: 'app.skin-site.reset.password-hint', + defaultMessage: '6–50 characters with upper case, lower case and a digit.', + }, + mismatch: { id: 'app.skin-site.reset.mismatch', defaultMessage: 'Passwords do not match.' }, + submit: { id: 'app.skin-site.reset.submit', defaultMessage: 'Reset password' }, + success: { + id: 'app.skin-site.reset.success', + defaultMessage: 'Password updated. You can sign in now.', + }, +}) + +const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/ +const COUNTDOWN_SECONDS = 60 + +const modal = ref>() +const captchaModal = ref>() +const email = ref('') +const code = ref('') +const newPassword = ref('') +const repeatPassword = ref('') +const sending = ref(false) +const working = ref(false) +const countdown = ref(0) +let timer: ReturnType | undefined + +const emit = defineEmits<{ (e: 'done' | 'cancel'): void }>() + +function stopTimer() { + if (timer) clearInterval(timer) + timer = undefined +} + +function startCountdown() { + countdown.value = COUNTDOWN_SECONDS + stopTimer() + timer = setInterval(() => { + countdown.value -= 1 + if (countdown.value <= 0) stopTimer() + }, 1000) +} + +const canSendCode = computed( + () => email.value.trim().length > 0 && !sending.value && countdown.value <= 0, +) + +const passwordValid = computed(() => PASSWORD_RE.test(newPassword.value)) +const passwordsMatch = computed(() => newPassword.value === repeatPassword.value) +const canSubmit = computed( + () => + email.value.trim().length > 0 && + code.value.trim().length === 6 && + passwordValid.value && + passwordsMatch.value && + !working.value, +) + +function reset() { + stopTimer() + email.value = '' + code.value = '' + newPassword.value = '' + repeatPassword.value = '' + sending.value = false + working.value = false + countdown.value = 0 +} + +function show() { + reset() + modal.value?.show() +} + +async function sendCode() { + if (!canSendCode.value) return + sending.value = true + try { + const result = await skinSitePasswordResetEmailVerify(email.value.trim()) + if (!result.success) { + handleError(new Error(result.errorMessage)) + return + } + addNotification({ type: 'success', title: result.payload }) + startCountdown() + } catch (error) { + handleError(error) + } finally { + sending.value = false + } +} + +function beginReset() { + if (!canSubmit.value) return + if (!passwordsMatch.value) { + handleError(new Error(formatMessage(messages.mismatch))) + return + } + captchaModal.value?.show() +} + +async function onCaptchaVerified(clientId: string) { + working.value = true + try { + const result = await skinSitePasswordReset({ + clientId, + email: email.value.trim(), + emailVerifyCode: code.value.trim(), + newPassword: newPassword.value, + }) + if (!result.success) { + handleError(new Error(result.errorMessage)) + return + } + addNotification({ type: 'success', title: formatMessage(messages.success) }) + modal.value?.hide() + emit('done') + } catch (error) { + handleError(error) + } finally { + working.value = false + } +} + +function cancel() { + modal.value?.hide() + emit('cancel') +} + +onUnmounted(stopTimer) + +defineExpose({ show }) + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteRegisterModal.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteRegisterModal.vue new file mode 100644 index 0000000..399dae6 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteRegisterModal.vue @@ -0,0 +1,299 @@ + + + diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 97fc534..78b029e 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -333,3 +333,52 @@ export function skinSiteRenamePlayer( { method: 'POST', type: 'skin' }, ) } +// --------------------------------------------------------------------------- +// Registration & password reset +// --------------------------------------------------------------------------- + +/** Send the email verification code used during registration. */ +export function skinSiteRegisterEmailVerify(email: string): Promise> { + return skinSiteRequest('register/emailVerify', { + method: 'POST', + body: { email }, + auth: false, + }) +} + +export function skinSiteRegister(payload: { + qq?: number + email: string + emailVerifyCode: string + password: string + inviteCode?: string + clientId: string +}): Promise> { + return skinSiteRequest('register', { + method: 'POST', + body: payload, + auth: false, + }) +} + +/** Ask the server to email a reset code. Always answers success to avoid probing. */ +export function skinSitePasswordResetEmailVerify(email: string): Promise> { + return skinSiteRequest('password/emailVerify', { + method: 'POST', + body: { email }, + auth: false, + }) +} + +export function skinSitePasswordReset(payload: { + clientId: string + email: string + emailVerifyCode: string + newPassword: string +}): Promise> { + return skinSiteRequest('password/reset', { + method: 'POST', + body: payload, + auth: false, + }) +}