Skip to content

Commit 5305174

Browse files
committed
feat: add elevation dialog to protected actions
1 parent a874448 commit 5305174

10 files changed

Lines changed: 318 additions & 41 deletions

File tree

ui/src/ElevateStore.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import axios from 'axios';
2+
import {action, observable, runInAction} from 'mobx';
3+
import * as config from './config';
4+
import {SnackReporter} from './snack/SnackManager';
5+
import {CurrentUser} from './CurrentUser';
6+
7+
export class ElevateStore {
8+
@observable accessor elevated = false;
9+
@observable accessor oidcElevatePending = false;
10+
private oidcPollIntervalId: number | undefined = undefined;
11+
private oidcPopup: Window | null = null;
12+
13+
public constructor(
14+
private readonly snack: SnackReporter,
15+
private readonly currentUser: CurrentUser
16+
) {}
17+
18+
@action
19+
public refreshElevated = (): number => {
20+
const elevatedUntil = this.currentUser.user.elevatedUntil;
21+
if (!elevatedUntil) {
22+
this.elevated = false;
23+
return 0;
24+
}
25+
const ms = new Date(elevatedUntil).getTime() - 30_000 - Date.now();
26+
if (ms <= 0) {
27+
this.elevated = false;
28+
return 0;
29+
}
30+
this.elevated = true;
31+
return ms;
32+
};
33+
34+
public localElevate = async (password: string, durationSeconds: number): Promise<void> => {
35+
await axios.create().request({
36+
url: config.get('url') + 'client:elevate',
37+
method: 'POST',
38+
data: {id: this.currentUser.user.clientId, durationSeconds},
39+
headers: {
40+
Authorization: 'Basic ' + btoa(this.currentUser.user.name + ':' + password),
41+
},
42+
});
43+
await this.currentUser.tryAuthenticate();
44+
this.cleanupOidcElevate();
45+
};
46+
47+
public oidcElevate = (durationSeconds: number): void => {
48+
// prevent double execution
49+
if (this.oidcElevatePending) return;
50+
51+
const url =
52+
config.get('url') +
53+
'auth/oidc/elevate?id=' +
54+
this.currentUser.user.clientId +
55+
'&durationSeconds=' +
56+
durationSeconds;
57+
58+
this.oidcPopup = window.open(url, 'gotify-oidc-elevate', 'width=600,height=700');
59+
if (!this.oidcPopup) {
60+
this.snack('Popup was blocked. Please allow popups for this site and try again.');
61+
return;
62+
}
63+
64+
runInAction(() => (this.oidcElevatePending = true));
65+
66+
this.oidcPollIntervalId = window.setInterval(this.checkOidcPopup, 500);
67+
};
68+
69+
private checkOidcPopup = async () => {
70+
if (this.oidcPopup && !this.oidcPopup.closed) {
71+
// waiting for the popup to close.
72+
return;
73+
}
74+
75+
window.clearInterval(this.oidcPollIntervalId);
76+
this.oidcPollIntervalId = undefined;
77+
78+
try {
79+
await this.currentUser.tryAuthenticate();
80+
} catch {
81+
// errors handled in tryAuthenticate
82+
}
83+
84+
if (!this.elevated) {
85+
this.snack('OIDC elevation was not completed.');
86+
}
87+
this.cleanupOidcElevate();
88+
};
89+
90+
public cleanupOidcElevate = () => {
91+
window.clearInterval(this.oidcPollIntervalId);
92+
this.oidcPollIntervalId = undefined;
93+
94+
if (this.oidcPopup && !this.oidcPopup.closed) {
95+
this.oidcPopup.close();
96+
}
97+
this.oidcPopup = null;
98+
runInAction(() => {
99+
this.oidcElevatePending = false;
100+
});
101+
};
102+
}

ui/src/application/Applications.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ const Applications = observer(() => {
179179
text={'Delete ' + toDeleteApp.name + '?'}
180180
fClose={() => setToDeleteApp(undefined)}
181181
fOnSubmit={() => appStore.remove(toDeleteApp.id)}
182+
requireElevated
182183
/>
183184
)}
184185
{toDeleteImage != null && (

ui/src/client/Clients.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const Clients = observer(() => {
8787
text={'Delete ' + toDeleteClient.name + '?'}
8888
fClose={() => setToDeleteClient(undefined)}
8989
fOnSubmit={() => clientStore.remove(toDeleteClient.id)}
90+
requireElevated
9091
/>
9192
)}
9293
</DefaultPage>

ui/src/common/ConfirmDialog.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,60 @@ import DialogContent from '@mui/material/DialogContent';
55
import DialogContentText from '@mui/material/DialogContentText';
66
import DialogTitle from '@mui/material/DialogTitle';
77
import React from 'react';
8+
import {observer} from 'mobx-react-lite';
9+
import {useStores} from '../stores';
10+
import ElevationForm from './ElevationForm';
811

912
interface IProps {
1013
title: string;
1114
text: string;
1215
fClose: VoidFunction;
1316
fOnSubmit: VoidFunction;
17+
requireElevated?: boolean;
1418
}
1519

16-
export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) {
20+
const ConfirmDialog = observer(({title, text, fClose, fOnSubmit, requireElevated}: IProps) => {
21+
const {elevateStore} = useStores();
22+
23+
const needsElevation = requireElevated && !elevateStore.elevated;
24+
1725
const submitAndClose = () => {
1826
fOnSubmit();
1927
fClose();
2028
};
29+
30+
const handleClose = () => {
31+
elevateStore.cleanupOidcElevate();
32+
fClose();
33+
};
34+
2135
return (
2236
<Dialog
2337
open={true}
24-
onClose={fClose}
38+
onClose={handleClose}
2539
aria-labelledby="form-dialog-title"
2640
className="confirm-dialog">
2741
<DialogTitle id="form-dialog-title">{title}</DialogTitle>
2842
<DialogContent>
29-
<DialogContentText>{text}</DialogContentText>
43+
{needsElevation ? <ElevationForm /> : <DialogContentText>{text}</DialogContentText>}
3044
</DialogContent>
3145
<DialogActions>
32-
<Button onClick={fClose} className="cancel">
33-
No
34-
</Button>
35-
<Button
36-
onClick={submitAndClose}
37-
autoFocus
38-
color="primary"
39-
variant="contained"
40-
className="confirm">
41-
Yes
46+
<Button onClick={handleClose} className="cancel">
47+
Cancel
4248
</Button>
49+
{!needsElevation && (
50+
<Button
51+
onClick={submitAndClose}
52+
autoFocus
53+
color="primary"
54+
variant="contained"
55+
className="confirm">
56+
Yes
57+
</Button>
58+
)}
4359
</DialogActions>
4460
</Dialog>
4561
);
46-
}
62+
});
63+
64+
export default ConfirmDialog;

ui/src/common/ElevationForm.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React, {useState} from 'react';
2+
import Button from '@mui/material/Button';
3+
import TextField from '@mui/material/TextField';
4+
import Typography from '@mui/material/Typography';
5+
import {observer} from 'mobx-react-lite';
6+
import {useStores} from '../stores';
7+
import * as config from '../config';
8+
import CircularProgress from '@mui/material/CircularProgress';
9+
import {Box, Divider} from '@mui/material';
10+
11+
const ElevateDuration = 60 * 60;
12+
13+
const ElevationForm = observer(() => {
14+
const {elevateStore} = useStores();
15+
const [password, setPassword] = useState('');
16+
const [error, setError] = useState('');
17+
18+
const oidcEnabled = config.get('oidc');
19+
const oidcPending = elevateStore.oidcElevatePending;
20+
21+
const handleLocalElevate = async () => {
22+
try {
23+
await elevateStore.localElevate(password, ElevateDuration);
24+
} catch {
25+
setError('Elevation failed. Check your password.');
26+
}
27+
};
28+
29+
if (oidcPending) {
30+
return (
31+
<Box sx={{textAlign: 'center', my: 2}}>
32+
<CircularProgress sx={{mb: 2}} />
33+
<Typography sx={{mb: 1}}>Waiting for OIDC sign-in...</Typography>
34+
<Typography variant="body2" color="textSecondary" sx={{mb: 1}}>
35+
Complete sign-in in the new tab, then close it to continue.
36+
</Typography>
37+
<Button
38+
variant="outlined"
39+
fullWidth
40+
onClick={() => elevateStore.cleanupOidcElevate()}>
41+
Cancel OIDC Login
42+
</Button>
43+
</Box>
44+
);
45+
}
46+
47+
return (
48+
<>
49+
<Typography>This action requires re-authentication.</Typography>
50+
<form
51+
onSubmit={(e) => {
52+
e.preventDefault();
53+
handleLocalElevate();
54+
}}>
55+
<TextField
56+
autoFocus
57+
margin="dense"
58+
type="password"
59+
label="Password"
60+
value={password}
61+
onChange={(e) => {
62+
setPassword(e.target.value);
63+
setError('');
64+
}}
65+
fullWidth
66+
error={!!error}
67+
helperText={error}
68+
/>
69+
<Button
70+
type="submit"
71+
disabled={password.length === 0}
72+
color="primary"
73+
variant="contained"
74+
fullWidth>
75+
Elevate with Password
76+
</Button>
77+
</form>
78+
79+
{oidcEnabled && (
80+
<>
81+
<Divider sx={{my: 2}}>or</Divider>
82+
<Button
83+
variant="contained"
84+
color="primary"
85+
fullWidth
86+
onClick={() => elevateStore.oidcElevate(ElevateDuration)}>
87+
Elevate via OIDC
88+
</Button>
89+
</>
90+
)}
91+
</>
92+
);
93+
});
94+
95+
export default ElevationForm;

ui/src/common/SettingsDialog.tsx

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,65 @@ import TextField from '@mui/material/TextField';
88
import Tooltip from '@mui/material/Tooltip';
99
import {observer} from 'mobx-react-lite';
1010
import {useStores} from '../stores';
11+
import ElevationForm from './ElevationForm';
1112

1213
interface IProps {
1314
fClose: VoidFunction;
1415
}
1516

1617
const SettingsDialog = observer(({fClose}: IProps) => {
1718
const [pass, setPass] = useState('');
18-
const {currentUser} = useStores();
19+
const {currentUser, elevateStore} = useStores();
1920

20-
const submitAndClose = async () => {
21+
const handleClose = () => {
22+
elevateStore.cleanupOidcElevate();
23+
fClose();
24+
};
25+
26+
const submitAndClose = () => {
2127
currentUser.changePassword(pass);
2228
fClose();
2329
};
2430

2531
return (
2632
<Dialog
2733
open={true}
28-
onClose={fClose}
34+
onClose={handleClose}
2935
aria-labelledby="form-dialog-title"
3036
id="changepw-dialog">
3137
<DialogTitle id="form-dialog-title">Change Password</DialogTitle>
3238
<DialogContent>
33-
<TextField
34-
className="newpass"
35-
autoFocus
36-
margin="dense"
37-
type="password"
38-
label="New Password *"
39-
value={pass}
40-
onChange={(e) => setPass(e.target.value)}
41-
fullWidth
42-
/>
39+
{elevateStore.elevated ? (
40+
<TextField
41+
className="newpass"
42+
autoFocus
43+
margin="dense"
44+
type="password"
45+
label="New Password *"
46+
value={pass}
47+
onChange={(e) => setPass(e.target.value)}
48+
fullWidth
49+
/>
50+
) : (
51+
<ElevationForm />
52+
)}
4353
</DialogContent>
4454
<DialogActions>
45-
<Button onClick={fClose}>Cancel</Button>
46-
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
47-
<div>
48-
<Button
49-
className="change"
50-
disabled={pass.length === 0}
51-
onClick={submitAndClose}
52-
color="primary"
53-
variant="contained">
54-
Change
55-
</Button>
56-
</div>
57-
</Tooltip>
55+
<Button onClick={handleClose}>Cancel</Button>
56+
{elevateStore.elevated && (
57+
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
58+
<div>
59+
<Button
60+
className="change"
61+
disabled={pass.length === 0}
62+
onClick={submitAndClose}
63+
color="primary"
64+
variant="contained">
65+
Change
66+
</Button>
67+
</div>
68+
</Tooltip>
69+
)}
5870
</DialogActions>
5971
</Dialog>
6072
);

0 commit comments

Comments
 (0)