@@ -0,0 +1,142 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {User} from '../../../user';
|
||||
import {IllustratedMessage} from '../../../../ui/images/illustrated-message';
|
||||
import {SvgImage} from '../../../../ui/images/svg-image/svg-image';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {FormattedDate} from '../../../../i18n/formatted-date';
|
||||
import {AccessToken} from '../../../access-token';
|
||||
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteAccessToken} from './delete-access-token';
|
||||
import {CreateNewTokenDialog} from './create-new-token-dialog';
|
||||
import {LinkStyle} from '../../../../ui/buttons/external-link';
|
||||
import {useAuth} from '../../../use-auth';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import secureFilesSvg from './secure-files.svg';
|
||||
import {useSettings} from '../../../../core/settings/use-settings';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function AccessTokenPanel({user}: Props) {
|
||||
const tokens = user.tokens || [];
|
||||
const {hasPermission} = useAuth();
|
||||
const {api} = useSettings();
|
||||
if (!api?.integrated || !hasPermission('api.access')) return null;
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Developers}
|
||||
title={<Trans message="API access tokens" />}
|
||||
titleSuffix={
|
||||
<Link className={LinkStyle} to="/api-docs" target="_blank">
|
||||
<Trans message="Documentation" />
|
||||
</Link>
|
||||
}
|
||||
actions={<CreateNewTokenButton />}
|
||||
>
|
||||
{!tokens.length ? (
|
||||
<IllustratedMessage
|
||||
className="py-40"
|
||||
image={<SvgImage src={secureFilesSvg} />}
|
||||
title={<Trans message="You have no personal access tokens yet" />}
|
||||
/>
|
||||
) : (
|
||||
tokens.map((token, index) => (
|
||||
<TokenLine
|
||||
token={token}
|
||||
key={token.id}
|
||||
isLast={index === tokens.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenLineProps {
|
||||
token: AccessToken;
|
||||
isLast: boolean;
|
||||
}
|
||||
function TokenLine({token, isLast}: TokenLineProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-24',
|
||||
!isLast && 'mb-12 pb-12 border-b',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">
|
||||
<Trans message="Name" />
|
||||
</div>
|
||||
<div>{token.name}</div>
|
||||
<div className="font-semibold mt-10">
|
||||
<Trans message="Last used" />
|
||||
</div>
|
||||
<div>
|
||||
{token.last_used_at ? (
|
||||
<FormattedDate date={token.last_used_at} />
|
||||
) : (
|
||||
<Trans message="Never" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DeleteTokenButton token={token} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNewTokenButton() {
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<Button variant="flat" color="primary">
|
||||
<Trans message="Create token" />
|
||||
</Button>
|
||||
<CreateNewTokenDialog />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteTokenButtonProps {
|
||||
token: AccessToken;
|
||||
}
|
||||
function DeleteTokenButton({token}: DeleteTokenButtonProps) {
|
||||
const deleteToken = useDeleteAccessToken();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteToken.mutate(
|
||||
{id: token.id},
|
||||
{
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({queryKey: ['users']}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="danger"
|
||||
className="flex-shrink-0 ml-auto"
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete token?" />}
|
||||
body={
|
||||
<Trans message="This token will be deleted immediately and permanently. Once deleted, it can no longer be used to make API requests." />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useState} from 'react';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
import {Dialog} from '../../../../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../../../../ui/overlays/dialog/dialog-header';
|
||||
import {DialogBody} from '../../../../ui/overlays/dialog/dialog-body';
|
||||
import {Form} from '../../../../ui/forms/form';
|
||||
import {
|
||||
FormTextField,
|
||||
TextField,
|
||||
} from '../../../../ui/forms/input-field/text-field/text-field';
|
||||
import {useDialogContext} from '../../../../ui/overlays/dialog/dialog-context';
|
||||
import {DialogFooter} from '../../../../ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '../../../../ui/buttons/button';
|
||||
import {
|
||||
CreateAccessTokenPayload,
|
||||
useCreateAccessToken,
|
||||
} from './create-new-token';
|
||||
import {ErrorIcon} from '../../../../icons/material/Error';
|
||||
import {Trans} from '../../../../i18n/trans';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
|
||||
export function CreateNewTokenDialog() {
|
||||
const form = useForm<CreateAccessTokenPayload>();
|
||||
const {formId, close} = useDialogContext();
|
||||
const createToken = useCreateAccessToken(form);
|
||||
|
||||
const [plainTextToken, setPlainTextToken] = useState<string>();
|
||||
|
||||
const formNode = (
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={values => {
|
||||
createToken.mutate(values, {
|
||||
onSuccess: response => {
|
||||
setPlainTextToken(response.plainTextToken);
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
name="tokenName"
|
||||
label={<Trans message="Token name" />}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Create new token" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{plainTextToken ? (
|
||||
<PlainTextPreview plainTextToken={plainTextToken} />
|
||||
) : (
|
||||
formNode
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="text" onClick={close}>
|
||||
<Trans message="Done" />
|
||||
</Button>
|
||||
{!plainTextToken && (
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createToken.isPending}
|
||||
>
|
||||
<Trans message="Create" />
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlainTextPreviewProps {
|
||||
plainTextToken: string;
|
||||
}
|
||||
function PlainTextPreview({plainTextToken}: PlainTextPreviewProps) {
|
||||
const [isCopied, copyToClipboard] = useClipboard(plainTextToken || '', {
|
||||
successDuration: 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
readOnly
|
||||
value={plainTextToken}
|
||||
autoFocus
|
||||
onClick={e => {
|
||||
e.currentTarget.focus();
|
||||
e.currentTarget.select();
|
||||
}}
|
||||
endAppend={
|
||||
<Button variant="flat" color="primary" onClick={copyToClipboard}>
|
||||
{isCopied ? <Trans message="Copied!" /> : <Trans message="Copy" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-10 mt-14 text-sm">
|
||||
<ErrorIcon size="sm" className="text-danger" />
|
||||
<Trans message="Make sure to store the token in a safe place. After this dialog is closed, token will not be viewable anymore." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {AccessToken} from '../../../access-token';
|
||||
import {onFormQueryError} from '../../../../errors/on-form-query-error';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
token: AccessToken;
|
||||
plainTextToken: string;
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenPayload {
|
||||
tokenName: string;
|
||||
}
|
||||
|
||||
function createAccessToken(
|
||||
payload: CreateAccessTokenPayload,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(`access-tokens`, payload).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useCreateAccessToken(
|
||||
form: UseFormReturn<CreateAccessTokenPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (props: CreateAccessTokenPayload) => createAccessToken(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Token create'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function deleteAccessToken({id}: Props): Promise<Response> {
|
||||
return apiClient.delete(`access-tokens/${id}`).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useDeleteAccessToken() {
|
||||
return useMutation({
|
||||
mutationFn: (props: Props) => deleteAccessToken(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Token deleted'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
76
common/resources/client/auth/ui/account-settings/account-settings-page.tsx
Executable file
76
common/resources/client/auth/ui/account-settings/account-settings-page.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
import {Navbar} from '@common/ui/navigation/navbar/navbar';
|
||||
import {useUser} from '../use-user';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {SocialLoginPanel} from './social-login-panel';
|
||||
import {BasicInfoPanel} from './basic-info-panel/basic-info-panel';
|
||||
import {ChangePasswordPanel} from './change-password-panel/change-password-panel';
|
||||
import {LocalizationPanel} from './localization-panel';
|
||||
import {AccessTokenPanel} from './access-token-panel/access-token-panel';
|
||||
import {DangerZonePanel} from './danger-zone-panel/danger-zone-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {StaticPageTitle} from '@common/seo/static-page-title';
|
||||
import {AccountSettingsPanel} from '@common/auth/ui/account-settings/account-settings-panel';
|
||||
import {TwoFactorStepper} from '@common/auth/ui/two-factor/stepper/two-factor-auth-stepper';
|
||||
import {
|
||||
AccountSettingsId,
|
||||
AccountSettingsSidenav,
|
||||
} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {SessionsPanel} from '@common/auth/ui/account-settings/sessions-panel/sessions-panel';
|
||||
import {useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const {data, isLoading} = useUser('me', {
|
||||
with: ['roles', 'social_profiles', 'tokens'],
|
||||
});
|
||||
return (
|
||||
<div className="min-h-screen bg-alt">
|
||||
<StaticPageTitle>
|
||||
<Trans message="Account Settings" />
|
||||
</StaticPageTitle>
|
||||
<Navbar menuPosition="account-settings-page" />
|
||||
<div>
|
||||
<div className="container mx-auto px-24 py-24">
|
||||
<h1 className="text-3xl">
|
||||
<Trans message="Account settings" />
|
||||
</h1>
|
||||
<div className="mb-40 text-base text-muted">
|
||||
<Trans message="View and update your account details, profile and more." />
|
||||
</div>
|
||||
{isLoading || !data ? (
|
||||
<ProgressCircle
|
||||
className="my-80"
|
||||
aria-label="Loading user.."
|
||||
isIndeterminate
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-24">
|
||||
<AccountSettingsSidenav />
|
||||
<main className="flex-auto">
|
||||
{auth.accountSettingsPanels?.map(panel => (
|
||||
<panel.component key={panel.id} user={data.user} />
|
||||
))}
|
||||
<BasicInfoPanel user={data.user} />
|
||||
<SocialLoginPanel user={data.user} />
|
||||
<ChangePasswordPanel />
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.TwoFactor}
|
||||
title={<Trans message="Two factor authentication" />}
|
||||
>
|
||||
<div className="max-w-580">
|
||||
<TwoFactorStepper user={data.user} />
|
||||
</div>
|
||||
</AccountSettingsPanel>
|
||||
<SessionsPanel user={data.user} />
|
||||
<LocalizationPanel user={data.user} />
|
||||
<AccessTokenPanel user={data.user} />
|
||||
<DangerZonePanel />
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
common/resources/client/auth/ui/account-settings/account-settings-panel.tsx
Executable file
32
common/resources/client/auth/ui/account-settings/account-settings-panel.tsx
Executable file
@@ -0,0 +1,32 @@
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export function AccountSettingsPanel({
|
||||
id,
|
||||
title,
|
||||
titleSuffix,
|
||||
children,
|
||||
actions,
|
||||
}: Props) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="mb-24 w-full rounded-panel border bg px-24 py-20"
|
||||
>
|
||||
<div className="flex items-center gap-14 border-b pb-10">
|
||||
<div className="text-lg font-light">{title}</div>
|
||||
{titleSuffix && <div className="ml-auto">{titleSuffix}</div>}
|
||||
</div>
|
||||
<div className="pt-24">{children}</div>
|
||||
{actions && (
|
||||
<div className="mt-36 flex justify-end border-t pt-10">{actions}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
106
common/resources/client/auth/ui/account-settings/account-settings-sidenav.tsx
Executable file
106
common/resources/client/auth/ui/account-settings/account-settings-sidenav.tsx
Executable file
@@ -0,0 +1,106 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {PersonIcon} from '@common/icons/material/Person';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {LoginIcon} from '@common/icons/material/Login';
|
||||
import {LockIcon} from '@common/icons/material/Lock';
|
||||
import {PhonelinkLockIcon} from '@common/icons/material/PhonelinkLock';
|
||||
import {LanguageIcon} from '@common/icons/material/Language';
|
||||
import {ApiIcon} from '@common/icons/material/Api';
|
||||
import {DangerousIcon} from '@common/icons/material/Dangerous';
|
||||
import {ReactNode, useContext} from 'react';
|
||||
import {DevicesIcon} from '@common/icons/material/Devices';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export enum AccountSettingsId {
|
||||
AccountDetails = 'account-details',
|
||||
SocialLogin = 'social-login',
|
||||
Password = 'password',
|
||||
TwoFactor = 'two-factor',
|
||||
LocationAndLanguage = 'location-and-language',
|
||||
Developers = 'developers',
|
||||
DeleteAccount = 'delete-account',
|
||||
Sessions = 'sessions',
|
||||
}
|
||||
|
||||
export function AccountSettingsSidenav() {
|
||||
const p = AccountSettingsId;
|
||||
|
||||
const {hasPermission} = useAuth();
|
||||
const {api, social} = useSettings();
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
|
||||
const socialEnabled =
|
||||
social?.envato || social?.google || social?.facebook || social?.twitter;
|
||||
|
||||
return (
|
||||
<aside className="sticky top-10 hidden flex-shrink-0 lg:block">
|
||||
<List padding="p-0">
|
||||
{auth.accountSettingsPanels?.map(panel => (
|
||||
<Item
|
||||
key={panel.id}
|
||||
icon={<panel.icon viewBox="0 0 50 50" />}
|
||||
panel={panel.id as AccountSettingsId}
|
||||
>
|
||||
<Trans {...panel.label} />
|
||||
</Item>
|
||||
))}
|
||||
<Item icon={<PersonIcon />} panel={p.AccountDetails}>
|
||||
<Trans message="Account details" />
|
||||
</Item>
|
||||
{socialEnabled && (
|
||||
<Item icon={<LoginIcon />} panel={p.SocialLogin}>
|
||||
<Trans message="Social login" />
|
||||
</Item>
|
||||
)}
|
||||
<Item icon={<LockIcon />} panel={p.Password}>
|
||||
<Trans message="Password" />
|
||||
</Item>
|
||||
<Item icon={<PhonelinkLockIcon />} panel={p.TwoFactor}>
|
||||
<Trans message="Two factor authentication" />
|
||||
</Item>
|
||||
<Item icon={<DevicesIcon />} panel={p.Sessions}>
|
||||
<Trans message="Active sessions" />
|
||||
</Item>
|
||||
<Item icon={<LanguageIcon />} panel={p.LocationAndLanguage}>
|
||||
<Trans message="Location and language" />
|
||||
</Item>
|
||||
{api?.integrated && hasPermission('api.access') ? (
|
||||
<Item icon={<ApiIcon />} panel={p.Developers}>
|
||||
<Trans message="Developers" />
|
||||
</Item>
|
||||
) : null}
|
||||
<Item icon={<DangerousIcon />} panel={p.DeleteAccount}>
|
||||
<Trans message="Delete account" />
|
||||
</Item>
|
||||
</List>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
children: ReactNode;
|
||||
icon: ReactNode;
|
||||
isLast?: boolean;
|
||||
panel: AccountSettingsId;
|
||||
}
|
||||
function Item({children, icon, isLast, panel}: ItemProps) {
|
||||
return (
|
||||
<ListItem
|
||||
startIcon={icon}
|
||||
className={isLast ? undefined : 'mb-10'}
|
||||
onSelected={() => {
|
||||
const panelEl = document.querySelector(`#${panel}`);
|
||||
if (panelEl) {
|
||||
panelEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSvgIcon} from '@common/icons/create-svg-icon';
|
||||
|
||||
export const AvatarPlaceholderIcon = createSvgIcon(
|
||||
<path d="M24,12 C28.418278,12 32,15.581722 32,20 L32,22 C32,26.418278 28.418278,30 24,30 C19.581722,30 16,26.418278 16,22 L16,20 C16,15.581722 19.581722,12 24,12 Z M24,32 C33.8734019,32 42.1092023,38.8710577 44,48 L4,48 C5.89079771,38.8710577 14.1265981,32 24,32 Z"></path>
|
||||
);
|
||||
27
common/resources/client/auth/ui/account-settings/avatar/remove-avatar.ts
Executable file
27
common/resources/client/auth/ui/account-settings/avatar/remove-avatar.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '../../../../ui/toast/toast';
|
||||
import {BackendResponse} from '../../../../http/backend-response/backend-response';
|
||||
import {User} from '../../../user';
|
||||
import {message} from '../../../../i18n/message';
|
||||
import {apiClient} from '../../../../http/query-client';
|
||||
import {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface UserProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
function removeAvatar(user: User): Promise<Response> {
|
||||
return apiClient.delete(`users/${user.id}/avatar`).then(r => r.data);
|
||||
}
|
||||
|
||||
export function useRemoveAvatar({user}: UserProps) {
|
||||
return useMutation({
|
||||
mutationFn: () => removeAvatar(user),
|
||||
onSuccess: () => {
|
||||
toast(message('Removed avatar'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
55
common/resources/client/auth/ui/account-settings/avatar/upload-avatar.ts
Executable file
55
common/resources/client/auth/ui/account-settings/avatar/upload-avatar.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {UploadedFile} from '@common/uploads/uploaded-file';
|
||||
import {User} from '@common/auth/user';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {getAxiosErrorMessage} from '@common/utils/http/get-axios-error-message';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
file?: UploadedFile;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface UserProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
function UploadAvatar({file, url}: Payload, user: User): Promise<Response> {
|
||||
const payload = new FormData();
|
||||
if (file) {
|
||||
payload.set('file', file.native);
|
||||
} else {
|
||||
payload.set('url', url!);
|
||||
}
|
||||
return apiClient
|
||||
.post(`users/${user.id}/avatar`, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
export function useUploadAvatar({user}: UserProps) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => UploadAvatar(payload, user),
|
||||
onSuccess: () => {
|
||||
toast(message('Uploaded avatar'));
|
||||
},
|
||||
onError: err => {
|
||||
const message = getAxiosErrorMessage(err, 'file');
|
||||
if (message) {
|
||||
toast.danger(message);
|
||||
} else {
|
||||
showHttpErrorToast(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {User} from '../../../user';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useUpdateAccountDetails} from './update-account-details';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useUploadAvatar} from '../avatar/upload-avatar';
|
||||
import {useRemoveAvatar} from '../avatar/remove-avatar';
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function BasicInfoPanel({user}: Props) {
|
||||
const uploadAvatar = useUploadAvatar({user});
|
||||
const removeAvatar = useRemoveAvatar({user});
|
||||
const formId = useId();
|
||||
const form = useForm<Partial<Omit<User, 'subscriptions'>>>({
|
||||
defaultValues: {
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
avatar: user.avatar,
|
||||
},
|
||||
});
|
||||
const updateDetails = useUpdateAccountDetails(form);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.AccountDetails}
|
||||
title={<Trans message="Update name and profile image" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
disabled={updateDetails.isPending || !form.formState.isValid}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col flex-col-reverse md:flex-row items-center gap-40 md:gap-80"
|
||||
onSubmit={newDetails => {
|
||||
updateDetails.mutate(newDetails);
|
||||
}}
|
||||
id={formId}
|
||||
>
|
||||
<div className="flex-auto w-full">
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="first_name"
|
||||
label={<Trans message="First name" />}
|
||||
/>
|
||||
<FormTextField
|
||||
name="last_name"
|
||||
label={<Trans message="Last name" />}
|
||||
/>
|
||||
</div>
|
||||
<FileUploadProvider>
|
||||
<FormImageSelector
|
||||
className="md:mr-80"
|
||||
variant="avatar"
|
||||
previewSize="w-90 h-90"
|
||||
showRemoveButton
|
||||
name="avatar"
|
||||
diskPrefix="avatars"
|
||||
label={<Trans message="Profile image" />}
|
||||
onChange={url => {
|
||||
if (url) {
|
||||
uploadAvatar.mutate({url});
|
||||
} else {
|
||||
removeAvatar.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileUploadProvider>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {User} from '@common/auth/user';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export function useUpdateAccountDetails(form: UseFormReturn<Partial<User>>) {
|
||||
return useMutation({
|
||||
mutationFn: (props: Payload) => updateAccountDetails(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Updated account details'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateAccountDetails(payload: Payload): Promise<Response> {
|
||||
return apiClient.put('users/me', payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {AccountSettingsPanel} from '@common/auth/ui/account-settings/account-settings-panel';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {UpdatePasswordPayload, useUpdatePassword} from './use-update-password';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
export function ChangePasswordPanel() {
|
||||
const form = useForm<UpdatePasswordPayload>();
|
||||
const formId = useId();
|
||||
const updatePassword = useUpdatePassword(form);
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Password}
|
||||
title={<Trans message="Update password" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={!form.formState.isValid || updatePassword.isPending}
|
||||
>
|
||||
<Trans message="Update password" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
id={formId}
|
||||
onSubmit={newValues => {
|
||||
updatePassword.mutate(newValues, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="current_password"
|
||||
label={<Trans message="Current password" />}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="password"
|
||||
label={<Trans message="New password" />}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
name="password_confirmation"
|
||||
label={<Trans message="Confirm password" />}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export interface UpdatePasswordPayload {
|
||||
current_password: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
export function useUpdatePassword(form: UseFormReturn<UpdatePasswordPayload>) {
|
||||
return useMutation({
|
||||
mutationFn: (props: UpdatePasswordPayload) => updatePassword(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Password changed'));
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updatePassword(payload: UpdatePasswordPayload): Promise<Response> {
|
||||
return apiClient.put('auth/user/password', payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteAccount} from './delete-account';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import React, {useState} from 'react';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
|
||||
export function DangerZonePanel() {
|
||||
const deleteAccount = useDeleteAccount();
|
||||
const {withConfirmedPassword, isLoading: confirmingPassword} =
|
||||
usePasswordConfirmedAction();
|
||||
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.DeleteAccount}
|
||||
title={<Trans message="Danger zone" />}
|
||||
>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={confirmDialogOpen}
|
||||
onOpenChange={setConfirmDialogOpen}
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteAccount.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete account?" />}
|
||||
body={
|
||||
<Trans message="Your account will be deleted immediately and permanently. Once deleted, accounts can not be restored." />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="danger"
|
||||
disabled={confirmingPassword || deleteAccount.isPending}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
setConfirmDialogOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Delete account" />
|
||||
</Button>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useLogout} from '@common/auth/requests/logout';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const {user} = useAuth();
|
||||
const logout = useLogout();
|
||||
return useMutation({
|
||||
mutationFn: () => deleteAccount(user!.id),
|
||||
onSuccess: () => {
|
||||
toast('Account deleted');
|
||||
logout.mutate();
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAccount(userId: number): Promise<Response> {
|
||||
return apiClient
|
||||
.delete(`users/${userId}`, {params: {deleteCurrentUser: true}})
|
||||
.then(r => r.data);
|
||||
}
|
||||
98
common/resources/client/auth/ui/account-settings/localization-panel.tsx
Executable file
98
common/resources/client/auth/ui/account-settings/localization-panel.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useId} from 'react';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {AccountSettingsPanel} from './account-settings-panel';
|
||||
import {useUpdateAccountDetails} from './basic-info-panel/update-account-details';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {User} from '../../user';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {Option} from '../../../ui/forms/combobox/combobox';
|
||||
import {FormSelect} from '../../../ui/forms/select/select';
|
||||
import {useChangeLocale} from '@common/i18n/change-locale';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {getLocalTimeZone} from '@internationalized/date';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {TimezoneSelect} from '@common/auth/ui/account-settings/timezone-select';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function LocalizationPanel({user}: Props) {
|
||||
const formId = useId();
|
||||
const {trans} = useTrans();
|
||||
const form = useForm<Partial<User>>({
|
||||
defaultValues: {
|
||||
language: user.language || '',
|
||||
country: user.country || '',
|
||||
timezone: user.timezone || getLocalTimeZone(),
|
||||
},
|
||||
});
|
||||
const updateDetails = useUpdateAccountDetails(form);
|
||||
const changeLocale = useChangeLocale();
|
||||
const {data} = useValueLists(['timezones', 'countries', 'localizations']);
|
||||
|
||||
const countries = data?.countries || [];
|
||||
const localizations = data?.localizations || [];
|
||||
const timezones = data?.timezones || {};
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.LocationAndLanguage}
|
||||
title={<Trans message="Date, time and language" />}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
form={formId}
|
||||
disabled={updateDetails.isPending || !form.formState.isValid}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={newDetails => {
|
||||
updateDetails.mutate(newDetails);
|
||||
changeLocale.mutate({locale: newDetails.language});
|
||||
}}
|
||||
id={formId}
|
||||
>
|
||||
<FormSelect
|
||||
className="mb-24"
|
||||
selectionMode="single"
|
||||
name="language"
|
||||
label={<Trans message="Language" />}
|
||||
>
|
||||
{localizations.map(localization => (
|
||||
<Option key={localization.language} value={localization.language}>
|
||||
{localization.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<FormSelect
|
||||
className="mb-24"
|
||||
selectionMode="single"
|
||||
name="country"
|
||||
label={<Trans message="Country" />}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search countries'))}
|
||||
>
|
||||
{countries.map(country => (
|
||||
<Option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
<TimezoneSelect
|
||||
label={<Trans message="Timezone" />}
|
||||
name="timezone"
|
||||
timezones={timezones}
|
||||
/>
|
||||
</Form>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function useLogoutOtherSessions() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => logoutOther(payload),
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function logoutOther(payload: Payload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('user-sessions/logout-other', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
|
||||
export interface ActiveSession {
|
||||
id: string;
|
||||
platform?: string;
|
||||
device_type?: 'mobile' | 'tablet' | 'desktop';
|
||||
browser?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
ip_address?: string;
|
||||
token?: string;
|
||||
is_current_device: boolean;
|
||||
last_active: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
sessions: ActiveSession[];
|
||||
}
|
||||
|
||||
export function useUserSessions() {
|
||||
return useQuery({
|
||||
queryKey: ['user-sessions'],
|
||||
queryFn: () => fetchUserSessions(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchUserSessions() {
|
||||
return apiClient
|
||||
.get<Response>(`user-sessions`)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {User} from '../../../user';
|
||||
import {AccountSettingsPanel} from '../account-settings-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
import {
|
||||
ActiveSession,
|
||||
useUserSessions,
|
||||
} from '@common/auth/ui/account-settings/sessions-panel/requests/use-user-sessions';
|
||||
import {ComputerIcon} from '@common/icons/material/Computer';
|
||||
import {SmartphoneIcon} from '@common/icons/material/Smartphone';
|
||||
import {TabletIcon} from '@common/icons/material/Tablet';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {useLogoutOtherSessions} from '@common/auth/ui/account-settings/sessions-panel/requests/use-logout-other-sessions';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function SessionsPanel({user}: Props) {
|
||||
const {data, isLoading} = useUserSessions();
|
||||
const logoutOther = useLogoutOtherSessions();
|
||||
const {withConfirmedPassword, isLoading: checkingPasswordStatus} =
|
||||
usePasswordConfirmedAction({needsPassword: true});
|
||||
|
||||
const sessionList = (
|
||||
<div className="max-h-400 overflow-y-auto">
|
||||
{data?.sessions?.map(session => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.Sessions}
|
||||
title={<Trans message="Active sessions" />}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<Trans message="If necessary, you may log out of all of your other browser sessions across all of your devices. Your recent sessions are listed below. If you feel your account has been compromised, you should also update your password." />
|
||||
</p>
|
||||
<div className="my-30">
|
||||
{isLoading ? (
|
||||
<div className="min-h-60">
|
||||
<ProgressCircle isIndeterminate />
|
||||
</div>
|
||||
) : (
|
||||
sessionList
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
disabled={checkingPasswordStatus || logoutOther.isPending}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(password => {
|
||||
logoutOther.mutate(
|
||||
{password: password!},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(message('Logged out other sessions.'));
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Logout other sessions" />
|
||||
</Button>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function SessionItem({session}: SessionItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-14 text-sm mb-14">
|
||||
<div className="flex-shrink-0 text-muted">
|
||||
<DeviceIcon device={session.device_type} size="lg" />
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<div>
|
||||
<ValueOrUnknown>{session.platform}</ValueOrUnknown> -{' '}
|
||||
<ValueOrUnknown>{session.browser}</ValueOrUnknown>
|
||||
</div>
|
||||
<div className="text-xs my-4">
|
||||
{session.city}, {session.country}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<IpAddress session={session} /> - <LastActive session={session} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceIconProps {
|
||||
device: ActiveSession['device_type'];
|
||||
size: SvgIconProps['size'];
|
||||
}
|
||||
function DeviceIcon({device, size}: DeviceIconProps) {
|
||||
switch (device) {
|
||||
case 'mobile':
|
||||
return <SmartphoneIcon size={size} />;
|
||||
case 'tablet':
|
||||
return <TabletIcon size={size} />;
|
||||
default:
|
||||
return <ComputerIcon size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface LastActiveProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function LastActive({session}: LastActiveProps) {
|
||||
if (session.is_current_device) {
|
||||
return (
|
||||
<span className="text-positive">
|
||||
<Trans message="This device" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <FormattedRelativeTime date={session.last_active} />;
|
||||
}
|
||||
|
||||
interface IpAddressProps {
|
||||
session: ActiveSession;
|
||||
}
|
||||
function IpAddress({session}: IpAddressProps) {
|
||||
if (session.ip_address) {
|
||||
return <span>{session.ip_address}</span>;
|
||||
} else if (session.token) {
|
||||
return <Trans message="API Token" />;
|
||||
}
|
||||
return <Trans message="Unknown IP" />;
|
||||
}
|
||||
|
||||
interface ValueOrUnknownProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
function ValueOrUnknown({children}: ValueOrUnknownProps) {
|
||||
return children ? (
|
||||
<Fragment>{children}</Fragment>
|
||||
) : (
|
||||
<Trans message="Unknown" />
|
||||
);
|
||||
}
|
||||
142
common/resources/client/auth/ui/account-settings/social-login-panel.tsx
Executable file
142
common/resources/client/auth/ui/account-settings/social-login-panel.tsx
Executable file
@@ -0,0 +1,142 @@
|
||||
import clsx from 'clsx';
|
||||
import {cloneElement, ReactElement} from 'react';
|
||||
import {SocialService, useSocialLogin} from '../../requests/use-social-login';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {EnvatoIcon} from '@common/icons/social/envato';
|
||||
import {GoogleIcon} from '@common/icons/social/google';
|
||||
import {FacebookIcon} from '@common/icons/social/facebook';
|
||||
import {TwitterIcon} from '@common/icons/social/twitter';
|
||||
import {User} from '../../user';
|
||||
import {AccountSettingsPanel} from './account-settings-panel';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {AccountSettingsId} from '@common/auth/ui/account-settings/account-settings-sidenav';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function SocialLoginPanel({user}: Props) {
|
||||
const {social} = useSettings();
|
||||
|
||||
if (
|
||||
!social.envato?.enable &&
|
||||
!social.google?.enable &&
|
||||
!social.facebook?.enable &&
|
||||
!social.twitter?.enable
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSettingsPanel
|
||||
id={AccountSettingsId.SocialLogin}
|
||||
title={<Trans message="Manage social login" />}
|
||||
>
|
||||
<SocialLoginPanelRow
|
||||
icon={
|
||||
<EnvatoIcon
|
||||
viewBox="0 0 50 50"
|
||||
className="border-envato bg-envato text-white"
|
||||
/>
|
||||
}
|
||||
service="envato"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<GoogleIcon viewBox="0 0 48 48" />}
|
||||
service="google"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<FacebookIcon className="text-facebook" />}
|
||||
service="facebook"
|
||||
user={user}
|
||||
/>
|
||||
<SocialLoginPanelRow
|
||||
icon={<TwitterIcon className="text-twitter" />}
|
||||
service="twitter"
|
||||
user={user}
|
||||
/>
|
||||
<div className="pb-6 pt-16 text-sm text-muted">
|
||||
<Trans message="If you disable social logins, you'll still be able to log in using your email and password." />
|
||||
</div>
|
||||
</AccountSettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
interface SocialLoginPanelRowProps {
|
||||
service: SocialService;
|
||||
user: User;
|
||||
className?: string;
|
||||
icon: ReactElement;
|
||||
}
|
||||
|
||||
function SocialLoginPanelRow({
|
||||
service,
|
||||
user,
|
||||
className,
|
||||
icon,
|
||||
}: SocialLoginPanelRowProps) {
|
||||
const {social} = useSettings();
|
||||
const {connectSocial, disconnectSocial} = useSocialLogin();
|
||||
const username = user?.social_profiles?.find(s => s.service_name === service)
|
||||
?.username;
|
||||
|
||||
if (!social?.[service]?.enable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-14 border-b px-10 py-20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{cloneElement(icon, {
|
||||
size: 'xl',
|
||||
className: clsx(icon.props.className, 'border p-8 rounded'),
|
||||
})}
|
||||
<div className="mr-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<div className="overflow-hidden text-ellipsis text-sm font-bold first-letter:capitalize">
|
||||
<Trans message=":service account" values={{service}} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
{username || <Trans message="Disabled" />}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={disconnectSocial.isPending}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color={username ? 'danger' : 'primary'}
|
||||
onClick={async () => {
|
||||
if (username) {
|
||||
disconnectSocial.mutate(
|
||||
{service},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(
|
||||
message('Disabled :service account', {values: {service}}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const e = await connectSocial(service);
|
||||
if (e?.status === 'SUCCESS') {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
toast(message('Enabled :service account', {values: {service}}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{username ? <Trans message="Disable" /> : <Trans message="Enable" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
common/resources/client/auth/ui/account-settings/timezone-select.tsx
Executable file
48
common/resources/client/auth/ui/account-settings/timezone-select.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
FormSelect,
|
||||
OptionGroup,
|
||||
SelectProps,
|
||||
} from '@common/ui/forms/select/select';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Option} from '@common/ui/forms/combobox/combobox';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {Timezone} from '@common/http/value-lists';
|
||||
import {InputSize} from '@common/ui/forms/input-field/input-size';
|
||||
import {ReactNode} from 'react';
|
||||
|
||||
interface Props extends Omit<SelectProps<any>, 'selectionMode' | 'children'> {
|
||||
name: string;
|
||||
timezones: Record<string, Timezone[]>;
|
||||
size?: InputSize;
|
||||
label?: ReactNode;
|
||||
}
|
||||
export function TimezoneSelect({
|
||||
name,
|
||||
size,
|
||||
timezones,
|
||||
label,
|
||||
...selectProps
|
||||
}: Props) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<FormSelect
|
||||
selectionMode="single"
|
||||
name={name}
|
||||
size={size}
|
||||
label={label}
|
||||
showSearchField
|
||||
searchPlaceholder={trans(message('Search timezones'))}
|
||||
{...selectProps}
|
||||
>
|
||||
{Object.entries(timezones).map(([sectionName, sectionItems]) => (
|
||||
<OptionGroup label={sectionName} key={sectionName}>
|
||||
{sectionItems.map(timezone => (
|
||||
<Option key={timezone.value} value={timezone.value}>
|
||||
{timezone.text}
|
||||
</Option>
|
||||
))}
|
||||
</OptionGroup>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user