Skip to content

Commit fb16b46

Browse files
authored
Merge pull request #8210 from LawnGnome/sudo
Add sudo mode for admins
2 parents ac034a4 + a5c3387 commit fb16b46

File tree

10 files changed

+373
-13
lines changed

10 files changed

+373
-13
lines changed

Diff for: app/components/header.hbs

+22-5
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,32 @@
2121
{{#if this.session.currentUser}}
2222
<Dropdown data-test-user-menu as |dd|>
2323
<dd.Trigger local-class="dropdown-button" data-test-toggle>
24+
{{#if this.session.isSudoEnabled}}
25+
<div local-class="wizard-hat">🧙</div>
26+
{{/if}}
2427
<UserAvatar @user={{this.session.currentUser}} @size="small" local-class="avatar" data-test-avatar />
2528
{{ this.session.currentUser.name }}
2629
</dd.Trigger>
2730

28-
<dd.Menu local-class="current-user-links" as |menu|>
29-
<menu.Item><LinkTo @route="dashboard">Dashboard</LinkTo></menu.Item>
30-
<menu.Item><LinkTo @route="settings" data-test-settings>Account Settings</LinkTo></menu.Item>
31-
<menu.Item><LinkTo @route="me.pending-invites">Owner Invites</LinkTo></menu.Item>
32-
<menu.Item local-class="menu-item-with-separator">
31+
<dd.Menu local-class='current-user-links' as |menu|>
32+
<menu.Item><LinkTo @route='dashboard'>Dashboard</LinkTo></menu.Item>
33+
<menu.Item><LinkTo @route='settings' data-test-settings>Account Settings</LinkTo></menu.Item>
34+
<menu.Item><LinkTo @route='me.pending-invites'>Owner Invites</LinkTo></menu.Item>
35+
{{#if this.session.isAdmin}}
36+
<menu.Item local-class='sudo'>
37+
{{#if this.session.isSudoEnabled}}
38+
<button local-class='sudo-menu-item' type='button' {{on 'click' this.disableSudo}}>
39+
Disable admin actions
40+
<div local-class='expires-in'>expires at {{date-format this.session.sudoEnabledUntil 'HH:mm'}}</div>
41+
</button>
42+
{{else}}
43+
<button local-class='sudo-menu-item' type='button' {{on 'click' this.enableSudo}}>
44+
Enable admin actions
45+
</button>
46+
{{/if}}
47+
</menu.Item>
48+
{{/if}}
49+
<menu.Item local-class='menu-item-with-separator'>
3350
<button
3451
type="button"
3552
disabled={{this.session.logoutTask.isRunning}}

Diff for: app/components/header.js

+15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1+
import { action } from '@ember/object';
12
import { inject as service } from '@ember/service';
23
import Component from '@glimmer/component';
34

5+
// Six hours.
6+
const SUDO_SESSION_DURATION_MS = 6 * 60 * 60 * 1000;
7+
48
export default class Header extends Component {
9+
/** @type {import("../services/session").default} */
510
@service session;
11+
12+
@action
13+
enableSudo() {
14+
this.session.setSudo(SUDO_SESSION_DURATION_MS);
15+
}
16+
17+
@action
18+
disableSudo() {
19+
this.session.setSudo(0);
20+
}
621
}

Diff for: app/components/header.module.css

+16-1
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@
154154
margin-right: var(--space-2xs);
155155
}
156156

157+
.wizard-hat {
158+
margin-right: var(--space-3xs);
159+
}
160+
157161
.current-user-links {
158162
left: auto;
159163
right: 0;
@@ -172,7 +176,8 @@
172176
}
173177

174178
.login-menu-item,
175-
.logout-menu-item {
179+
.logout-menu-item,
180+
.sudo-menu-item {
176181
composes: button-reset from '../styles/shared/buttons.module.css';
177182
cursor: pointer;
178183

@@ -184,3 +189,13 @@
184189
margin-right: var(--space-2xs);
185190
}
186191
}
192+
193+
.sudo-menu-item {
194+
flex-direction: column;
195+
196+
> .expires-in {
197+
font-size: 80%;
198+
font-style: italic;
199+
padding-top: var(--space-3xs);
200+
}
201+
}

Diff for: app/components/privileged-action.hbs

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{{#if this.isPrivileged}}
2+
<div>
3+
{{yield}}
4+
</div>
5+
{{else if this.canBePrivileged}}
6+
{{#if (has-block 'placeholder')}}
7+
<div>
8+
{{yield to='placeholder'}}
9+
</div>
10+
{{else}}
11+
<div local-class='placeholder'>
12+
<fieldset disabled="disabled">
13+
{{yield}}
14+
</fieldset>
15+
<EmberTooltip>
16+
You must enable admin actions before you can perform this operation.
17+
</EmberTooltip>
18+
</div>
19+
{{/if}}
20+
{{else}}
21+
<div>
22+
{{#if (has-block 'unprivileged')}}
23+
{{yield to='unprivileged'}}
24+
{{/if}}
25+
</div>
26+
{{/if}}

Diff for: app/components/privileged-action.js

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { inject as service } from '@ember/service';
2+
import Component from '@glimmer/component';
3+
4+
/**
5+
* A component that wraps elements (probably mostly buttons in practice) that
6+
* can be used to perform potentially privileged actions.
7+
*
8+
* This component requires a `userAuthorised` property, which must be a
9+
* `boolean` indicating whether the user is authorised for this action. Admin
10+
* rights need not be taken into account.
11+
*
12+
* If the current user is an admin and they have enabled sudo mode, then they
13+
* are always allowed to perform the action, regardless of the return value of
14+
* `userAuthorised`.
15+
*
16+
* There are three content blocks supported by this component:
17+
*
18+
* - `default`: required; this is displayed when the user is authorised to
19+
* perform the action.
20+
* - `placeholder`: this is displayed when the user _could_ be authorised to
21+
* perform the action (that is, they're an admin but have not
22+
* enabled sudo mode), but currently cannot perform the action.
23+
* If omitted, the `default` block is used with all form
24+
* controls disabled and a tooltip added.
25+
* - `unprivileged`: this is displayed when the user is not able to perform this
26+
* action, and cannot be authorised to do so. If omitted, an
27+
* empty block will be used.
28+
*
29+
* Note that all blocks will be output with a wrapping `<div>` for technical
30+
* reasons, so be sure to style accordingly if necessary.
31+
*/
32+
export default class PrivilegedAction extends Component {
33+
/** @type {import("../services/session").default} */
34+
@service session;
35+
36+
/** @return {boolean} */
37+
get isPrivileged() {
38+
return this.session.isSudoEnabled || this.args.userAuthorised;
39+
}
40+
41+
/** @return {boolean} */
42+
get canBePrivileged() {
43+
return !this.args.userAuthorised && this.session.currentUser?.is_admin && !this.session.isSudoEnabled;
44+
}
45+
}

Diff for: app/components/privileged-action.module.css

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.placeholder {
2+
fieldset {
3+
border: 0;
4+
margin: 0;
5+
padding: 0;
6+
}
7+
8+
fieldset[disabled] {
9+
cursor: not-allowed;
10+
11+
[disabled] {
12+
cursor: not-allowed;
13+
}
14+
15+
button,
16+
.yellow-button,
17+
.tan-button {
18+
/* This duplicates the styles in .button[disabled] as there's no
19+
* obvious way to compose them, given the target selectors. */
20+
background: linear-gradient(to bottom, var(--bg-color-top-light) 0%, var(--bg-color-bottom-light) 100%);
21+
color: var(--disabled-text-color);
22+
cursor: not-allowed;
23+
}
24+
}
25+
}

Diff for: app/components/version-list/row.hbs

+2-2
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
{{/if}}
110110
</div>
111111

112-
{{#if this.canYank}}
112+
<PrivilegedAction @userAuthorised={{this.isOwner}}>
113113
<YankButton @version={{@version}} local-class="yank-button" />
114-
{{/if}}
114+
</PrivilegedAction>
115115
</div>

Diff for: app/components/version-list/row.js

-4
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,6 @@ export default class VersionRow extends Component {
5151
return this.args.version.crate?.owner_user?.findBy('id', this.session.currentUser?.id);
5252
}
5353

54-
get canYank() {
55-
return this.isOwner || this.session.currentUser?.is_admin;
56-
}
57-
5854
@action setFocused(value) {
5955
this.focused = value;
6056
}

Diff for: app/services/session.js

+82-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Service, { inject as service } from '@ember/service';
2+
import { tracked } from '@glimmer/tracking';
23

3-
import { dropTask, race, rawTimeout, task, waitForEvent } from 'ember-concurrency';
4+
import { dropTask, race, rawTimeout, restartableTask, task, waitForEvent } from 'ember-concurrency';
45
import window from 'ember-window-mock';
56
import { alias } from 'macro-decorators';
67

@@ -15,6 +16,15 @@ export default class SessionService extends Service {
1516

1617
savedTransition = null;
1718

19+
/**
20+
* The timestamp (in milliseconds since the UNIX epoch, as returned by
21+
* {@link Date.now()}) that the user has sudo enabled until.
22+
*
23+
* @type {number | null}
24+
*/
25+
@tracked sudoEnabledUntil = null;
26+
27+
/** @type {import("../models/user").default | null} */
1828
@alias('loadUserTask.last.value.currentUser') currentUser;
1929
@alias('loadUserTask.last.value.ownedCrates') ownedCrates;
2030

@@ -30,6 +40,34 @@ export default class SessionService extends Service {
3040
}
3141
}
3242

43+
get isAdmin() {
44+
return this.currentUser?.is_admin === true;
45+
}
46+
47+
get isSudoEnabled() {
48+
return this.isAdmin && this.sudoTask.isRunning;
49+
}
50+
51+
/**
52+
* Enables or disables sudo mode based on the `duration_ms` parameter.
53+
*
54+
* If the user is not an admin, nothing happens, successfully.
55+
*
56+
* @param {number} duration_ms If non-zero, enables sudo mode for this
57+
* length of time. If zero, disables sudo mode
58+
* immediately.
59+
*/
60+
setSudo(duration_ms) {
61+
if (this.isAdmin) {
62+
if (duration_ms) {
63+
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
64+
this.sudoTask.perform(Date.now() + duration_ms);
65+
} else {
66+
this.sudoTask.cancelAll();
67+
}
68+
}
69+
}
70+
3371
/**
3472
* This task will open a popup window, query the `/api/private/session/begin` API
3573
* endpoint and then navigate the popup window to the received URL.
@@ -158,6 +196,49 @@ export default class SessionService extends Service {
158196
let { id } = currentUser;
159197
this.sentry.setUser({ id });
160198

199+
// If the user is an admin, we need to look up whether they have enabled
200+
// sudo mode.
201+
if (currentUser?.is_admin) {
202+
const expiry = localStorage.getItem('sudo');
203+
if (expiry !== null) {
204+
try {
205+
// Trigger sudoTask, but without waiting for it to complete.
206+
//
207+
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
208+
this.sudoTask.perform(+expiry);
209+
} catch {
210+
// It doesn't really matter if this fails; any invalid value will just
211+
// be treated as the user not being in sudo mode.
212+
}
213+
}
214+
}
215+
161216
return { currentUser, ownedCrates };
162217
});
218+
219+
sudoTask = restartableTask(async until => {
220+
try {
221+
const now = Date.now();
222+
223+
if (until > now) {
224+
// Since this task will replace any running task, we should update local
225+
// storage.
226+
localStorage.setItem('sudo', until.toString());
227+
228+
// We'll also surface the expiry as a property on the session service,
229+
// since that can be tracked and updated by other components.
230+
this.sudoEnabledUntil = until;
231+
232+
// Now we sleep until sudo mode has expired.
233+
await rawTimeout(until - now);
234+
}
235+
} finally {
236+
// Clear the local storage, since we're no longer in sudo mode, regardless
237+
// of whether the await finished or the task was cancelled.
238+
localStorage.removeItem('sudo');
239+
240+
// Again, update the session service property.
241+
this.sudoEnabledUntil = null;
242+
}
243+
});
163244
}

0 commit comments

Comments
 (0)