mirror of
https://github.com/flarum/core.git
synced 2025-07-30 21:20:24 +02:00
feat: access tokens user management UI (#3587)
Signed-off-by: Sami Mazouz <ilyasmazouz@gmail.com> Co-authored-by: David <hi@davwheat.dev>
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"@types/mithril": "^2.0.8",
|
||||
"@types/punycode": "^2.1.0",
|
||||
"@types/textarea-caret": "^3.0.1",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"bundlewatch": "^0.3.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"expose-loader": "^3.1.0",
|
||||
|
@@ -236,6 +236,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||
90
|
||||
);
|
||||
|
||||
items.add(
|
||||
'createAccessToken',
|
||||
{
|
||||
icon: 'fas fa-key',
|
||||
label: app.translator.trans('core.admin.permissions.create_access_token_label'),
|
||||
permission: 'createAccessToken',
|
||||
},
|
||||
80
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('start'));
|
||||
|
||||
return items;
|
||||
@@ -396,6 +406,16 @@ export default class PermissionGrid<CustomAttrs extends IPermissionGridAttrs = I
|
||||
60
|
||||
);
|
||||
|
||||
items.add(
|
||||
'moderateAccessTokens',
|
||||
{
|
||||
icon: 'fas fa-key',
|
||||
label: app.translator.trans('core.admin.permissions.moderate_access_tokens_label'),
|
||||
permission: 'moderateAccessTokens',
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
items.merge(app.extensionData.getAllExtensionPermissions('moderate'));
|
||||
|
||||
return items;
|
||||
|
@@ -36,6 +36,7 @@ import Model, { SavedModelData } from './Model';
|
||||
import fireApplicationError from './helpers/fireApplicationError';
|
||||
import IHistory from './IHistory';
|
||||
import IExtender from './extenders/IExtender';
|
||||
import AccessToken from './models/AccessToken';
|
||||
|
||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||
|
||||
@@ -178,6 +179,7 @@ export default class Application {
|
||||
* The app's data store.
|
||||
*/
|
||||
store: Store = new Store({
|
||||
'access-tokens': AccessToken,
|
||||
forums: Forum,
|
||||
users: User,
|
||||
discussions: Discussion,
|
||||
|
29
framework/core/js/src/common/components/LabelValue.tsx
Normal file
29
framework/core/js/src/common/components/LabelValue.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Component, { ComponentAttrs } from '../Component';
|
||||
import type Mithril from 'mithril';
|
||||
import app from '../app';
|
||||
|
||||
export interface ILabelValueAttrs extends ComponentAttrs {
|
||||
label: Mithril.Children;
|
||||
value: Mithril.Children;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic component for displaying a label and value inline.
|
||||
* Created to avoid reinventing the wheel.
|
||||
*
|
||||
* `label: value`
|
||||
*/
|
||||
export default class LabelValue<CustomAttrs extends ILabelValueAttrs = ILabelValueAttrs> extends Component<CustomAttrs> {
|
||||
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
|
||||
return (
|
||||
<div className="LabelValue">
|
||||
<div className="LabelValue-label">
|
||||
{app.translator.trans('core.lib.data_segment.label', {
|
||||
label: this.attrs.label,
|
||||
})}
|
||||
</div>
|
||||
<div className="LabelValue-value">{this.attrs.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
34
framework/core/js/src/common/models/AccessToken.ts
Normal file
34
framework/core/js/src/common/models/AccessToken.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Model from '../Model';
|
||||
|
||||
export default class AccessToken extends Model {
|
||||
token() {
|
||||
return Model.attribute<string | undefined>('token').call(this);
|
||||
}
|
||||
userId() {
|
||||
return Model.attribute<string>('userId').call(this);
|
||||
}
|
||||
title() {
|
||||
return Model.attribute<string | null>('title').call(this);
|
||||
}
|
||||
type() {
|
||||
return Model.attribute<string>('type').call(this);
|
||||
}
|
||||
createdAt() {
|
||||
return Model.attribute<Date, string>('createdAt', Model.transformDate).call(this);
|
||||
}
|
||||
lastActivityAt() {
|
||||
return Model.attribute<Date, string>('lastActivityAt', Model.transformDate).call(this);
|
||||
}
|
||||
lastIpAddress() {
|
||||
return Model.attribute<string>('lastIpAddress').call(this);
|
||||
}
|
||||
device() {
|
||||
return Model.attribute<string>('device').call(this);
|
||||
}
|
||||
isCurrent() {
|
||||
return Model.attribute<boolean>('isCurrent').call(this);
|
||||
}
|
||||
isSessionToken() {
|
||||
return Model.attribute<boolean>('isSessionToken').call(this);
|
||||
}
|
||||
}
|
@@ -72,3 +72,10 @@ getPlainContent.removeSelectors = ['blockquote', 'script'];
|
||||
export function ucfirst(string: string): string {
|
||||
return string.substr(0, 1).toUpperCase() + string.substr(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a camel case string to snake case.
|
||||
*/
|
||||
export function camelCaseToSnakeCase(str: string): string {
|
||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||
}
|
||||
|
197
framework/core/js/src/forum/components/AccessTokensList.tsx
Normal file
197
framework/core/js/src/forum/components/AccessTokensList.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import app from '../app';
|
||||
import Component, { ComponentAttrs } from '../../common/Component';
|
||||
import icon from '../../common/helpers/icon';
|
||||
import Button from '../../common/components/Button';
|
||||
import humanTime from '../../common/helpers/humanTime';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import LabelValue from '../../common/components/LabelValue';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import classList from '../../common/utils/classList';
|
||||
import Tooltip from '../../common/components/Tooltip';
|
||||
import type Mithril from 'mithril';
|
||||
import type AccessToken from '../../common/models/AccessToken';
|
||||
import { NestedStringArray } from '@askvortsov/rich-icu-message-formatter';
|
||||
|
||||
export interface IAccessTokensListAttrs extends ComponentAttrs {
|
||||
tokens: AccessToken[];
|
||||
type: 'session' | 'developer_token';
|
||||
hideTokens?: boolean;
|
||||
icon?: string;
|
||||
ondelete?: (token: AccessToken) => void;
|
||||
}
|
||||
|
||||
export default class AccessTokensList<CustomAttrs extends IAccessTokensListAttrs = IAccessTokensListAttrs> extends Component<CustomAttrs> {
|
||||
protected loading: Record<string, boolean | undefined> = {};
|
||||
protected showingTokens: Record<string, boolean | undefined> = {};
|
||||
|
||||
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
|
||||
return (
|
||||
<div className="AccessTokensList">
|
||||
{this.attrs.tokens.length ? (
|
||||
this.attrs.tokens.map(this.tokenView.bind(this))
|
||||
) : (
|
||||
<div className="AccessTokensList--empty">{app.translator.trans('core.forum.security.empty_text')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
tokenView(token: AccessToken): Mithril.Children {
|
||||
return (
|
||||
<div
|
||||
className={classList('AccessTokensList-item', {
|
||||
'AccessTokensList-item--active': token.isCurrent(),
|
||||
})}
|
||||
key={token.id()!}
|
||||
>
|
||||
{this.tokenViewItems(token).toArray()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
tokenViewItems(token: AccessToken): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add('icon', <div className="AccessTokensList-item-icon">{icon(this.attrs.icon || 'fas fa-key')}</div>, 50);
|
||||
|
||||
items.add('info', <div className="AccessTokensList-item-info">{this.tokenInfoItems(token).toArray()}</div>, 40);
|
||||
|
||||
items.add('actions', <div className="AccessTokensList-item-actions">{this.tokenActionItems(token).toArray()}</div>, 30);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
tokenInfoItems(token: AccessToken) {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
if (this.attrs.type === 'session') {
|
||||
items.add(
|
||||
'title',
|
||||
<div className="AccessTokensList-item-title">
|
||||
<span className="AccessTokensList-item-title-main">{token.device()}</span>
|
||||
{token.isCurrent() && [
|
||||
' — ',
|
||||
<span className="AccessTokensList-item-title-sub">{app.translator.trans('core.forum.security.current_active_session')}</span>,
|
||||
]}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
items.add(
|
||||
'title',
|
||||
<div className="AccessTokensList-item-title">
|
||||
<span className="AccessTokensList-item-title-main">{this.generateTokenTitle(token)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'createdAt',
|
||||
<div className="AccessTokensList-item-createdAt">
|
||||
<LabelValue label={app.translator.trans('core.forum.security.created')} value={humanTime(token.createdAt())} />
|
||||
</div>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'lastActivityAt',
|
||||
<div className="AccessTokensList-item-lastActivityAt">
|
||||
<LabelValue
|
||||
label={app.translator.trans('core.forum.security.last_activity')}
|
||||
value={
|
||||
token.lastActivityAt() ? (
|
||||
<>
|
||||
{humanTime(token.lastActivityAt())}
|
||||
{token.lastIpAddress() && ` — ${token.lastIpAddress()}`}
|
||||
{this.attrs.type === 'developer_token' && token.device() && (
|
||||
<>
|
||||
{' '}
|
||||
— <span className="AccessTokensList-item-title-sub">{token.device()}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
app.translator.trans('core.forum.security.never')
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
tokenActionItems(token: AccessToken) {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const deleteKey = {
|
||||
session: 'terminate_session',
|
||||
developer_token: 'revoke_access_token',
|
||||
}[this.attrs.type];
|
||||
|
||||
if (this.attrs.type === 'developer_token') {
|
||||
const isHidden = !this.showingTokens[token.id()!];
|
||||
const displayKey = isHidden ? 'show_access_token' : 'hide_access_token';
|
||||
|
||||
items.add(
|
||||
'toggleDisplay',
|
||||
<Button
|
||||
className="Button Button--inverted"
|
||||
icon={isHidden ? 'fas fa-eye' : 'fas fa-eye-slash'}
|
||||
onclick={() => {
|
||||
this.showingTokens[token.id()!] = isHidden;
|
||||
m.redraw();
|
||||
}}
|
||||
>
|
||||
{app.translator.trans(`core.forum.security.${displayKey}`)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
let revokeButton = (
|
||||
<Button className="Button Button--danger" disabled={token.isCurrent()} loading={!!this.loading[token.id()!]} onclick={() => this.revoke(token)}>
|
||||
{app.translator.trans(`core.forum.security.${deleteKey}`)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (token.isCurrent()) {
|
||||
revokeButton = (
|
||||
<Tooltip text={app.translator.trans('core.forum.security.cannot_terminate_current_session')}>
|
||||
<div tabindex="0">{revokeButton}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
items.add('revoke', revokeButton);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async revoke(token: AccessToken) {
|
||||
if (!confirm(extractText(app.translator.trans('core.forum.security.revoke_access_token_confirmation')))) return;
|
||||
|
||||
this.loading[token.id()!] = true;
|
||||
|
||||
await token.delete();
|
||||
|
||||
this.loading[token.id()!] = false;
|
||||
this.attrs.ondelete?.(token);
|
||||
|
||||
const key = this.attrs.type === 'session' ? 'session_terminated' : 'token_revoked';
|
||||
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans(`core.forum.security.${key}`, { count: 1 }));
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
generateTokenTitle(token: AccessToken): NestedStringArray {
|
||||
const name = token.title() || app.translator.trans('core.forum.security.token_title_placeholder');
|
||||
const value = this.tokenValueDisplay(token);
|
||||
|
||||
return app.translator.trans('core.forum.security.token_item_title', { name, value });
|
||||
}
|
||||
|
||||
tokenValueDisplay(token: AccessToken): Mithril.Children {
|
||||
const obfuscatedName = Array(12).fill('*').join('');
|
||||
const value = this.showingTokens[token.id()!] ? token.token() : obfuscatedName;
|
||||
|
||||
return <code className="AccessTokensList-item-token">{value}</code>;
|
||||
}
|
||||
}
|
@@ -0,0 +1,64 @@
|
||||
import app from '../app';
|
||||
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||
import Button from '../../common/components/Button';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import type AccessToken from '../../common/models/AccessToken';
|
||||
import type { SaveAttributes } from '../../common/Model';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
export interface INewAccessTokenModalAttrs extends IInternalModalAttrs {
|
||||
onsuccess: (token: AccessToken) => void;
|
||||
}
|
||||
|
||||
export default class NewAccessTokenModal<CustomAttrs extends INewAccessTokenModalAttrs = INewAccessTokenModalAttrs> extends Modal<CustomAttrs> {
|
||||
protected titleInput = Stream('');
|
||||
|
||||
className(): string {
|
||||
return 'Modal--small NewAccessTokenModal';
|
||||
}
|
||||
|
||||
title(): Mithril.Children {
|
||||
return app.translator.trans('core.forum.security.new_access_token_modal.title');
|
||||
}
|
||||
|
||||
content(): Mithril.Children {
|
||||
const titleLabel = app.translator.trans('core.forum.security.new_access_token_modal.title_placeholder');
|
||||
|
||||
return (
|
||||
<div className="Modal-body">
|
||||
<div className="Form Form--centered">
|
||||
<div className="Form-group">
|
||||
<input type="text" className="FormControl" bidi={this.titleInput} placeholder={titleLabel} aria-label={titleLabel} />
|
||||
</div>
|
||||
<div className="Form-group">
|
||||
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
|
||||
{app.translator.trans('core.forum.security.new_access_token_modal.submit_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
submitData(): SaveAttributes {
|
||||
return {
|
||||
title: this.titleInput(),
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit(e: SubmitEvent) {
|
||||
super.onsubmit(e);
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app.store
|
||||
.createRecord<AccessToken>('access-tokens')
|
||||
.save(this.submitData())
|
||||
.then((token) => {
|
||||
this.attrs.onsuccess(token);
|
||||
app.modal.close();
|
||||
})
|
||||
.finally(this.loaded.bind(this));
|
||||
}
|
||||
}
|
@@ -131,6 +131,7 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
|
||||
navItems() {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
const user = this.user!;
|
||||
const isActor = app.session.user === user;
|
||||
|
||||
items.add(
|
||||
'posts',
|
||||
@@ -148,7 +149,7 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
|
||||
90
|
||||
);
|
||||
|
||||
if (app.session.user === user) {
|
||||
if (isActor) {
|
||||
items.add('separator', <Separator />, -90);
|
||||
items.add(
|
||||
'settings',
|
||||
@@ -159,6 +160,20 @@ export default class UserPage<CustomAttrs extends IUserPageAttrs = IUserPageAttr
|
||||
);
|
||||
}
|
||||
|
||||
if (isActor || app.forum.attribute<boolean>('canModerateAccessTokens')) {
|
||||
if (!isActor) {
|
||||
items.add('security-separator', <Separator />, -90);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'security',
|
||||
<LinkButton href={app.route('user.security', { username: user.slug() })} icon="fas fa-shield-alt">
|
||||
{app.translator.trans('core.forum.user.security_link')}
|
||||
</LinkButton>,
|
||||
-100
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
199
framework/core/js/src/forum/components/UserSecurityPage.tsx
Normal file
199
framework/core/js/src/forum/components/UserSecurityPage.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import app from '../../forum/app';
|
||||
import UserPage, { IUserPageAttrs } from './UserPage';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import FieldSet from '../../common/components/FieldSet';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import AccessTokensList from './AccessTokensList';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import Button from '../../common/components/Button';
|
||||
import NewAccessTokenModal from './NewAccessTokenModal';
|
||||
import { camelCaseToSnakeCase } from '../../common/utils/string';
|
||||
import type AccessToken from '../../common/models/AccessToken';
|
||||
import type Mithril from 'mithril';
|
||||
import Tooltip from '../../common/components/Tooltip';
|
||||
import UserSecurityPageState from '../states/UserSecurityPageState';
|
||||
|
||||
/**
|
||||
* The `UserSecurityPage` component displays the user's security control panel, in
|
||||
* the context of their user profile.
|
||||
*/
|
||||
export default class UserSecurityPage<CustomAttrs extends IUserPageAttrs = IUserPageAttrs> extends UserPage<CustomAttrs, UserSecurityPageState> {
|
||||
state = new UserSecurityPageState();
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
const routeUsername = m.route.param('username');
|
||||
|
||||
if (routeUsername !== app.session.user?.slug() && !app.forum.attribute<boolean>('canModerateAccessTokens')) {
|
||||
m.route.set('/');
|
||||
}
|
||||
|
||||
this.loadUser(routeUsername);
|
||||
|
||||
app.setTitle(extractText(app.translator.trans('core.forum.security.title')));
|
||||
|
||||
this.loadTokens();
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="UserSecurityPage">
|
||||
<ul>{listItems(this.settingsItems().toArray())}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's settings controls.
|
||||
*/
|
||||
settingsItems() {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
['developerTokens', 'sessions'].forEach((section) => {
|
||||
const sectionName = `${section}Items` as 'developerTokensItems' | 'sessionsItems';
|
||||
const sectionLocale = camelCaseToSnakeCase(section);
|
||||
|
||||
items.add(
|
||||
section,
|
||||
<FieldSet className={`Security-${section}`} label={app.translator.trans(`core.forum.security.${sectionLocale}_heading`)}>
|
||||
{this[sectionName]().toArray()}
|
||||
</FieldSet>
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's access accessToken settings.
|
||||
*/
|
||||
developerTokensItems() {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add(
|
||||
'accessTokenList',
|
||||
!this.state.hasLoadedTokens() ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<AccessTokensList
|
||||
type="developer_token"
|
||||
ondelete={(token: AccessToken) => {
|
||||
this.state.removeToken(token);
|
||||
m.redraw();
|
||||
}}
|
||||
tokens={this.state.getDeveloperTokens()}
|
||||
icon="fas fa-key"
|
||||
hideTokens={false}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (this.user!.id() === app.session.user!.id()) {
|
||||
items.add(
|
||||
'newAccessToken',
|
||||
<Button
|
||||
className="Button"
|
||||
disabled={!app.forum.attribute<boolean>('canCreateAccessToken')}
|
||||
onclick={() =>
|
||||
app.modal.show(NewAccessTokenModal, {
|
||||
onsuccess: (token: AccessToken) => {
|
||||
this.state.pushToken(token);
|
||||
m.redraw();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{app.translator.trans('core.forum.security.new_access_token_button')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's access accessToken settings.
|
||||
*/
|
||||
sessionsItems() {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
items.add(
|
||||
'sessionsList',
|
||||
!this.state.hasLoadedTokens() ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<AccessTokensList
|
||||
type="session"
|
||||
ondelete={(token: AccessToken) => {
|
||||
this.state.removeToken(token);
|
||||
m.redraw();
|
||||
}}
|
||||
tokens={this.state.getSessionTokens()}
|
||||
icon="fas fa-laptop"
|
||||
hideTokens={true}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (this.user!.id() === app.session.user!.id()) {
|
||||
const isDisabled = !this.state.hasOtherActiveSessions();
|
||||
|
||||
let terminateAllOthersButton = (
|
||||
<Button className="Button" onclick={this.terminateAllOtherSessions.bind(this)} loading={this.state.isLoading()} disabled={isDisabled}>
|
||||
{app.translator.trans('core.forum.security.terminate_all_other_sessions')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
terminateAllOthersButton = (
|
||||
<Tooltip text={app.translator.trans('core.forum.security.cannot_terminate_current_session')}>
|
||||
<span tabindex="0">{terminateAllOthersButton}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
items.add('terminateAllOtherSessions', terminateAllOthersButton);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
loadTokens() {
|
||||
return app.store
|
||||
.find<AccessToken[]>('access-tokens', {
|
||||
filter: { user: this.user!.id()! },
|
||||
})
|
||||
.then((tokens) => {
|
||||
this.state.setTokens(tokens);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
terminateAllOtherSessions() {
|
||||
if (!confirm(extractText(app.translator.trans('core.forum.security.terminate_all_other_sessions_confirmation')))) return;
|
||||
|
||||
this.state.setLoading(true);
|
||||
|
||||
return app
|
||||
.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/sessions',
|
||||
})
|
||||
.then(() => {
|
||||
// Count terminated sessions first.
|
||||
const count = this.state.getOtherSessionTokens().length;
|
||||
|
||||
this.state.removeOtherSessionTokens();
|
||||
|
||||
app.alerts.show({ type: 'success' }, app.translator.trans('core.forum.security.session_terminated', { count }));
|
||||
|
||||
m.redraw();
|
||||
})
|
||||
.catch(() => {
|
||||
app.alerts.show({ type: 'error' }, app.translator.trans('core.forum.security.session_termination_failed'));
|
||||
})
|
||||
.finally(() => this.state.setLoading(false));
|
||||
}
|
||||
}
|
@@ -9,6 +9,7 @@ import DiscussionPageResolver from './resolvers/DiscussionPageResolver';
|
||||
import Discussion from '../common/models/Discussion';
|
||||
import type Post from '../common/models/Post';
|
||||
import type User from '../common/models/User';
|
||||
import UserSecurityPage from './components/UserSecurityPage';
|
||||
|
||||
/**
|
||||
* Helper functions to generate URLs to form pages.
|
||||
@@ -34,6 +35,7 @@ export default function (app: ForumApplication) {
|
||||
'user.discussions': { path: '/u/:username/discussions', component: DiscussionsUserPage },
|
||||
|
||||
settings: { path: '/settings', component: SettingsPage },
|
||||
'user.security': { path: '/u/:username/security', component: UserSecurityPage },
|
||||
notifications: { path: '/notifications', component: NotificationsPage },
|
||||
};
|
||||
}
|
||||
|
57
framework/core/js/src/forum/states/UserSecurityPageState.ts
Normal file
57
framework/core/js/src/forum/states/UserSecurityPageState.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import AccessToken from '../../common/models/AccessToken';
|
||||
|
||||
export default class UserSecurityPageState {
|
||||
protected tokens: AccessToken[] | null = null;
|
||||
protected loading: boolean = false;
|
||||
|
||||
public isLoading(): boolean {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
public hasLoadedTokens(): boolean {
|
||||
return this.tokens !== null;
|
||||
}
|
||||
|
||||
public setLoading(loading: boolean): void {
|
||||
this.loading = loading;
|
||||
}
|
||||
|
||||
public getTokens(): AccessToken[] | null {
|
||||
return this.tokens;
|
||||
}
|
||||
|
||||
public setTokens(tokens: AccessToken[]): void {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
public pushToken(token: AccessToken): void {
|
||||
this.tokens?.push(token);
|
||||
}
|
||||
|
||||
public removeToken(token: AccessToken): void {
|
||||
this.tokens = this.tokens!.filter((t) => t !== token);
|
||||
}
|
||||
|
||||
public getSessionTokens(): AccessToken[] {
|
||||
return this.tokens?.filter((token) => token.isSessionToken()).sort((a, b) => (b.isCurrent() ? 1 : -1)) || [];
|
||||
}
|
||||
|
||||
public getDeveloperTokens(): AccessToken[] | null {
|
||||
return this.tokens?.filter((token) => !token.isSessionToken()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up session tokens other than the current one.
|
||||
*/
|
||||
public getOtherSessionTokens(): AccessToken[] {
|
||||
return this.tokens?.filter((token) => token.isSessionToken() && !token.isCurrent()) || [];
|
||||
}
|
||||
|
||||
public hasOtherActiveSessions(): boolean {
|
||||
return (this.getOtherSessionTokens() || []).length > 0;
|
||||
}
|
||||
|
||||
public removeOtherSessionTokens() {
|
||||
this.tokens = this.tokens!.filter((token) => !token.isSessionToken() || token.isCurrent());
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user