@reqquest/ui 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/env.template +5 -0
- package/app/runtime/apply-env.sh +10 -0
- package/app/runtime/nginx.conf +37 -0
- package/app/src/app.css +21 -0
- package/app/src/app.d.ts +13 -0
- package/app/src/app.html +12 -0
- package/app/src/internal/api.ts +1707 -0
- package/app/src/internal/appreq-utils.ts +33 -0
- package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
- package/app/src/internal/components/AppRequestCard.svelte +173 -0
- package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
- package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
- package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
- package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
- package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
- package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
- package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
- package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
- package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
- package/app/src/internal/components/IntroPanel.svelte +41 -0
- package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
- package/app/src/internal/components/PeriodPanel.svelte +125 -0
- package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
- package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
- package/app/src/internal/components/ReviewerActions.svelte +171 -0
- package/app/src/internal/components/ReviewerQuestions.svelte +508 -0
- package/app/src/internal/components/StatusMessageList.svelte +54 -0
- package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
- package/app/src/internal/components/TabLinks.svelte +265 -0
- package/app/src/internal/components/WarningIconYellow.svelte +22 -0
- package/app/src/internal/components/index.ts +19 -0
- package/app/src/internal/csv.ts +30 -0
- package/app/src/internal/index.ts +8 -0
- package/app/src/internal/prompt-save-queue.ts +91 -0
- package/app/src/internal/prompt-utils.ts +40 -0
- package/app/src/internal/status-utils.ts +503 -0
- package/app/src/internal/stores/IStateStore.ts +5 -0
- package/app/src/internal/util.ts +27 -0
- package/app/src/routes/+error.svelte +4 -0
- package/app/src/routes/+layout.svelte +134 -0
- package/app/src/routes/+layout.ts +18 -0
- package/app/src/routes/+page.ts +14 -0
- package/app/src/routes/announcement/+page.svelte +164 -0
- package/app/src/routes/announcement/+page.ts +16 -0
- package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
- package/app/src/routes/dashboards/applicant/+page.ts +95 -0
- package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
- package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
- package/app/src/routes/periods/+page.svelte +158 -0
- package/app/src/routes/periods/+page.ts +13 -0
- package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
- package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
- package/app/src/routes/requests/+page.svelte +246 -0
- package/app/src/routes/requests/+page.ts +24 -0
- package/app/src/routes/requests/[id]/+layout.ts +11 -0
- package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
- package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
- package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
- package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
- package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
- package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
- package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
- package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
- package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
- package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
- package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
- package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
- package/app/src/routes/requests/[id]/export/+page.ts +12 -0
- package/app/src/routes/roles/+layout.svelte +16 -0
- package/app/src/routes/roles/+layout.ts +14 -0
- package/app/src/routes/roles/+page.svelte +225 -0
- package/app/src/routes/roles/+page.ts +13 -0
- package/app/src/routes/roles/[id]/+layout.ts +10 -0
- package/app/src/routes/roles/[id]/+page.svelte +335 -0
- package/app/src/routes/roles/[id]/+page.ts +10 -0
- package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
- package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
- package/app/src/routes/roles/users/+layout.ts +13 -0
- package/app/src/routes/roles/users/+page.svelte +46 -0
- package/app/src/routes/roles/users/+page.ts +90 -0
- package/app/static/favicon.png +0 -0
- package/bin/reqquest-ui.js +117 -0
- package/cli/config.js +105 -0
- package/cli/sync.js +296 -0
- package/cli/util.js +29 -0
- package/dist/components.md +5 -4
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +3 -0
- package/package.json +29 -15
- package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ColumnList, FieldCheckboxList, FieldHidden, FieldMultiselect, FieldSelect, Panel, PanelFormDialog, Toasts } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import { toasts } from '@txstate-mws/svelte-components'
|
|
4
|
+
import { Modal, TooltipDefinition } from 'carbon-components-svelte'
|
|
5
|
+
import Add from 'carbon-icons-svelte/lib/Add.svelte'
|
|
6
|
+
import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
|
|
7
|
+
import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'
|
|
8
|
+
import { setContext } from 'svelte'
|
|
9
|
+
import { pick, ucfirst } from 'txstate-utils'
|
|
10
|
+
import { invalidate } from '$app/navigation'
|
|
11
|
+
import { api, IntroPanel } from '$internal'
|
|
12
|
+
import type { AccessRoleGrantCreate, AccessRoleGrantUpdate, AccessTagInput } from '@reqquest/ui'
|
|
13
|
+
import type { PageData } from './$types'
|
|
14
|
+
import ControlWithTooltip from './ControlWithTooltip.svelte'
|
|
15
|
+
import TagsWithTooltip from './TagsWithTooltip.svelte'
|
|
16
|
+
|
|
17
|
+
export let data: PageData
|
|
18
|
+
$: ({ role, controlGroups, controlGroupLookup } = data)
|
|
19
|
+
|
|
20
|
+
$: grants = role.grants.filter(g => g.allow)
|
|
21
|
+
$: exceptions = role.grants.filter(g => !g.allow)
|
|
22
|
+
|
|
23
|
+
setContext('controlGroupLookup', () => controlGroupLookup)
|
|
24
|
+
|
|
25
|
+
// the multiselect only renders option text, so a tag's description (e.g. a program's other titles)
|
|
26
|
+
// goes on a second line of the label, styled as helper text below - and the filter matches it too
|
|
27
|
+
function tagItems (tags: { value: string, label: string, description?: string | null }[]) {
|
|
28
|
+
return tags.map(t => ({ value: t.value, label: t.description ? `${t.label}\n${t.description}` : t.label }))
|
|
29
|
+
}
|
|
30
|
+
type AccessRoleGrantCreateForm = Omit<AccessRoleGrantCreate, 'tags'> & { tags: Record<string, string[]> }
|
|
31
|
+
type AccessRoleGrantUpdateForm = Omit<AccessRoleGrantUpdate, 'tags'> & { tags: Record<string, string[]> }
|
|
32
|
+
function transformFromAPI (data: PageData['role']['grants'][number]): AccessRoleGrantUpdateForm {
|
|
33
|
+
return {
|
|
34
|
+
...pick(data, 'controls', 'allow'),
|
|
35
|
+
controlGroup: data.controlGroup.name,
|
|
36
|
+
tags: data.tags.reduce((acc: Record<string, string[]>, curr) => ({ ...acc, [curr.category]: [...(acc[curr.category] ?? []), curr.tag] }), {})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function transformToAPI<T extends { tags?: Record<string, string[]> }> (data: T): Omit<T, 'tags'> & { tags: AccessTagInput[] } {
|
|
40
|
+
return {
|
|
41
|
+
...data,
|
|
42
|
+
tags: Object.entries(data.tags ?? {}).flatMap(([category, tags]) => tags.map(tag => ({ category, tag })))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let showGrantEdit = false
|
|
47
|
+
let grantToEdit: PageData['role']['grants'][number] | undefined
|
|
48
|
+
let grantToEditInput: AccessRoleGrantUpdateForm | undefined
|
|
49
|
+
function onGrantEdit (row: PageData['role']['grants'][number]) {
|
|
50
|
+
return () => {
|
|
51
|
+
showGrantEdit = true
|
|
52
|
+
grantToEdit = row
|
|
53
|
+
grantToEditInput = transformFromAPI(row)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function onGrantDelete (row: PageData['role']['grants'][number]) {
|
|
57
|
+
return () => {
|
|
58
|
+
grantToDelete = row
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function onCloseEdit () {
|
|
62
|
+
showGrantCreate = false
|
|
63
|
+
showGrantEdit = false
|
|
64
|
+
grantToEdit = undefined
|
|
65
|
+
grantToEditInput = undefined
|
|
66
|
+
}
|
|
67
|
+
async function onSaveGrant (event: CustomEvent<AccessRoleGrantCreateForm | AccessRoleGrantUpdateForm>) {
|
|
68
|
+
const controlGroupName = event.detail.controlGroup
|
|
69
|
+
if (!controlGroupName) {
|
|
70
|
+
console.error('No control group in form submission')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const grantName = controlGroupLookup[controlGroupName].title
|
|
74
|
+
if (!grantName) {
|
|
75
|
+
console.error('Control group not found in lookup')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const isEdit = !!grantToEdit
|
|
79
|
+
const type = isEdit ? (grantToEdit!.allow ? 'grant' : 'exception') : (grantCreateAllow ? 'grant' : 'exception')
|
|
80
|
+
const action = isEdit ? 'edited' : 'added'
|
|
81
|
+
toasts.add({
|
|
82
|
+
type: 'success',
|
|
83
|
+
title: `${ucfirst(type)} ${action}`,
|
|
84
|
+
message: `The ${type} ${grantName} was successfully ${action}.`
|
|
85
|
+
})
|
|
86
|
+
await invalidate('api:getRoleDetails')
|
|
87
|
+
onCloseEdit()
|
|
88
|
+
}
|
|
89
|
+
async function onEditSubmit (data: AccessRoleGrantUpdateForm) {
|
|
90
|
+
const response = await api.updateGrant(grantToEdit!.id, transformToAPI(data), false)
|
|
91
|
+
return { ...response, data }
|
|
92
|
+
}
|
|
93
|
+
async function onEditValidate (data: AccessRoleGrantUpdateForm) {
|
|
94
|
+
const response = await api.updateGrant(grantToEdit!.id, transformToAPI(data), true)
|
|
95
|
+
return response.messages
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let showGrantCreate = false
|
|
99
|
+
let grantCreateAllow = true
|
|
100
|
+
async function onCreateSubmit (data: AccessRoleGrantCreateForm) {
|
|
101
|
+
const response = await api.createGrant(role.id, transformToAPI(data), false)
|
|
102
|
+
return { ...response, data }
|
|
103
|
+
}
|
|
104
|
+
async function onCreateValidate (data: AccessRoleGrantCreateForm) {
|
|
105
|
+
const response = await api.createGrant(role.id, transformToAPI(data), true)
|
|
106
|
+
return response.messages
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let grantToDelete: PageData['role']['grants'][number] | undefined
|
|
110
|
+
function closeDeleteConfirmation () {
|
|
111
|
+
grantToDelete = undefined
|
|
112
|
+
}
|
|
113
|
+
async function executeDelete () {
|
|
114
|
+
try {
|
|
115
|
+
if (!grantToDelete) {
|
|
116
|
+
console.error('No grant selected for deletion')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const type = grantToDelete.allow ? 'grant' : 'exception'
|
|
120
|
+
const grantName = controlGroupLookup[grantToDelete.controlGroup.name].title
|
|
121
|
+
if (!grantName) {
|
|
122
|
+
console.error('Control group not found in lookup')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
await api.deleteGrant(grantToDelete.id)
|
|
126
|
+
toasts.add({
|
|
127
|
+
type: 'success',
|
|
128
|
+
title: `${ucfirst(type)} deleted`,
|
|
129
|
+
message: `The ${type} ${grantName} was successfully deleted.`
|
|
130
|
+
})
|
|
131
|
+
await invalidate('api:getRoleDetails')
|
|
132
|
+
} finally {
|
|
133
|
+
closeDeleteConfirmation()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<IntroPanel title="{role.name} permissions" subtitle={role.description ?? ''}>
|
|
139
|
+
<div class="groups [ text-sm ] flow" style="--flow-space:0.5rem;">
|
|
140
|
+
<h3 class="font-medium ">Groups</h3>
|
|
141
|
+
<ul>
|
|
142
|
+
{#each role.groups as group (group.groupName)}
|
|
143
|
+
<li>{group.groupName}</li>
|
|
144
|
+
{/each}
|
|
145
|
+
</ul>
|
|
146
|
+
</div>
|
|
147
|
+
</IntroPanel>
|
|
148
|
+
<div class="columnlist-wrap flow" style="--flow-space:3em;">
|
|
149
|
+
<div class="role-list">
|
|
150
|
+
<ColumnList
|
|
151
|
+
title="Grants for Role: {role.name}"
|
|
152
|
+
listActions={[
|
|
153
|
+
{
|
|
154
|
+
label: 'Add Grant',
|
|
155
|
+
icon: Add,
|
|
156
|
+
onClick: () => {
|
|
157
|
+
showGrantCreate = true
|
|
158
|
+
grantCreateAllow = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
]}
|
|
162
|
+
columns={[
|
|
163
|
+
{ id: 'controlGroup', label: 'Grants', render: grant => grant.controlGroup.title },
|
|
164
|
+
{ id: 'controls', label: 'Controls', component: ControlWithTooltip },
|
|
165
|
+
{ id: 'tags', label: 'Restrictions', component: TagsWithTooltip }
|
|
166
|
+
]}
|
|
167
|
+
rows={grants}
|
|
168
|
+
actions={row => [
|
|
169
|
+
{
|
|
170
|
+
label: 'Edit',
|
|
171
|
+
icon: Edit,
|
|
172
|
+
onClick: onGrantEdit(row),
|
|
173
|
+
disabled: !row.actions.update
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
label: 'Delete',
|
|
177
|
+
icon: TrashCan,
|
|
178
|
+
onClick: onGrantDelete(row),
|
|
179
|
+
disabled: !row.actions.delete
|
|
180
|
+
}
|
|
181
|
+
]}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="role-list">
|
|
186
|
+
<ColumnList
|
|
187
|
+
title="Exceptions for Role: {role.name}"
|
|
188
|
+
columns={[
|
|
189
|
+
{ id: 'controlGroup', label: 'Exceptions', render: grant => grant.controlGroup.title },
|
|
190
|
+
{ id: 'controls', label: 'Controls', render: grant => grant.controls.join(', ') },
|
|
191
|
+
{ id: 'tags', label: 'Restrictions', component: TagsWithTooltip }
|
|
192
|
+
]}
|
|
193
|
+
noItemsKind='info'
|
|
194
|
+
noItemsTitle=''
|
|
195
|
+
noItemsSubtitle='No exceptions on this role.'
|
|
196
|
+
rows={exceptions}
|
|
197
|
+
listActions={[
|
|
198
|
+
{
|
|
199
|
+
label: 'Add Exception',
|
|
200
|
+
icon: Add,
|
|
201
|
+
onClick: () => {
|
|
202
|
+
showGrantCreate = true
|
|
203
|
+
grantCreateAllow = false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
]}
|
|
207
|
+
actions={row => [
|
|
208
|
+
{
|
|
209
|
+
label: 'Edit',
|
|
210
|
+
icon: Edit,
|
|
211
|
+
onClick: onGrantEdit(row),
|
|
212
|
+
disabled: !row.actions.update
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
label: 'Delete',
|
|
216
|
+
icon: TrashCan,
|
|
217
|
+
onClick: onGrantDelete(row),
|
|
218
|
+
disabled: !row.actions.delete
|
|
219
|
+
}
|
|
220
|
+
]}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
{#if showGrantEdit && grantToEdit}
|
|
225
|
+
{@const controlGroup = controlGroupLookup[grantToEdit.controlGroup.name]}
|
|
226
|
+
<PanelFormDialog open title="Edit {grantToEdit.allow ? 'Grant' : 'Exception'}" submit={onEditSubmit} validate={onEditValidate} on:cancel={onCloseEdit} on:saved={onSaveGrant} preload={grantToEditInput} let:data>
|
|
227
|
+
<FieldHidden path="allow" value={grantToEdit.allow} />
|
|
228
|
+
<FieldHidden path="controlGroup" value={grantToEdit.controlGroup.name} />
|
|
229
|
+
<FieldCheckboxList
|
|
230
|
+
path="controls"
|
|
231
|
+
legendText="Controls"
|
|
232
|
+
items={controlGroup.controls.map(c => ({ value: c.name }))}
|
|
233
|
+
>
|
|
234
|
+
<svelte:fragment slot="labelText" let:item>
|
|
235
|
+
{@const control = controlGroup.controls.find(c => c.name === item.value)}
|
|
236
|
+
{#if control}
|
|
237
|
+
<TooltipDefinition tooltipText={control.description} direction="bottom" align="start">
|
|
238
|
+
{control.name}
|
|
239
|
+
</TooltipDefinition>
|
|
240
|
+
{:else}
|
|
241
|
+
{item.value}
|
|
242
|
+
{/if}
|
|
243
|
+
</svelte:fragment>
|
|
244
|
+
</FieldCheckboxList>
|
|
245
|
+
{#each controlGroup.tags as category (category.category)}
|
|
246
|
+
<div class="tag-multiselect">
|
|
247
|
+
<FieldMultiselect
|
|
248
|
+
path="tags.{category.category}"
|
|
249
|
+
titleText={category.label}
|
|
250
|
+
helperText={category.description}
|
|
251
|
+
label="Restrict to {category.label}"
|
|
252
|
+
items={tagItems(category.tags)}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
{/each}
|
|
256
|
+
</PanelFormDialog>
|
|
257
|
+
{:else if showGrantCreate}
|
|
258
|
+
<PanelFormDialog open title="New {grantCreateAllow ? 'Grant' : 'Exception'}" submit={onCreateSubmit} validate={onCreateValidate} on:cancel={onCloseEdit} on:saved={onSaveGrant} let:data>
|
|
259
|
+
<FieldHidden path="allow" value={grantCreateAllow} />
|
|
260
|
+
{@const controlGroup = data.controlGroup ? controlGroupLookup[data.controlGroup] : undefined}
|
|
261
|
+
<FieldSelect
|
|
262
|
+
path="controlGroup"
|
|
263
|
+
labelText="Control Group"
|
|
264
|
+
items={controlGroups.map(st => ({ value: st.name, label: st.title }))}
|
|
265
|
+
helperText={controlGroup?.description}
|
|
266
|
+
/>
|
|
267
|
+
{#if controlGroup}
|
|
268
|
+
<FieldCheckboxList
|
|
269
|
+
path="controls"
|
|
270
|
+
legendText="Controls"
|
|
271
|
+
items={controlGroup.controls.map(c => ({ value: c.name }))}
|
|
272
|
+
>
|
|
273
|
+
<svelte:fragment slot="labelText" let:item>
|
|
274
|
+
{@const control = controlGroup.controls.find(c => c.name === item.value)}
|
|
275
|
+
{#if control}
|
|
276
|
+
<TooltipDefinition tooltipText={control.description} direction="bottom" align="start">
|
|
277
|
+
{control.name}
|
|
278
|
+
</TooltipDefinition>
|
|
279
|
+
{:else}
|
|
280
|
+
{item.value}
|
|
281
|
+
{/if}
|
|
282
|
+
</svelte:fragment>
|
|
283
|
+
</FieldCheckboxList>
|
|
284
|
+
|
|
285
|
+
{#each controlGroup.tags as category (category.category)}
|
|
286
|
+
<div class="tag-multiselect">
|
|
287
|
+
<FieldMultiselect
|
|
288
|
+
path="tags.{category.category}"
|
|
289
|
+
titleText={category.label}
|
|
290
|
+
helperText={category.description}
|
|
291
|
+
label="Restrict to {category.label}"
|
|
292
|
+
items={tagItems(category.tags)}
|
|
293
|
+
filterable
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
{/each}
|
|
297
|
+
{/if}
|
|
298
|
+
</PanelFormDialog>
|
|
299
|
+
{:else if grantToDelete}
|
|
300
|
+
<Modal
|
|
301
|
+
open
|
|
302
|
+
modalHeading="Delete {grantToDelete.allow ? 'Grant' : 'Exception'}"
|
|
303
|
+
primaryButtonText="Delete"
|
|
304
|
+
secondaryButtonText="Cancel"
|
|
305
|
+
danger
|
|
306
|
+
size="sm"
|
|
307
|
+
on:click:button--primary={executeDelete}
|
|
308
|
+
on:click:button--secondary={closeDeleteConfirmation}
|
|
309
|
+
on:submit={executeDelete}
|
|
310
|
+
on:close={closeDeleteConfirmation}
|
|
311
|
+
>
|
|
312
|
+
<p>Are you sure you want to delete this {grantToDelete.allow ? 'grant' : 'exception'}?<br><em>{controlGroupLookup[grantToDelete.controlGroup.name].title}</em></p>
|
|
313
|
+
<p>This action cannot be undone.</p>
|
|
314
|
+
</Modal>
|
|
315
|
+
{/if}
|
|
316
|
+
|
|
317
|
+
<style>
|
|
318
|
+
.role-list :global(.column-list-head) {
|
|
319
|
+
background: var(--cds-ui-03);
|
|
320
|
+
}
|
|
321
|
+
/* options carrying a description are two lines - the title, then the description as helper text.
|
|
322
|
+
carbon-svelte's multiselect already lets unvirtualized options grow, but sets white-space: normal on
|
|
323
|
+
the label with a five-class selector, so these need a sixth to keep the line break */
|
|
324
|
+
.tag-multiselect :global(.bx--list-box .bx--list-box__menu-item .bx--list-box__menu-item__option .bx--checkbox-label-text) {
|
|
325
|
+
white-space: pre-line;
|
|
326
|
+
font-size: 0.75rem;
|
|
327
|
+
line-height: 1rem;
|
|
328
|
+
color: var(--cds-text-secondary);
|
|
329
|
+
}
|
|
330
|
+
.tag-multiselect :global(.bx--list-box .bx--list-box__menu-item .bx--list-box__menu-item__option .bx--checkbox-label-text::first-line) {
|
|
331
|
+
font-size: 0.875rem;
|
|
332
|
+
line-height: 1.125rem;
|
|
333
|
+
color: var(--cds-text-primary);
|
|
334
|
+
}
|
|
335
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { api } from '$internal'
|
|
2
|
+
import { error } from '@sveltejs/kit'
|
|
3
|
+
import type { PageLoad } from './$types'
|
|
4
|
+
|
|
5
|
+
export const load: PageLoad = async ({ params, depends }) => {
|
|
6
|
+
const role = await api.getRoleDetails(params.id)
|
|
7
|
+
if (!role) throw error(404, 'Role not found')
|
|
8
|
+
depends('api:getRoleDetails')
|
|
9
|
+
return { role }
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { TooltipDefinition } from 'carbon-components-svelte'
|
|
3
|
+
import { getContext } from 'svelte'
|
|
4
|
+
|
|
5
|
+
export let row: { controlGroup: { name: string }, controls: string[] }
|
|
6
|
+
const controlGroupLookup = getContext<() => Record<string, { controls: { name: string, description: string }[] }>>('controlGroupLookup')()
|
|
7
|
+
$: controlGroup = controlGroupLookup[row.controlGroup.name]
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
{#each row.controls as control, controlIndex}
|
|
11
|
+
{@const controlInfo = controlGroup.controls.find(c => c.name === control)}
|
|
12
|
+
<TooltipDefinition tooltipText={controlInfo?.description}>
|
|
13
|
+
{controlInfo?.name ?? control}
|
|
14
|
+
</TooltipDefinition>{#if controlIndex < row.controls.length - 1},{' '}{/if}
|
|
15
|
+
{/each}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { TooltipDefinition } from 'carbon-components-svelte'
|
|
3
|
+
import { groupby } from 'txstate-utils'
|
|
4
|
+
|
|
5
|
+
export let row: { tags: { categoryLabel: string, label: string, description?: string | null }[] }
|
|
6
|
+
|
|
7
|
+
// past this many tags the restrictions read better as a list per category than inline
|
|
8
|
+
const THRESHOLD = 5
|
|
9
|
+
$: categories = Object.entries(groupby(row.tags, 'categoryLabel'))
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
{#if row.tags.length > THRESHOLD}
|
|
13
|
+
{#each categories as [categoryLabel, tags], i (categoryLabel)}
|
|
14
|
+
{#if i > 0}<br />{/if}
|
|
15
|
+
<div>
|
|
16
|
+
<strong>{categoryLabel}</strong>
|
|
17
|
+
<ul>
|
|
18
|
+
{#each tags as tag, tagIndex (tagIndex)}
|
|
19
|
+
<li>
|
|
20
|
+
{#if tag.description}
|
|
21
|
+
<TooltipDefinition tooltipText={tag.description}>{tag.label}</TooltipDefinition>
|
|
22
|
+
{:else}
|
|
23
|
+
{tag.label}
|
|
24
|
+
{/if}
|
|
25
|
+
</li>
|
|
26
|
+
{/each}
|
|
27
|
+
</ul>
|
|
28
|
+
</div>
|
|
29
|
+
{/each}
|
|
30
|
+
{:else}
|
|
31
|
+
{#each categories as [categoryLabel, tags], i (categoryLabel)}
|
|
32
|
+
{#if i > 0}<br />{/if}
|
|
33
|
+
<strong>{categoryLabel}</strong>:
|
|
34
|
+
{#each tags as tag, tagIndex (tagIndex)}
|
|
35
|
+
{#if tag.description}
|
|
36
|
+
<TooltipDefinition tooltipText={tag.description}>{tag.label}</TooltipDefinition>
|
|
37
|
+
{:else}
|
|
38
|
+
{tag.label}
|
|
39
|
+
{/if}{#if tagIndex < tags.length - 1},{' '}{/if}
|
|
40
|
+
{/each}
|
|
41
|
+
{/each}
|
|
42
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { api } from '$internal'
|
|
2
|
+
import { extractMergedFilters, type ComboMenuItem } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import type { LayoutLoad } from '../$types'
|
|
4
|
+
|
|
5
|
+
export const load: LayoutLoad = async ({ url, depends }) => {
|
|
6
|
+
const { search, institutionalRoles, applicationRoles } = extractMergedFilters(url)
|
|
7
|
+
// Get access data
|
|
8
|
+
const availableInstitutionalRoles: ComboMenuItem[] = [{ value: 'Faculty' }, { value: 'Staff' }, { value: 'Student' }]
|
|
9
|
+
const appRoles = (await api.getRoleList()) ?? []
|
|
10
|
+
const availableApplicationRoles: ComboMenuItem[] = appRoles.map(role => ({ value: role.name }))
|
|
11
|
+
depends('api:getRoleList')
|
|
12
|
+
return { availableApplicationRoles, availableInstitutionalRoles }
|
|
13
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ColumnList, FieldMultiselect, FilterUI, Pagination } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import type { PageData } from './$types'
|
|
4
|
+
import { IntroPanel } from '$internal'
|
|
5
|
+
import { uiRegistry } from '$local'
|
|
6
|
+
|
|
7
|
+
export let data: PageData
|
|
8
|
+
$: ({ columns, rows, filters, page, totalItems, availableApplicationRoles, roleUsersAttributeDescription } = data)
|
|
9
|
+
|
|
10
|
+
function handlePagination (event: CustomEvent<{ page: number, pageSize: number, totalItems: number }>) {
|
|
11
|
+
// event: {"isTrusted": false}
|
|
12
|
+
console.debug(`handlePagination event: ${JSON.stringify(event)}`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// TODO: Dynamically add groupings
|
|
16
|
+
interface UsersSearchForm {
|
|
17
|
+
search?: string
|
|
18
|
+
applicationRoles?: string[]
|
|
19
|
+
// institutionalRoles?: string[]
|
|
20
|
+
[key: string]: string | string[] | undefined
|
|
21
|
+
}
|
|
22
|
+
let usersSearchFormData: UsersSearchForm | undefined
|
|
23
|
+
|
|
24
|
+
</script>
|
|
25
|
+
<IntroPanel
|
|
26
|
+
title="User Lookup"
|
|
27
|
+
subtitle={roleUsersAttributeDescription}
|
|
28
|
+
/>
|
|
29
|
+
<FilterUI
|
|
30
|
+
search
|
|
31
|
+
on:apply={e => { usersSearchFormData = e.detail }}
|
|
32
|
+
on:mount={e => { usersSearchFormData = e.detail }}>
|
|
33
|
+
<svelte:fragment slot="quickfilters">
|
|
34
|
+
<FieldMultiselect path="applicationRoles" label={`${uiRegistry.getWord('appRequest')} Roles`} items={availableApplicationRoles} />
|
|
35
|
+
{#each filters as filter (filter.id)}
|
|
36
|
+
<FieldMultiselect path={filter.id} label={filter.label} items={filter.items} />
|
|
37
|
+
{/each}
|
|
38
|
+
</svelte:fragment>
|
|
39
|
+
</FilterUI>
|
|
40
|
+
<ColumnList
|
|
41
|
+
title='Users'
|
|
42
|
+
columns={columns}
|
|
43
|
+
rows={rows}
|
|
44
|
+
>
|
|
45
|
+
</ColumnList>
|
|
46
|
+
<Pagination {page} {totalItems} on:update={handlePagination} on:mount={handlePagination} noun="user" nounPlural="users" />
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { api } from '$internal'
|
|
2
|
+
import { extractMergedFilters, extractPaginationParams, type ColumnDefinition, type ComboMenuItem } from '@txstate-mws/carbon-svelte'
|
|
3
|
+
import type { PageLoad } from './$types'
|
|
4
|
+
import type { AccessUserFilter, Pagination } from '@reqquest/ui'
|
|
5
|
+
import { omit } from 'txstate-utils'
|
|
6
|
+
import { uiRegistry } from '$local'
|
|
7
|
+
|
|
8
|
+
export const load: PageLoad = async ({ url, depends }) => {
|
|
9
|
+
const roleUsersAttributeDescription = uiRegistry.config.roleUsersAttributeDescription ?? ''
|
|
10
|
+
const query = extractMergedFilters(url)
|
|
11
|
+
const { search, applicationRoles } = query
|
|
12
|
+
const groupingsQuery = omit(query, 'search', 'applicationRoles')
|
|
13
|
+
const { page, pagesize } = extractPaginationParams(url)
|
|
14
|
+
const pageFilter: Pagination = { page, perPage: pagesize }
|
|
15
|
+
// Get access data
|
|
16
|
+
const accessUsersFilter: AccessUserFilter = {}
|
|
17
|
+
if (search) accessUsersFilter.search = search
|
|
18
|
+
// groupings is a map<label, ids>
|
|
19
|
+
if (Array.isArray(applicationRoles) && applicationRoles.length > 0) {
|
|
20
|
+
accessUsersFilter.roles = applicationRoles
|
|
21
|
+
}
|
|
22
|
+
// Dynamically add groupings
|
|
23
|
+
const groupings = Object.keys(groupingsQuery)
|
|
24
|
+
for (const grouping of groupings) {
|
|
25
|
+
if (groupingsQuery[grouping] != null) {
|
|
26
|
+
accessUsersFilter.otherCategoriesByLabel ??= []
|
|
27
|
+
accessUsersFilter.otherCategoriesByLabel.push({ category: grouping, tags: groupingsQuery[grouping] })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const { users, pageInfo } = await api.getAccessUsers(accessUsersFilter, pageFilter)
|
|
31
|
+
|
|
32
|
+
interface User {
|
|
33
|
+
// Local data
|
|
34
|
+
id: string
|
|
35
|
+
// ids = id + otherId
|
|
36
|
+
ids: string
|
|
37
|
+
// contact = fullname + email
|
|
38
|
+
contact: string
|
|
39
|
+
applicationRoles: string
|
|
40
|
+
groups: string
|
|
41
|
+
// Remote data
|
|
42
|
+
// otherInfo?: any
|
|
43
|
+
// institutionalRoles?: string[]
|
|
44
|
+
// lastLogin?: string
|
|
45
|
+
[key: string]: string | undefined
|
|
46
|
+
}
|
|
47
|
+
const rows = users.map(u => {
|
|
48
|
+
const user: User = {
|
|
49
|
+
id: u.login,
|
|
50
|
+
ids: [u.login, u.otherInfo?.otherId].filter(id => id != null).join('\n'),
|
|
51
|
+
contact: u.otherInfo?.email ? u.fullname + '\n' + u.otherInfo.email : u.fullname,
|
|
52
|
+
applicationRoles: u.roles.map(r => r.name).join(', '),
|
|
53
|
+
groups: u.groups.join(', ')
|
|
54
|
+
// otherInfo: u.otherInfo
|
|
55
|
+
// institutionalRoles: u.otherInfo?.institutionalRoles ?? [],
|
|
56
|
+
// lastLogin: u.otherInfo?.lastLogin ? DateTime.fromISO(u.otherInfo?.lastLogin).toFormat('MM/dd/yyyy') : 'unknown'
|
|
57
|
+
}
|
|
58
|
+
if (u.otherInfo?.categories) {
|
|
59
|
+
for (const group of pageInfo?.categories ?? []) {
|
|
60
|
+
const category = group.category
|
|
61
|
+
if (group.useInList) user[category] = Array.isArray(u.otherInfo.categories[category]) ? u.otherInfo.categories[category].map(tl => tl.label ?? tl.tag).join(', ') : undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return user
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
interface Filter {
|
|
68
|
+
id: string
|
|
69
|
+
label: string
|
|
70
|
+
items: ComboMenuItem[]
|
|
71
|
+
}
|
|
72
|
+
const filters: Filter[] = []
|
|
73
|
+
const columns: ColumnDefinition<User>[] = [
|
|
74
|
+
{ id: 'contact', label: 'Name', get: 'contact' },
|
|
75
|
+
{ id: 'id', label: 'IDs', get: 'ids' },
|
|
76
|
+
{ id: 'applicationRoles', label: `${uiRegistry.getWord('appRequest')} Roles`, get: 'applicationRoles' },
|
|
77
|
+
{ id: 'groups', label: 'Groups', get: 'groups' }
|
|
78
|
+
// TODO: Dynamically pull extra grouping columns
|
|
79
|
+
// { id: 'institutionalRoles', label: 'Institutional Roles', render: user => user['institutionalRoles'] ? user['institutionalRoles'].join(', ') : '' },
|
|
80
|
+
// { id: 'lastLogin', label: 'Last Login', get: 'lastLogin' }
|
|
81
|
+
]
|
|
82
|
+
for (const group of pageInfo?.categories ?? []) {
|
|
83
|
+
const category = group.category
|
|
84
|
+
if (group.useInList) columns.push({ id: category, label: group.label ?? category, get: category })
|
|
85
|
+
if (group.useInFilters) filters.push({ id: category, label: group.label ?? category, items: group.tags.map(tl => ({ value: tl.tag, label: tl.label ?? tl.tag })) })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
depends('api:getAccessUsers')
|
|
89
|
+
return { columns, rows, filters, page: pageInfo?.currentPage, totalItems: pageInfo?.totalItems, roleUsersAttributeDescription }
|
|
90
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Builds and runs a downstream ReqQuest UI. The project needs only @reqquest/ui installed and a
|
|
4
|
+
* src/local/index.ts exporting `uiRegistry`; svelte, SvelteKit, Vite and their configuration all come
|
|
5
|
+
* from this package. See docs/downstream-ui.md.
|
|
6
|
+
*
|
|
7
|
+
* reqquest-ui sync [--strict] [--accept] assemble .reqquest/ (see cli/sync.js)
|
|
8
|
+
* reqquest-ui dev [vite args...] sync, then the Vite dev server; re-syncs on changes
|
|
9
|
+
* reqquest-ui build sync --strict, registry check, production build
|
|
10
|
+
* -> build/ (static site) and build-runtime/ (nginx)
|
|
11
|
+
* reqquest-ui check [svelte-check args] type-check everything, including src/local
|
|
12
|
+
* reqquest-ui check-registry fail if src/local/index.ts has type errors
|
|
13
|
+
*
|
|
14
|
+
* Run it from the project root (the directory holding package.json and src/local).
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync, spawn } from 'node:child_process'
|
|
17
|
+
import { cpSync, existsSync, mkdirSync, watch } from 'node:fs'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { sync, SyncError } from '../cli/sync.js'
|
|
21
|
+
import { packageBin } from '../cli/util.js'
|
|
22
|
+
|
|
23
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
const projectRoot = process.cwd()
|
|
25
|
+
const appRoot = join(projectRoot, '.reqquest')
|
|
26
|
+
const [command, ...args] = process.argv.slice(2)
|
|
27
|
+
|
|
28
|
+
function run (pkg, bin, binArgs) {
|
|
29
|
+
const result = spawnSync(process.execPath, [packageBin(pkg, PACKAGE_ROOT, bin), ...binArgs], { cwd: appRoot, stdio: 'inherit' })
|
|
30
|
+
if (result.status !== 0) process.exit(result.status ?? 1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function svelteKitSync () {
|
|
34
|
+
run('@sveltejs/kit', 'svelte-kit', ['sync'])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Ported from ui/check-registry.js, which explains why the gate is narrowed to one file. */
|
|
38
|
+
function checkRegistry () {
|
|
39
|
+
const REGISTRY = 'src/local/index.ts'
|
|
40
|
+
const result = spawnSync(process.execPath, [packageBin('svelte-check', PACKAGE_ROOT), '--tsconfig', './tsconfig.json', '--output', 'machine'], { cwd: appRoot, encoding: 'utf8' })
|
|
41
|
+
const errors = (result.stdout ?? '').split('\n').filter(line => line.includes(' ERROR ') && line.includes(`${REGISTRY}"`))
|
|
42
|
+
if (errors.length > 0) {
|
|
43
|
+
console.error(`${REGISTRY} has ${errors.length} type error(s).\n`)
|
|
44
|
+
for (const line of errors) console.error(line)
|
|
45
|
+
console.error('\nA missing prompt means the API defines a prompt this UI has no component for.')
|
|
46
|
+
console.error(`Add it to the prompts object in ${REGISTRY}, or regenerate the key declaration if the API's definitions changed.`)
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
console.log(`${REGISTRY} is in step with the generated key declaration.`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dev () {
|
|
53
|
+
sync(projectRoot)
|
|
54
|
+
svelteKitSync()
|
|
55
|
+
const vite = spawn(process.execPath, [packageBin('vite', PACKAGE_ROOT), 'dev', ...args], { cwd: appRoot, stdio: 'inherit' })
|
|
56
|
+
vite.on('exit', code => process.exit(code ?? 0))
|
|
57
|
+
|
|
58
|
+
// src/local is aliased in place, so Vite already watches it; routes and static are copied
|
|
59
|
+
let timer
|
|
60
|
+
const resync = () => {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
try {
|
|
64
|
+
sync(projectRoot)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (!(e instanceof SyncError)) throw e
|
|
67
|
+
console.error(`reqquest-ui: ${e.message}`)
|
|
68
|
+
}
|
|
69
|
+
}, 100)
|
|
70
|
+
}
|
|
71
|
+
for (const dir of ['src/routes', 'static']) {
|
|
72
|
+
if (existsSync(join(projectRoot, dir))) watch(join(projectRoot, dir), { recursive: true }, resync)
|
|
73
|
+
}
|
|
74
|
+
watch(join(projectRoot, 'package.json'), resync)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function build () {
|
|
78
|
+
sync(projectRoot, { strict: true })
|
|
79
|
+
svelteKitSync()
|
|
80
|
+
checkRegistry()
|
|
81
|
+
run('vite', 'vite', ['build'])
|
|
82
|
+
const runtime = join(projectRoot, 'build-runtime')
|
|
83
|
+
mkdirSync(runtime, { recursive: true })
|
|
84
|
+
cpSync(join(PACKAGE_ROOT, 'app/runtime'), runtime, { recursive: true })
|
|
85
|
+
console.log('reqquest-ui: site written to build/, nginx.conf and apply-env.sh to build-runtime/')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
switch (command) {
|
|
90
|
+
case 'sync':
|
|
91
|
+
sync(projectRoot, { strict: args.includes('--strict'), accept: args.includes('--accept') })
|
|
92
|
+
break
|
|
93
|
+
case 'dev':
|
|
94
|
+
dev()
|
|
95
|
+
break
|
|
96
|
+
case 'build':
|
|
97
|
+
build()
|
|
98
|
+
break
|
|
99
|
+
case 'check':
|
|
100
|
+
sync(projectRoot)
|
|
101
|
+
svelteKitSync()
|
|
102
|
+
run('svelte-check', 'svelte-check', ['--tsconfig', './tsconfig.json', ...args])
|
|
103
|
+
break
|
|
104
|
+
case 'check-registry':
|
|
105
|
+
sync(projectRoot)
|
|
106
|
+
svelteKitSync()
|
|
107
|
+
checkRegistry()
|
|
108
|
+
break
|
|
109
|
+
default:
|
|
110
|
+
console.error('usage: reqquest-ui <sync [--strict] [--accept] | dev | build | check | check-registry>')
|
|
111
|
+
process.exit(command ? 1 : 0)
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (!(e instanceof SyncError)) throw e
|
|
115
|
+
console.error(`reqquest-ui: ${e.message}`)
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|