Skip to content

Commit 70ebb34

Browse files
committed
mend
1 parent 0da3755 commit 70ebb34

10 files changed

Lines changed: 450 additions & 3 deletions

File tree

dist/tsconfig.tsbuildinfo

Lines changed: 0 additions & 1 deletion
This file was deleted.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
},
3838
"scripts": {
3939
"dev": "vite",
40+
"mock-admin-api": "node scripts/mock-admin-api.mjs",
4041
"build": "vite build",
4142
"preview": "vite preview",
4243
"test": "vitest",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from 'react';
2+
import { Button, Modal } from 'react-bootstrap';
3+
4+
interface GrantChangesModalProps {
5+
show: boolean;
6+
userEmail?: string;
7+
addedGrants: string[];
8+
removedGrants: string[];
9+
saving: boolean;
10+
onHide: () => void;
11+
onConfirm: () => void;
12+
}
13+
14+
const GrantChangesModal: React.FC<GrantChangesModalProps> = ({
15+
show,
16+
userEmail,
17+
addedGrants,
18+
removedGrants,
19+
saving,
20+
onHide,
21+
onConfirm,
22+
}) => (
23+
<Modal show={show} onHide={onHide} centered dir='ltr' dialogClassName='admin-modal'>
24+
<Modal.Header>
25+
<Modal.Title>Confirm grant changes</Modal.Title>
26+
<button
27+
type='button'
28+
className='btn-close'
29+
aria-label='Close'
30+
onClick={onHide}
31+
style={{ marginLeft: 'auto', marginRight: 0 }}
32+
/>
33+
</Modal.Header>
34+
<Modal.Body>
35+
<p>
36+
Update grants for <strong>{userEmail}</strong>?
37+
</p>
38+
{addedGrants.length > 0 && (
39+
<>
40+
<p className='mb-1 fw-semibold'>Add:</p>
41+
<ul>
42+
{addedGrants.map((name) => (
43+
<li key={`add-${name}`}>{name}</li>
44+
))}
45+
</ul>
46+
</>
47+
)}
48+
{removedGrants.length > 0 && (
49+
<>
50+
<p className='mb-1 fw-semibold'>Remove:</p>
51+
<ul>
52+
{removedGrants.map((name) => (
53+
<li key={`remove-${name}`}>{name}</li>
54+
))}
55+
</ul>
56+
</>
57+
)}
58+
{addedGrants.length === 0 && removedGrants.length === 0 && (
59+
<p className='text-muted mb-0'>No changes detected.</p>
60+
)}
61+
</Modal.Body>
62+
<Modal.Footer>
63+
<Button variant='secondary' onClick={onHide}>
64+
Cancel
65+
</Button>
66+
<Button variant='primary' onClick={onConfirm} disabled={saving}>
67+
{saving ? 'Saving...' : 'Apply Changes'}
68+
</Button>
69+
</Modal.Footer>
70+
</Modal>
71+
);
72+
73+
export default GrantChangesModal;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { Alert, Button, Form } from 'react-bootstrap';
3+
import { AdminUserInfo } from '../../types/adminUserInfo';
4+
import { Grant } from '../../types/grant';
5+
6+
interface GrantChecklistProps {
7+
user: AdminUserInfo;
8+
grants: Grant[];
9+
selectedGrantNames: string[];
10+
hasChanges: boolean;
11+
saving: boolean;
12+
onToggle: (grantName: string) => void;
13+
onReview: () => void;
14+
}
15+
16+
const GrantChecklist: React.FC<GrantChecklistProps> = ({
17+
user,
18+
grants,
19+
selectedGrantNames,
20+
hasChanges,
21+
saving,
22+
onToggle,
23+
onReview,
24+
}) => (
25+
<>
26+
<Alert variant='light' className='border user-info-alert'>
27+
<strong>{user.email}</strong>
28+
{(user.first_name || user.last_name) && (
29+
<div className='text-muted'>
30+
({[user.first_name, user.last_name].filter(Boolean).join(' ')})
31+
</div>
32+
)}
33+
</Alert>
34+
35+
<h5 className='mb-3'>Grants</h5>
36+
{grants.map((grant) => (
37+
<Form.Check
38+
key={grant.id}
39+
type='checkbox'
40+
id={`user-grant-${grant.id}`}
41+
className='mb-2'
42+
label={
43+
<span className='grant-check-label'>
44+
<strong className='grant-check-name'>{grant.name}</strong>
45+
<span className='text-muted grant-check-description'>{grant.description}</span>
46+
</span>
47+
}
48+
checked={selectedGrantNames.includes(grant.name)}
49+
onChange={() => onToggle(grant.name)}
50+
/>
51+
))}
52+
53+
<div className='mt-4'>
54+
<Button variant='primary' onClick={onReview} disabled={!hasChanges || saving}>
55+
{saving ? 'Saving...' : 'Review Changes'}
56+
</Button>
57+
</div>
58+
</>
59+
);
60+
61+
export default GrantChecklist;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { observer } from 'mobx-react-lite';
3+
import { Alert, Spinner } from 'react-bootstrap';
4+
import { useStore } from '../../stores/storeConfig';
5+
import GrantChangesModal from './GrantChangesModal';
6+
import GrantChecklist from './GrantChecklist';
7+
import UserSearchForm from './UserSearchForm';
8+
9+
const UserGrantsTab: React.FC = observer(() => {
10+
const { grantStore } = useStore();
11+
const {
12+
grants,
13+
loading,
14+
selectedUser,
15+
userSearchLoading,
16+
userSearchError,
17+
savingUserGrants,
18+
searchUserByEmail,
19+
setUserGrants,
20+
clearSelectedUser,
21+
} = grantStore;
22+
23+
const [emailInput, setEmailInput] = useState('');
24+
const [draftGrantNames, setDraftGrantNames] = useState<string[]>([]);
25+
const [showConfirmModal, setShowConfirmModal] = useState(false);
26+
27+
useEffect(() => {
28+
if (selectedUser) {
29+
setDraftGrantNames([...selectedUser.grants]);
30+
} else {
31+
setDraftGrantNames([]);
32+
}
33+
}, [selectedUser]);
34+
35+
const savedGrantNames = selectedUser?.grants ?? [];
36+
const hasChanges = useMemo(() => {
37+
if (draftGrantNames.length !== savedGrantNames.length) return true;
38+
const saved = new Set(savedGrantNames);
39+
return draftGrantNames.some((name) => !saved.has(name));
40+
}, [draftGrantNames, savedGrantNames]);
41+
42+
const addedGrants = draftGrantNames.filter((name) => !savedGrantNames.includes(name));
43+
const removedGrants = savedGrantNames.filter((name) => !draftGrantNames.includes(name));
44+
45+
const handleSearch = async (e: React.FormEvent) => {
46+
e.preventDefault();
47+
await searchUserByEmail(emailInput);
48+
};
49+
50+
const handleClear = () => {
51+
setEmailInput('');
52+
clearSelectedUser();
53+
};
54+
55+
const toggleGrant = (grantName: string) => {
56+
setDraftGrantNames((current) =>
57+
current.includes(grantName)
58+
? current.filter((name) => name !== grantName)
59+
: [...current, grantName],
60+
);
61+
};
62+
63+
const handleSave = () => {
64+
if (!selectedUser || !hasChanges) return;
65+
setShowConfirmModal(true);
66+
};
67+
68+
const handleConfirmSave = async () => {
69+
if (!selectedUser) return;
70+
const success = await setUserGrants(selectedUser.email, draftGrantNames);
71+
if (success) {
72+
setShowConfirmModal(false);
73+
}
74+
};
75+
76+
if (loading) {
77+
return <Spinner animation='border' className='mt-3' />;
78+
}
79+
80+
if (grants.length === 0) {
81+
return (
82+
<Alert variant='info' className='mt-3'>
83+
No grants available. Create a grant in the Grants tab first.
84+
</Alert>
85+
);
86+
}
87+
88+
return (
89+
<div className='mt-3'>
90+
<h4 className='mb-3'>Manage grants for a user</h4>
91+
92+
<UserSearchForm
93+
email={emailInput}
94+
loading={userSearchLoading}
95+
showClear={Boolean(selectedUser)}
96+
onEmailChange={setEmailInput}
97+
onSubmit={handleSearch}
98+
onClear={handleClear}
99+
/>
100+
101+
{userSearchError && <Alert variant='danger'>{userSearchError}</Alert>}
102+
103+
{selectedUser && (
104+
<GrantChecklist
105+
user={selectedUser}
106+
grants={grants}
107+
selectedGrantNames={draftGrantNames}
108+
hasChanges={hasChanges}
109+
saving={savingUserGrants}
110+
onToggle={toggleGrant}
111+
onReview={handleSave}
112+
/>
113+
)}
114+
115+
<GrantChangesModal
116+
show={showConfirmModal}
117+
userEmail={selectedUser?.email}
118+
addedGrants={addedGrants}
119+
removedGrants={removedGrants}
120+
saving={savingUserGrants}
121+
onHide={() => setShowConfirmModal(false)}
122+
onConfirm={handleConfirmSave}
123+
/>
124+
</div>
125+
);
126+
});
127+
128+
export default UserGrantsTab;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from 'react';
2+
import { Button, Form } from 'react-bootstrap';
3+
4+
interface UserSearchFormProps {
5+
email: string;
6+
loading: boolean;
7+
showClear: boolean;
8+
onEmailChange: (email: string) => void;
9+
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
10+
onClear: () => void;
11+
}
12+
13+
const UserSearchForm: React.FC<UserSearchFormProps> = ({
14+
email,
15+
loading,
16+
showClear,
17+
onEmailChange,
18+
onSubmit,
19+
onClear,
20+
}) => (
21+
<Form onSubmit={onSubmit} className='mb-4'>
22+
<Form.Group className='mb-3'>
23+
<Form.Label>User Email</Form.Label>
24+
<div className='d-flex gap-2'>
25+
<Form.Control
26+
type='email'
27+
className='user-email-input'
28+
value={email}
29+
onChange={(event) => onEmailChange(event.target.value)}
30+
placeholder='user@example.com'
31+
required
32+
/>
33+
<Button type='submit' variant='primary' disabled={loading}>
34+
{loading ? 'Searching...' : 'Search'}
35+
</Button>
36+
{showClear && (
37+
<Button type='button' variant='outline-secondary' onClick={onClear}>
38+
Clear
39+
</Button>
40+
)}
41+
</div>
42+
</Form.Group>
43+
</Form>
44+
);
45+
46+
export default UserSearchForm;

0 commit comments

Comments
 (0)