Skip to content

Commit 91fc79e

Browse files
authored
Add initial permissions system (#44)
This sets up the UI for role management and integrates access controls into the Workspaces frontend. Backend PR: TaskarCenterAtUW/workspaces-backend#4 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Members page in workspace settings for viewing/managing project-group admins, data generators, and workspace members; leads can change member roles and assign/remove roles via Workspaces API. * Permission-aware export flow to TDEI with selectable eligible project groups and project-group selection UI integration. * New composable useWorkspaceRole() exposing role, isLead, and isValidator to gate UI and actions across the app. * New Workspaces API methods: getWorkspaceMembers(id), assignRole(id, userUuid, role), removeRole(id, userUuid). * New TDEI user APIs: getMyProjectGroups(), getMyRolesForProjectGroup(projectGroupId, pgName), getProjectGroupUsers(projectGroupId). * **UI/UX Improvements / Access Controls** * Site-wide role-aware UI gating: many settings panels (General, Imagery, Apps, Delete, Teams) and actions (create team, delete workspace, rename, save settings) now show informational alerts and disable inputs/buttons for non-leads. * Dashboard and workspace lists show a new "My Role" row and role badges (Owner/Lead, Validator, Member, POC, Data Generator) in DetailsTable, WorkspaceItem, and related components. * Review toolbar and feedback controls gated by validator/lead role logic. * Settings navigation updated to include a "Members" item; team items and team dialogs respect lead gating. * **Types & Utilities** * New types: WorkspaceRole ('lead' | 'validator' | 'contributor'), WorkspaceMember, TdeiProjectGroup, TdeiUserItem, TdeiRoleAssignment. * New util ROLE_LABELS mapping WorkspaceRole to display labels. * ProjectGroupPicker updated to use tdei_project_group_id keys and accept options prop; ProjectGroup types and compare sorting used. * **Service & Backend Integration** * services/workspaces.ts migrated various endpoints to a new API wrapper and added workspace member management methods. * services/tdei.ts expanded TDEI user client surface to return typed project groups and users and added role-query helpers. * Frontend pages/components updated to fetch and use TDEI project groups and roles for eligibility and UI defaults (export flow, dashboard grouping). * **Notable Component Changes** * New pages/workspace/[id]/settings/members.vue added (major new file). * pages/dashboard.vue: reworked to model currentProjectGroup/currentWorkspace with typed bindings and pass currentWorkspaceTdeiRoles to details table. * pages/workspace/[id]/export/tdei.vue: adds project group selection, eligibility checks, and permission messaging. * Many components now consume useWorkspaceRole() and conditionalize UI and actions on isLead/isValidator. Overall, this PR implements the initial permissions/roles system end-to-end: types and service methods, a composable for role derivation, UI wiring across dashboard and workspace settings, a members management page, and permission-aware flows for TDEI export and settings management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 079b2ce + dc1db43 commit 91fc79e

24 files changed

Lines changed: 807 additions & 131 deletions

File tree

components/ProjectGroupPicker.vue

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@
4545
</li>
4646
<li
4747
v-for="(pg, index) in projectGroups"
48-
:key="pg.id"
48+
:key="pg.tdei_project_group_id"
4949
:id="'pg-item-' + index"
5050
class="list-group-item list-group-item-action cursor-pointer"
51-
:class="{ highlighted: activeIndex === index, 'fw-bold': model === pg.id }"
52-
@click="selectGroup(pg.id)"
51+
:class="{ highlighted: activeIndex === index, 'fw-bold': model === pg.tdei_project_group_id }"
52+
@click="selectGroup(pg.tdei_project_group_id)"
5353
@mouseenter="activeIndex = index"
5454
>
5555
{{ pg.name }}
@@ -86,29 +86,34 @@ function persistCachedName(id: string, name: string) {
8686
<script setup lang="ts">
8787
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
8888
import { tdeiUserClient } from '~/services/index'
89+
import type { TdeiProjectGroupItem } from '~/types/tdei'
8990
90-
const props = withDefaults(defineProps<{ disabled?: boolean }>(), {
91+
const props = withDefaults(defineProps<{ disabled?: boolean; options?: TdeiProjectGroupItem[] }>(), {
9192
disabled: false,
9293
})
9394
9495
const model = defineModel({ required: true })
9596
const searchText = ref('')
9697
const isOpen = ref(false)
97-
const projectGroups = ref<{ id: string; name: string }[]>([])
98+
const fetchedGroups = ref<TdeiProjectGroupItem[]>([])
9899
const selectedGroupName = ref('')
99100
const loading = ref(false)
100101
const totalCount = ref<number | undefined>(undefined)
101102
const pickerRef = ref<HTMLElement | null>(null)
102103
const listRef = ref<HTMLElement | null>(null)
103104
const activeIndex = ref(-1)
104105
106+
const projectGroups = computed(() => props.options ?? fetchedGroups.value)
107+
105108
let pageNo = 1
106109
const hasMore = ref(true)
107110
let pendingReset = false
108111
const pageSize = 10
109112
let hasUnfilteredResults = false
110113
111114
const loadGroups = async (reset = false) => {
115+
if (props.options) return
116+
112117
if (loading.value) {
113118
pendingReset = pendingReset || reset
114119
return
@@ -117,7 +122,7 @@ const loadGroups = async (reset = false) => {
117122
if (reset) {
118123
pageNo = 1
119124
hasMore.value = true
120-
projectGroups.value = []
125+
fetchedGroups.value = []
121126
activeIndex.value = -1
122127
totalCount.value = undefined
123128
}
@@ -136,9 +141,9 @@ const loadGroups = async (reset = false) => {
136141
137142
const { items: newGroups, total } = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize)
138143
if (total !== undefined) totalCount.value = total
139-
projectGroups.value.push(...newGroups)
140-
const selected = newGroups.find(g => g.id === model.value)
141-
if (selected) persistCachedName(selected.id, selected.name)
144+
fetchedGroups.value.push(...newGroups)
145+
const selected = newGroups.find(g => g.tdei_project_group_id === model.value)
146+
if (selected) persistCachedName(selected.tdei_project_group_id, selected.name)
142147
143148
if (newGroups.length < pageSize) {
144149
hasMore.value = false
@@ -178,7 +183,7 @@ const onInput = () => {
178183
}
179184
180185
watch(model, (newId) => {
181-
const pg = projectGroups.value.find(p => p.id === newId)
186+
const pg = projectGroups.value.find(p => p.tdei_project_group_id === newId)
182187
if (pg && !isOpen.value) {
183188
searchText.value = pg.name
184189
selectedGroupName.value = pg.name
@@ -195,11 +200,11 @@ const onScroll = (e: Event) => {
195200
const selectGroup = (id: string) => {
196201
model.value = id
197202
isOpen.value = false
198-
const pg = projectGroups.value.find(p => p.id === id)
203+
const pg = projectGroups.value.find(p => p.tdei_project_group_id === id)
199204
if (pg) {
200205
searchText.value = pg.name
201206
selectedGroupName.value = pg.name
202-
persistCachedName(pg.id, pg.name)
207+
persistCachedName(pg.tdei_project_group_id, pg.name)
203208
}
204209
}
205210
@@ -257,7 +262,7 @@ const onKeydown = (e: KeyboardEvent) => {
257262
e.preventDefault()
258263
if (activeIndex.value >= 0 && activeIndex.value < projectGroups.value.length) {
259264
const pg = projectGroups.value[activeIndex.value]
260-
if (pg) selectGroup(pg.id)
265+
if (pg) selectGroup(pg.tdei_project_group_id)
261266
}
262267
} else if (e.key === 'Escape') {
263268
e.preventDefault()
@@ -273,7 +278,7 @@ const applyCachedName = () => {
273278
274279
const closeDropdown = () => {
275280
isOpen.value = false
276-
const pg = projectGroups.value.find(p => p.id === model.value)
281+
const pg = projectGroups.value.find(p => p.tdei_project_group_id === model.value)
277282
const name = pg?.name ?? selectedGroupName.value
278283
searchText.value = name
279284
if (pg) selectedGroupName.value = name
@@ -285,38 +290,58 @@ const onFocusOut = (e: FocusEvent) => {
285290
}
286291
}
287292
293+
watch(
294+
projectGroups,
295+
(groups) => {
296+
if (groups.length > 0) {
297+
const pgId = model.value as string | undefined
298+
if (!pgId || !groups.some(pg => pg.tdei_project_group_id === pgId)) {
299+
model.value = groups[0]?.tdei_project_group_id
300+
}
301+
const selected = groups.find(pg => pg.tdei_project_group_id === model.value)
302+
if (selected && !isOpen.value) {
303+
searchText.value = selected.name
304+
selectedGroupName.value = selected.name
305+
}
306+
}
307+
},
308+
{ immediate: true },
309+
)
310+
288311
onMounted(async () => {
289312
// Show cached name immediately before the API call completes
290313
if (model.value && loadCachedName(model.value as string)) {
291314
applyCachedName()
292315
}
293316
294-
await loadGroups(true)
295-
296-
if (projectGroups.value.length > 0) {
297-
const selected = projectGroups.value.find(pg => pg.id === model.value)
298-
if (selected) {
299-
searchText.value = selected.name
300-
selectedGroupName.value = selected.name
301-
} else if (model.value && loadCachedName(model.value as string)) {
302-
// Group is beyond page 1 — use the cached name for display
303-
applyCachedName()
304-
} else if (model.value) {
305-
// model is set but name is unknown — paginate until the group is found
306-
while (hasMore.value) {
307-
await loadGroups()
308-
const found = projectGroups.value.find(pg => pg.id === model.value)
309-
if (found) {
310-
searchText.value = found.name
311-
selectedGroupName.value = found.name
312-
break
317+
if (!props.options) {
318+
await loadGroups(true)
319+
320+
if (fetchedGroups.value.length > 0) {
321+
const selected = fetchedGroups.value.find(pg => pg.tdei_project_group_id === model.value)
322+
if (selected) {
323+
searchText.value = selected.name
324+
selectedGroupName.value = selected.name
325+
} else if (model.value && loadCachedName(model.value as string)) {
326+
// Group is beyond page 1 — use the cached name for display
327+
applyCachedName()
328+
} else if (model.value) {
329+
// model is set but name is unknown — paginate until the group is found
330+
while (hasMore.value) {
331+
await loadGroups()
332+
const found = fetchedGroups.value.find(pg => pg.tdei_project_group_id === model.value)
333+
if (found) {
334+
searchText.value = found.name
335+
selectedGroupName.value = found.name
336+
break
337+
}
313338
}
339+
} else if (!model.value) {
340+
const first = fetchedGroups.value[0]!
341+
model.value = first.tdei_project_group_id
342+
searchText.value = first.name
343+
selectedGroupName.value = first.name
314344
}
315-
} else if (!model.value) {
316-
const first = projectGroups.value[0]!
317-
model.value = first.id
318-
searchText.value = first.name
319-
selectedGroupName.value = first.name
320345
}
321346
}
322347
})

components/dashboard/DetailsTable.vue

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
2-
<div class="table-responsive border-top">
3-
<table class="table table-striped mb-0">
2+
<div class="table-responsive border-top mb-0">
3+
<table class="table table-striped">
44
<tbody>
55
<tr>
66
<th><app-icon variant="schedule" />Created At</th>
@@ -10,6 +10,41 @@
1010
<th><app-icon variant="person_outline" />Created By</th>
1111
<td>{{ workspace.createdByName }}</td>
1212
</tr>
13+
<tr>
14+
<th><app-icon variant="badge" />My Role</th>
15+
<td>
16+
<span
17+
v-if="workspace.role === 'lead'"
18+
class="badge bg-dark text-uppercase"
19+
>
20+
<app-icon variant="star" /> Owner
21+
</span>
22+
<span
23+
v-else-if="workspace.role === 'validator'"
24+
class="badge bg-dark text-uppercase"
25+
>
26+
<app-icon variant="task_alt" /> Validator
27+
</span>
28+
<span
29+
v-else
30+
class="badge bg-secondary text-uppercase"
31+
>
32+
<app-icon variant="person" /> Member
33+
</span>
34+
<span
35+
v-if="isPoc"
36+
class="badge bg-warning text-dark text-uppercase ms-1"
37+
>
38+
<app-icon variant="local_police" /> POC
39+
</span>
40+
<span
41+
v-else-if="isDataGenerator"
42+
class="badge bg-warning text-dark text-uppercase ms-1"
43+
>
44+
<app-icon variant="offline_bolt" /> Data Generator
45+
</span>
46+
</td>
47+
</tr>
1348
<tr>
1449
<th><app-icon variant="phonelink_setup" />App Access</th>
1550
<td>
@@ -42,12 +77,19 @@
4277
</template>
4378

4479
<script setup lang="ts">
45-
const props = defineProps({
46-
workspace: {
47-
type: Object,
48-
required: true
49-
}
50-
});
80+
import type { Workspace } from '~/types/workspaces';
81+
82+
interface Props {
83+
workspace: Workspace;
84+
myTdeiRoles: string[];
85+
}
86+
87+
const props = defineProps<Props>();
88+
89+
const isPoc = computed(() => props.myTdeiRoles.includes('poc'));
90+
const isDataGenerator = computed(() =>
91+
props.myTdeiRoles.includes(`${props.workspace.type}_data_generator`),
92+
);
5193
</script>
5294

5395
<style lang="scss">

components/dashboard/Toolbar.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@
5252
<app-icon variant="settings" size="24" no-margin />
5353
<span class="d-none d-sm-inline ms-2">Settings</span>
5454
</nuxt-link>
55-
</div>
56-
</div>
55+
</div><!-- .btn-group -->
56+
</div><!-- .btn-toolbar -->
5757
</template>
5858

5959
<script setup lang="ts">

components/dashboard/WorkspaceItem.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,25 @@
99
<app-icon v-else variant="lock" />
1010
App
1111
</span>
12+
13+
<span
14+
v-if="workspace.role === 'lead'"
15+
class="badge bg-dark ms-2"
16+
>
17+
<app-icon variant="star" /> {{ ROLE_LABELS.lead }}
18+
</span>
19+
<span
20+
v-else-if="workspace.role === 'validator'"
21+
class="badge bg-dark ms-2"
22+
>
23+
<app-icon variant="task_alt" /> {{ ROLE_LABELS.validator }}
24+
</span>
1225
</button>
1326
</template>
1427

1528
<script setup lang="ts">
29+
import { ROLE_LABELS } from '~/util/roles';
30+
1631
const props = defineProps({
1732
workspace: {
1833
type: Object,

components/review/Toolbar.vue

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,27 @@
4545
{{ props.item.commentCount }}
4646
</span>
4747
</button>
48-
<button
49-
v-show="props.item.isFeedback"
50-
class="btn btn-sm btn-success ms-2"
48+
<BPopover
49+
content="Only validators and owners can resolve feedback"
50+
placement="bottom"
51+
:manual="isValidator"
5152
>
52-
<app-icon
53-
variant="check"
54-
no-margin
55-
/>
56-
<span class="d-none d-sm-inline ms-2">Mark as Resolved</span>
57-
</button>
53+
<template #target>
54+
<div class="d-inline-block ms-2">
55+
<button
56+
v-show="props.item.isFeedback && !props.item.isResolved"
57+
class="btn btn-sm btn-success"
58+
:disabled="!isValidator"
59+
>
60+
<app-icon
61+
variant="check"
62+
no-margin
63+
/>
64+
<span class="d-none d-sm-inline ms-2">Mark as Resolved</span>
65+
</button>
66+
</div>
67+
</template>
68+
</BPopover>
5869
</nav>
5970
</template>
6071

@@ -66,6 +77,7 @@ interface Props {
6677
}
6778
6879
const props = defineProps<Props>();
80+
const { isValidator } = useWorkspaceRole();
6981
7082
const emit = defineEmits(['edit']);
7183
const showDetails = defineModel<boolean>('showDetails');

components/settings/Nav.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@
66
>
77
General
88
</settings-nav-link>
9+
<settings-nav-link
10+
to="/members"
11+
icon="admin_panel_settings"
12+
>
13+
Members
14+
</settings-nav-link>
915
<settings-nav-link
1016
to="/teams"
11-
icon="group"
17+
icon="diversity_3"
1218
>
1319
Teams
1420
</settings-nav-link>

0 commit comments

Comments
 (0)