@@ -0,0 +1,142 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {User} from '../../../user';
|
||||
import {IllustratedMessage} from '../../../../ui/images/illustrated-message';
|
||||
import {SvgImage} from '../../../../ui/images/svg-image/svg-image';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {FormattedDate} from '../../../../i18n/formatted-date';
|
||||
import {AccessToken} from '../../../access-token';
|
||||
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteAccessToken} from './delete-access-token';
|
||||
import {CreateNewTokenDialog} from './create-new-token-dialog';
|
||||
import {LinkStyle} from '../../../../ui/buttons/external-link';
|
||||
import {useAuth} from '../../../use-auth';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import secureFilesSvg from './secure-files.svg';
|
||||
import {useSettings} from '../../../../core/settings/use-settings';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function AccessTokenPanel({user}: Props) {
|
||||
const tokens = user.tokens || [];
|
||||
const {hasPermission} = useAuth();
|
||||
const {api} = useSettings();
|
||||
if (!api?.integrated || !hasPermission('api.access')) return null;
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Developers}
|
||||
title={<Trans message="API access tokens" />}
|
||||
titleSuffix={
|
||||
<Link className={LinkStyle} to="/api-docs" target="_blank">
|
||||
<Trans message="Documentation" />
|
||||
</Link>
|
||||
}
|
||||
actions={<CreateNewTokenButton />}
|
||||
>
|
||||
{!tokens.length ? (
|
||||
<IllustratedMessage
|
||||
className="py-40"
|
||||
image={<SvgImage src={secureFilesSvg} />}
|
||||
title={<Trans message="You have no personal access tokens yet" />}
|
||||
/>
|
||||
) : (
|
||||
tokens.map((token, index) => (
|
||||
<TokenLine
|
||||
token={token}
|
||||
key={token.id}
|
||||
isLast={index === tokens.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenLineProps {
|
||||
token: AccessToken;
|
||||
isLast: boolean;
|
||||
}
|
||||
function TokenLine({token, isLast}: TokenLineProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-24',
|
||||
!isLast && 'mb-12 pb-12 border-b',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">
|
||||
<Trans message="Name" />
|
||||
</div>
|
||||
<div>{token.name}</div>
|
||||
<div className="font-semibold mt-10">
|
||||
<Trans message="Last used" />
|
||||
</div>
|
||||
<div>
|
||||
{token.last_used_at ? (
|
||||
<FormattedDate date={token.last_used_at} />
|
||||
) : (
|
||||
<Trans message="Never" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DeleteTokenButton token={token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNewTokenButton() {
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<Button variant="flat" color="primary">
|
||||
<Trans message="Create token" />
|
||||
</Button>
|
||||
<CreateNewTokenDialog />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteTokenButtonProps {
|
||||
token: AccessToken;
|
||||
}
|
||||
function DeleteTokenButton({token}: DeleteTokenButtonProps) {
|
||||
const deleteToken = useDeleteAccessToken();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteToken.mutate(
|
||||
{id: token.id},
|
||||
{
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({queryKey: ['users']}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="danger"
|
||||
className="flex-shrink-0 ml-auto"
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete token?" />}
|
||||
body={
|
||||
<Trans message="This token will be deleted immediately and permanently. Once deleted, it can no longer be used to make API requests." />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useState} from 'react';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
import {Dialog} from '../../../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../../../ui/overlays/dialog/dialog-body';
|
||||
import {Form} from '../../../../ui/forms/form';
|
||||
import {
|
||||
FormTextField,
|
||||
TextField,
|
||||
} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {useDialogContext} from '../../../../ui/overlays/dialog/dialog-context';
|
||||
import {DialogFooter} from '../../../../ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {
|
||||
CreateAccessTokenPayload,
|
||||
useCreateAccessToken,
|
||||
} from './create-new-token';
|
||||
import {ErrorIcon} from '../../../../icons/material/Error';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
|
||||
export function CreateNewTokenDialog() {
|
||||
const form = useForm<CreateAccessTokenPayload>();
|
||||
const {formId, close} = useDialogContext();
|
||||
const createToken = useCreateAccessToken(form);
|
||||
|
||||
const [plainTextToken, setPlainTextToken] = useState<string>();
|
||||
|
||||
const formNode = (
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={values => {
|
||||
createToken.mutate(values, {
|
||||
onSuccess: response => {
|
||||
setPlainTextToken(response.plainTextToken);
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
name="tokenName"
|
||||
label={<Trans message="Token name" />}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Create new token" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{plainTextToken ? (
|
||||
<PlainTextPreview plainTextToken={plainTextToken} />
|
||||
) : (
|
||||
formNode
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="text" onClick={close}>
|
||||
<Trans message="Done" />
|
||||
</Button>
|
||||
{!plainTextToken && (
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createToken.isPending}
|
||||
>
|
||||
<Trans message="Create" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlainTextPreviewProps {
|
||||
plainTextToken: string;
|
||||
}
|
||||
function PlainTextPreview({plainTextToken}: PlainTextPreviewProps) {
|
||||
const [isCopied, copyToClipboard] = useClipboard(plainTextToken || '', {
|
||||
successDuration: 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
readOnly
|
||||
value={plainTextToken}
|
||||
autoFocus
|
||||
onClick={e => {
|
||||
e.currentTarget.focus();
|
||||
e.currentTarget.select();
|
||||
}}
|
||||
endAppend={
|
||||
<Button variant="flat" color="primary" onClick={copyToClipboard}>
|
||||
{isCopied ? <Trans message="Copied!" /> : <Trans message="Copy" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-10 mt-14 text-sm">
|
||||
<ErrorIcon size="sm" className="text-danger" />
|
||||
<Trans message="Make sure to store the token in a safe place. After this dialog is closed, token will not be viewable anymore." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {AccessToken} from '../../../access-token';
|
||||
import {onFormQueryError} from '../../../../errors/on-form-query-error';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
token: AccessToken;
|
||||
plainTextToken: string;
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenPayload {
|
||||
tokenName: string;
|
||||
}
|
||||
|
||||
function createAccessToken(
|
||||
payload: CreateAccessTokenPayload,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(`access-tokens`, payload).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useCreateAccessToken(
|
||||
form: UseFormReturn<CreateAccessTokenPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (props: CreateAccessTokenPayload) => createAccessToken(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Token create'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function deleteAccessToken({id}: Props): Promise<Response> {
|
||||
return apiClient.delete(`access-tokens/${id}`).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useDeleteAccessToken() {
|
||||
return useMutation({
|
||||
mutationFn: (props: Props) => deleteAccessToken(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Token deleted'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
76
common/resources/client/auth/ui/account-settings/account-settings-page.tsx
Executable file
76
common/resources/client/auth/ui/account-settings/account-settings-page.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
import {Navbar} from '@common/ui/navigation/navbar/navbar';
|
||||
import {useUser} from '../use-user';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {SocialLoginPanel} from './social-login-panel';
|
||||
import {BasicInfoPanel} from './basic-info-panel/basic-info-panel';
|
||||
import {ChangePasswordPanel} from './change-password-panel/change-password-panel';
|
||||
import {LocalizationPanel} from './localization-panel';
|
||||
import {AccessTokenPanel} from './access-token-panel/access-token-panel';
|
||||
import {DangerZonePanel} from './danger-zone-panel/danger-zone-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {StaticPageTitle} from '@common/seo/static-page-title';
|
||||
import {AccountSettingsPanel} from '@common/auth/ui/account-settings/account-settings-panel';
|
||||
import {TwoFactorStepper} from '@common/auth/ui/two-factor/stepper/two-factor-auth-stepper';
|
||||
import {
|
||||
AccountSettingsId,
|
||||
AccountSettingsSidenav,
|
||||
} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {SessionsPanel} from '@common/auth/ui/account-settings/sessions-panel/sessions-panel';
|
||||
import {useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const {data, isLoading} = useUser('me', {
|
||||
with: ['roles', 'social_profiles', 'tokens'],
|
||||
});
|
||||
return (
|
||||
<div className="min-h-screen bg-alt">
|
||||
<StaticPageTitle>
|
||||
<Trans message="Account Settings" />
|
||||
</StaticPageTitle>
|
||||
<Navbar menuPosition="account-settings-page" />
|
||||
<div>
|
||||
<div className="container mx-auto px-24 py-24">
|
||||
<h1 className="text-3xl">
|
||||
<Trans message="Account settings" />
|
||||
</h1>
|
||||
<div className="mb-40 text-base text-muted">
|
||||
<Trans message="View and update your account details, profile and more." />
|
||||
</div>
|
||||
{isLoading || !data ? (
|
||||
<ProgressCircle
|
||||
className="my-80"
|
||||
aria-label="Loading user.."
|
||||
isIndeterminate
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-24">
|
||||
<AccountSettingsSidenav />
|
||||
<main className="flex-auto">
|
||||
{auth.accountSettingsPanels?.map(panel => (
|
||||
<panel.component key={panel.id} user={data.user} />
|
||||
))}
|
||||
<BasicInfoPanel user={data.user} />
|
||||
<SocialLoginPanel user={data.user} />
|
||||
<ChangePasswordPanel />
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.TwoFactor}
|
||||
title={<Trans message="Two factor authentication" />}
|
||||
>
|
||||
<div className="max-w-580">
|
||||
<TwoFactorStepper user={data.user} />
|
||||
</div>
|
||||
</AccountSettingsPanel>
|
||||
<SessionsPanel user={data.user} />
|
||||
<LocalizationPanel user={data.user} />
|
||||
<AccessTokenPanel user={data.user} />
|
||||
<DangerZonePanel />
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
common/resources/client/auth/ui/account-settings/account-settings-panel.tsx
Executable file
32
common/resources/client/auth/ui/account-settings/account-settings-panel.tsx
Executable file
@@ -0,0 +1,32 @@
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export function AccountSettingsPanel({
|
||||
id,
|
||||
title,
|
||||
titleSuffix,
|
||||
children,
|
||||
actions,
|
||||
}: Props) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="mb-24 w-full rounded-panel border bg px-24 py-20"
|
||||
>
|
||||
<div className="flex items-center gap-14 border-b pb-10">
|
||||
<div className="text-lg font-light">{title}</div>
|
||||
{titleSuffix && <div className="ml-auto">{titleSuffix}</div>}
|
||||
</div>
|
||||
<div className="pt-24">{children}</div>
|
||||
{actions && (
|
||||
<div className="mt-36 flex justify-end border-t pt-10">{actions}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
106
common/resources/client/auth/ui/account-settings/account-settings-sidenav.tsx
Executable file
106
common/resources/client/auth/ui/account-settings/account-settings-sidenav.tsx
Executable file
@@ -0,0 +1,106 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {PersonIcon} from '@common/icons/material/Person';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {LoginIcon} from '@common/icons/material/Login';
|
||||
import {LockIcon} from '@common/icons/material/Lock';
|
||||
import {PhonelinkLockIcon} from '@common/icons/material/PhonelinkLock';
|
||||
import {LanguageIcon} from '@common/icons/material/Language';
|
||||
import {ApiIcon} from '@common/icons/material/Api';
|
||||
import {DangerousIcon} from '@common/icons/material/Dangerous';
|
||||
import {ReactNode, useContext} from 'react';
|
||||
import {DevicesIcon} from '@common/icons/material/Devices';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export enum AccountSettingsId {
|
||||
AccountDetails = 'account-details',
|
||||
SocialLogin = 'social-login',
|
||||
Password = 'password',
|
||||
TwoFactor = 'two-factor',
|
||||
LocationAndLanguage = 'location-and-language',
|
||||
Developers = 'developers',
|
||||
DeleteAccount = 'delete-account',
|
||||
Sessions = 'sessions',
|
||||
}
|
||||
|
||||
export function AccountSettingsSidenav() {
|
||||
const p = AccountSettingsId;
|
||||
|
||||
const {hasPermission} = useAuth();
|
||||
const {api, social} = useSettings();
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
|
||||
const socialEnabled =
|
||||
social?.envato || social?.google || social?.facebook || social?.twitter;
|
||||
|
||||
return (
|
||||
<aside className="sticky top-10 hidden flex-shrink-0 lg:block">
|
||||
<List padding="p-0">
|
||||
{auth.accountSettingsPanels?.map(panel => (
|
||||
<Item
|
||||
key={panel.id}
|
||||
icon={<panel.icon viewBox="0 0 50 50" />}
|
||||
panel={panel.id as AccountSettingsId}
|
||||
>
|
||||
<Trans {...panel.label} />
|
||||
</Item>
|
||||
))}
|
||||
<Item icon={<PersonIcon />} panel={p.AccountDetails}>
|
||||
<Trans message="Account details" />
|
||||
</Item>
|
||||
{socialEnabled && (
|
||||
<Item icon={<LoginIcon />} panel={p.SocialLogin}>
|
||||
<Trans message="Social login" />
|
||||
</Item>
|
||||
)}
|
||||
<Item icon={<LockIcon />} panel={p.Password}>
|
||||
<Trans message="Password" />
|
||||
</Item>
|
||||
<Item icon={<PhonelinkLockIcon />} panel={p.TwoFactor}>
|
||||
<Trans message="Two factor authentication" />
|
||||
</Item>
|
||||
<Item icon={<DevicesIcon />} panel={p.Sessions}>
|
||||
<Trans message="Active sessions" />
|
||||
</Item>
|
||||
<Item icon={<LanguageIcon />} panel={p.LocationAndLanguage}>
|
||||
<Trans message="Location and language" />
|
||||
</Item>
|
||||
{api?.integrated && hasPermission('api.access') ? (
|
||||
<Item icon={<ApiIcon />} panel={p.Developers}>
|
||||
<Trans message="Developers" />
|
||||
</Item>
|
||||
) : null}
|
||||
<Item icon={<DangerousIcon />} panel={p.DeleteAccount}>
|
||||
<Trans message="Delete account" />
|
||||
</Item>
|
||||
</List>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
children: ReactNode;
|
||||
icon: ReactNode;
|
||||
isLast?: boolean;
|
||||
panel: AccountSettingsId;
|
||||
}
|
||||
function Item({children, icon, isLast, panel}: ItemProps) {
|
||||
return (
|
||||
<ListItem
|
||||
startIcon={icon}
|
||||
className={isLast ? undefined : 'mb-10'}
|
||||
onSelected={() => {
|
||||
const panelEl = document.querySelector(`#${panel}`);
|
||||
if (panelEl) {
|
||||
panelEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSvgIcon} from '@common/icons/create-svg-icon';
|
||||
|
||||
export const AvatarPlaceholderIcon = createSvgIcon(
|
||||
<path d="M24,12 C28.418278,12 32,15.581722 32,20 L32,22 C32,26.418278 28.418278,30 24,30 C19.581722,30 16,26.418278 16,22 L16,20 C16,15.581722 19.581722,12 24,12 Z M24,32 C33.8734019,32 42.1092023,38.8710577 44,48 L4,48 C5.89079771,38.8710577 14.1265981,32 24,32 Z"></path>
|
||||
);
|
||||
27
common/resources/client/auth/ui/account-settings/avatar/remove-avatar.ts
Executable file
27
common/resources/client/auth/ui/account-settings/avatar/remove-avatar.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {User} from '../../../user';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface UserProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
function removeAvatar(user: User): Promise<Response> {
|
||||
return apiClient.delete(`users/${user.id}/avatar`).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useRemoveAvatar({user}: UserProps) {
|
||||
return useMutation({
|
||||
mutationFn: () => removeAvatar(user),
|
||||
onSuccess: () => {
|
||||
toast(message('Removed avatar'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
55
common/resources/client/auth/ui/account-settings/avatar/upload-avatar.ts
Executable file
55
common/resources/client/auth/ui/account-settings/avatar/upload-avatar.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {UploadedFile} from '@common/uploads/uploaded-file';
|
||||
import {User} from '@common/auth/user';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
file?: UploadedFile;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface UserProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
function UploadAvatar({file, url}: Payload, user: User): Promise<Response> {
|
||||
const payload = new FormData();
|
||||
if (file) {
|
||||
payload.set('file', file.native);
|
||||
} else {
|
||||
payload.set('url', url!);
|
||||
}
|
||||
return apiClient
|
||||
.post(`users/${user.id}/avatar`, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
export function useUploadAvatar({user}: UserProps) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => UploadAvatar(payload, user),
|
||||
onSuccess: () => {
|
||||
toast(message('Uploaded avatar'));
|
||||
},
|
||||
onError: err => {
|
||||
const message = getAxiosErrorMessage(err, 'file');
|
||||
if (message) {
|
||||
toast.danger(message);
|
||||
} else {
|
||||
showHttpErrorToast(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {User} from '../../../user';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useUpdateAccountDetails} from './update-account-details';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useUploadAvatar} from '../avatar/upload-avatar';
|
||||
import {useRemoveAvatar} from '../avatar/remove-avatar';
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function BasicInfoPanel({user}: Props) {
|
||||
const uploadAvatar = useUploadAvatar({user});
|
||||
const removeAvatar = useRemoveAvatar({user});
|
||||
const formId = useId();
|
||||
const form = useForm<Partial<Omit<User, 'subscriptions'>>>({
|
||||
defaultValues: {
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
avatar: user.avatar,
|
||||
},
|
||||
});
|
||||
const updateDetails = useUpdateAccountDetails(form);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.AccountDetails}
|
||||
title={<Trans message="Update name and profile image" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
disabled={updateDetails.isPending || !form.formState.isValid}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col flex-col-reverse md:flex-row items-center gap-40 md:gap-80"
|
||||
onSubmit={newDetails => {
|
||||
updateDetails.mutate(newDetails);
|
||||
}}
|
||||
id={formId}
|
||||
>
|
||||
<div className="flex-auto w-full">
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="first_name"
|
||||
label={<Trans message="First name" />}
|
||||
/>
|
||||
<FormTextField
|
||||
name="last_name"
|
||||
label={<Trans message="Last name" />}
|
||||
/>
|
||||
</div>
|
||||
<FileUploadProvider>
|
||||
<FormImageSelector
|
||||
className="md:mr-80"
|
||||
variant="avatar"
|
||||
previewSize="w-90 h-90"
|
||||
showRemoveButton
|
||||
name="avatar"
|
||||
diskPrefix="avatars"
|
||||
label={<Trans message="Profile image" />}
|
||||
onChange={url => {
|
||||
if (url) {
|
||||
uploadAvatar.mutate({url});
|
||||
} else {
|
||||
removeAvatar.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileUploadProvider>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {User} from '@common/auth/user';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export function useUpdateAccountDetails(form: UseFormReturn<Partial<User>>) {
|
||||
return useMutation({
|
||||
mutationFn: (props: Payload) => updateAccountDetails(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Updated account details'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateAccountDetails(payload: Payload): Promise<Response> {
|
||||
return apiClient.put('users/me', payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {AccountSettingsPanel} from '@common/auth/ui/account-settings/account-settings-panel';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {UpdatePasswordPayload, useUpdatePassword} from './use-update-password';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
export function ChangePasswordPanel() {
|
||||
const form = useForm<UpdatePasswordPayload>();
|
||||
const formId = useId();
|
||||
const updatePassword = useUpdatePassword(form);
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Password}
|
||||
title={<Trans message="Update password" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={!form.formState.isValid || updatePassword.isPending}
|
||||
>
|
||||
<Trans message="Update password" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={newValues => {
|
||||
updatePassword.mutate(newValues, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="current_password"
|
||||
label={<Trans message="Current password" />}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="password"
|
||||
label={<Trans message="New password" />}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
name="password_confirmation"
|
||||
label={<Trans message="Confirm password" />}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export interface UpdatePasswordPayload {
|
||||
current_password: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
export function useUpdatePassword(form: UseFormReturn<UpdatePasswordPayload>) {
|
||||
return useMutation({
|
||||
mutationFn: (props: UpdatePasswordPayload) => updatePassword(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Password changed'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updatePassword(payload: UpdatePasswordPayload): Promise<Response> {
|
||||
return apiClient.put('auth/user/password', payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteAccount} from './delete-account';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import React, {useState} from 'react';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
|
||||
export function DangerZonePanel() {
|
||||
const deleteAccount = useDeleteAccount();
|
||||
const {withConfirmedPassword, isLoading: confirmingPassword} =
|
||||
usePasswordConfirmedAction();
|
||||
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.DeleteAccount}
|
||||
title={<Trans message="Danger zone" />}
|
||||
>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteAccount.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete account?" />}
|
||||
body={
|
||||
<Trans message="Your account will be deleted immediately and permanently. Once deleted, accounts can not be restored." />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="danger"
|
||||
disabled={confirmingPassword || deleteAccount.isPending}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
setConfirmDialogOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Delete account" />
|
||||
</Button>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useLogout} from '@common/auth/requests/logout';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const {user} = useAuth();
|
||||
const logout = useLogout();
|
||||
return useMutation({
|
||||
mutationFn: () => deleteAccount(user!.id),
|
||||
onSuccess: () => {
|
||||
toast('Account deleted');
|
||||
logout.mutate();
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAccount(userId: number): Promise<Response> {
|
||||
return apiClient
|
||||
.delete(`users/${userId}`, {params: {deleteCurrentUser: true}})
|
||||
.then(r => r.data);
|
||||
}
|
||||
98
common/resources/client/auth/ui/account-settings/localization-panel.tsx
Executable file
98
common/resources/client/auth/ui/account-settings/localization-panel.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {AccountSettingsPanel} from './account-settings-panel';
|
||||
import {useUpdateAccountDetails} from './basic-info-panel/update-account-details';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {User} from '../../user';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {Option} from '../../../ui/forms/combobox/combobox';
|
||||
import {FormSelect} from '../../../ui/forms/select/select';
|
||||
import {useChangeLocale} from '@common/i18n/change-locale';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {getLocalTimeZone} from '@internationalized/date';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {TimezoneSelect} from '@common/auth/ui/account-settings/timezone-select';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function LocalizationPanel({user}: Props) {
|
||||
const formId = useId();
|
||||
const {trans} = useTrans();
|
||||
const form = useForm<Partial<User>>({
|
||||
defaultValues: {
|
||||
language: user.language || '',
|
||||
country: user.country || '',
|
||||
timezone: user.timezone || getLocalTimeZone(),
|
||||
},
|
||||
});
|
||||
const updateDetails = useUpdateAccountDetails(form);
|
||||
const changeLocale = useChangeLocale();
|
||||
const {data} = useValueLists(['timezones', 'countries', 'localizations']);
|
||||
|
||||
const countries = data?.countries || [];
|
||||
const localizations = data?.localizations || [];
|
||||
const timezones = data?.timezones || {};
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.LocationAndLanguage}
|
||||
title={<Trans message="Date, time and language" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
disabled={updateDetails.isPending || !form.formState.isValid}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={newDetails => {
|
||||
updateDetails.mutate(newDetails);
|
||||
changeLocale.mutate({locale: newDetails.language});
|
||||
}}
|
||||
id={formId}
|
||||
>
|
||||
<FormSelect
|
||||
className="mb-24"
|
||||
selectionMode="single"
|
||||
name="language"
|
||||
label={<Trans message="Language" />}
|
||||
>
|
||||
{localizations.map(localization => (
|
||||
<Option key={localization.language} value={localization.language}>
|
||||
{localization.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormSelect
|
||||
className="mb-24"
|
||||
selectionMode="single"
|
||||
name="country"
|
||||
label={<Trans message="Country" />}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search countries'))}
|
||||
>
|
||||
{countries.map(country => (
|
||||
<Option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<TimezoneSelect
|
||||
label={<Trans message="Timezone" />}
|
||||
name="timezone"
|
||||
timezones={timezones}
|
||||
/>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function useLogoutOtherSessions() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => logoutOther(payload),
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function logoutOther(payload: Payload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('user-sessions/logout-other', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
|
||||
export interface ActiveSession {
|
||||
id: string;
|
||||
platform?: string;
|
||||
device_type?: 'mobile' | 'tablet' | 'desktop';
|
||||
browser?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
ip_address?: string;
|
||||
token?: string;
|
||||
is_current_device: boolean;
|
||||
last_active: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
sessions: ActiveSession[];
|
||||
}
|
||||
|
||||
export function useUserSessions() {
|
||||
return useQuery({
|
||||
queryKey: ['user-sessions'],
|
||||
queryFn: () => fetchUserSessions(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchUserSessions() {
|
||||
return apiClient
|
||||
.get<Response>(`user-sessions`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {User} from '../../../user';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {
|
||||
ActiveSession,
|
||||
useUserSessions,
|
||||
} from '@common/auth/ui/account-settings/sessions-panel/requests/use-user-sessions';
|
||||
import {ComputerIcon} from '@common/icons/material/Computer';
|
||||
import {SmartphoneIcon} from '@common/icons/material/Smartphone';
|
||||
import {TabletIcon} from '@common/icons/material/Tablet';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {useLogoutOtherSessions} from '@common/auth/ui/account-settings/sessions-panel/requests/use-logout-other-sessions';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function SessionsPanel({user}: Props) {
|
||||
const {data, isLoading} = useUserSessions();
|
||||
const logoutOther = useLogoutOtherSessions();
|
||||
const {withConfirmedPassword, isLoading: checkingPasswordStatus} =
|
||||
usePasswordConfirmedAction({needsPassword: true});
|
||||
|
||||
const sessionList = (
|
||||
<div className="max-h-400 overflow-y-auto">
|
||||
{data?.sessions?.map(session => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Sessions}
|
||||
title={<Trans message="Active sessions" />}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<Trans message="If necessary, you may log out of all of your other browser sessions across all of your devices. Your recent sessions are listed below. If you feel your account has been compromised, you should also update your password." />
|
||||
</p>
|
||||
<div className="my-30">
|
||||
{isLoading ? (
|
||||
<div className="min-h-60">
|
||||
<ProgressCircle isIndeterminate />
|
||||
</div>
|
||||
) : (
|
||||
sessionList
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
disabled={checkingPasswordStatus || logoutOther.isPending}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(password => {
|
||||
logoutOther.mutate(
|
||||
{password: password!},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(message('Logged out other sessions.'));
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Logout other sessions" />
|
||||
</Button>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function SessionItem({session}: SessionItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-14 text-sm mb-14">
|
||||
<div className="flex-shrink-0 text-muted">
|
||||
<DeviceIcon device={session.device_type} size="lg" />
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<div>
|
||||
<ValueOrUnknown>{session.platform}</ValueOrUnknown> -{' '}
|
||||
<ValueOrUnknown>{session.browser}</ValueOrUnknown>
|
||||
</div>
|
||||
<div className="text-xs my-4">
|
||||
{session.city}, {session.country}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<IpAddress session={session} /> - <LastActive session={session} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceIconProps {
|
||||
device: ActiveSession['device_type'];
|
||||
size: SvgIconProps['size'];
|
||||
}
|
||||
function DeviceIcon({device, size}: DeviceIconProps) {
|
||||
switch (device) {
|
||||
case 'mobile':
|
||||
return <SmartphoneIcon size={size} />;
|
||||
case 'tablet':
|
||||
return <TabletIcon size={size} />;
|
||||
default:
|
||||
return <ComputerIcon size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface LastActiveProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function LastActive({session}: LastActiveProps) {
|
||||
if (session.is_current_device) {
|
||||
return (
|
||||
<span className="text-positive">
|
||||
<Trans message="This device" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <FormattedRelativeTime date={session.last_active} />;
|
||||
}
|
||||
|
||||
interface IpAddressProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function IpAddress({session}: IpAddressProps) {
|
||||
if (session.ip_address) {
|
||||
return <span>{session.ip_address}</span>;
|
||||
} else if (session.token) {
|
||||
return <Trans message="API Token" />;
|
||||
}
|
||||
return <Trans message="Unknown IP" />;
|
||||
}
|
||||
|
||||
interface ValueOrUnknownProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
function ValueOrUnknown({children}: ValueOrUnknownProps) {
|
||||
return children ? (
|
||||
<Fragment>{children}</Fragment>
|
||||
) : (
|
||||
<Trans message="Unknown" />
|
||||
);
|
||||
}
|
||||
142
common/resources/client/auth/ui/account-settings/social-login-panel.tsx
Executable file
142
common/resources/client/auth/ui/account-settings/social-login-panel.tsx
Executable file
@@ -0,0 +1,142 @@
|
||||
import clsx from 'clsx';
|
||||
import {cloneElement, ReactElement} from 'react';
|
||||
import {SocialService, useSocialLogin} from '../../requests/use-social-login';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {EnvatoIcon} from '@common/icons/social/envato';
|
||||
import {GoogleIcon} from '@common/icons/social/google';
|
||||
import {FacebookIcon} from '@common/icons/social/facebook';
|
||||
import {TwitterIcon} from '@common/icons/social/twitter';
|
||||
import {User} from '../../user';
|
||||
import {AccountSettingsPanel} from './account-settings-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function SocialLoginPanel({user}: Props) {
|
||||
const {social} = useSettings();
|
||||
|
||||
if (
|
||||
!social.envato?.enable &&
|
||||
!social.google?.enable &&
|
||||
!social.facebook?.enable &&
|
||||
!social.twitter?.enable
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.SocialLogin}
|
||||
title={<Trans message="Manage social login" />}
|
||||
>
|
||||
<SocialLoginPanelRow
|
||||
icon={
|
||||
<EnvatoIcon
|
||||
viewBox="0 0 50 50"
|
||||
className="border-envato bg-envato text-white"
|
||||
/>
|
||||
}
|
||||
service="envato"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<GoogleIcon viewBox="0 0 48 48" />}
|
||||
service="google"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<FacebookIcon className="text-facebook" />}
|
||||
service="facebook"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<TwitterIcon className="text-twitter" />}
|
||||
service="twitter"
|
||||
user={user}
|
||||
/>
|
||||
<div className="pb-6 pt-16 text-sm text-muted">
|
||||
<Trans message="If you disable social logins, you'll still be able to log in using your email and password." />
|
||||
</div>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface SocialLoginPanelRowProps {
|
||||
service: SocialService;
|
||||
user: User;
|
||||
className?: string;
|
||||
icon: ReactElement;
|
||||
}
|
||||
|
||||
function SocialLoginPanelRow({
|
||||
service,
|
||||
user,
|
||||
className,
|
||||
icon,
|
||||
}: SocialLoginPanelRowProps) {
|
||||
const {social} = useSettings();
|
||||
const {connectSocial, disconnectSocial} = useSocialLogin();
|
||||
const username = user?.social_profiles?.find(s => s.service_name === service)
|
||||
?.username;
|
||||
|
||||
if (!social?.[service]?.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-14 border-b px-10 py-20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{cloneElement(icon, {
|
||||
size: 'xl',
|
||||
className: clsx(icon.props.className, 'border p-8 rounded'),
|
||||
})}
|
||||
<div className="mr-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<div className="overflow-hidden text-ellipsis text-sm font-bold first-letter:capitalize">
|
||||
<Trans message=":service account" values={{service}} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
{username || <Trans message="Disabled" />}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disconnectSocial.isPending}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color={username ? 'danger' : 'primary'}
|
||||
onClick={async () => {
|
||||
if (username) {
|
||||
disconnectSocial.mutate(
|
||||
{service},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(
|
||||
message('Disabled :service account', {values: {service}}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const e = await connectSocial(service);
|
||||
if (e?.status === 'SUCCESS') {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(message('Enabled :service account', {values: {service}}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{username ? <Trans message="Disable" /> : <Trans message="Enable" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
common/resources/client/auth/ui/account-settings/timezone-select.tsx
Executable file
48
common/resources/client/auth/ui/account-settings/timezone-select.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
FormSelect,
|
||||
OptionGroup,
|
||||
SelectProps,
|
||||
} from '@common/ui/forms/select/select';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Option} from '@common/ui/forms/combobox/combobox';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {Timezone} from '@common/http/value-lists';
|
||||
import {InputSize} from '@common/ui/forms/input-field/input-size';
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
interface Props extends Omit<SelectProps<any>, 'selectionMode' | 'children'> {
|
||||
name: string;
|
||||
timezones: Record<string, Timezone[]>;
|
||||
size?: InputSize;
|
||||
label?: ReactNode;
|
||||
}
|
||||
export function TimezoneSelect({
|
||||
name,
|
||||
size,
|
||||
timezones,
|
||||
label,
|
||||
...selectProps
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<FormSelect
|
||||
selectionMode="single"
|
||||
name={name}
|
||||
size={size}
|
||||
label={label}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search timezones'))}
|
||||
{...selectProps}
|
||||
>
|
||||
{Object.entries(timezones).map(([sectionName, sectionItems]) => (
|
||||
<OptionGroup label={sectionName} key={sectionName}>
|
||||
{sectionItems.map(timezone => (
|
||||
<Option key={timezone.value} value={timezone.value}>
|
||||
{timezone.text}
|
||||
</Option>
|
||||
))}
|
||||
</OptionGroup>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/auth/ui/auth-layout/auth-bg.svg
Executable file
1
common/resources/client/auth/ui/auth-layout/auth-bg.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.2 KiB |
19
common/resources/client/auth/ui/auth-layout/auth-layout-footer.tsx
Executable file
19
common/resources/client/auth/ui/auth-layout/auth-layout-footer.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {CustomMenu} from '../../../menus/custom-menu';
|
||||
import {useSettings} from '../../../core/settings/use-settings';
|
||||
|
||||
export function AuthLayoutFooter() {
|
||||
const {branding} = useSettings();
|
||||
return (
|
||||
<div className="pt-42 pb-32 flex items-center gap-30 text-sm text-muted mt-auto">
|
||||
<Link className="hover:text-fg-base transition-colors" to="/">
|
||||
© {branding.site_name}
|
||||
</Link>
|
||||
<CustomMenu
|
||||
menu="auth-page-footer"
|
||||
orientation="horizontal"
|
||||
itemClassName="hover:text-fg-base transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
common/resources/client/auth/ui/auth-layout/auth-layout.tsx
Executable file
43
common/resources/client/auth/ui/auth-layout/auth-layout.tsx
Executable file
@@ -0,0 +1,43 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {ReactNode} from 'react';
|
||||
import {AuthLayoutFooter} from './auth-layout-footer';
|
||||
import {useIsDarkMode} from '@common/ui/themes/use-is-dark-mode';
|
||||
import authBgSvg from './auth-bg.svg';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
interface AuthPageProps {
|
||||
heading?: ReactNode;
|
||||
message?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
export function AuthLayout({heading, children, message}: AuthPageProps) {
|
||||
const {branding} = useSettings();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const {trans} = useTrans();
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex h-screen flex-col items-center overflow-y-auto bg-alt px-14 pt-70 dark:bg-none md:px-10vw"
|
||||
style={{backgroundImage: isDarkMode ? undefined : `url("${authBgSvg}")`}}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-40 block flex-shrink-0"
|
||||
aria-label={trans({message: 'Go to homepage'})}
|
||||
>
|
||||
<img
|
||||
src={isDarkMode ? branding.logo_light : branding?.logo_dark}
|
||||
className="m-auto block h-42 w-auto"
|
||||
alt=""
|
||||
/>
|
||||
</Link>
|
||||
<div className="mx-auto w-full max-w-440 rounded-lg bg px-40 pb-32 pt-40 shadow md:shadow-xl">
|
||||
{heading && <h1 className="mb-20 text-xl">{heading}</h1>}
|
||||
{children}
|
||||
</div>
|
||||
{message && <div className="mt-36 text-sm">{message}</div>}
|
||||
<AuthLayoutFooter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
63
common/resources/client/auth/ui/confirm-password/confirm-password-dialog.tsx
Executable file
63
common/resources/client/auth/ui/confirm-password/confirm-password-dialog.tsx
Executable file
@@ -0,0 +1,63 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {
|
||||
ConfirmPasswordPayload,
|
||||
useConfirmPassword,
|
||||
} from '@common/auth/ui/confirm-password/requests/use-confirm-password';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
export function ConfirmPasswordDialog() {
|
||||
const {close, formId} = useDialogContext();
|
||||
const form = useForm<ConfirmPasswordPayload>();
|
||||
const confirmPassword = useConfirmPassword(form);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Confirm password" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p className="text-sm mb-16">
|
||||
<Trans message="For your security, please confirm your password to continue." />
|
||||
</p>
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
onSubmit={values =>
|
||||
confirmPassword.mutate(values, {
|
||||
onSuccess: () => close(values.password),
|
||||
})
|
||||
}
|
||||
>
|
||||
<FormTextField
|
||||
name="password"
|
||||
label={<Trans message="Password" />}
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
disabled={confirmPassword.isPending}
|
||||
>
|
||||
<Trans message="Confirm" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export interface ConfirmPasswordPayload {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function useConfirmPassword(
|
||||
form: UseFormReturn<ConfirmPasswordPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: ConfirmPasswordPayload) => confirm(payload),
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function confirm(payload: ConfirmPasswordPayload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/confirm-password', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export function usePasswordConfirmationStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['password-confirmation-status'],
|
||||
queryFn: () => fetchStatus(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchStatus(): Promise<Response> {
|
||||
return apiClient
|
||||
.get('auth/user/confirmed-password-status', {params: {seconds: 9000}})
|
||||
.then(response => response.data);
|
||||
}
|
||||
|
||||
export function setPasswordConfirmationStatus(confirmed: boolean) {
|
||||
queryClient.setQueryData(['password-confirmation-status'], {confirmed});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
setPasswordConfirmationStatus,
|
||||
usePasswordConfirmationStatus,
|
||||
} from '@common/auth/ui/confirm-password/requests/use-password-confirmation-status';
|
||||
import {openDialog} from '@common/ui/overlays/store/dialog-store';
|
||||
import {ConfirmPasswordDialog} from '@common/auth/ui/confirm-password/confirm-password-dialog';
|
||||
import {useCallback, useRef} from 'react';
|
||||
|
||||
interface Props {
|
||||
needsPassword?: boolean;
|
||||
}
|
||||
export function usePasswordConfirmedAction({needsPassword}: Props = {}) {
|
||||
const {data, isLoading} = usePasswordConfirmationStatus();
|
||||
const passwordRef = useRef<string>();
|
||||
|
||||
const withConfirmedPassword = useCallback(
|
||||
async (action: (password?: string) => void) => {
|
||||
if (data?.confirmed && (passwordRef.current || !needsPassword)) {
|
||||
action(passwordRef.current);
|
||||
} else {
|
||||
const password = await openDialog(ConfirmPasswordDialog);
|
||||
if (password) {
|
||||
passwordRef.current = password;
|
||||
setPasswordConfirmationStatus(true);
|
||||
action(passwordRef.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[data?.confirmed, needsPassword]
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
withConfirmedPassword,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {useUser} from '@common/auth/ui/use-user';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useResendVerificationEmail} from '@common/auth/requests/use-resend-verification-email';
|
||||
import {useIsDarkMode} from '@common/ui/themes/use-is-dark-mode';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {useLogout} from '@common/auth/requests/logout';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
|
||||
import {
|
||||
useValidateEmailVerificationOtp,
|
||||
ValidateEmailVerificationOtpPayload,
|
||||
} from '@common/auth/requests/use-validate-email-verification-otp';
|
||||
|
||||
export function EmailVerificationPage() {
|
||||
const {trans} = useTrans();
|
||||
const {data} = useUser('me');
|
||||
const resendEmail = useResendVerificationEmail();
|
||||
const {
|
||||
branding: {logo_light, logo_dark},
|
||||
} = useSettings();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const logoSrc = isDarkMode ? logo_light : logo_dark;
|
||||
const logout = useLogout();
|
||||
|
||||
const form = useForm<ValidateEmailVerificationOtpPayload>();
|
||||
const validateOtp = useValidateEmailVerificationOtp(form);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-screen bg-alt p-24">
|
||||
<div className="mx-auto mt-40 max-w-440">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => logout.mutate()}
|
||||
startIcon={<KeyboardArrowLeftIcon />}
|
||||
size="xs"
|
||||
className="mb-54 mr-auto"
|
||||
>
|
||||
<Trans message="Logout" />
|
||||
</Button>
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Site logo"
|
||||
className="mx-auto mb-44 block h-42 w-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<h1 className="mb-24 text-3xl">
|
||||
<Trans message="Verify your email" />
|
||||
</h1>
|
||||
<h2 className="text-lg">
|
||||
<Trans
|
||||
message="Enter the verification code we sent to :email"
|
||||
values={{email: maskEmailAddress(data?.user.email)}}
|
||||
/>
|
||||
</h2>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values => validateOtp.mutate(values)}
|
||||
className="my-16"
|
||||
>
|
||||
<FormTextField
|
||||
name="code"
|
||||
label={<Trans message="Code" />}
|
||||
placeholder={trans(message('Enter your verification code'))}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
maxLength={6}
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
className="mt-24 w-full"
|
||||
disabled={validateOtp.isPending}
|
||||
>
|
||||
<Trans message="Next" />
|
||||
</Button>
|
||||
</Form>
|
||||
<div className="mb-24 text-sm">
|
||||
<Trans
|
||||
message="If you don't see the email in your inbox, check your spam folder and promotions tab. If you still don't see it, <a>request a resend</a>."
|
||||
values={{
|
||||
a: text => (
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
disabled={resendEmail.isPending || !data?.user.email}
|
||||
onClick={() => {
|
||||
resendEmail.mutate({email: data!.user.email});
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maskEmailAddress(email: string | undefined) {
|
||||
if (!email) return '*******************';
|
||||
const [username, domain] = email.split('@');
|
||||
return `${username.slice(0, 2)}****@${domain}`;
|
||||
}
|
||||
1
common/resources/client/auth/ui/email-verification-page/mail-sent.svg
Executable file
1
common/resources/client/auth/ui/email-verification-page/mail-sent.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="570" height="511.67482" viewBox="0 0 570 511.67482" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M879.99927,389.83741a.99678.99678,0,0,1-.5708-.1792L602.86963,197.05469a5.01548,5.01548,0,0,0-5.72852.00977L322.57434,389.65626a1.00019,1.00019,0,0,1-1.14868-1.6377l274.567-192.5918a7.02216,7.02216,0,0,1,8.02-.01318l276.55883,192.603a1.00019,1.00019,0,0,1-.57226,1.8208Z" transform="translate(-315 -194.16259)" fill="#3f3d56"/><polygon points="23.264 202.502 285.276 8.319 549.276 216.319 298.776 364.819 162.776 333.819 23.264 202.502" fill="#e6e6e6"/><path d="M489.25553,650.70367H359.81522a6.04737,6.04737,0,1,1,0-12.09473H489.25553a6.04737,6.04737,0,1,1,0,12.09473Z" transform="translate(-315 -194.16259)" fill="rgb(var(--be-primary))"/><path d="M406.25553,624.70367H359.81522a6.04737,6.04737,0,1,1,0-12.09473h46.44031a6.04737,6.04737,0,1,1,0,12.09473Z" transform="translate(-315 -194.16259)" fill="rgb(var(--be-primary))"/><path d="M603.96016,504.82207a7.56366,7.56366,0,0,1-2.86914-.562L439.5002,437.21123v-209.874a7.00817,7.00817,0,0,1,7-7h310a7.00818,7.00818,0,0,1,7,7v210.0205l-.30371.12989L606.91622,504.22734A7.61624,7.61624,0,0,1,603.96016,504.82207Z" transform="translate(-315 -194.16259)" fill="#fff"/><path d="M603.96016,505.32158a8.07177,8.07177,0,0,1-3.05957-.59863L439.0002,437.54521v-210.208a7.50851,7.50851,0,0,1,7.5-7.5h310a7.50851,7.50851,0,0,1,7.5,7.5V437.68779l-156.8877,66.999A8.10957,8.10957,0,0,1,603.96016,505.32158Zm-162.96-69.1123,160.66309,66.66455a6.1182,6.1182,0,0,0,4.668-.02784l155.669-66.47851V227.33721a5.50653,5.50653,0,0,0-5.5-5.5h-310a5.50653,5.50653,0,0,0-5.5,5.5Z" transform="translate(-315 -194.16259)" fill="#3f3d56"/><path d="M878,387.83741h-.2002L763,436.85743l-157.06982,67.07a5.06614,5.06614,0,0,1-3.88038.02L440,436.71741l-117.62012-48.8-.17968-.08H322a7.00778,7.00778,0,0,0-7,7v304a7.00779,7.00779,0,0,0,7,7H878a7.00779,7.00779,0,0,0,7-7v-304A7.00778,7.00778,0,0,0,878,387.83741Zm5,311a5.002,5.002,0,0,1-5,5H322a5.002,5.002,0,0,1-5-5v-304a5.01106,5.01106,0,0,1,4.81006-5L440,438.87739l161.28027,66.92a7.12081,7.12081,0,0,0,5.43994-.03L763,439.02741l115.2002-49.19a5.01621,5.01621,0,0,1,4.7998,5Z" transform="translate(-315 -194.16259)" fill="#3f3d56"/><path d="M602.345,445.30958a27.49862,27.49862,0,0,1-16.5459-5.4961l-.2959-.22217-62.311-47.70752a27.68337,27.68337,0,1,1,33.67407-43.94921l40.36035,30.94775,95.37793-124.38672a27.68235,27.68235,0,0,1,38.81323-5.12353l-.593.80517.6084-.79346a27.71447,27.71447,0,0,1,5.12353,38.81348L624.36938,434.50586A27.69447,27.69447,0,0,1,602.345,445.30958Z" transform="translate(-315 -194.16259)" fill="rgb(var(--be-primary))"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
78
common/resources/client/auth/ui/forgot-password-page.tsx
Executable file
78
common/resources/client/auth/ui/forgot-password-page.tsx
Executable file
@@ -0,0 +1,78 @@
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {LinkStyle} from '../../ui/buttons/external-link';
|
||||
import {AuthLayout} from './auth-layout/auth-layout';
|
||||
import {
|
||||
SendPasswordResetEmailPayload,
|
||||
useSendPasswordResetEmail,
|
||||
} from '../requests/send-reset-password-email';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const {registration} = useSettings();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchParamsEmail = searchParams.get('email') || undefined;
|
||||
|
||||
const form = useForm<SendPasswordResetEmailPayload>({
|
||||
defaultValues: {email: searchParamsEmail},
|
||||
});
|
||||
const sendEmail = useSendPasswordResetEmail(form);
|
||||
|
||||
const message = !registration.disable && (
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<Link className={LinkStyle} to="/register">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
message="Don't have an account? <a>Sign up.</a>"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthLayout message={message}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Forgot Password" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={payload => {
|
||||
sendEmail.mutate(payload);
|
||||
}}
|
||||
>
|
||||
<div className="mb-32 text-sm">
|
||||
<Trans message="Enter your email address below and we will send you a link to reset or create your password." />
|
||||
</div>
|
||||
<FormTextField
|
||||
disabled={!!searchParamsEmail}
|
||||
className="mb-32"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
label={<Trans message="Email" />}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={sendEmail.isPending}
|
||||
>
|
||||
<Trans message="Continue" />
|
||||
</Button>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
12
common/resources/client/auth/ui/login-page-wrapper.tsx
Executable file
12
common/resources/client/auth/ui/login-page-wrapper.tsx
Executable file
@@ -0,0 +1,12 @@
|
||||
import {useState} from 'react';
|
||||
import {TwoFactorChallengePage} from '@common/auth/ui/two-factor/two-factor-challenge-page';
|
||||
import {LoginPage} from '@common/auth/ui/login-page';
|
||||
|
||||
export function LoginPageWrapper() {
|
||||
const [isTwoFactor, setIsTwoFactor] = useState(false);
|
||||
if (isTwoFactor) {
|
||||
return <TwoFactorChallengePage />;
|
||||
} else {
|
||||
return <LoginPage onTwoFactorChallenge={() => setIsTwoFactor(true)} />;
|
||||
}
|
||||
}
|
||||
145
common/resources/client/auth/ui/login-page.tsx
Executable file
145
common/resources/client/auth/ui/login-page.tsx
Executable file
@@ -0,0 +1,145 @@
|
||||
import {Link, useLocation, useSearchParams} from 'react-router-dom';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {LoginPayload, useLogin} from '../requests/use-login';
|
||||
import {FormCheckbox} from '../../ui/forms/toggle/checkbox';
|
||||
import {LinkStyle} from '../../ui/buttons/external-link';
|
||||
import {SocialAuthSection} from './social-auth-section';
|
||||
import {AuthLayout} from './auth-layout/auth-layout';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {useContext} from 'react';
|
||||
import {
|
||||
SiteConfigContext,
|
||||
SiteConfigContextValue,
|
||||
} from '../../core/settings/site-config-context';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
|
||||
interface Props {
|
||||
onTwoFactorChallenge: () => void;
|
||||
}
|
||||
export function LoginPage({onTwoFactorChallenge}: Props) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const {pathname} = useLocation();
|
||||
|
||||
const isWorkspaceLogin = pathname.includes('workspace');
|
||||
const searchParamsEmail = searchParams.get('email') || undefined;
|
||||
|
||||
const {branding, registration, site, social} = useSettings();
|
||||
const siteConfig = useContext(SiteConfigContext);
|
||||
|
||||
const demoDefaults =
|
||||
site.demo && !searchParamsEmail ? getDemoFormDefaults(siteConfig) : {};
|
||||
const form = useForm<LoginPayload>({
|
||||
defaultValues: {remember: true, email: searchParamsEmail, ...demoDefaults},
|
||||
});
|
||||
const login = useLogin(form);
|
||||
|
||||
const heading = isWorkspaceLogin ? (
|
||||
<Trans
|
||||
values={{siteName: branding?.site_name}}
|
||||
message="To join your team on :siteName, login to your account"
|
||||
/>
|
||||
) : (
|
||||
<Trans message="Sign in to your account" />
|
||||
);
|
||||
|
||||
const message = !registration.disable && (
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<Link className={LinkStyle} to="/register">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
message="Don't have an account? <a>Sign up.</a>"
|
||||
/>
|
||||
);
|
||||
|
||||
const isInvalid = !!Object.keys(form.formState.errors).length;
|
||||
|
||||
return (
|
||||
<AuthLayout heading={heading} message={message}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Login" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={payload => {
|
||||
login.mutate(payload, {
|
||||
onSuccess: response => {
|
||||
if (response.two_factor) {
|
||||
onTwoFactorChallenge();
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="email"
|
||||
type="email"
|
||||
label={<Trans message="Email" />}
|
||||
disabled={!!searchParamsEmail}
|
||||
invalid={isInvalid}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-12"
|
||||
name="password"
|
||||
type="password"
|
||||
label={<Trans message="Password" />}
|
||||
invalid={isInvalid}
|
||||
labelSuffix={
|
||||
<Link className={LinkStyle} to="/forgot-password" tabIndex={-1}>
|
||||
<Trans message="Forgot your password?" />
|
||||
</Link>
|
||||
}
|
||||
required
|
||||
/>
|
||||
<FormCheckbox name="remember" className="mb-32 block">
|
||||
<Trans message="Stay signed in for a month" />
|
||||
</FormCheckbox>
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={login.isPending}
|
||||
>
|
||||
<Trans message="Continue" />
|
||||
</Button>
|
||||
</Form>
|
||||
<SocialAuthSection
|
||||
dividerMessage={
|
||||
social.compact_buttons ? (
|
||||
<Trans message="Or sign in with" />
|
||||
) : (
|
||||
<Trans message="OR" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function getDemoFormDefaults(siteConfig: SiteConfigContextValue) {
|
||||
if (siteConfig.demo.loginPageDefaults === 'randomAccount') {
|
||||
// random number between 0 and 100, padded to 3 digits
|
||||
const number = Math.floor(Math.random() * 100) + 1;
|
||||
const paddedNumber = String(number).padStart(3, '0');
|
||||
return {
|
||||
email: `admin@demo${paddedNumber}.com`,
|
||||
password: 'admin',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
email: siteConfig.demo.email ?? 'admin@admin.com',
|
||||
password: siteConfig.demo.password ?? 'admin',
|
||||
};
|
||||
}
|
||||
}
|
||||
263
common/resources/client/auth/ui/permission-selector.tsx
Executable file
263
common/resources/client/auth/ui/permission-selector.tsx
Executable file
@@ -0,0 +1,263 @@
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import clsx from 'clsx';
|
||||
import {produce} from 'immer';
|
||||
import {Permission, PermissionRestriction} from '../permission';
|
||||
import {useValueLists} from '../../http/value-lists';
|
||||
import {ucFirst} from '../../utils/string/uc-first';
|
||||
import {Accordion, AccordionItem} from '../../ui/accordion/accordion';
|
||||
import {List, ListItem} from '../../ui/list/list';
|
||||
import {Switch} from '../../ui/forms/toggle/switch';
|
||||
import {TextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {DoneAllIcon} from '../../icons/material/DoneAll';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
|
||||
interface PermissionSelectorProps {
|
||||
value?: Permission[];
|
||||
onChange?: (value: Permission[]) => void;
|
||||
valueListKey?: 'permissions' | 'workspacePermissions';
|
||||
}
|
||||
export const PermissionSelector = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PermissionSelectorProps
|
||||
>(({valueListKey = 'permissions', ...props}, ref) => {
|
||||
const {data} = useValueLists([valueListKey]);
|
||||
const permissions = data?.permissions || data?.workspacePermissions;
|
||||
|
||||
const [value, setValue] = useControlledState(props.value, [], props.onChange);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
if (!permissions) return null;
|
||||
|
||||
const groupedPermissions = buildPermissionList(
|
||||
permissions,
|
||||
value,
|
||||
showAdvanced
|
||||
);
|
||||
|
||||
const onRestrictionChange = (newPermission: Permission) => {
|
||||
const newValue = [...value];
|
||||
const index = newValue.findIndex(p => p.id === newPermission.id);
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1, newPermission);
|
||||
}
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Accordion variant="outline" ref={ref}>
|
||||
{groupedPermissions.map(({groupName, items, anyChecked}) => (
|
||||
<AccordionItem
|
||||
label={<Trans message={prettyName(groupName)} />}
|
||||
key={groupName}
|
||||
startIcon={anyChecked ? <DoneAllIcon size="sm" /> : undefined}
|
||||
>
|
||||
<List>
|
||||
{items.map(permission => {
|
||||
const index = value.findIndex(v => v.id === permission.id);
|
||||
const isChecked = index > -1;
|
||||
|
||||
return (
|
||||
<div key={permission.id}>
|
||||
<ListItem
|
||||
onSelected={() => {
|
||||
if (isChecked) {
|
||||
const newValue = [...value];
|
||||
newValue.splice(index, 1);
|
||||
setValue(newValue);
|
||||
} else {
|
||||
setValue([...value, permission]);
|
||||
}
|
||||
}}
|
||||
endSection={
|
||||
<Switch
|
||||
tabIndex={-1}
|
||||
checked={isChecked}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
}
|
||||
description={<Trans message={permission.description} />}
|
||||
>
|
||||
<Trans
|
||||
message={permission.display_name || permission.name}
|
||||
/>
|
||||
</ListItem>
|
||||
{isChecked && (
|
||||
<Restrictions
|
||||
permission={permission}
|
||||
onChange={onRestrictionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<Switch
|
||||
className="mt-30"
|
||||
checked={showAdvanced}
|
||||
onChange={e => {
|
||||
setShowAdvanced(e.target.checked);
|
||||
}}
|
||||
>
|
||||
<Trans message="Show advanced permissions" />
|
||||
</Switch>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
interface RestrictionsProps {
|
||||
permission: Permission;
|
||||
onChange?: (newPermission: Permission) => void;
|
||||
}
|
||||
function Restrictions({permission, onChange}: RestrictionsProps) {
|
||||
if (!permission?.restrictions?.length) return null;
|
||||
|
||||
const setRestrictionValue = (
|
||||
name: string,
|
||||
value: PermissionRestriction['value']
|
||||
) => {
|
||||
const nextState = produce(permission, draftState => {
|
||||
const restriction = draftState.restrictions.find(r => r.name === name);
|
||||
if (restriction) {
|
||||
restriction.value = value;
|
||||
}
|
||||
});
|
||||
onChange?.(nextState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-40 py-20">
|
||||
{permission.restrictions.map((restriction, index) => {
|
||||
const isLast = index === permission.restrictions.length - 1;
|
||||
|
||||
const name = <Trans message={prettyName(restriction.name)} />;
|
||||
const description = restriction.description ? (
|
||||
<Trans message={restriction.description} />
|
||||
) : undefined;
|
||||
|
||||
if (restriction.type === 'bool') {
|
||||
return (
|
||||
<Switch
|
||||
description={description}
|
||||
key={restriction.name}
|
||||
className={clsx(!isLast && 'mb-30')}
|
||||
checked={Boolean(restriction.value)}
|
||||
onChange={e => {
|
||||
setRestrictionValue(restriction.name, e.target.checked);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
size="sm"
|
||||
label={name}
|
||||
description={description}
|
||||
type="number"
|
||||
key={restriction.name}
|
||||
className={clsx(!isLast && 'mb-30')}
|
||||
value={(restriction.value as string) || ''}
|
||||
onChange={e => {
|
||||
setRestrictionValue(
|
||||
restriction.name,
|
||||
e.target.value === '' ? undefined : parseInt(e.target.value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type FormChipFieldProps = PermissionSelectorProps & {
|
||||
name: string;
|
||||
};
|
||||
export function FormPermissionSelector(props: FormChipFieldProps) {
|
||||
const {
|
||||
field: {onChange, value = [], ref},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<PermissionSelectorProps> = {
|
||||
onChange,
|
||||
value,
|
||||
};
|
||||
|
||||
return <PermissionSelector ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
export const prettyName = (name: string) => {
|
||||
return ucFirst(name.replace('_', ' '));
|
||||
};
|
||||
|
||||
interface PermissionGroup {
|
||||
groupName: string;
|
||||
anyChecked: boolean;
|
||||
items: Permission[];
|
||||
}
|
||||
|
||||
// merge "restrictions" from selected value into all permissions to make
|
||||
// it easier to bind restriction values to form inputs
|
||||
export function buildPermissionList(
|
||||
allPermissions: Permission[],
|
||||
selectedPermissions: Permission[],
|
||||
showAdvanced: boolean
|
||||
) {
|
||||
const groupedPermissions: PermissionGroup[] = [];
|
||||
|
||||
allPermissions.forEach(permission => {
|
||||
const index = selectedPermissions.findIndex(p => p.id === permission.id);
|
||||
if (!showAdvanced && permission.advanced) return;
|
||||
|
||||
let group: PermissionGroup | undefined = groupedPermissions.find(
|
||||
g => g.groupName === permission.group
|
||||
);
|
||||
if (!group) {
|
||||
group = {groupName: permission.group, anyChecked: false, items: []};
|
||||
groupedPermissions.push(group);
|
||||
}
|
||||
|
||||
if (index > -1) {
|
||||
const mergedPermission = {
|
||||
...permission,
|
||||
restrictions: mergeRestrictions(
|
||||
permission.restrictions,
|
||||
selectedPermissions[index].restrictions
|
||||
),
|
||||
};
|
||||
group.anyChecked = true;
|
||||
group.items.push(mergedPermission);
|
||||
} else {
|
||||
group.items.push(permission);
|
||||
}
|
||||
});
|
||||
|
||||
return groupedPermissions;
|
||||
}
|
||||
|
||||
function mergeRestrictions(
|
||||
allRestrictions: PermissionRestriction[],
|
||||
selectedRestrictions: PermissionRestriction[]
|
||||
): PermissionRestriction[] {
|
||||
return allRestrictions?.map(restriction => {
|
||||
const selected = selectedRestrictions.find(
|
||||
r => r.name === restriction.name
|
||||
);
|
||||
if (selected) {
|
||||
return {...restriction, value: selected.value};
|
||||
} else {
|
||||
return restriction;
|
||||
}
|
||||
});
|
||||
}
|
||||
163
common/resources/client/auth/ui/register-page.tsx
Executable file
163
common/resources/client/auth/ui/register-page.tsx
Executable file
@@ -0,0 +1,163 @@
|
||||
import {Link, Navigate, useLocation, useSearchParams} from 'react-router-dom';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {LinkStyle} from '../../ui/buttons/external-link';
|
||||
import {RegisterPayload, useRegister} from '../requests/use-register';
|
||||
import {SocialAuthSection} from './social-auth-section';
|
||||
import {AuthLayout} from './auth-layout/auth-layout';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {FormCheckbox} from '../../ui/forms/toggle/checkbox';
|
||||
import {CustomMenuItem} from '../../menus/custom-menu';
|
||||
import {useRecaptcha} from '../../recaptcha/use-recaptcha';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
import {useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export function RegisterPage() {
|
||||
const {
|
||||
branding,
|
||||
registration: {disable},
|
||||
social,
|
||||
} = useSettings();
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const {verify, isVerifying} = useRecaptcha('register');
|
||||
|
||||
const {pathname} = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const isWorkspaceRegister = pathname.includes('workspace');
|
||||
const isBillingRegister = searchParams.get('redirectFrom') === 'pricing';
|
||||
const searchParamsEmail = searchParams.get('email') || undefined;
|
||||
|
||||
const form = useForm<RegisterPayload>({
|
||||
defaultValues: {email: searchParamsEmail},
|
||||
});
|
||||
const register = useRegister(form);
|
||||
|
||||
if (disable) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
let heading = <Trans message="Create a new account" />;
|
||||
if (isWorkspaceRegister) {
|
||||
heading = (
|
||||
<Trans
|
||||
values={{siteName: branding?.site_name}}
|
||||
message="To join your team on :siteName, create an account"
|
||||
/>
|
||||
);
|
||||
} else if (isBillingRegister) {
|
||||
heading = <Trans message="First, let's create your account" />;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<Link className={LinkStyle} to="/login">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
message="Already have an account? <a>Sign in.</a>"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthLayout heading={heading} message={message}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Register" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={async payload => {
|
||||
const isValid = await verify();
|
||||
if (isValid) {
|
||||
register.mutate(payload);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="email"
|
||||
type="email"
|
||||
disabled={!!searchParamsEmail}
|
||||
label={<Trans message="Email" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="password"
|
||||
type="password"
|
||||
label={<Trans message="Password" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
label={<Trans message="Confirm password" />}
|
||||
required
|
||||
/>
|
||||
{auth?.registerFields ? <auth.registerFields /> : null}
|
||||
<PolicyCheckboxes />
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={register.isPending || isVerifying}
|
||||
>
|
||||
<Trans message="Create account" />
|
||||
</Button>
|
||||
<SocialAuthSection
|
||||
dividerMessage={
|
||||
social.compact_buttons ? (
|
||||
<Trans message="Or sign up with" />
|
||||
) : (
|
||||
<Trans message="OR" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyCheckboxes() {
|
||||
const {
|
||||
registration: {policies},
|
||||
} = useSettings();
|
||||
|
||||
if (!policies) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-32">
|
||||
{policies.map(policy => (
|
||||
<FormCheckbox
|
||||
key={policy.id}
|
||||
name={policy.id}
|
||||
className="mb-4 block"
|
||||
required
|
||||
>
|
||||
<Trans
|
||||
message="I accept the :name"
|
||||
values={{
|
||||
name: (
|
||||
<CustomMenuItem
|
||||
unstyled
|
||||
className={() => LinkStyle}
|
||||
item={policy}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FormCheckbox>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
common/resources/client/auth/ui/reset-password-page.tsx
Executable file
80
common/resources/client/auth/ui/reset-password-page.tsx
Executable file
@@ -0,0 +1,80 @@
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {LinkStyle} from '../../ui/buttons/external-link';
|
||||
import {AuthLayout} from './auth-layout/auth-layout';
|
||||
import {
|
||||
ResetPasswordPayload,
|
||||
useResetPassword,
|
||||
} from '../requests/reset-password';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const {token} = useParams();
|
||||
const form = useForm<ResetPasswordPayload>({defaultValues: {token}});
|
||||
const resetPassword = useResetPassword(form);
|
||||
|
||||
const heading = <Trans message="Reset your account password" />;
|
||||
|
||||
const message = (
|
||||
<Trans
|
||||
values={{
|
||||
a: parts => (
|
||||
<Link className={LinkStyle} to="/register">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
message="Don't have an account? <a>Sign up.</a>"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthLayout heading={heading} message={message}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Reset Password" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={payload => {
|
||||
resetPassword.mutate(payload);
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="email"
|
||||
type="email"
|
||||
label={<Trans message="Email" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="password"
|
||||
type="password"
|
||||
label={<Trans message="New password" />}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-32"
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
label={<Trans message="Confirm password" />}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={resetPassword.isPending}
|
||||
>
|
||||
<Trans message="Reset password" />
|
||||
</Button>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
194
common/resources/client/auth/ui/social-auth-section.tsx
Executable file
194
common/resources/client/auth/ui/social-auth-section.tsx
Executable file
@@ -0,0 +1,194 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Fragment, ReactElement, ReactNode} from 'react';
|
||||
import {
|
||||
ConnectSocialPayload,
|
||||
useConnectSocialWithPassword,
|
||||
} from '../requests/connect-social-with-password';
|
||||
import {useDialogContext} from '../../ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../ui/overlays/dialog/dialog-body';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {SocialService, useSocialLogin} from '../requests/use-social-login';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {GoogleIcon} from '../../icons/social/google';
|
||||
import {FacebookIcon} from '../../icons/social/facebook';
|
||||
import {TwitterIcon} from '../../icons/social/twitter';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useNavigate} from '../../utils/hooks/use-navigate';
|
||||
import {useAuth} from '../use-auth';
|
||||
import {useTrans} from '../../i18n/use-trans';
|
||||
import {message} from '../../i18n/message';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import clsx from 'clsx';
|
||||
import {EnvatoIcon} from '@common/icons/social/envato';
|
||||
|
||||
const googleLabel = message('Continue with google');
|
||||
const facebookLabel = message('Continue with facebook');
|
||||
const twitterLabel = message('Continue with twitter');
|
||||
const envatoLabel = message('Continue with envato');
|
||||
|
||||
interface SocialAuthSectionProps {
|
||||
dividerMessage: ReactNode;
|
||||
}
|
||||
export function SocialAuthSection({dividerMessage}: SocialAuthSectionProps) {
|
||||
const {social} = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const {getRedirectUri} = useAuth();
|
||||
const {loginWithSocial, requestingPassword, setIsRequestingPassword} =
|
||||
useSocialLogin();
|
||||
|
||||
const allSocialsDisabled =
|
||||
!social?.google?.enable &&
|
||||
!social?.facebook?.enable &&
|
||||
!social?.twitter?.enable &&
|
||||
!social?.envato?.enable;
|
||||
|
||||
if (allSocialsDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSocialLogin = async (service: SocialService) => {
|
||||
const e = await loginWithSocial(service);
|
||||
if (e?.status === 'SUCCESS' || e?.status === 'ALREADY_LOGGED_IN') {
|
||||
navigate(getRedirectUri(), {replace: true});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="relative my-20 text-center before:absolute before:left-0 before:top-1/2 before:h-1 before:w-full before:-translate-y-1/2 before:bg-divider">
|
||||
<span className="relative z-10 bg-paper px-10 text-sm text-muted">
|
||||
{dividerMessage}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center gap-14',
|
||||
!social.compact_buttons && 'flex-col',
|
||||
)}
|
||||
>
|
||||
{social?.google?.enable ? (
|
||||
<SocialLoginButton
|
||||
label={googleLabel}
|
||||
icon={<GoogleIcon viewBox="0 0 48 48" />}
|
||||
onClick={() => handleSocialLogin('google')}
|
||||
/>
|
||||
) : null}
|
||||
{social?.facebook?.enable ? (
|
||||
<SocialLoginButton
|
||||
label={facebookLabel}
|
||||
icon={<FacebookIcon className="text-facebook" />}
|
||||
onClick={() => handleSocialLogin('facebook')}
|
||||
/>
|
||||
) : null}
|
||||
{social?.twitter?.enable ? (
|
||||
<SocialLoginButton
|
||||
label={twitterLabel}
|
||||
icon={<TwitterIcon className="text-twitter" />}
|
||||
onClick={() => handleSocialLogin('twitter')}
|
||||
/>
|
||||
) : null}
|
||||
{social?.envato?.enable ? (
|
||||
<SocialLoginButton
|
||||
label={envatoLabel}
|
||||
icon={<EnvatoIcon viewBox="0 0 50 50" className="text-envato" />}
|
||||
onClick={() => handleSocialLogin('envato')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={requestingPassword}
|
||||
onOpenChange={setIsRequestingPassword}
|
||||
>
|
||||
<RequestPasswordDialog />
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestPasswordDialog() {
|
||||
const form = useForm<ConnectSocialPayload>();
|
||||
const {formId} = useDialogContext();
|
||||
const connect = useConnectSocialWithPassword(form);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Password required" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="mb-30 text-sm text-muted">
|
||||
<Trans message="An account with this email address already exists. If you want to connect the two accounts, enter existing account password." />
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={payload => {
|
||||
connect.mutate(payload);
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
autoFocus
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
label={<Trans message="Password" />}
|
||||
/>
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="text">
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={connect.isPending}
|
||||
>
|
||||
<Trans message="Connect" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface SocialLoginButtonProps {
|
||||
onClick: () => void;
|
||||
label: MessageDescriptor;
|
||||
icon: ReactElement;
|
||||
}
|
||||
function SocialLoginButton({onClick, label, icon}: SocialLoginButtonProps) {
|
||||
const {trans} = useTrans();
|
||||
const {
|
||||
social: {compact_buttons},
|
||||
} = useSettings();
|
||||
|
||||
if (compact_buttons) {
|
||||
return (
|
||||
<IconButton variant="outline" aria-label={trans(label)} onClick={onClick}>
|
||||
{icon}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
startIcon={icon}
|
||||
onClick={onClick}
|
||||
className="min-h-42 w-full"
|
||||
>
|
||||
<span className="min-w-160 text-start">
|
||||
<Trans {...label} />
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export interface ConfirmTwoFactorPayload {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function useConfirmTwoFactor(
|
||||
form: UseFormReturn<ConfirmTwoFactorPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: ConfirmTwoFactorPayload) => confirm(payload),
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function confirm(payload: ConfirmTwoFactorPayload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/confirmed-two-factor-authentication', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useDisableTwoFactor() {
|
||||
return useMutation({
|
||||
mutationFn: disable,
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function disable(): Promise<Response> {
|
||||
return apiClient
|
||||
.delete('auth/user/two-factor-authentication')
|
||||
.then(response => response.data);
|
||||
}
|
||||
19
common/resources/client/auth/ui/two-factor/requests/use-enable-two-factor.ts
Executable file
19
common/resources/client/auth/ui/two-factor/requests/use-enable-two-factor.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useEnableTwoFactor() {
|
||||
return useMutation({
|
||||
mutationFn: enable,
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function enable(): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/two-factor-authentication')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useRegenerateTwoFactorCodes() {
|
||||
return useMutation({
|
||||
mutationFn: () => regenerate(),
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function regenerate(): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/two-factor-recovery-codes')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {useHandleLoginSuccess} from '@common/auth/requests/use-login';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
bootstrapData: string;
|
||||
two_factor: false;
|
||||
}
|
||||
|
||||
export interface TwoFactorChallengePayload {
|
||||
code?: string;
|
||||
recovery_code?: string;
|
||||
}
|
||||
export function useTwoFactorChallenge(
|
||||
form: UseFormReturn<TwoFactorChallengePayload>,
|
||||
) {
|
||||
const handleSuccess = useHandleLoginSuccess();
|
||||
return useMutation({
|
||||
mutationFn: (payload: TwoFactorChallengePayload) =>
|
||||
completeChallenge(payload),
|
||||
onSuccess: response => {
|
||||
handleSuccess(response);
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function completeChallenge(
|
||||
payload: TwoFactorChallengePayload,
|
||||
): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/two-factor-challenge', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
svg: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export function useTwoFactorQrCode() {
|
||||
return useQuery({
|
||||
queryKey: ['two-factor-qr-code'],
|
||||
queryFn: () => fetchCode(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCode(): Promise<Response> {
|
||||
return apiClient
|
||||
.get('auth/user/two-factor/qr-code')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {useState} from 'react';
|
||||
import {User} from '@common/auth/user';
|
||||
import {TwoFactorDisabledStep} from '@common/auth/ui/two-factor/stepper/two-factor-disabled-step';
|
||||
import {TwoFactorConfirmationStep} from '@common/auth/ui/two-factor/stepper/two-factor-confirmation-step';
|
||||
import {TwoFactorEnabledStep} from '@common/auth/ui/two-factor/stepper/two-factor-enabled-step';
|
||||
|
||||
enum Status {
|
||||
Disabled,
|
||||
WaitingForConfirmation,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function TwoFactorStepper({user}: Props) {
|
||||
const [status, setStatus] = useState<Status>(getStatus(user));
|
||||
switch (status) {
|
||||
case Status.Disabled:
|
||||
return (
|
||||
<TwoFactorDisabledStep
|
||||
onEnabled={() => setStatus(Status.WaitingForConfirmation)}
|
||||
/>
|
||||
);
|
||||
case Status.WaitingForConfirmation:
|
||||
return (
|
||||
<TwoFactorConfirmationStep
|
||||
onCancel={() => {
|
||||
setStatus(Status.Disabled);
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
setStatus(Status.Enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case Status.Enabled:
|
||||
return (
|
||||
<TwoFactorEnabledStep
|
||||
user={user}
|
||||
onDisabled={() => setStatus(Status.Disabled)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus(user: User): Status {
|
||||
if (user.two_factor_confirmed_at) {
|
||||
return Status.Enabled;
|
||||
} else if (user.two_factor_recovery_codes) {
|
||||
return Status.WaitingForConfirmation;
|
||||
}
|
||||
return Status.Disabled;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {ReactNode} from 'react';
|
||||
import {useTwoFactorQrCode} from '@common/auth/ui/two-factor/requests/use-two-factor-qr-code';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {
|
||||
ConfirmTwoFactorPayload,
|
||||
useConfirmTwoFactor,
|
||||
} from '@common/auth/ui/two-factor/requests/use-confirm-two-factor';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useDisableTwoFactor} from '@common/auth/ui/two-factor/requests/use-disable-two-factor';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onConfirmed: () => void;
|
||||
}
|
||||
export function TwoFactorConfirmationStep(props: Props) {
|
||||
const {data} = useTwoFactorQrCode();
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={<Trans message="Finish enabling two factor authentication." />}
|
||||
description={
|
||||
<Trans message="To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code." />
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{!data ? (
|
||||
<QrCodeLayout
|
||||
animationKey="svg-skeleton"
|
||||
svg={<Skeleton variant="rect" size="w-full h-full" />}
|
||||
secret={<Skeleton variant="text" className="max-w-224" />}
|
||||
/>
|
||||
) : (
|
||||
<QrCodeLayout
|
||||
animationKey="real-svg"
|
||||
svg={<div dangerouslySetInnerHTML={{__html: data.svg}} />}
|
||||
secret={
|
||||
<Trans message="Setup key: :key" values={{key: data.secret}} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<CodeForm {...props} />
|
||||
</TwoFactorStepperLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeForm({onCancel, onConfirmed}: Props) {
|
||||
const form = useForm<ConfirmTwoFactorPayload>();
|
||||
const confirmTwoFactor = useConfirmTwoFactor(form);
|
||||
const disableTwoFactor = useDisableTwoFactor();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading =
|
||||
confirmTwoFactor.isPending ||
|
||||
disableTwoFactor.isPending ||
|
||||
confirmPasswordIsLoading;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values =>
|
||||
withConfirmedPassword(() => {
|
||||
confirmTwoFactor.mutate(values, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
onConfirmed();
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<FormTextField
|
||||
required
|
||||
name="code"
|
||||
label={<Trans message="Code" />}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-12 mt-24">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
disableTwoFactor.mutate(undefined, {onSuccess: onCancel});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trans message="Confirm" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface QrCodeLayoutProps {
|
||||
animationKey: string;
|
||||
svg: ReactNode;
|
||||
secret: ReactNode;
|
||||
}
|
||||
function QrCodeLayout({animationKey, svg, secret}: QrCodeLayoutProps) {
|
||||
return (
|
||||
<m.div key={animationKey} {...opacityAnimation}>
|
||||
<div className="w-192 h-192 mb-16">{svg}</div>
|
||||
<div className="text-sm font-medium mb-16">{secret}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useEnableTwoFactor} from '@common/auth/ui/two-factor/requests/use-enable-two-factor';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
|
||||
interface Props {
|
||||
onEnabled: () => void;
|
||||
}
|
||||
export function TwoFactorDisabledStep({onEnabled}: Props) {
|
||||
const enableTwoFactor = useEnableTwoFactor();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading = enableTwoFactor.isPending || confirmPasswordIsLoading;
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={
|
||||
<Trans message="You have not enabled two factor authentication." />
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
enableTwoFactor.mutate(undefined, {
|
||||
onSuccess: onEnabled,
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Enable" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import {useDisableTwoFactor} from '@common/auth/ui/two-factor/requests/use-disable-two-factor';
|
||||
import {useRegenerateTwoFactorCodes} from '@common/auth/ui/two-factor/requests/use-regenerate-two-factor-codes';
|
||||
import {Fragment} from 'react';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onDisabled: () => void;
|
||||
}
|
||||
export function TwoFactorEnabledStep({user, onDisabled}: Props) {
|
||||
const disableTwoFactor = useDisableTwoFactor();
|
||||
const regenerateCodes = useRegenerateTwoFactorCodes();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading =
|
||||
disableTwoFactor.isPending ||
|
||||
regenerateCodes.isPending ||
|
||||
confirmPasswordIsLoading;
|
||||
|
||||
const actions = (
|
||||
<Fragment>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withConfirmedPassword(() => {
|
||||
regenerateCodes.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="mr-12"
|
||||
>
|
||||
<Trans message="Regenerate recovery codes" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
disableTwoFactor.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast(message('Two factor authentication has been disabled.'));
|
||||
onDisabled();
|
||||
},
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Disable" />
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={<Trans message="You have enabled two factor authentication." />}
|
||||
description={
|
||||
<Trans message="Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost." />
|
||||
}
|
||||
actions={actions}
|
||||
>
|
||||
<div className="bg-alt p-14 font-mono text-sm mb-16 rounded">
|
||||
{user.two_factor_recovery_codes?.map(code => (
|
||||
<div className="mb-4" key={code}>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TwoFactorStepperLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
export function TwoFactorStepperLayout({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: Props) {
|
||||
if (!subtitle) {
|
||||
subtitle = (
|
||||
<Trans message="When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application." />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="text-base font-medium mb-16">{title}</div>
|
||||
<div className="text-sm mb-24">{subtitle}</div>
|
||||
<p className="text-sm font-medium my-16">{description}</p>
|
||||
{children}
|
||||
<div className="flex items-center gap-12">{actions}</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
84
common/resources/client/auth/ui/two-factor/two-factor-challenge-page.tsx
Executable file
84
common/resources/client/auth/ui/two-factor/two-factor-challenge-page.tsx
Executable file
@@ -0,0 +1,84 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../../ui/buttons/button';
|
||||
import {Form} from '../../../ui/forms/form';
|
||||
import {AuthLayout} from '../auth-layout/auth-layout';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../../seo/static-page-title';
|
||||
import {
|
||||
TwoFactorChallengePayload,
|
||||
useTwoFactorChallenge,
|
||||
} from '@common/auth/ui/two-factor/requests/use-two-factor-challenge';
|
||||
import {useState} from 'react';
|
||||
|
||||
export function TwoFactorChallengePage() {
|
||||
const [usingRecoveryCode, setUsingRecoveryCode] = useState(false);
|
||||
|
||||
const form = useForm<TwoFactorChallengePayload>();
|
||||
const completeChallenge = useTwoFactorChallenge(form);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Two factor authentication" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={payload => {
|
||||
completeChallenge.mutate(payload);
|
||||
}}
|
||||
>
|
||||
<div className="mb-32 text-sm">
|
||||
<Trans message="Confirm access to your account by entering the authentication code provided by your authenticator application." />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
{usingRecoveryCode ? (
|
||||
<FormTextField
|
||||
name="recovery_code"
|
||||
minLength={21}
|
||||
maxLength={21}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
label={<Trans message="Recovery code" />}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<FormTextField
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
label={<Trans message="Code" />}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-32">
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={() => setUsingRecoveryCode(!usingRecoveryCode)}
|
||||
>
|
||||
<Trans message="Use recovery code instead" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={completeChallenge.isPending}
|
||||
>
|
||||
<Trans message="Continue" />
|
||||
</Button>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
32
common/resources/client/auth/ui/use-user.ts
Executable file
32
common/resources/client/auth/ui/use-user.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {User} from '../user';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
|
||||
export interface FetchUseUserResponse extends BackendResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
with: string[];
|
||||
}
|
||||
|
||||
type UserId = number | string | 'me';
|
||||
const queryKey = (id: UserId, params?: Params) => {
|
||||
const key: any[] = ['users', `${id}`];
|
||||
if (params) {
|
||||
key.push(params);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
export function useUser(id: UserId, params?: Params) {
|
||||
return useQuery({
|
||||
queryKey: queryKey(id, params),
|
||||
queryFn: () => fetchUser(id, params),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchUser(id: UserId, params?: Params): Promise<FetchUseUserResponse> {
|
||||
return apiClient.get(`users/${id}`, {params}).then(response => response.data);
|
||||
}
|
||||
Reference in New Issue
Block a user