Skip to content

Add sudo mode for admins #8210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 4, 2024
26 changes: 20 additions & 6 deletions app/components/header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,29 @@
{{#if this.session.currentUser}}
<Dropdown data-test-user-menu as |dd|>
<dd.Trigger local-class="dropdown-button" data-test-toggle>
<UserAvatar @user={{this.session.currentUser}} @size="small" local-class="avatar" data-test-avatar />
<UserAvatar @user={{this.session.currentUser}} @size="small" @sudo={{this.session.isSudoEnabled}} local-class="avatar" data-test-avatar />
{{ this.session.currentUser.name }}
</dd.Trigger>

<dd.Menu local-class="current-user-links" as |menu|>
<menu.Item><LinkTo @route="dashboard">Dashboard</LinkTo></menu.Item>
<menu.Item><LinkTo @route="settings" data-test-settings>Account Settings</LinkTo></menu.Item>
<menu.Item><LinkTo @route="me.pending-invites">Owner Invites</LinkTo></menu.Item>
<menu.Item local-class="menu-item-with-separator">
<dd.Menu local-class='current-user-links' as |menu|>
<menu.Item><LinkTo @route='dashboard'>Dashboard</LinkTo></menu.Item>
<menu.Item><LinkTo @route='settings' data-test-settings>Account Settings</LinkTo></menu.Item>
<menu.Item><LinkTo @route='me.pending-invites'>Owner Invites</LinkTo></menu.Item>
{{#if this.session.isAdmin}}
<menu.Item local-class='sudo'>
{{#if this.session.isSudoEnabled}}
<button local-class='sudo-menu-item' type='button' {{on 'click' this.disableSudo}}>
Disable admin actions
<div local-class='expires-in'>expires at {{date-format this.session.sudoEnabledUntil 'HH:mm'}}</div>
</button>
{{else}}
<button local-class='sudo-menu-item' type='button' {{on 'click' this.enableSudo}}>
Enable admin actions
</button>
{{/if}}
</menu.Item>
{{/if}}
<menu.Item local-class='menu-item-with-separator'>
<button
type="button"
disabled={{this.session.logoutTask.isRunning}}
Expand Down
13 changes: 13 additions & 0 deletions app/components/header.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';

export default class Header extends Component {
/** @type {import("../services/session").default} */
@service session;

@action
enableSudo() {
// FIXME: hard coded six hour duration.
this.session.setSudo(6 * 60 * 60 * 1000);
}

@action
disableSudo() {
this.session.setSudo(0);
}
}
13 changes: 12 additions & 1 deletion app/components/header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@
}

.login-menu-item,
.logout-menu-item {
.logout-menu-item,
.sudo-menu-item {
composes: button-reset from '../styles/shared/buttons.module.css';
cursor: pointer;

Expand All @@ -184,3 +185,13 @@
margin-right: var(--space-2xs);
}
}

.sudo > button {
flex-direction: column !important;

> .expires-in {
font-size: 80%;
font-style: italic;
padding-top: var(--space-3xs);
}
}
24 changes: 24 additions & 0 deletions app/components/privileged-action.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{{#if this.isPrivileged}}
<div>
{{yield}}
</div>
{{else if this.canBePrivileged}}
{{#if (has-block 'placeholder')}}
<div>
{{yield to='placeholder'}}
</div>
{{else}}
<div local-class='placeholder' {{did-insert this.disableComponents}}>
{{yield}}
<EmberTooltip>
You must enable admin actions before you can perform this operation.
</EmberTooltip>
</div>
{{/if}}
{{else}}
<div>
{{#if (has-block 'unprivileged')}}
{{yield to='unprivileged'}}
{{/if}}
</div>
{{/if}}
55 changes: 55 additions & 0 deletions app/components/privileged-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';

/**
* A component that wraps elements (probably mostly buttons in practice) that
* can be used to perform potentially privileged actions.
*
* This component requires a `userAuthorised` property, which must be a
* `boolean` indicating whether the user is authorised for this action. Admin
* rights need not be taken into account.
*
* If the current user is an admin and they have enabled sudo mode, then they
* are always allowed to perform the action, regardless of the return value of
* `userAuthorised`.
*
* There are three content blocks supported by this component:
*
* - `default`: required; this is displayed when the user is authorised to
* perform the action.
* - `placeholder`: this is displayed when the user _could_ be authorised to
* perform the action (that is, they're an admin but have not
* enabled sudo mode), but currently cannot perform the action.
* If omitted, the `default` block is used with all form
* controls disabled and a tooltip added.
* - `unprivileged`: this is displayed when the user is not able to perform this
* action, and cannot be authorised to do so. If omitted, an
* empty block will be used.
*
* Note that all blocks will be output with a wrapping `<div>` for technical
* reasons, so be sure to style accordingly if necessary.
*/
export default class PrivilegedAction extends Component {
/** @type {import("../services/session").default} */
@service session;

/** @return {boolean} */
get isPrivileged() {
return this.session.isSudoEnabled || this.args.userAuthorised;
}

/** @return {boolean} */
get canBePrivileged() {
return !this.args.userAuthorised && this.session.currentUser?.is_admin && !this.session.isSudoEnabled;
}

/**
* @param {Element} element
* @return {void}
*/
disableComponents(element) {
[...element.querySelectorAll('input, select, textarea, button')].forEach(control => {
control.setAttribute('disabled', 'disabled');
});
}
}
5 changes: 5 additions & 0 deletions app/components/privileged-action.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.placeholder {
[disabled] {
cursor: not-allowed;
}
}
5 changes: 4 additions & 1 deletion app/components/user-avatar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
title={{this.title}}
decoding="async"
...attributes
/>
/>
{{#if this.sudo}}
🧙
{{/if}}
5 changes: 5 additions & 0 deletions app/components/user-avatar.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,9 @@ export default class UserAvatar extends Component {
get src() {
return `${this.args.user.avatar}&s=${this.size * 2}`;
}

/** @return {boolean} */
get sudo() {
return this.args.sudo;
}
}
3 changes: 3 additions & 0 deletions app/components/user-avatar.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.sudo {
margin-right: var(--space-2xs);
}
4 changes: 2 additions & 2 deletions app/components/version-list/row.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
{{/if}}
</div>

{{#if this.canYank}}
<PrivilegedAction @userAuthorised={{this.isOwner}}>
<YankButton @version={{@version}} local-class="yank-button" />
{{/if}}
</PrivilegedAction>
</div>
4 changes: 0 additions & 4 deletions app/components/version-list/row.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ export default class VersionRow extends Component {
return this.args.version.crate?.owner_user?.findBy('id', this.session.currentUser?.id);
}

get canYank() {
return this.isOwner || this.session.currentUser?.is_admin;
}

@action setFocused(value) {
this.focused = value;
}
Expand Down
54 changes: 54 additions & 0 deletions app/services/session.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { dropTask, race, rawTimeout, task, waitForEvent } from 'ember-concurrency';
import window from 'ember-window-mock';
Expand All @@ -15,6 +16,15 @@ export default class SessionService extends Service {

savedTransition = null;

/**
* The timestamp (in milliseconds since the UNIX epoch, as returned by
* {@link Date.now()}) that the user has sudo enabled until.
*
* @type {number | null}
*/
@tracked sudoEnabledUntil = null;

/** @type {import("../models/user").default | null} */
@alias('loadUserTask.last.value.currentUser') currentUser;
@alias('loadUserTask.last.value.ownedCrates') ownedCrates;

Expand All @@ -30,6 +40,36 @@ export default class SessionService extends Service {
}
}

get isAdmin() {
return this.currentUser?.is_admin === true;
}

get isSudoEnabled() {
return this.currentUser?.is_admin === true && this.sudoEnabledUntil !== null && this.sudoEnabledUntil >= Date.now();
}

/**
* Enables or disables sudo mode based on the `duration_ms` parameter.
*
* If the user is not an admin, nothing happens, successfully.
*
* @param {number} duration_ms If non-zero, enables sudo mode for this
* length of time. If zero, disables sudo mode
* immediately.
*/
setSudo(duration_ms) {
if (this.currentUser?.is_admin) {
if (duration_ms) {
const expiry = Date.now() + duration_ms;
localStorage.setItem('sudo', expiry);
this.sudoEnabledUntil = expiry;
} else {
localStorage.removeItem('sudo');
this.sudoEnabledUntil = null;
}
}
}

/**
* This task will open a popup window, query the `/api/private/session/begin` API
* endpoint and then navigate the popup window to the received URL.
Expand Down Expand Up @@ -158,6 +198,20 @@ export default class SessionService extends Service {
let { id } = currentUser;
this.sentry.setUser({ id });

// If the user is an admin, we need to look up whether they have enabled
// sudo mode.
if (currentUser?.is_admin) {
const expiry = localStorage.getItem('sudo');
if (expiry !== null) {
try {
this.sudoEnabledUntil = +expiry;
} catch {
// It doesn't really matter if this fails; any invalid value will just
// be treated as the user not being in sudo mode.
}
}
}

return { currentUser, ownedCrates };
});
}
Loading