0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-03 22:15:32 -05:00

feat(console): add mainflow like modal style for change password

This commit is contained in:
Charles Zhao 2023-03-01 14:15:22 +08:00
parent e8a7094e37
commit ae9edd3ebc
No known key found for this signature in database
GPG key ID: 4858774754C92DF2
34 changed files with 757 additions and 312 deletions

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.2821 6.71324L13.2821 3.71324C13.2122 3.64331 13.1292 3.58784 13.0378 3.55C12.9464 3.51215 12.8485 3.49267 12.7496 3.49267C12.5499 3.49267 12.3583 3.57201 12.2171 3.71324C12.0759 3.85447 11.9965 4.04602 11.9965 4.24574C11.9965 4.44547 12.0759 4.63701 12.2171 4.77824L13.9421 6.49574H5.24962C5.0507 6.49574 4.85994 6.57476 4.71929 6.71541C4.57863 6.85607 4.49962 7.04683 4.49962 7.24574C4.49962 7.44466 4.57863 7.63542 4.71929 7.77607C4.85994 7.91673 5.0507 7.99574 5.24962 7.99574H15.7496C15.8977 7.995 16.0422 7.95045 16.165 7.8677C16.2878 7.78495 16.3834 7.66771 16.4396 7.53074C16.4971 7.39416 16.5127 7.24362 16.4847 7.09813C16.4567 6.95264 16.3862 6.81871 16.2821 6.71324ZM12.7496 10.4957H2.24962C2.10155 10.4965 1.95701 10.541 1.83422 10.6238C1.71143 10.7065 1.61588 10.8238 1.55962 10.9607C1.50218 11.0973 1.48649 11.2479 1.51452 11.3934C1.54255 11.5388 1.61305 11.6728 1.71712 11.7782L4.71712 14.7782C4.78684 14.8485 4.86979 14.9043 4.96118 14.9424C5.05258 14.9805 5.15061 15.0001 5.24962 15.0001C5.34863 15.0001 5.44665 14.9805 5.53805 14.9424C5.62944 14.9043 5.71239 14.8485 5.78212 14.7782C5.85241 14.7085 5.90821 14.6256 5.94629 14.5342C5.98436 14.4428 6.00397 14.3448 6.00397 14.2457C6.00397 14.1467 5.98436 14.0487 5.94629 13.9573C5.90821 13.8659 5.85241 13.783 5.78212 13.7132L4.05712 11.9957H12.7496C12.9485 11.9957 13.1393 11.9167 13.2799 11.7761C13.4206 11.6354 13.4996 11.4447 13.4996 11.2457C13.4996 11.0468 13.4206 10.8561 13.2799 10.7154C13.1393 10.5748 12.9485 10.4957 12.7496 10.4957Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.2898 11.9999L14.8298 8.45995C15.0161 8.27259 15.1206 8.01913 15.1206 7.75495C15.1206 7.49076 15.0161 7.23731 14.8298 7.04995C14.7369 6.95622 14.6263 6.88183 14.5044 6.83106C14.3825 6.78029 14.2518 6.75415 14.1198 6.75415C13.9878 6.75415 13.8571 6.78029 13.7352 6.83106C13.6134 6.88183 13.5028 6.95622 13.4098 7.04995L9.16982 11.2899C9.07609 11.3829 9.0017 11.4935 8.95093 11.6154C8.90016 11.7372 8.87402 11.8679 8.87402 11.9999C8.87402 12.132 8.90016 12.2627 8.95093 12.3845C9.0017 12.5064 9.07609 12.617 9.16982 12.7099L13.4098 16.9999C13.5033 17.0926 13.6141 17.166 13.7359 17.2157C13.8578 17.2655 13.9882 17.2907 14.1198 17.2899C14.2514 17.2907 14.3819 17.2655 14.5037 17.2157C14.6256 17.166 14.7364 17.0926 14.8298 16.9999C15.0161 16.8126 15.1206 16.5591 15.1206 16.2949C15.1206 16.0308 15.0161 15.7773 14.8298 15.5899L11.2898 11.9999Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View file

@ -46,6 +46,9 @@ import UserSettings from '@/pages/UserDetails/UserSettings';
import Users from '@/pages/Users';
import Welcome from '@/pages/Welcome';
import ChangePasswordModal from '../Profile/containers/ChangePasswordModal';
import VerifyPasswordModal from '../Profile/containers/VerifyPasswordModal';
const Main = () => {
const swrOptions = useSwrOptions();
const { userEndpoint } = useContext(AppEndpointsContext);
@ -132,7 +135,11 @@ const Main = () => {
</Route>
</Route>
<Route path="settings" element={<Settings />} />
<Route path="profile" element={<Profile />} />
<Route path="profile">
<Route index element={<Profile />} />
<Route path="verify-password" element={<VerifyPasswordModal />} />
<Route path="change-password" element={<ChangePasswordModal />} />
</Route>
</Route>
</Route>
</Routes>

View file

@ -5,8 +5,8 @@ import { useState } from 'react';
import UserAvatar from '@/components/UserAvatar';
import { isCloud } from '@/consts/cloud';
import type { BasicUserField } from '../../modals/BasicUserInfoUpdateModal';
import BasicUserInfoUpdateModal from '../../modals/BasicUserInfoUpdateModal';
import type { BasicUserField } from '../../containers/BasicUserInfoUpdateModal';
import BasicUserInfoUpdateModal from '../../containers/BasicUserInfoUpdateModal';
import type { Row } from '../CardContent';
import CardContent from '../CardContent';
import Section from '../Section';
@ -38,20 +38,10 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => {
];
// Get the value of the editing simple field (avatar, username or name)
const getSimpleFieldValue = (field?: BasicUserField): string => {
if (field === 'avatar') {
return avatar ?? '';
}
const getSimpleFieldValue = (field: BasicUserField): string => {
const value = field === 'avatar' ? avatar : user[field];
if (field === 'name') {
return name ?? '';
}
if (field === 'username') {
return username ?? '';
}
return '';
return value ?? '';
};
return (
@ -81,15 +71,17 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => {
...conditionalUsername,
]}
/>
<BasicUserInfoUpdateModal
value={getSimpleFieldValue(editingField)}
field={editingField}
isOpen={isUpdateModalOpen}
onClose={() => {
setIsUpdateModalOpen(false);
onUpdate?.();
}}
/>
{editingField && (
<BasicUserInfoUpdateModal
value={getSimpleFieldValue(editingField)}
field={editingField}
isOpen={isUpdateModalOpen}
onClose={() => {
setIsUpdateModalOpen(false);
onUpdate?.();
}}
/>
)}
</Section>
);
};

View file

@ -0,0 +1,47 @@
@use '@/scss/underscore' as _;
.container {
background: var(--color-base);
padding: _.unit(5) 0;
}
.wrapper {
display: flex;
flex-direction: column;
width: 640px;
min-height: 640px;
background: var(--color-layer-1);
position: relative;
padding: _.unit(26) _.unit(30);
border-radius: 16px;
> * {
margin-bottom: _.unit(4);
}
}
.backButton {
position: absolute;
left: 16px;
top: 24px;
color: var(--color-text);
display: flex;
align-items: center;
&:not(:disabled):hover {
text-decoration: none;
}
}
.subtitle {
font: var(--font-body-2);
color: var(--color-text-secondary);
}
.title {
font: var(--font-headline-2);
+ .subtitle {
margin-top: _.unit(-3);
}
}

View file

@ -0,0 +1,46 @@
import type { AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';
import type { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useNavigate } from 'react-router-dom';
import Arrow from '@/assets/images/arrow-left.svg';
import TextLink from '@/components/TextLink';
import * as modalStyles from '@/scss/modal.module.scss';
import * as styles from './index.module.scss';
type Props = PropsWithChildren<{
title: AdminConsoleKey;
subtitle?: AdminConsoleKey;
onClose: () => void;
}>;
const MainFlowLikeModal = ({ title, subtitle, children, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
return (
<ReactModal shouldCloseOnEsc isOpen className={modalStyles.fullScreen} onRequestClose={onClose}>
<div className={classNames(modalStyles.content, styles.container)}>
<div className={styles.wrapper}>
<TextLink
className={styles.backButton}
icon={<Arrow />}
onClick={() => {
navigate(-1);
}}
>
{t('general.back')}
</TextLink>
<span className={styles.title}>{t(title)}</span>
{subtitle && <span className={styles.subtitle}>{t(subtitle)}</span>}
{children}
</div>
</div>
</ReactModal>
);
};
export default MainFlowLikeModal;

View file

@ -1,35 +0,0 @@
import { useState } from 'react';
import ChangePasswordModal from '../../modals/ChangePasswordModal';
import CardContent from '../CardContent';
import Section from '../Section';
const PasswordSection = () => {
const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false);
return (
<Section title="profile.password.title">
<CardContent
title="profile.password.reset_password"
data={[
{
label: 'profile.password.password',
value: '******',
actionName: 'profile.change',
action: () => {
setIsChangePasswordModalOpen(true);
},
},
]}
/>
<ChangePasswordModal
isOpen={isChangePasswordModalOpen}
onClose={() => {
setIsChangePasswordModalOpen(false);
}}
/>
</Section>
);
};
export default PasswordSection;

View file

@ -0,0 +1,126 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
export type BasicUserField = 'avatar' | 'username' | 'name';
type Props = {
field?: BasicUserField;
value: string;
isOpen: boolean;
onClose: () => void;
};
type FormFields = {
[key in BasicUserField]: string;
};
const BasicUserInfoUpdateModal = ({ field, value: initialValue, isOpen, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const {
register,
clearErrors,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormFields>({ reValidateMode: 'onBlur' });
useEffect(() => {
clearErrors();
if (!field) {
return;
}
setValue(field, initialValue);
return () => {
reset();
};
}, [clearErrors, field, initialValue, reset, setValue]);
if (!field) {
return null;
}
const getModalTitle = (): AdminConsoleKey => {
if (field === 'name') {
return initialValue ? 'profile.change_name' : 'profile.set_name';
}
return `profile.change_${field}`;
};
const getInputPlaceholder = (): string => {
const i18nKey: AdminConsoleKey =
field === 'avatar' ? 'user_details.field_avatar_placeholder' : `profile.settings.${field}`;
return t(i18nKey);
};
const onSubmit = async () => {
clearErrors();
void handleSubmit(async (data) => {
await api.patch(`me/user`, { json: { [field]: data[field] } });
onClose();
})();
};
return (
<ReactModal
shouldCloseOnEsc
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title={getModalTitle()}
footer={
<Button
type="primary"
size="large"
title="general.save"
isLoading={isSubmitting}
onClick={onSubmit}
/>
}
onClose={() => {
onClose();
}}
>
<div>
<TextInput
{...register(field, {
validate: (value) =>
field !== 'username' ||
!!value ||
t('errors.required_field_missing', { field: t(`profile.settings.${field}`) }),
})}
placeholder={getInputPlaceholder()}
errorMessage={errors[field]?.message}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void onSubmit();
}
}}
/>
</div>
</ModalLayout>
</ReactModal>
);
};
export default BasicUserInfoUpdateModal;

View file

@ -0,0 +1,101 @@
import type { KeyboardEventHandler } from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import Checkbox from '@/components/Checkbox';
import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
type FormFields = {
newPassword: string;
confirmPassword: string;
showPassword: boolean;
};
const defaultValues: FormFields = {
newPassword: '',
confirmPassword: '',
showPassword: false,
};
const ChangePasswordModal = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const {
watch,
reset,
register,
clearErrors,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormFields>({
reValidateMode: 'onBlur',
defaultValues,
});
const [showPassword, setShowPassword] = useState(false);
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const onClose = () => {
navigate('/profile');
};
const onSubmit = () => {
clearErrors();
void handleSubmit(async ({ newPassword }) => {
await api.post(`me/password`, { json: { password: newPassword } }).json();
toast.success(t('settings.password_changed'));
reset({});
onClose();
})();
};
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<MainFlowLikeModal title="profile.password.set_password" onClose={onClose}>
<TextInput
placeholder={t('profile.password.password')}
{...register('newPassword', {
required: t('profile.password.required'),
minLength: {
value: 6,
message: t('profile.password.min_length', { min: 6 }),
},
})}
type={showPassword ? 'text' : 'password'}
errorMessage={errors.newPassword?.message}
onKeyDown={onKeyDown}
/>
<TextInput
placeholder={t('profile.password.confirm_password')}
{...register('confirmPassword', {
validate: (value) => value === watch('newPassword') || t('profile.password.do_not_match'),
})}
type={showPassword ? 'text' : 'password'}
errorMessage={errors.confirmPassword?.message}
onKeyDown={onKeyDown}
/>
<Checkbox
checked={showPassword}
label={t('profile.password.show_password')}
onChange={() => {
setShowPassword((show) => !show);
}}
/>
<Button type="primary" title="general.create" isLoading={isSubmitting} onClick={onSubmit} />
</MainFlowLikeModal>
);
};
export default ChangePasswordModal;

View file

@ -0,0 +1,79 @@
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ArrowConnection from '@/assets/images/arrow-connection.svg';
import Button from '@/components/Button';
import TextInput from '@/components/TextInput';
import TextLink from '@/components/TextLink';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import MainFlowLikeModal from '../../components/MainFlowLikeModal';
type FormFields = {
password: string;
};
const VerifyPasswordModal = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const {
register,
reset,
clearErrors,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormFields>({
reValidateMode: 'onBlur',
});
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const onClose = () => {
navigate('/profile');
};
const onSubmit = () => {
clearErrors();
void handleSubmit(async ({ password }) => {
await api.post(`me/password/verify`, { json: { password } }).json();
reset({});
navigate('../change-password');
})();
};
return (
<MainFlowLikeModal
title="profile.password.enter_password"
subtitle="profile.password.enter_password_subtitle"
onClose={onClose}
>
<TextInput
{...register('password', {
required: t('profile.password.required'),
minLength: {
value: 6,
message: t('profile.password.min_length', { min: 6 }),
},
})}
errorMessage={errors.password?.message}
type="password"
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
/>
<Button
type="primary"
size="large"
title="general.continue"
isLoading={isSubmitting}
onClick={onSubmit}
/>
<TextLink icon={<ArrowConnection />}>{t('profile.code.verify_via_code')}</TextLink>
</MainFlowLikeModal>
);
};
export default VerifyPasswordModal;

View file

@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
@ -8,12 +9,12 @@ import * as resourcesStyles from '@/scss/resources.module.scss';
import BasicUserInfoSection from './components/BasicUserInfoSection';
import CardContent from './components/CardContent';
import PasswordSection from './components/PasswordSection';
import Section from './components/Section';
import * as styles from './index.module.scss';
const Profile = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const [user, fetchUser] = useLogtoUserInfo();
if (!user) {
@ -43,7 +44,21 @@ const Profile = () => {
/>
</Section>
)}
<PasswordSection />
<Section title="profile.password.title">
<CardContent
title="profile.password.password_setting"
data={[
{
label: 'profile.password.password',
value: '******',
actionName: 'profile.change',
action: () => {
navigate('verify-password');
},
},
]}
/>
</Section>
{isCloud && (
<Section title="profile.delete_account.title">
<div className={styles.deleteAccount}>

View file

@ -1,96 +0,0 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { useEffect, useState } from 'react';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
export type BasicUserField = 'avatar' | 'username' | 'name';
type Props = {
field?: BasicUserField;
value: string;
isOpen?: boolean;
onClose: () => void;
};
const BasicUserInfoUpdateModal = ({ field, value: defaultValue, isOpen, onClose }: Props) => {
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const [value, setValue] = useState(defaultValue);
const [loading, setLoading] = useState(false);
useEffect(() => {
setValue(defaultValue);
}, [defaultValue]);
if (!field) {
return null;
}
const getModalTitle = (): AdminConsoleKey => {
if (field === 'avatar') {
return 'profile.change_avatar';
}
if (field === 'username') {
return 'profile.change_username';
}
return defaultValue ? 'profile.change_name' : 'profile.set_name';
};
const onSubmit = async () => {
setLoading(true);
try {
await api.patch(`me/user`, { json: { [field]: value } }).json();
} finally {
setLoading(false);
onClose();
}
};
return (
<ReactModal
shouldCloseOnEsc
isOpen={!!isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title={getModalTitle()}
footer={
<Button
type="primary"
title="general.save"
isLoading={loading}
disabled={value === defaultValue || (!value && field === 'username')}
onClick={onSubmit}
/>
}
onClose={() => {
onClose();
}}
>
<div>
<TextInput
name={field}
value={value}
onChange={(event) => {
setValue(event.currentTarget.value);
}}
/>
</div>
</ModalLayout>
</ReactModal>
);
};
export default BasicUserInfoUpdateModal;

View file

@ -1,17 +0,0 @@
@use '@/scss/underscore' as _;
.changePassword {
border: 1px solid var(--color-divider);
border-radius: 8px;
padding: _.unit(4);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.description {
font: var(--font-body-2);
color: var(--color-text);
margin-right: _.unit(4);
}
}

View file

@ -1,77 +0,0 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import Button from '@/components/Button';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import { adminTenantEndpoint, meApi } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
type FormFields = {
password: string;
confirmPassword: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
};
const ChangePasswordModal = ({ isOpen, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { watch, register, reset } = useForm<FormFields>();
const [isLoading, setIsLoading] = useState(false);
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const password = watch('password');
const confirmPassword = watch('confirmPassword');
const isDisabled = !password || password !== confirmPassword;
const onSubmit = async () => {
setIsLoading(true);
await api.post(`me/password`, { json: { password } }).json();
setIsLoading(false);
onClose();
toast.success(t('settings.password_changed'));
reset({});
};
return (
<ReactModal
shouldCloseOnEsc
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onClose}
>
<ModalLayout
title="settings.change_modal_title"
subtitle="settings.change_modal_description"
footer={
<Button
type="primary"
title="general.confirm"
disabled={isDisabled || isLoading}
onClick={onSubmit}
/>
}
onClose={onClose}
>
<div>
<FormField title="settings.new_password">
<TextInput {...register('password', { required: true })} type="password" />
</FormField>
<FormField title="settings.confirm_password">
<TextInput {...register('confirmPassword', { required: true })} type="password" />
</FormField>
</div>
</ModalLayout>
</ReactModal>
);
};
export default ChangePasswordModal;

View file

@ -28,4 +28,8 @@
right: 0;
bottom: 0;
z-index: 100;
&:focus-visible {
outline: none;
}
}

View file

@ -1,12 +1,13 @@
import { passwordRegEx } from '@logto/core-kit';
import { arbitraryObjectGuard } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { object, string } from 'zod';
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
import type { PasswordVerificationData } from '@logto/schemas';
import { passwordVerificationGuard, arbitraryObjectGuard } from '@logto/schemas';
import { literal, object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { encryptUserPassword, verifyUserPassword } from '#src/libraries/user.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { convertCookieToMap } from '#src/utils/cookie.js';
import type { RouterInitArgs } from '../routes/types.js';
import type { AuthedMeRouter } from './types.js';
@ -20,24 +21,18 @@ export default function userRoutes<T extends AuthedMeRouter>(
'/user',
koaGuard({
body: object({
avatar: string().optional(),
name: string().optional(),
username: string().optional(),
}),
username: string().regex(usernameRegEx),
primaryEmail: string().regex(emailRegEx),
name: string().or(literal('')).nullable(),
avatar: string().url().or(literal('')).nullable(),
}).partial(),
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { avatar, name, username } = ctx.guard.body;
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await updateUserById(userId, {
...conditional(avatar !== undefined && { avatar }),
...conditional(name !== undefined && { name }),
...conditional(username !== undefined && { username }),
});
await updateUserById(userId, ctx.guard.body);
ctx.status = 204;
return next();
@ -83,10 +78,23 @@ export default function userRoutes<T extends AuthedMeRouter>(
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { password } = ctx.guard.body;
const cookieMap = convertCookieToMap(ctx.request.headers.cookie);
const sessionId = cookieMap.get('_session');
assertThat(Boolean(sessionId), new RequestError({ code: 'session.not_found', status: 401 }));
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
await verifyUserPassword(user, password);
const customData: PasswordVerificationData = {
passwordVerifiedAt: Date.now(),
passwordVerifiedWithSessionId: sessionId,
};
await updateUserById(userId, { customData });
ctx.status = 204;
return next();
@ -100,8 +108,23 @@ export default function userRoutes<T extends AuthedMeRouter>(
const { id: userId } = ctx.auth;
const { password } = ctx.guard.body;
const user = await findUserById(userId);
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
const { customData, isSuspended } = await findUserById(userId);
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
const cookieMap = convertCookieToMap(ctx.request.headers.cookie);
const sessionId = cookieMap.get('_session');
const parsed = passwordVerificationGuard.safeParse(customData);
// The password verification status is considered valid if:
// 1. The password is verified within 10 minutes.
// 2. The password is verified with the same session.
const isValid =
parsed.success &&
Date.now() - parsed.data.passwordVerifiedAt < 1000 * 60 * 10 &&
Boolean(sessionId) &&
parsed.data.passwordVerifiedWithSessionId === sessionId;
assertThat(isValid, new RequestError({ code: 'session.verification_failed', status: 401 }));
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });

View file

@ -0,0 +1,15 @@
import type { Optional } from '@silverhand/essentials';
export const convertCookieToMap = (cookie?: string): Map<string, Optional<string>> => {
const map = new Map<string, Optional<string>>();
for (const element of cookie?.split(';') ?? []) {
const [key, value] = element.trim().split('=');
if (key) {
map.set(key, value);
}
}
return map;
};

View file

@ -40,6 +40,7 @@ const general = {
stay_on_page: 'Auf Seite bleiben',
type_to_search: 'Tippe um zu suchen',
got_it: 'Alles klar',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED
learn_more: 'Learn more', // UNTRANSLATED
tab_errors: '{{count, number}} errors', // UNTRANSLATED

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -39,6 +39,7 @@ const general = {
stay_on_page: 'Stay on Page',
type_to_search: 'Type to search',
got_it: 'Got it',
continue: 'Continue',
page_info: '{{min, number}}-{{max, number}} of {{total, number}}',
learn_more: 'Learn more',
tab_errors: '{{count, number}} errors',

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In',
email: 'Email',
social_sign_in: 'Social sign-In',
link_email: 'Link email',
link_email_subtitle: 'Link your email to sign in or help with account recovery.',
email_required: 'Email is required',
invalid_email: 'Invalid email address',
identical_email_address: 'The input email address is identical to the current one',
},
password: {
title: 'PASSWORD & SECURITY',
password: 'Password',
reset_password: 'Reset Password',
password_setting: 'Password setting',
new_password: 'New password',
confirm_password: 'Confirm password',
enter_password: 'Enter password',
enter_password_subtitle: 'Verify its you to protect your account security.',
set_password: 'Set password',
verify_via_password: 'Verify via password',
show_password: 'Show password',
required: 'Password is required',
min_length: 'Password requires a minimum of {{min}} characters',
do_not_match: 'Passwords do not match. Please try again.',
},
code: {
enter_verification_code: 'Enter verification code',
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>',
verify_via_code: 'Verify via verification code',
resend: 'Resend verification code',
resend_countdown: 'Resend in {{countdown}} seconds',
},
delete_account: {
title: 'DELETE ACCOUNT',
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name',
change_username: 'Change username',
set_name: 'Set name',
set_password: 'Set password',
link_email: 'Link email',
enter_password: 'Enter password',
enter_verification_code: 'Enter verification code',
email_changed: 'Email changed!',
password_changed: 'Password changed!',
updated: '{{target}} updated!',
linked: '{{target}} linked!',
unlinked: '{{target}} unlinked!',
};
export default profile;

View file

@ -40,6 +40,7 @@ const general = {
stay_on_page: 'Rester sur la page',
type_to_search: 'Type to search', // UNTRANSLATED
got_it: 'Compris.',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED
learn_more: 'Learn more', // UNTRANSLATED
tab_errors: '{{count, number}} errors', // UNTRANSLATED

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -39,6 +39,7 @@ const general = {
stay_on_page: '페이지 유지하기',
type_to_search: '검색어 입력',
got_it: '알겠어요',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} / {{total, number}}',
learn_more: '더 알아보기',
tab_errors: '{{count, number}} 오류',

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -40,6 +40,7 @@ const general = {
stay_on_page: 'Ficar na página',
type_to_search: 'Digite para pesquisar',
got_it: 'Entendi',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} de {{total, number}}',
learn_more: 'Saber mais',
tab_errors: '{{count, number}} erros',

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -39,6 +39,7 @@ const general = {
stay_on_page: 'Ficar na página',
type_to_search: 'Type to search', // UNTRANSLATED
got_it: 'Entendi',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED
learn_more: 'Learn more', // UNTRANSLATED
tab_errors: '{{count, number}} errors', // UNTRANSLATED

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -40,6 +40,7 @@ const general = {
stay_on_page: 'Bu sayfada kal',
type_to_search: 'Type to search', // UNTRANSLATED
got_it: 'Anladım',
continue: 'Continue', // UNTRANSLATED
page_info: '{{min, number}}-{{max, number}} of {{total, number}}', // UNTRANSLATED
learn_more: 'Learn more', // UNTRANSLATED
tab_errors: '{{count, number}} errors', // UNTRANSLATED

View file

@ -14,11 +14,34 @@ const profile = {
email_sign_in: 'Email sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
social_sign_in: 'Social sign-In', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
link_email_subtitle: 'Link your email to sign in or help with account recovery.', // UNTRANSLATED
email_required: 'Email is required', // UNTRANSLATED
invalid_email: 'Invalid email address', // UNTRANSLATED
identical_email_address: 'The input email address is identical to the current one', // UNTRANSLATED
},
password: {
title: 'PASSWORD & SECURITY', // UNTRANSLATED
password: 'Password', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
password_setting: 'Password setting', // UNTRANSLATED
new_password: 'New password', // UNTRANSLATED
confirm_password: 'Confirm password', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_password_subtitle: 'Verify its you to protect your account security.', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
verify_via_password: 'Verify via password', // UNTRANSLATED
show_password: 'Show password', // UNTRANSLATED
required: 'Password is required', // UNTRANSLATED
min_length: 'Password requires a minimum of {{min}} characters', // UNTRANSLATED
do_not_match: 'Passwords do not match. Please try again.', // UNTRANSLATED
},
code: {
enter_verification_code: 'Enter verification code', // UNTRANSLATED
enter_verification_code_subtitle:
'The verification code has been sent to <strong>{{target}}</strong>', // UNTRANSLATED
verify_via_code: 'Verify via verification code', // UNTRANSLATED
resend: 'Resend verification code', // UNTRANSLATED
resend_countdown: 'Resend in {{countdown}} seconds', // UNTRANSLATED
},
delete_account: {
title: 'DELETE ACCOUNT', // UNTRANSLATED
@ -42,10 +65,11 @@ const profile = {
change_name: 'Change name', // UNTRANSLATED
change_username: 'Change username', // UNTRANSLATED
set_name: 'Set name', // UNTRANSLATED
set_password: 'Set password', // UNTRANSLATED
link_email: 'Link email', // UNTRANSLATED
enter_password: 'Enter password', // UNTRANSLATED
enter_verification_code: 'Enter verification code', // UNTRANSLATED
email_changed: 'Email changed!', // UNTRANSLATED
password_changed: 'Password changed!', // UNTRANSLATED
updated: '{{target}} updated!', // UNTRANSLATED
linked: '{{target}} linked!', // UNTRANSLATED
unlinked: '{{target}} unlinked!', // UNTRANSLATED
};
export default profile;

View file

@ -39,7 +39,8 @@ const general = {
stay_on_page: '留在此页',
type_to_search: '输入搜索',
got_it: '知道了',
page_info: '{{min, number}}-{{max, number}} 共 {{total, number}} 条', // UNTRANSLATED
continue: '继续',
page_info: '{{min, number}}-{{max, number}} 共 {{total, number}} 条',
learn_more: 'Learn more', // UNTRANSLATED
tab_errors: '{{count, number}} errors', // UNTRANSLATED
skip_for_now: 'Skip for now', // UNTRANSLATED

View file

@ -13,11 +13,33 @@ const profile = {
email_sign_in: '邮件登录',
email: '邮件',
social_sign_in: '社交账号登录',
link_email: '绑定邮箱',
link_email_subtitle: '绑定邮箱以便登录或帮助恢复账户。',
email_required: '邮箱不能为空',
invalid_email: '无效的邮箱地址',
identical_email_address: '输入的邮箱地址与当前邮箱地址相同',
},
password: {
title: '密码和安全',
title: '密码安全',
password: '密码',
reset_password: '重置密码',
password_setting: '密码设置',
new_password: '新密码',
confirm_password: '确认密码',
enter_password: '输入密码',
enter_password_subtitle: '为确保你的账户安全,请进行身份验证。',
set_password: '设置密码',
verify_via_password: '通过密码验证',
show_password: '显示密码',
required: '密码不能为空',
min_length: '密码最少需要{{min}}个字符',
do_not_match: '密码不匹配,请重新输入。',
},
code: {
enter_verification_code: '输入验证码',
enter_verification_code_subtitle: '验证码已发送至 <strong>{{target}}</strong>',
verify_via_code: '通过邮箱验证码验证',
resend: '重新发送验证码',
resend_countdown: '在 {{countdown}} 秒后重新发送',
},
delete_account: {
title: '删除账户',
@ -40,10 +62,11 @@ const profile = {
change_name: '修改姓名',
change_username: '修改用户名',
set_name: '设置姓名',
set_password: '设置密码',
link_email: '关联邮件',
enter_password: '输入密码',
enter_verification_code: '输入验证码',
email_changed: '已成功绑定邮箱!',
password_changed: '已重置密码!',
updated: '{{target}}更改成功!',
linked: '{{target}}账号绑定成功!',
unlinked: '{{target}}账号解绑成功!',
};
export default profile;

View file

@ -1,3 +1,6 @@
import type { z } from 'zod';
import { number, object, string } from 'zod';
import type { CreateUser } from '../db-entries/index.js';
export const userInfoSelectFields = Object.freeze([
@ -31,3 +34,10 @@ export enum UserRole {
export enum PredefinedScope {
All = 'all',
}
export const passwordVerificationGuard = object({
passwordVerifiedAt: number(),
passwordVerifiedWithSessionId: string().optional(),
});
export type PasswordVerificationData = z.infer<typeof passwordVerificationGuard>;