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
27 changes: 22 additions & 5 deletions app/components/header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,32 @@
{{#if this.session.currentUser}}
<Dropdown data-test-user-menu as |dd|>
<dd.Trigger local-class="dropdown-button" data-test-toggle>
{{#if this.session.isSudoEnabled}}
<div local-class="wizard-hat">🧙</div>
{{/if}}
<UserAvatar @user={{this.session.currentUser}} @size="small" 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
15 changes: 15 additions & 0 deletions app/components/header.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';

// Six hours.
const SUDO_SESSION_DURATION_MS = 6 * 60 * 60 * 1000;

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

@action
enableSudo() {
this.session.setSudo(SUDO_SESSION_DURATION_MS);
}

@action
disableSudo() {
this.session.setSudo(0);
}
}
17 changes: 16 additions & 1 deletion app/components/header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@
margin-right: var(--space-2xs);
}

.wizard-hat {
margin-right: var(--space-3xs);
}

.current-user-links {
left: auto;
right: 0;
Expand All @@ -172,7 +176,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 +189,13 @@
margin-right: var(--space-2xs);
}
}

.sudo-menu-item {
flex-direction: column;

> .expires-in {
font-size: 80%;
font-style: italic;
padding-top: var(--space-3xs);
}
}
26 changes: 26 additions & 0 deletions app/components/privileged-action.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{{#if this.isPrivileged}}
<div>
{{yield}}
</div>
{{else if this.canBePrivileged}}
{{#if (has-block 'placeholder')}}
<div>
{{yield to='placeholder'}}
</div>
{{else}}
<div local-class='placeholder'>
<fieldset disabled="disabled">
{{yield}}
</fieldset>
<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}}
45 changes: 45 additions & 0 deletions app/components/privileged-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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;
}
}
25 changes: 25 additions & 0 deletions app/components/privileged-action.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.placeholder {
fieldset {
border: 0;
margin: 0;
padding: 0;
}

fieldset[disabled] {
cursor: not-allowed;

[disabled] {
cursor: not-allowed;
}

button,
.yellow-button,
.tan-button {
/* This duplicates the styles in .button[disabled] as there's no
* obvious way to compose them, given the target selectors. */
background: linear-gradient(to bottom, var(--bg-color-top-light) 0%, var(--bg-color-bottom-light) 100%);
color: var(--disabled-text-color);
cursor: not-allowed;
}
}
}
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
83 changes: 82 additions & 1 deletion app/services/session.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { dropTask, race, rawTimeout, task, waitForEvent } from 'ember-concurrency';
import { dropTask, race, rawTimeout, restartableTask, task, waitForEvent } from 'ember-concurrency';
import window from 'ember-window-mock';
import { alias } from 'macro-decorators';

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,34 @@ export default class SessionService extends Service {
}
}

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

get isSudoEnabled() {
return this.isAdmin && this.sudoTask.isRunning;
}

/**
* 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.isAdmin) {
if (duration_ms) {
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
this.sudoTask.perform(Date.now() + duration_ms);
} else {
this.sudoTask.cancelAll();
}
}
}

/**
* 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 +196,49 @@ 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 {
// Trigger sudoTask, but without waiting for it to complete.
//
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
this.sudoTask.perform(+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 };
});

sudoTask = restartableTask(async until => {
try {
const now = Date.now();

if (until > now) {
// Since this task will replace any running task, we should update local
// storage.
localStorage.setItem('sudo', until.toString());

// We'll also surface the expiry as a property on the session service,
// since that can be tracked and updated by other components.
this.sudoEnabledUntil = until;

// Now we sleep until sudo mode has expired.
await rawTimeout(until - now);
}
} finally {
// Clear the local storage, since we're no longer in sudo mode, regardless
// of whether the await finished or the task was cancelled.
localStorage.removeItem('sudo');

// Again, update the session service property.
this.sudoEnabledUntil = null;
}
});
}
Loading