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