1
0
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:
Sami Mazouz
2023-02-21 14:14:53 +01:00
committed by GitHub
parent ea7b270f47
commit 9342903d68
45 changed files with 1821 additions and 5 deletions

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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