diff --git a/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx b/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx new file mode 100644 index 0000000000..ac3ee860e3 --- /dev/null +++ b/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx @@ -0,0 +1,55 @@ +// Libraries +import React from 'react'; + +// Partial components +import Button from 'js/components/common/button'; +import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniversalTable.component'; + +// Utilities +import useAccessLogsQuery, {type AccessLog} from 'js/query/queries/accessLogs.query'; +import {formatTime} from 'js/utils'; +import sessionStore from 'js/stores/session'; + +// Styles +import securityStyles from 'js/account/security/securityRoute.module.scss'; + +export default function AccessLogsSection() { + function logOutAllSessions() { + sessionStore.logOutAll(); + } + + return ( + <> +
+

+ {t('Recent account activity')} +

+ +
+
+
+ + + queryHook={useAccessLogsQuery} + columns={[ + // The `key`s of these columns are matching the `AccessLog` interface + // properties (from `accessLogs.query.ts` file) using dot notation. + {key: 'metadata.source', label: t('Source')}, + { + key: 'date_created', + label: t('Last activity'), + cellFormatter: (date: string) => formatTime(date), + }, + {key: 'metadata.ip_address', label: t('IP Address')}, + ]} + /> + + ); +} diff --git a/jsapp/js/account/security/apiToken/apiTokenSection.component.tsx b/jsapp/js/account/security/apiToken/apiTokenSection.component.tsx index ea435a56e1..49c5f36b2e 100644 --- a/jsapp/js/account/security/apiToken/apiTokenSection.component.tsx +++ b/jsapp/js/account/security/apiToken/apiTokenSection.component.tsx @@ -1,12 +1,21 @@ +// Libraries import React, { useState, useEffect, } from 'react'; -import {dataInterface} from 'js/dataInterface'; +import cx from 'classnames'; + +// Partial components import TextBox from 'js/components/common/textBox'; import Button from 'js/components/common/button'; + +// Utils +import {dataInterface} from 'js/dataInterface'; import {notify} from 'js/utils'; + +// Styles import styles from './apiTokenSection.module.scss'; +import securityStyles from 'js/account/security/securityRoute.module.scss'; const HIDDEN_TOKEN_VALUE = '*'.repeat(40); @@ -42,28 +51,28 @@ export default function ApiTokenDisplay() { }, [isVisible]); return ( -
-
-

{t('API Key')}

-
- -
- -
+
+
+

{t('API Key')}

+
-
-
-
+
+ +
+
+
+ ); } diff --git a/jsapp/js/account/security/apiToken/apiTokenSection.module.scss b/jsapp/js/account/security/apiToken/apiTokenSection.module.scss index 130237141b..5a2525338f 100644 --- a/jsapp/js/account/security/apiToken/apiTokenSection.module.scss +++ b/jsapp/js/account/security/apiToken/apiTokenSection.module.scss @@ -2,59 +2,19 @@ @use 'scss/sizes'; @use 'scss/libs/_mdl'; -.root { - margin-top: sizes.$x20; - margin-bottom: sizes.$x60; - padding-top: sizes.$x14; - display: flex; - align-items: baseline; - column-gap: sizes.$x16; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-300; -} - -.titleSection { - flex: 2; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; -} - -.title { - margin: 0; - color: colors.$kobo-gray-700; - font-weight: 600; - line-height: 1.6; +.body { + flex: 5; } -.bodySection { - flex: 6; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - padding-left: sizes.$x4; - - label { - display: inline-block; - vertical-align: top; - width: 100%; - - input { - // API token display - font-family: mdl.$font_mono; - // 40 characters + padding + border - max-width: calc(40ch + 22px); - } +.token { + input { + // API token display + font-family: mdl.$font_mono; } } -.optionsSection { - flex: 2; - text-align: right; - - button { - display: inline-block; - } +.options { + flex: 3; + display: flex; + justify-content: flex-end; } diff --git a/jsapp/js/account/security/email/emailSection.component.tsx b/jsapp/js/account/security/email/emailSection.component.tsx index 9df4cac7d5..e350affae5 100644 --- a/jsapp/js/account/security/email/emailSection.component.tsx +++ b/jsapp/js/account/security/email/emailSection.component.tsx @@ -1,4 +1,8 @@ +// Libraries import React, {useEffect, useState} from 'react'; +import cx from 'classnames'; + +// Stores and email related import sessionStore from 'js/stores/session'; import { getUserEmails, @@ -6,12 +10,18 @@ import { deleteUnverifiedUserEmails, } from './emailSection.api'; import type {EmailResponse} from './emailSection.api'; -import style from './emailSection.module.scss'; + +// Partial components import Button from 'jsapp/js/components/common/button'; import TextBox from 'jsapp/js/components/common/textBox'; import Icon from 'jsapp/js/components/common/icon'; -import {formatTime} from 'jsapp/js/utils'; -import {notify} from 'js/utils'; + +// Utils +import {formatTime, notify} from 'js/utils'; + +// Styles +import styles from './emailSection.module.scss'; +import securityStyles from 'js/account/security/securityRoute.module.scss'; interface EmailState { emails: EmailResponse[]; @@ -23,9 +33,14 @@ interface EmailState { export default function EmailSection() { const [session] = useState(() => sessionStore); + let initialEmail = ''; + if ('email' in session.currentAccount) { + initialEmail = session.currentAccount.email; + } + const [email, setEmail] = useState({ emails: [], - newEmail: '', + newEmail: initialEmail, refreshedEmail: false, refreshedEmailDate: '', }); @@ -48,7 +63,7 @@ export default function EmailSection() { newEmail: '', }); }); - }); + }, () => {/* Avoid crashing app when 500 error happens */}); } function deleteNewUserEmail() { @@ -103,16 +118,21 @@ export default function EmailSection() { ); return ( -
-
-

{t('Email address')}

+
+
+

{t('Email address')}

-
+
{!session.isPending && session.isInitialLoadComplete && 'email' in currentAccount && ( -

{currentAccount.email}

+ )} {unverifiedEmail?.email && @@ -120,9 +140,9 @@ export default function EmailSection() { session.isInitialLoadComplete && 'email' in currentAccount && ( <> -
+
-

+

{t('Check your email ##UNVERIFIED_EMAIL##. ').replace( '##UNVERIFIED_EMAIL##', @@ -136,7 +156,7 @@ export default function EmailSection() {

-
+
+
); } diff --git a/jsapp/js/account/security/email/emailSection.module.scss b/jsapp/js/account/security/email/emailSection.module.scss index 23a2c7e377..4d9eeb2f3c 100644 --- a/jsapp/js/account/security/email/emailSection.module.scss +++ b/jsapp/js/account/security/email/emailSection.module.scss @@ -1,49 +1,15 @@ @use 'scss/colors'; @use 'scss/sizes'; -@use 'scss/libs/_mdl'; -.root { - margin-top: sizes.$x20; - margin-bottom: sizes.$x60; - padding-top: sizes.$x14; - display: flex; - align-items: baseline; - column-gap: sizes.$x16; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-300; -} - -.titleSection { - flex: 2; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; -} - -.title { - margin: 0; - color: colors.$kobo-gray-700; - font-weight: 600; - line-height: 1.6; -} - -.bodySection { +.body { flex: 5; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - padding-left: sizes.$x16; + gap: 10px; } -.optionsSection { - flex: 3; // widen for email input - - // stack input and button, right-aligned. +.options { + flex: 3; display: flex; - flex-direction: column; - row-gap: sizes.$x12; - align-items: flex-end; + justify-content: flex-end; } .currentEmail { @@ -65,9 +31,7 @@ } } -.editEmail { +.unverifiedEmailButtons { display: flex; - column-gap: sizes.$x12; - padding-top: sizes.$x12; - padding-bottom: sizes.$x12; + gap: 10px; } diff --git a/jsapp/js/account/security/mfa/mfaSection.component.tsx b/jsapp/js/account/security/mfa/mfaSection.component.tsx index b2749b76cb..d9f1bdb6e9 100644 --- a/jsapp/js/account/security/mfa/mfaSection.component.tsx +++ b/jsapp/js/account/security/mfa/mfaSection.component.tsx @@ -1,40 +1,32 @@ +// Libraries import React from 'react'; -import bem, {makeBem} from 'js/bem'; +import cx from 'classnames'; + +// Partial components import Button from 'js/components/common/button'; import ToggleSwitch from 'js/components/common/toggleSwitch'; import Icon from 'js/components/common/icon'; import InlineMessage from 'js/components/common/inlineMessage'; import LoadingSpinner from 'js/components/common/loadingSpinner'; + +// Reflux import type { MfaUserMethodsResponse, MfaActivatedResponse, } from 'js/actions/mfaActions'; import mfaActions from 'js/actions/mfaActions'; + +// Constants and utils import {MODAL_TYPES} from 'jsapp/js/constants'; -import envStore from 'js/envStore'; -import './mfaSection.scss'; import {formatTime, formatDate} from 'js/utils'; + +// Stores +import envStore from 'js/envStore'; import pageState from 'js/pageState.store'; -bem.SecurityRow = makeBem(null, 'security-row'); -bem.SecurityRow__header = makeBem(bem.SecurityRow, 'header'); -bem.SecurityRow__title = makeBem(bem.SecurityRow, 'title', 'h2'); -bem.SecurityRow__buttons = makeBem(bem.SecurityRow, 'buttons'); -bem.SecurityRow__description = makeBem(bem.SecurityRow, 'description', 'p'); -bem.SecurityRow__switch = makeBem(bem.SecurityRow, 'switch'); - -bem.MFAOptions = makeBem(null, 'mfa-options'); -bem.MFAOptions__row = makeBem(bem.MFAOptions, 'row'); -bem.MFAOptions__label = makeBem(bem.MFAOptions, 'label'); -bem.MFAOptions__buttons = makeBem(bem.MFAOptions, 'buttons'); -bem.MFAOptions__date = makeBem(bem.MFAOptions, 'date'); - -bem.TableMediaPreviewHeader = makeBem(null, 'table-media-preview-header'); -bem.TableMediaPreviewHeader__title = makeBem( - bem.TableMediaPreviewHeader, - 'title', - 'div' -); +// Styles +import styles from './mfaSection.module.scss'; +import securityStyles from 'js/account/security/securityRoute.module.scss'; interface SecurityState { isMfaAvailable?: boolean; @@ -93,7 +85,7 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { } } - onGetMfaAvailability(response: {isMfaAvailable: boolean, isPlansMessageVisible: boolean}) { + onGetMfaAvailability(response: {isMfaAvailable: boolean; isPlansMessageVisible: boolean}) { // Determine whether MFA is allowed based on per-user availability and subscription status this.setState({ isMfaAvailable: response.isMfaAvailable, @@ -160,12 +152,12 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { renderCustomHeader() { return ( - - +
+
{t('Two-factor authentication')} - - +
+
); } @@ -179,74 +171,64 @@ export default class SecurityRoute extends React.Component<{}, SecurityState> { } return ( - <> - - - - {t('Two-factor authentication')} - - - +
+
+

+ {t('Two-factor authentication')} +

+
+ +
+
+

{t( 'Two-factor authentication (2FA) verifies your identity using an authenticator application in addition to your usual password. ' + 'We recommend enabling two-factor authentication for an additional layer of protection.' )} - - - - - - - - - - {this.state.isMfaActive && this.state.isMfaAvailable && ( - - - - {t('Authenticator app')} - - - {this.state.dateModified && ( - - {formatTime(this.state.dateModified)} - - )} - - +

+ + {this.state.isMfaActive && this.state.isMfaAvailable && ( +
+
+

+ {t('Authenticator app')} +

+ + {this.state.dateModified && ( +
+ {formatTime(this.state.dateModified)} +
+ )} +
- - - {t('Recovery codes')} - +
+

+ {t('Recovery codes')} +

-
+
+ )} +
{!this.state.isMfaActive && this.state.isMfaAvailable && this.state.dateDisabled && ( { ).replace('##date##', formatDate(this.state.dateDisabled))} /> )} - - {this.state.isPlansMessageVisible && ( - - {t('This feature is not available on your current plan. Please visit the ')} - {t('Plans page')} - {t(' to upgrade your account.')} - - } + + {this.state.isPlansMessageVisible && ( + + {t('This feature is not available on your current plan. Please visit the ')} + {t('Plans page')} + {t(' to upgrade your account.')} + + } + /> + )} +
+ +
+ - )} - +
+
); } } diff --git a/jsapp/js/account/security/mfa/mfaSection.module.scss b/jsapp/js/account/security/mfa/mfaSection.module.scss new file mode 100644 index 0000000000..4a95272487 --- /dev/null +++ b/jsapp/js/account/security/mfa/mfaSection.module.scss @@ -0,0 +1,52 @@ +@use 'scss/mixins'; +@use 'scss/colors'; +@use 'scss/sizes'; + +.isUnauthorized .bodyMain, +.isUnauthorized .options { + opacity: 0.5; + pointer-events: none; +} + +// We need stronger specificity +.body.body { + flex: 6; + flex-direction: column; +} + +.options { + flex: 2; + display: flex; + justify-content: flex-end; +} + +.mfaDescription { + margin: 0; +} + +.mfaOptions { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; + width: 100%; +} + +.mfaOptionsRow { + @include mixins.centerRowFlex; + flex-wrap: wrap; + gap: 10px; + padding: 16px; + justify-content: space-between; + background-color: colors.$kobo-gray-200; + border-radius: 6px; +} + +.mfaOptionsLabel { + flex: 1; + display: flex; + align-items: center; + font-weight: 600; + font-size: sizes.$x16; + margin: 0; +} diff --git a/jsapp/js/account/security/mfa/mfaSection.scss b/jsapp/js/account/security/mfa/mfaSection.scss deleted file mode 100644 index e7a03e97ee..0000000000 --- a/jsapp/js/account/security/mfa/mfaSection.scss +++ /dev/null @@ -1,81 +0,0 @@ -@use 'scss/mixins'; -@use 'scss/colors'; -@use 'scss/sizes'; - -.security-row { - margin-bottom: sizes.$x60; - - .security-row__header { - margin-top: sizes.$x20; - padding-top: sizes.$x14; - display: flex; - align-items: baseline; - column-gap: sizes.$x16; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-300; - - > * { - margin: 0; - } - } - - .security-row__title { - flex: 2; - color: colors.$kobo-gray-700; - font-weight: 600; - line-height: 1.6; - } - - .security-row__description { - flex: 6; - padding-left: sizes.$x16; - } - - .security-row__switch { - flex: 2; - text-align: right; - } - - .security-row__buttons { - margin-right: sizes.$x20; - - .toggle-switch__wrapper { - display: flex; - } - - .toggle-switch__label { - font-weight: 600; - color: colors.$kobo-gray-700; - } - } -} - -.security-row--unauthorized { - opacity: 0.5; - pointer-events: none; - margin-bottom: sizes.$x36; -} - -.mfa-options { - .mfa-options__buttons { - margin: sizes.$x14 0; - padding: 0 sizes.$x14 0 sizes.$x28; - } - - .mfa-options__row { - @include mixins.centerRowFlex; - - margin: sizes.$x14 0; - padding: 0 sizes.$x14 0 sizes.$x28; - justify-content: space-between; - background-color: colors.$kobo-gray-300; - } - - .mfa-options__label { - flex: 1; - display: flex; - align-items: center; - font-weight: 600; - font-size: sizes.$x16; - } -} diff --git a/jsapp/js/account/security/password/passwordSection.component.tsx b/jsapp/js/account/security/password/passwordSection.component.tsx index 74eb9b6e20..c8cb62fef5 100644 --- a/jsapp/js/account/security/password/passwordSection.component.tsx +++ b/jsapp/js/account/security/password/passwordSection.component.tsx @@ -1,37 +1,51 @@ +// Libraries import React from 'react'; -import {PATHS} from 'js/router/routerConstants'; -import Button from 'jsapp/js/components/common/button'; -import styles from './passwordSection.module.scss'; + +// Partial components import {NavLink} from 'react-router-dom'; +import Button from 'jsapp/js/components/common/button'; + +// Constants +import {PATHS} from 'js/router/routerConstants'; import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; +// Styles +import styles from './passwordSection.module.scss'; +import securityStyles from 'js/account/security/securityRoute.module.scss'; + const HIDDEN_TOKEN_VALUE = '● '.repeat(10); export default function PasswordSection() { return ( -
-
-

{t('Password')}

+
+
+

{t('Password')}

-
+

{HIDDEN_TOKEN_VALUE}

-
- {t('forgot password')} +
+ +
-
+
); } diff --git a/jsapp/js/account/security/password/passwordSection.module.scss b/jsapp/js/account/security/password/passwordSection.module.scss index a741a39d13..5cffd6dadd 100644 --- a/jsapp/js/account/security/password/passwordSection.module.scss +++ b/jsapp/js/account/security/password/passwordSection.module.scss @@ -1,68 +1,22 @@ @use 'scss/colors'; @use 'scss/sizes'; -@use 'scss/libs/_mdl'; +@use 'scss/mixins'; -.root { - margin-top: sizes.$x20; - margin-bottom: sizes.$x60; - padding-top: sizes.$x14; - display: flex; - align-items: baseline; - column-gap: sizes.$x16; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-300; -} - -.titleSection { - flex: 2; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; -} - -.title { +.passwordDisplay { margin: 0; - color: colors.$kobo-gray-700; - font-weight: 600; - line-height: 1.6; -} - -.bodySection { - flex: 3; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - padding-left: sizes.$x16; - .passwordDisplay { - margin: 0; - - // gray circles - color: colors.$kobo-gray-500; - cursor: default; - font-size: sizes.$x14; - // Allow cutoff if low on space - overflow-y: hidden; - height: sizes.$x20; - } + // gray circles + color: colors.$kobo-gray-500; + cursor: default; + font-size: sizes.$x14; + // Allow cutoff if low on space + overflow-y: hidden; + height: sizes.$x20; } -.optionsSection { - text-align: right; +.options { + @include mixins.centerRowFlex; + gap: 10px; flex: 5; - - a { - display: inline-block; - margin-bottom: sizes.$x12; - - &:hover { - text-decoration: underline; - } - } -} - -.optionsSection > *:not(:first-child) { - margin-left: sizes.$x12; + justify-content: flex-end; } diff --git a/jsapp/js/account/security/securityRoute.component.tsx b/jsapp/js/account/security/securityRoute.component.tsx index ff35aa7fcb..4446e4227c 100644 --- a/jsapp/js/account/security/securityRoute.component.tsx +++ b/jsapp/js/account/security/securityRoute.component.tsx @@ -1,20 +1,37 @@ +// Libraries import React from 'react'; + +// Partial components import MfaSection from './mfa/mfaSection.component'; import PasswordSection from './password/passwordSection.component'; import EmailSection from './email/emailSection.component'; import ApiTokenSection from './apiToken/apiTokenSection.component'; import SsoSection from './sso/ssoSection.component'; -import style from './securityRoute.module.scss'; +import AccessLogsSection from './accessLogs/accessLogsSection.component'; + +// Styles +import styles from './securityRoute.module.scss'; export default function securityRoute() { return ( -
-

{t('Security')}

+
+
+

+ {t('Security')} +

+
+ + + + + + +
); } diff --git a/jsapp/js/account/security/securityRoute.module.scss b/jsapp/js/account/security/securityRoute.module.scss index 20ee7157c5..4de6c0b598 100644 --- a/jsapp/js/account/security/securityRoute.module.scss +++ b/jsapp/js/account/security/securityRoute.module.scss @@ -1,14 +1,13 @@ +@use 'scss/mixins'; +@use 'scss/colors'; @use 'scss/sizes'; +@use 'scss/breakpoints'; -.security-section { - padding: sizes.$x50; +.securityRouteRoot { + padding: sizes.$x20; overflow-y: auto; height: 100%; - h1 { - margin-bottom: sizes.$x36; - } - :global { // Harmonize button widths .k-button__label { @@ -18,3 +17,76 @@ } } } + +header.securityHeader { + @include mixins.centerRowFlex; + margin: 24px 0; + + &:not(:first-child) { + margin-top: 44px; + } +} + +h2.securityHeaderText { + color: colors.$kobo-storm; + text-transform: uppercase; + font-size: 18px; + font-weight: 700; + flex: 1; + margin: 0; +} + +.securityHeaderActions { + @include mixins.centerRowFlex; +} + +// Shared styles for sections + +.securitySection { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 16px 0; + border-top: 1px solid colors.$kobo-gray-200; + + &:last-of-type { + border-bottom: 1px solid colors.$kobo-gray-200; + } +} + +.securitySectionTitle { + width: 100%; + flex: initial; + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +.securitySectionTitleText { + margin: 0; + color: colors.$kobo-gray-600; + font-weight: 600; + line-height: 1.6; + font-size: 16px; +} + +.securitySectionBody { + flex: 3; + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +@include breakpoints.breakpoint(mediumAndUp) { + .securitySectionTitle { + width: initial; + flex: 2; + } + + .securityRouteRoot { + padding: sizes.$x50; + } +} diff --git a/jsapp/js/account/security/sso/ssoSection.component.tsx b/jsapp/js/account/security/sso/ssoSection.component.tsx index 167466f0ac..0274c36718 100644 --- a/jsapp/js/account/security/sso/ssoSection.component.tsx +++ b/jsapp/js/account/security/sso/ssoSection.component.tsx @@ -1,11 +1,19 @@ +// Libraries import React, {useCallback} from 'react'; import {observer} from 'mobx-react-lite'; +import cx from 'classnames'; + +// Partial components +import Button from 'jsapp/js/components/common/button'; + +// Stores and utils import sessionStore from 'js/stores/session'; -import styles from './ssoSection.module.scss'; +import envStore, {type SocialApp} from 'jsapp/js/envStore'; import {deleteSocialAccount} from './sso.api'; -import Button from 'jsapp/js/components/common/button'; -import envStore, {SocialApp} from 'jsapp/js/envStore'; -import classNames from 'classnames'; + +// Styles +import styles from './ssoSection.module.scss'; +import securityStyles from 'js/account/security/securityRoute.module.scss'; const SsoSection = observer(() => { const socialApps = envStore.isReady ? envStore.data.social_apps : []; @@ -25,48 +33,49 @@ const SsoSection = observer(() => { const providerLink = useCallback((socialApp: SocialApp) => { let providerPath = ''; - if(socialApp.provider === 'openid_connect') { + if (socialApp.provider === 'openid_connect') { providerPath = 'oidc/' + socialApp.provider_id; } else { providerPath = socialApp.provider_id || socialApp.provider; } return `accounts/${providerPath}/login/?process=connect&next=%2F%23%2Faccount%2Fsecurity`; - }, [sessionStore.currentAccount]) + }, [sessionStore.currentAccount]); if (socialApps.length === 0 && socialAccounts.length === 0) { return <>; } return ( -
-
-

{t('Single-Sign On')}

+
+
+

{t('Single-Sign On')}

+ {socialAccounts.length === 0 ? ( -
-
- {t( - "Connect your KoboToolbox account with your organization's identity provider for single-sign on (SSO). Afterwards, you will only " + - 'be able to sign in via SSO unless you disable this setting here. This will also update your email address in case your current ' + - 'address is different.' - )} -
+
+ {t( + "Connect your KoboToolbox account with your organization's identity provider for single-sign on (SSO). Afterwards, you will only " + + 'be able to sign in via SSO unless you disable this setting here. This will also update your email address in case your current ' + + 'address is different.' + )}
) : ( -
{t('Already connected')}
+
+ {t('Already connected')} +
)} {socialAccounts.length === 0 ? ( -
); }); diff --git a/jsapp/js/account/security/sso/ssoSection.module.scss b/jsapp/js/account/security/sso/ssoSection.module.scss index f90a601ad9..356bab7ab9 100644 --- a/jsapp/js/account/security/sso/ssoSection.module.scss +++ b/jsapp/js/account/security/sso/ssoSection.module.scss @@ -1,43 +1,11 @@ @use 'scss/colors'; @use 'scss/sizes'; -@use 'scss/libs/_mdl'; -.root { - margin-top: sizes.$x20; - margin-bottom: sizes.$x60; - padding-top: sizes.$x14; - display: flex; - align-items: baseline; - column-gap: sizes.$x16; - border-top: sizes.$x1 solid; - border-color: colors.$kobo-gray-300; -} - -.titleSection { - flex: 2; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; -} - -.title { - margin: 0; - color: colors.$kobo-gray-700; - font-weight: 600; - line-height: 1.6; -} - -.bodySection { +.body { flex: 6; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - padding-left: sizes.$x16; } -.optionsSection { +.options { text-align: right; flex: 2; diff --git a/jsapp/js/actions/mfaActions.ts b/jsapp/js/actions/mfaActions.ts index 098c7afa1a..8128867a67 100644 --- a/jsapp/js/actions/mfaActions.ts +++ b/jsapp/js/actions/mfaActions.ts @@ -102,7 +102,7 @@ mfaActions.getMfaAvailability.listen(() => { mfaActions.getMfaAvailability.failed({isMfaAvailable: false, isPlansMessageVisible: false}); }); } - }) + }); }); mfaActions.confirmCode.listen((mfaCode: string) => { diff --git a/jsapp/js/api.endpoints.ts b/jsapp/js/api.endpoints.ts index 9d0ade0300..5a4c6e899d 100644 --- a/jsapp/js/api.endpoints.ts +++ b/jsapp/js/api.endpoints.ts @@ -10,4 +10,6 @@ export const endpoints = { PORTAL_URL: '/api/v2/stripe/customer-portal', /** Expected parameters: price_id and subscription_id **/ CHANGE_PLAN_URL: '/api/v2/stripe/change-plan', + ACCESS_LOGS_URL: '/api/v2/access-logs/me', + LOGOUT_ALL: '/logout-all/', }; diff --git a/jsapp/js/query/queries/accessLogs.query.ts b/jsapp/js/query/queries/accessLogs.query.ts new file mode 100644 index 0000000000..ea2bf8df1d --- /dev/null +++ b/jsapp/js/query/queries/accessLogs.query.ts @@ -0,0 +1,53 @@ +import {keepPreviousData, useQuery} from '@tanstack/react-query'; +import {endpoints} from 'js/api.endpoints'; +import type {PaginatedResponse} from 'js/dataInterface'; +import {fetchGet} from 'js/api'; + +export interface AccessLog { + app_label: 'kobo_auth' | string; + model_name: 'User' | string; + object_id: number; + /** User URL */ + user: string; + user_uid: string; + username: string; + action: 'auth' | string; + metadata: { + /** E.g. "Firefox (Ubuntu)" */ + source: string; + auth_type: 'Digest' | string; + ip_address: string; + }; + /** Date string */ + date_created: string; + log_type: 'access' | string; +} + +async function getAccessLogs(limit: number, offset: number) { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + return fetchGet>( + endpoints.ACCESS_LOGS_URL + '?' + params, + { + errorMessageDisplay: t('There was an error getting the list.'), + } + ); +} + +export default function useAccessLogsQuery( + itemLimit: number, + pageOffset: number +) { + return useQuery({ + queryKey: ['accessLogs', itemLimit, pageOffset], + queryFn: () => getAccessLogs(itemLimit, pageOffset), + placeholderData: keepPreviousData, + // We might want to improve this in future, for now let's not retry + retry: false, + // The `refetchOnWindowFocus` option is `true` by default, I'm setting it + // here so we don't forget about it. + refetchOnWindowFocus: true, + }); +} diff --git a/jsapp/js/stores/session.ts b/jsapp/js/stores/session.ts index a862740c4e..b9dcf5c2a4 100644 --- a/jsapp/js/stores/session.ts +++ b/jsapp/js/stores/session.ts @@ -5,6 +5,8 @@ import type {AccountResponse, FailResponse} from 'js/dataInterface'; import {log, currentLang} from 'js/utils'; import type {Json} from 'js/components/common/common.interfaces'; import type {ProjectViewsSettings} from 'js/projects/customViewStore'; +import {fetchPost, handleApiFail} from 'js/api'; +import {endpoints} from 'js/api.endpoints'; class SessionStore { currentAccount: AccountResponse | {username: string; date_joined: string} = { @@ -97,6 +99,20 @@ class SessionStore { ); } + /** + * Useful if you need to log out all sessions. + */ + public async logOutAll() { + try { + // Logging out is simply POSTing to this endpoint + await fetchPost(endpoints.LOGOUT_ALL, {}); + // After that we force reload + window.location.replace(''); + } catch (err) { + // `fetchPost` is handling the error + } + } + private saveUiLanguage() { // We want to save the language if it differs from the one we saved or if // none is saved yet. diff --git a/jsapp/js/universalTable/universalTable.component.tsx b/jsapp/js/universalTable/universalTable.component.tsx index 490cb1a770..eafe0c980d 100644 --- a/jsapp/js/universalTable/universalTable.component.tsx +++ b/jsapp/js/universalTable/universalTable.component.tsx @@ -162,7 +162,7 @@ export default function UniversalTable( .map((col) => col.key); options.state.columnPinning = {left: pinnedColumns || []}; - const hasPagination = ( + const hasPagination = Boolean( props.pageIndex !== undefined && props.pageCount && props.pageSize &&