Skip to content

Commit f4926ba

Browse files
satellite/admin-ui: add update entitlements capabilities
This change adds dialogs to update project entitlements for new buckets placements, placement-product mappings, and compute access tokens. Issue: storj/storj-private#1614 Change-Id: Iab8bf69987bbb71bb32fbc23b26753a1428e2457
1 parent 81de69c commit f4926ba

File tree

6 files changed

+690
-7
lines changed

6 files changed

+690
-7
lines changed

satellite/admin/back-office/ui/src/components/EntitlementsDialog.vue

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@
1919
<v-divider>
2020
<span class="text-caption">Compute access token</span>
2121
</v-divider>
22-
<v-chip class="mt-3">
23-
<template v-if="entitlements?.computeAccessToken">***************************</template>
24-
<template v-else>Not Set</template>
25-
</v-chip>
22+
<div class="d-flex flex-wrap ga-2 mt-3">
23+
<v-chip>
24+
<template v-if="entitlements?.computeAccessToken">***************************</template>
25+
<template v-else>Not Set</template>
26+
</v-chip>
27+
<v-btn
28+
class="align-self-center"
29+
density="compact"
30+
flat
31+
@click="computeAccessTokenDialog = true"
32+
>
33+
Update
34+
</v-btn>
35+
</div>
2636

2737
<v-divider class="my-5">
2838
<span class="text-caption">New bucket placements</span>
@@ -33,6 +43,15 @@
3343
{{ placement }}
3444
</v-chip>
3545
<v-chip v-if="!entitlements?.newBucketPlacements?.length">Not Set</v-chip>
46+
47+
<v-btn
48+
class="align-self-center"
49+
density="compact"
50+
flat
51+
@click="newBucketsPlacementsDialog = true"
52+
>
53+
Update
54+
</v-btn>
3655
</div>
3756

3857
<v-divider class="my-5">
@@ -42,23 +61,46 @@
4261
<v-data-table :items="placementProductMappings" :headers="headers">
4362
<template #no-data> No mappings set </template>
4463
<template #bottom>
45-
<div class="v-data-table-footer" />
64+
<div class="v-data-table-footer">
65+
<div class="d-flex justify-end w-100">
66+
<v-btn density="compact" flat @click="placementProductMappingsDialog = true">
67+
Update
68+
</v-btn>
69+
</div>
70+
</div>
4671
</template>
4772
</v-data-table>
4873
</div>
4974
</v-card>
75+
76+
<UpdateComputeAccessTokenDialog
77+
v-model="computeAccessTokenDialog"
78+
:project="project"
79+
/>
80+
<UpdateNewBucketsPlacementsDialog
81+
v-model="newBucketsPlacementsDialog"
82+
:project="project"
83+
/>
84+
<UpdatePlacementProductMappingsDialog
85+
v-model="placementProductMappingsDialog"
86+
:project="project"
87+
/>
5088
</v-dialog>
5189
</template>
5290

5391
<script setup lang="ts">
5492
import { VBtn, VCard, VChip, VDataTable, VDialog, VDivider } from 'vuetify/components';
55-
import { computed } from 'vue';
93+
import { computed, ref } from 'vue';
5694
import { X } from 'lucide-vue-next';
5795
5896
import { Project, ProjectEntitlements } from '@/api/client.gen';
5997
import { DataTableHeader } from '@/types/common';
6098
import { centsToDollars } from '@/utils/strings';
6199
100+
import UpdateComputeAccessTokenDialog from '@/components/UpdateComputeAccessTokenDialog.vue';
101+
import UpdateNewBucketsPlacementsDialog from '@/components/UpdateNewBucketsPlacementsDialog.vue';
102+
import UpdatePlacementProductMappingsDialog from '@/components/UpdatePlacementProductMappingsDialog.vue';
103+
62104
const props = defineProps<{
63105
project: Project;
64106
}>();
@@ -74,6 +116,10 @@ const headers: DataTableHeader[] = [
74116
{ title: 'Segment / Month', key: 'segmentMonthCents', sortable: false, align: 'end' },
75117
];
76118
119+
const computeAccessTokenDialog = ref<boolean>(false);
120+
const newBucketsPlacementsDialog = ref<boolean>(false);
121+
const placementProductMappingsDialog = ref<boolean>(false);
122+
77123
const entitlements = computed<ProjectEntitlements | null>(() => props.project.entitlements);
78124
79125
const placementProductMappings = computed(() => {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (C) 2025 Storj Labs, Inc.
2+
// See LICENSE for copying information.
3+
4+
<template>
5+
<RequireReasonFormDialog
6+
v-model="model"
7+
:loading="isLoading"
8+
:initial-form-data="initialFormData"
9+
:form-config="formConfig"
10+
title="Update compute access token"
11+
width="500"
12+
@submit="update"
13+
/>
14+
</template>
15+
16+
<script setup lang="ts">
17+
import { computed } from 'vue';
18+
19+
import { Project, UpdateProjectEntitlementsRequest } from '@/api/client.gen';
20+
import { useLoading } from '@/composables/useLoading';
21+
import { useNotify } from '@/composables/useNotify';
22+
import { FieldType, FormConfig } from '@/types/forms';
23+
import { useProjectsStore } from '@/store/projects';
24+
25+
import RequireReasonFormDialog from '@/components/RequireReasonFormDialog.vue';
26+
27+
const projectsStore = useProjectsStore();
28+
29+
const { isLoading, withLoading } = useLoading();
30+
const notify = useNotify();
31+
32+
const model = defineModel<boolean>({ required: true });
33+
34+
const props = defineProps<{
35+
project: Project;
36+
}>();
37+
38+
const initialFormData = computed(() => ({ token: props.project.entitlements?.computeAccessToken ?? '' }));
39+
40+
const formConfig = computed((): FormConfig => {
41+
return {
42+
sections: [
43+
{
44+
rows: [
45+
{
46+
fields: [
47+
{
48+
key: 'token',
49+
type: FieldType.Text,
50+
label: 'Compute Access Token',
51+
placeholder: 'Compute Access Token',
52+
clearable: true,
53+
},
54+
],
55+
},
56+
],
57+
},
58+
],
59+
};
60+
});
61+
62+
function update(formData: Record<string, unknown>) {
63+
withLoading(async () => {
64+
try {
65+
const request = new UpdateProjectEntitlementsRequest();
66+
request.computeAccessToken = formData.token as string;
67+
request.reason = formData.reason as string;
68+
const entitlements = await projectsStore.updateEntitlements(props.project.id, request);
69+
const project = { ...props.project, entitlements };
70+
await projectsStore.updateCurrentProject(project);
71+
72+
notify.success('Compute access token updated successfully.');
73+
model.value = false;
74+
} catch (error) {
75+
notify.error(`Failed to update compute Access Token. ${error.message}`);
76+
return;
77+
}
78+
});
79+
}
80+
</script>
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright (C) 2025 Storj Labs, Inc.
2+
// See LICENSE for copying information.
3+
4+
<template>
5+
<v-dialog
6+
v-model="model"
7+
transition="fade-transition"
8+
width="500"
9+
>
10+
<v-card
11+
rounded="xlg"
12+
:title="step === Steps.Form ? 'Update new buckets placement' : 'Audit'"
13+
:subtitle="step === Steps.Form ? '' : 'Enter a reason for this change'"
14+
>
15+
<template #append>
16+
<v-btn
17+
:icon="X" :disabled="isLoading"
18+
variant="text" size="small" color="default" @click="model = false"
19+
/>
20+
</template>
21+
22+
<v-window v-model="step" :touch="false" class="pa-6">
23+
<v-window-item :value="Steps.Form">
24+
<v-chip-group v-model="newBucketPlacements" column multiple>
25+
<v-chip v-for="placement in availablePlacements" :key="placement.id" :value="placement.location">
26+
{{ placement.location }}
27+
</v-chip>
28+
</v-chip-group>
29+
</v-window-item>
30+
<v-window-item :value="Steps.Reason">
31+
<v-form :model-value="!!reason" :disabled="isLoading" @submit.prevent="update">
32+
<button type="submit" hidden />
33+
<v-textarea
34+
v-model="reason"
35+
:rules="[RequiredRule]"
36+
label="Reason"
37+
placeholder="Enter reason for this change"
38+
hide-details="auto"
39+
variant="solo-filled"
40+
autofocus
41+
flat
42+
/>
43+
</v-form>
44+
</v-window-item>
45+
</v-window>
46+
47+
<v-card-actions class="pa-6">
48+
<v-row>
49+
<v-col>
50+
<v-btn
51+
variant="outlined"
52+
color="default"
53+
block
54+
:disabled="isLoading"
55+
@click="onSecondaryAction"
56+
>
57+
{{ secondaryActionText }}
58+
</v-btn>
59+
</v-col>
60+
<v-col>
61+
<v-btn
62+
color="primary"
63+
variant="flat"
64+
:disabled="submitDisabled"
65+
:loading="isLoading"
66+
block
67+
@click="onPrimaryAction"
68+
>
69+
{{ primaryActionText }}
70+
</v-btn>
71+
</v-col>
72+
</v-row>
73+
</v-card-actions>
74+
</v-card>
75+
</v-dialog>
76+
</template>
77+
78+
<script setup lang="ts">
79+
import { computed, ref, watch } from 'vue';
80+
import { X } from 'lucide-vue-next';
81+
import {
82+
VBtn,
83+
VCard,
84+
VCardActions,
85+
VChip,
86+
VChipGroup,
87+
VCol,
88+
VDialog,
89+
VForm,
90+
VRow,
91+
VTextarea,
92+
VWindow,
93+
VWindowItem,
94+
} from 'vuetify/components';
95+
96+
import { PlacementInfo, Project, UpdateProjectEntitlementsRequest } from '@/api/client.gen';
97+
import { useLoading } from '@/composables/useLoading';
98+
import { useNotify } from '@/composables/useNotify';
99+
import { RequiredRule } from '@/types/common';
100+
import { useProjectsStore } from '@/store/projects';
101+
import { useAppStore } from '@/store/app';
102+
103+
enum Steps {
104+
Form = 1,
105+
Reason = 2,
106+
}
107+
108+
const appStore = useAppStore();
109+
const projectsStore = useProjectsStore();
110+
111+
const { isLoading, withLoading } = useLoading();
112+
const notify = useNotify();
113+
114+
const model = defineModel<boolean>({ required: true });
115+
116+
const props = defineProps<{
117+
project: Project;
118+
}>();
119+
120+
const step = ref<Steps>(Steps.Form);
121+
const reason = ref('');
122+
const newBucketPlacements = ref<string[]>([]);
123+
124+
const availablePlacements = computed<PlacementInfo[]>(() => {
125+
return appStore.state.placements.filter(p => !!p.location).map(p => ({
126+
id: p.id,
127+
location: `(${p.id}) - ${p.location}`,
128+
}));
129+
});
130+
131+
// Create lookup map for efficient conversion
132+
const placementLookup = computed(() => {
133+
return new Map(availablePlacements.value.map(p => [p.location, p.id]));
134+
});
135+
136+
const originalPlacements = computed(() => {
137+
return props.project.entitlements?.newBucketPlacements ?? [];
138+
});
139+
140+
const secondaryActionText = computed(() => (step.value === Steps.Form ? 'Cancel' : 'Back'));
141+
const primaryActionText = computed(() => (step.value === Steps.Form ? 'Continue' : 'Submit'));
142+
143+
const hasSelectionChanged = computed(() => {
144+
const current = newBucketPlacements.value;
145+
const original = originalPlacements.value;
146+
147+
if (current.length !== original.length) return true;
148+
149+
const originalSet = new Set(original);
150+
return !current.every(item => originalSet.has(item));
151+
});
152+
153+
const submitDisabled = computed(() => {
154+
if (step.value === Steps.Form) {
155+
return !hasSelectionChanged.value || newBucketPlacements.value.length === 0;
156+
}
157+
return !reason.value;
158+
});
159+
160+
function onPrimaryAction() {
161+
if (submitDisabled.value) return;
162+
if (step.value === Steps.Form) {
163+
step.value = Steps.Reason;
164+
} else {
165+
update();
166+
}
167+
}
168+
169+
function onSecondaryAction() {
170+
if (step.value === Steps.Form) {
171+
model.value = false;
172+
} else {
173+
step.value = Steps.Form;
174+
}
175+
}
176+
177+
function update() {
178+
if (!reason.value || !hasSelectionChanged.value || newBucketPlacements.value.length === 0) return;
179+
withLoading(async () => {
180+
try {
181+
const request = new UpdateProjectEntitlementsRequest();
182+
request.reason = reason.value;
183+
184+
request.newBucketPlacements = newBucketPlacements.value
185+
.map(location => placementLookup.value.get(location))
186+
.filter((id): id is number => id !== undefined);
187+
188+
const updatedEntitlements = await projectsStore.updateEntitlements(props.project.id, request);
189+
if (projectsStore.state.currentProject) {
190+
projectsStore.state.currentProject.entitlements = updatedEntitlements;
191+
}
192+
193+
notify.success('New buckets placements updated successfully.');
194+
model.value = false;
195+
} catch (error) {
196+
notify.error(`Failed to update new buckets placements. ${error.message}`);
197+
}
198+
});
199+
}
200+
201+
function resetForm() {
202+
step.value = Steps.Form;
203+
reason.value = '';
204+
newBucketPlacements.value = [...originalPlacements.value];
205+
}
206+
207+
watch(model, (newValue) => {
208+
if (newValue) resetForm();
209+
});
210+
</script>

0 commit comments

Comments
 (0)