Skip to content

Commit 9f0fe1b

Browse files
committed
feat: new membership invitations
1 parent 6453188 commit 9f0fe1b

File tree

7 files changed

+576
-46
lines changed

7 files changed

+576
-46
lines changed

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
with:
3434
repository: opendatateam/udata
3535
path: udata
36-
ref: main
36+
ref: add_member_invitation_from_org
3737

3838
- name: Set up uv
3939
uses: astral-sh/setup-uv@v6

components/AdminMembershipRequest/AdminMembershipRequest.vue

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,107 @@
11
<template>
2+
<!-- Invitation envoyée (par l'organisation) -->
3+
<div
4+
v-if="request.kind === 'invitation'"
5+
class="relative bg-white shadow rounded-sm p-5 mt-3"
6+
>
7+
<AdminBadge
8+
class="absolute top-0 left-2.5 -translate-y-1/2"
9+
size="sm"
10+
type="secondary"
11+
:icon="RiMailSendLine"
12+
>
13+
{{ $t('Invitation envoyée') }}
14+
</AdminBadge>
15+
16+
<div class="flex flex-wrap justify-between gap-5">
17+
<div class="space-y-1">
18+
<div class="flex flex-wrap items-start gap-2">
19+
<Avatar
20+
v-if="request.user"
21+
:user="request.user"
22+
rounded
23+
:size="24"
24+
/>
25+
<div
26+
v-else
27+
class="size-6 rounded-full border border-gray-default bg-gray-lower flex items-center justify-center"
28+
>
29+
<RiMailLine class="size-3 text-gray-medium" />
30+
</div>
31+
<div>
32+
<div class="flex flex-wrap items-baseline gap-1 text-gray-title text-sm/6">
33+
<template v-if="request.user">
34+
<div class="font-bold">
35+
{{ request.user.first_name }} {{ request.user.last_name }}
36+
</div>
37+
<code
38+
v-if="request.user.email"
39+
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
40+
>{{ request.user.email }}</code>
41+
</template>
42+
<code
43+
v-else-if="request.email"
44+
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
45+
>{{ request.email }}</code>
46+
<div>{{ t("a été invité(e) à rejoindre l'organisation.") }}</div>
47+
</div>
48+
<div
49+
v-if="roleLabel"
50+
class="text-sm text-gray-medium"
51+
>
52+
{{ t('Rôle proposé :') }}
53+
<AdminBadge
54+
size="xs"
55+
:type="request.role === 'admin' ? 'primary' : 'secondary'"
56+
>
57+
{{ roleLabel }}
58+
</AdminBadge>
59+
</div>
60+
</div>
61+
</div>
62+
<div
63+
v-if="request.comment"
64+
class="flex items-stretch gap-1"
65+
>
66+
<div class="w-6 flex items-center justify-center">
67+
<div class="h-full w-1 bg-gray-default" />
68+
</div>
69+
<div class="text-xs/5 italic">
70+
« {{ request.comment }} »
71+
</div>
72+
</div>
73+
<div class="text-sm/6 text-gray-medium">
74+
{{ formatDate(new Date(request.created), { dateStyle: 'long', timeStyle: 'short' }) }}
75+
</div>
76+
</div>
77+
<div
78+
v-if="showActions"
79+
class="flex flex-col gap-2.5 items-end"
80+
>
81+
<BrandedButton
82+
color="danger"
83+
size="xs"
84+
:loading="loading"
85+
@click="cancelInvitation"
86+
>
87+
{{ t("Annuler l'invitation") }}
88+
</BrandedButton>
89+
</div>
90+
</div>
91+
</div>
92+
93+
<!-- Demande d'adhésion (par un utilisateur) -->
294
<BannerNotif
95+
v-else
396
type="primary"
497
:icon="RiUserAddLine"
598
:badge="$t(`Demande de rattachement`)"
6-
:user="request.user"
99+
:user="request.user!"
7100
:date="new Date(request.created)"
8101
>
9102
<template #title>
10103
<code
11-
v-if="request.user.email"
104+
v-if="request.user?.email"
12105
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
13106
>{{ request.user.email }}</code>
14107
{{ t("demande à rejoindre l'organisation.") }}
@@ -94,12 +187,13 @@
94187
</template>
95188

96189
<script setup lang="ts">
97-
import { BrandedButton } from '@datagouv/components-next'
98-
import { ref } from 'vue'
99-
import { RiCheckLine, RiUserAddLine } from '@remixicon/vue'
190+
import { Avatar, BrandedButton, useFormatDate } from '@datagouv/components-next'
191+
import { computed, ref } from 'vue'
192+
import { RiCheckLine, RiMailLine, RiMailSendLine, RiUserAddLine } from '@remixicon/vue'
100193
import InputGroup from '../InputGroup/InputGroup.vue'
101194
import ModalWithButton from '../Modal/ModalWithButton.vue'
102-
import type { PendingMembershipRequest } from '~/types/types'
195+
import AdminBadge from '../AdminBadge/AdminBadge.vue'
196+
import type { MemberRole, PendingMembershipRequest } from '~/types/types'
103197
104198
const props = defineProps<{
105199
oid: string
@@ -112,8 +206,17 @@ const emits = defineEmits<{
112206
113207
const { t } = useTranslation()
114208
const { $api } = useNuxtApp()
209+
const { formatDate } = useFormatDate()
115210
const loading = ref(false)
116211
212+
const { data: roles } = await useAPI<Array<{ id: MemberRole, label: string }>>('/api/1/organizations/roles/', { lazy: true })
213+
214+
const roleLabel = computed(() => {
215+
if (!roles.value || !props.request.role) return null
216+
const role = roles.value.find(r => r.id === props.request.role)
217+
return role?.label ?? props.request.role
218+
})
219+
117220
const accept = async () => {
118221
try {
119222
loading.value = true
@@ -123,7 +226,24 @@ const accept = async () => {
123226
emits('refresh')
124227
}
125228
catch {
126-
// toast.error(t('An error occurred while refusing this membership.'))
229+
// TODO: toast error
230+
}
231+
finally {
232+
loading.value = false
233+
}
234+
}
235+
236+
const cancelInvitation = async () => {
237+
try {
238+
loading.value = true
239+
await $api(`/api/1/organizations/${props.oid}/membership/${props.request.id}/refuse`, {
240+
method: 'POST',
241+
body: JSON.stringify({ comment: 'Invitation annulée' }),
242+
})
243+
emits('refresh')
244+
}
245+
catch {
246+
// TODO: toast error
127247
}
128248
finally {
129249
loading.value = false
@@ -143,7 +263,7 @@ const refuse = async (close: () => void) => {
143263
close()
144264
}
145265
catch {
146-
// toast.error(t('An error occurred while refusing this membership.'))
266+
// TODO: toast error
147267
}
148268
finally {
149269
loading.value = false
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<template>
2+
<div class="relative bg-white shadow rounded-sm p-5 mt-3">
3+
<AdminBadge
4+
class="absolute top-0 left-2.5 -translate-y-1/2"
5+
size="sm"
6+
type="primary"
7+
:icon="RiUserAddLine"
8+
>
9+
{{ $t('Invitation') }}
10+
</AdminBadge>
11+
12+
<div class="flex flex-wrap justify-between gap-5">
13+
<div class="space-y-1">
14+
<div class="flex flex-wrap items-start gap-2">
15+
<NuxtImg
16+
v-if="invitation.organization.logo"
17+
class="rounded-sm border border-gray-default size-10 object-contain bg-white"
18+
:src="invitation.organization.logo"
19+
loading="lazy"
20+
alt=""
21+
/>
22+
<div
23+
v-else
24+
class="size-10 rounded-sm border border-gray-default bg-gray-lower flex items-center justify-center"
25+
>
26+
<RiBuilding2Line class="size-5 text-gray-medium" />
27+
</div>
28+
<div>
29+
<div class="flex flex-wrap items-baseline gap-1 text-gray-title text-sm/6">
30+
<div class="font-bold">
31+
{{ invitation.organization.name }}
32+
</div>
33+
<div>{{ t("vous invite à rejoindre l'organisation.") }}</div>
34+
</div>
35+
<div
36+
v-if="roleLabel"
37+
class="text-sm text-gray-medium"
38+
>
39+
{{ t('Rôle proposé :') }}
40+
<AdminBadge
41+
size="xs"
42+
:type="invitation.role === 'admin' ? 'primary' : 'secondary'"
43+
>
44+
{{ roleLabel }}
45+
</AdminBadge>
46+
</div>
47+
</div>
48+
</div>
49+
<div
50+
v-if="invitation.comment"
51+
class="flex items-stretch gap-1"
52+
>
53+
<div class="w-10 flex items-center justify-center">
54+
<div class="h-full w-1 bg-gray-default" />
55+
</div>
56+
<div class="text-xs/5 italic">
57+
« {{ invitation.comment }} »
58+
</div>
59+
</div>
60+
<div class="text-sm/6 text-gray-medium">
61+
{{ formatDate(new Date(invitation.created), { dateStyle: 'long', timeStyle: 'short' }) }}
62+
</div>
63+
</div>
64+
<div class="flex flex-col gap-2.5 items-end">
65+
<BrandedButton
66+
color="primary"
67+
size="xs"
68+
:icon="RiCheckLine"
69+
:loading="loading"
70+
@click="accept"
71+
>
72+
{{ $t('Accepter') }}
73+
</BrandedButton>
74+
<BrandedButton
75+
color="danger"
76+
size="xs"
77+
:loading="loading"
78+
@click="refuse"
79+
>
80+
{{ t("Refuser") }}
81+
</BrandedButton>
82+
</div>
83+
</div>
84+
</div>
85+
</template>
86+
87+
<script setup lang="ts">
88+
import { BrandedButton, useFormatDate } from '@datagouv/components-next'
89+
import { ref } from 'vue'
90+
import { RiBuilding2Line, RiCheckLine, RiUserAddLine } from '@remixicon/vue'
91+
import AdminBadge from '../AdminBadge/AdminBadge.vue'
92+
import type { MemberRole, OrgInvitation } from '~/types/types'
93+
94+
const props = defineProps<{
95+
invitation: OrgInvitation
96+
}>()
97+
const emits = defineEmits<{
98+
(e: 'refresh'): void
99+
}>()
100+
101+
const { t } = useTranslation()
102+
const { $api } = useNuxtApp()
103+
const { formatDate } = useFormatDate()
104+
const loading = ref(false)
105+
106+
const { data: roles } = await useAPI<Array<{ id: MemberRole, label: string }>>('/api/1/organizations/roles/', { lazy: true })
107+
108+
const roleLabel = computed(() => {
109+
if (!roles.value) return null
110+
const role = roles.value.find(r => r.id === props.invitation.role)
111+
return role?.label ?? props.invitation.role
112+
})
113+
114+
const accept = async () => {
115+
try {
116+
loading.value = true
117+
await $api(`/api/1/me/org_invitations/${props.invitation.id}/accept/`, {
118+
method: 'POST',
119+
})
120+
emits('refresh')
121+
}
122+
catch {
123+
// TODO: toast error
124+
}
125+
finally {
126+
loading.value = false
127+
}
128+
}
129+
130+
const refuse = async () => {
131+
try {
132+
loading.value = true
133+
await $api(`/api/1/me/org_invitations/${props.invitation.id}/refuse/`, {
134+
method: 'POST',
135+
})
136+
emits('refresh')
137+
}
138+
catch {
139+
// TODO: toast error
140+
}
141+
finally {
142+
loading.value = false
143+
}
144+
}
145+
</script>

pages/admin/me/profile.vue

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@
44
:user="me"
55
/>
66

7+
<div
8+
v-if="invitations && invitations.length > 0"
9+
class="space-y-4"
10+
>
11+
<h2 class="text-sm font-bold uppercase">
12+
{{ t("{n} invitation | {n} invitation | {n} invitations", { n: invitations.length }) }}
13+
</h2>
14+
<div class="space-y-4 max-w-4xl">
15+
<AdminOrgInvitation
16+
v-for="invitation in invitations"
17+
:key="invitation.id"
18+
:invitation="invitation"
19+
@refresh="refreshAll"
20+
/>
21+
</div>
22+
</div>
23+
724
<TabLinks
825
:links="[
926
{ href: '/admin/me/profile', label: $t('Profil') },
@@ -21,14 +38,29 @@
2138

2239
<script setup lang="ts">
2340
import AdminUserProfileHeader from '~/components/User/AdminUserProfileHeader.vue'
41+
import AdminOrgInvitation from '~/components/AdminOrgInvitation/AdminOrgInvitation.vue'
42+
import type { OrgInvitation } from '~/types/types'
2443
2544
definePageMeta({
2645
keepScroll: true,
2746
})
2847
48+
const { t } = useTranslation()
2949
const me = useMe()
3050
51+
const { data: invitations, refresh: refreshInvitations } = await useAPI<Array<OrgInvitation>>(
52+
'/api/1/me/org_invitations/',
53+
{ lazy: true },
54+
)
55+
3156
function refresh() {
3257
loadMe(me)
3358
}
59+
60+
async function refreshAll() {
61+
await Promise.all([
62+
refreshInvitations(),
63+
loadMe(me),
64+
])
65+
}
3466
</script>

0 commit comments

Comments
 (0)