first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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),
});
}

View File

@@ -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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);

View 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),
});
}

View 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);
}
},
});
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View 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>
);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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" />
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);
}

View File

@@ -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});
}

View File

@@ -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,
};
}

View File

@@ -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}`;
}

View 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

View 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>
);
}

View 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)} />;
}
}

View 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',
};
}
}

View 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;
}
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);
}

View 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 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);
}

View 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);
}

View 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 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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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);
}