Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
with:
repository: opendatateam/udata
path: udata
ref: main
ref: add_member_invitation_from_org

- name: Set up uv
uses: astral-sh/setup-uv@v6
Expand Down
136 changes: 128 additions & 8 deletions components/AdminMembershipRequest/AdminMembershipRequest.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,107 @@
<template>
<!-- Invitation envoyée (par l'organisation) -->
<div
v-if="request.kind === 'invitation'"
class="relative bg-white shadow rounded-sm p-5 mt-3"
>
<AdminBadge
class="absolute top-0 left-2.5 -translate-y-1/2"
size="sm"
type="secondary"
:icon="RiMailSendLine"
>
{{ $t('Invitation envoyée') }}
</AdminBadge>

<div class="flex flex-wrap justify-between gap-5">
<div class="space-y-1">
<div class="flex flex-wrap items-start gap-2">
<Avatar
v-if="request.user"
:user="request.user"
rounded
:size="24"
/>
<div
v-else
class="size-6 rounded-full border border-gray-default bg-gray-lower flex items-center justify-center"
>
<RiMailLine class="size-3 text-gray-medium" />
</div>
<div>
<div class="flex flex-wrap items-baseline gap-1 text-gray-title text-sm/6">
<template v-if="request.user">
<div class="font-bold">
{{ request.user.first_name }} {{ request.user.last_name }}
</div>
<code
v-if="request.user.email"
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
>{{ request.user.email }}</code>
</template>
<code
v-else-if="request.email"
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
>{{ request.email }}</code>
<div>{{ t("a été invité(e) à rejoindre l'organisation.") }}</div>
</div>
<div
v-if="roleLabel"
class="text-sm text-gray-medium"
>
{{ t('Rôle proposé :') }}
<AdminBadge
size="xs"
:type="request.role === 'admin' ? 'primary' : 'secondary'"
>
{{ roleLabel }}
</AdminBadge>
</div>
</div>
</div>
<div
v-if="request.comment"
class="flex items-stretch gap-1"
>
<div class="w-6 flex items-center justify-center">
<div class="h-full w-1 bg-gray-default" />
</div>
<div class="text-xs/5 italic">
« {{ request.comment }} »
</div>
</div>
<div class="text-sm/6 text-gray-medium">
{{ formatDate(new Date(request.created), { dateStyle: 'long', timeStyle: 'short' }) }}
</div>
</div>
<div
v-if="showActions"
class="flex flex-col gap-2.5 items-end"
>
<BrandedButton
color="danger"
size="xs"
:loading="loading"
@click="cancelInvitation"
>
{{ t("Annuler l'invitation") }}
</BrandedButton>
</div>
</div>
</div>

<!-- Demande d'adhésion (par un utilisateur) -->
<BannerNotif
v-else
type="primary"
:icon="RiUserAddLine"
:badge="$t(`Demande de rattachement`)"
:user="request.user"
:user="request.user!"
:date="new Date(request.created)"
>
<template #title>
<code
v-if="request.user.email"
v-if="request.user?.email"
class="text-gray-medium bg-gray-lower px-1 text-sm rounded-sm break-all"
>{{ request.user.email }}</code>
{{ t("demande à rejoindre l'organisation.") }}
Expand Down Expand Up @@ -94,12 +187,13 @@
</template>

<script setup lang="ts">
import { BrandedButton } from '@datagouv/components-next'
import { ref } from 'vue'
import { RiCheckLine, RiUserAddLine } from '@remixicon/vue'
import { Avatar, BrandedButton, useFormatDate } from '@datagouv/components-next'
import { computed, ref } from 'vue'
import { RiCheckLine, RiMailLine, RiMailSendLine, RiUserAddLine } from '@remixicon/vue'
import InputGroup from '../InputGroup/InputGroup.vue'
import ModalWithButton from '../Modal/ModalWithButton.vue'
import type { PendingMembershipRequest } from '~/types/types'
import AdminBadge from '../AdminBadge/AdminBadge.vue'
import type { MemberRole, PendingMembershipRequest } from '~/types/types'

const props = defineProps<{
oid: string
Expand All @@ -112,8 +206,17 @@ const emits = defineEmits<{

const { t } = useTranslation()
const { $api } = useNuxtApp()
const { formatDate } = useFormatDate()
const loading = ref(false)

const { data: roles } = await useAPI<Array<{ id: MemberRole, label: string }>>('/api/1/organizations/roles/', { lazy: true })

const roleLabel = computed(() => {
if (!roles.value || !props.request.role) return null
const role = roles.value.find(r => r.id === props.request.role)
return role?.label ?? props.request.role
})

const accept = async () => {
try {
loading.value = true
Expand All @@ -123,7 +226,24 @@ const accept = async () => {
emits('refresh')
}
catch {
// toast.error(t('An error occurred while refusing this membership.'))
// TODO: toast error
}
finally {
loading.value = false
}
}

const cancelInvitation = async () => {
try {
loading.value = true
await $api(`/api/1/organizations/${props.oid}/membership/${props.request.id}/refuse`, {
method: 'POST',
body: JSON.stringify({ comment: 'Invitation annulée' }),
})
emits('refresh')
}
catch {
// TODO: toast error
}
finally {
loading.value = false
Expand All @@ -143,7 +263,7 @@ const refuse = async (close: () => void) => {
close()
}
catch {
// toast.error(t('An error occurred while refusing this membership.'))
// TODO: toast error
}
finally {
loading.value = false
Expand Down
145 changes: 145 additions & 0 deletions components/AdminOrgInvitation/AdminOrgInvitation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<template>
<div class="relative bg-white shadow rounded-sm p-5 mt-3">
<AdminBadge
class="absolute top-0 left-2.5 -translate-y-1/2"
size="sm"
type="primary"
:icon="RiUserAddLine"
>
{{ $t('Invitation') }}
</AdminBadge>

<div class="flex flex-wrap justify-between gap-5">
<div class="space-y-1">
<div class="flex flex-wrap items-start gap-2">
<NuxtImg
v-if="invitation.organization.logo"
class="rounded-sm border border-gray-default size-10 object-contain bg-white"
:src="invitation.organization.logo"
loading="lazy"
alt=""
/>
<div
v-else
class="size-10 rounded-sm border border-gray-default bg-gray-lower flex items-center justify-center"
>
<RiBuilding2Line class="size-5 text-gray-medium" />
</div>
<div>
<div class="flex flex-wrap items-baseline gap-1 text-gray-title text-sm/6">
<div class="font-bold">
{{ invitation.organization.name }}
</div>
<div>{{ t("vous invite à rejoindre l'organisation.") }}</div>
</div>
<div
v-if="roleLabel"
class="text-sm text-gray-medium"
>
{{ t('Rôle proposé :') }}
<AdminBadge
size="xs"
:type="invitation.role === 'admin' ? 'primary' : 'secondary'"
>
{{ roleLabel }}
</AdminBadge>
</div>
</div>
</div>
<div
v-if="invitation.comment"
class="flex items-stretch gap-1"
>
<div class="w-10 flex items-center justify-center">
<div class="h-full w-1 bg-gray-default" />
</div>
<div class="text-xs/5 italic">
« {{ invitation.comment }} »
</div>
</div>
<div class="text-sm/6 text-gray-medium">
{{ formatDate(new Date(invitation.created), { dateStyle: 'long', timeStyle: 'short' }) }}
</div>
</div>
<div class="flex flex-col gap-2.5 items-end">
<BrandedButton
color="primary"
size="xs"
:icon="RiCheckLine"
:loading="loading"
@click="accept"
>
{{ $t('Accepter') }}
</BrandedButton>
<BrandedButton
color="danger"
size="xs"
:loading="loading"
@click="refuse"
>
{{ t("Refuser") }}
</BrandedButton>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { BrandedButton, useFormatDate } from '@datagouv/components-next'
import { ref } from 'vue'
import { RiBuilding2Line, RiCheckLine, RiUserAddLine } from '@remixicon/vue'
import AdminBadge from '../AdminBadge/AdminBadge.vue'
import type { MemberRole, OrgInvitation } from '~/types/types'

const props = defineProps<{
invitation: OrgInvitation
}>()
const emits = defineEmits<{
(e: 'refresh'): void
}>()

const { t } = useTranslation()
const { $api } = useNuxtApp()
const { formatDate } = useFormatDate()
const loading = ref(false)

const { data: roles } = await useAPI<Array<{ id: MemberRole, label: string }>>('/api/1/organizations/roles/', { lazy: true })

const roleLabel = computed(() => {
if (!roles.value) return null
const role = roles.value.find(r => r.id === props.invitation.role)
return role?.label ?? props.invitation.role
})

const accept = async () => {
try {
loading.value = true
await $api(`/api/1/me/org_invitations/${props.invitation.id}/accept/`, {
method: 'POST',
})
emits('refresh')
}
catch {
// TODO: toast error
}
finally {
loading.value = false
}
}

const refuse = async () => {
try {
loading.value = true
await $api(`/api/1/me/org_invitations/${props.invitation.id}/refuse/`, {
method: 'POST',
})
emits('refresh')
}
catch {
// TODO: toast error
}
finally {
loading.value = false
}
}
</script>
32 changes: 32 additions & 0 deletions pages/admin/me/profile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
:user="me"
/>

<div
v-if="invitations && invitations.length > 0"
class="space-y-4"
>
<h2 class="text-sm font-bold uppercase">
{{ t("{n} invitation | {n} invitation | {n} invitations", { n: invitations.length }) }}
</h2>
<div class="space-y-4 max-w-4xl">
<AdminOrgInvitation
v-for="invitation in invitations"
:key="invitation.id"
:invitation="invitation"
@refresh="refreshAll"
/>
</div>
</div>

<TabLinks
:links="[
{ href: '/admin/me/profile', label: $t('Profil') },
Expand All @@ -21,14 +38,29 @@

<script setup lang="ts">
import AdminUserProfileHeader from '~/components/User/AdminUserProfileHeader.vue'
import AdminOrgInvitation from '~/components/AdminOrgInvitation/AdminOrgInvitation.vue'
import type { OrgInvitation } from '~/types/types'

definePageMeta({
keepScroll: true,
})

const { t } = useTranslation()
const me = useMe()

const { data: invitations, refresh: refreshInvitations } = await useAPI<Array<OrgInvitation>>(
'/api/1/me/org_invitations/',
{ lazy: true },
)

function refresh() {
loadMe(me)
}

async function refreshAll() {
await Promise.all([
refreshInvitations(),
loadMe(me),
])
}
</script>
Loading
Loading