Browse Source

Merge branch 'feature/V8-7750-la-mire-de-connexion' into develop

# Conflicts:
#	i18n/lang/fr/general.json
Vincent 2 months ago
parent
commit
427221ef62

+ 67 - 0
components/Ui/Button/HelloAssoConnect.vue

@@ -0,0 +1,67 @@
+<!--
+Bouton de connexion à HelloAsso
+
+@see https://dev.helloasso.com/docs/le-bouton-se-connecter-avec-helloasso
+-->
+<template>
+  <button class="HaAuthorizeButton">
+    <v-img
+      src="https://api.helloasso.com/v5/img/logo-ha.svg"
+      alt=""
+      class="HaAuthorizeButtonLogo"
+    />
+
+    <span class="HaAuthorizeButtonTitle">
+      {{ $t('connect_to_helloasso') }}
+    </span>
+  </button>
+</template>
+
+<script setup lang="ts">
+
+
+
+</script>
+
+<style scoped lang="scss">
+.HaAuthorizeButton {
+  align-items: center;
+  -webkit-box-pack: center;
+  -ms-flex-pack: center;
+  background-color: #FFFFFF;
+  border: 0.0625rem solid #4B3FCF;
+  border-radius: 0.125rem;
+  display: -webkit-box;
+  display: -ms-flexbox;
+  display: flex;
+  padding: 0;
+}
+.HaAuthorizeButton:disabled {
+  background-color: #4B3FCF;
+  border-color: transparent;
+  cursor: not-allowed;
+}
+.HaAuthorizeButton:not(:disabled):focus {
+  box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25);
+  -webkit-box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25);
+}
+.HaAuthorizeButtonLogo {
+  padding: 0 0.8rem;
+  width: 60px;
+}
+.HaAuthorizeButtonTitle {
+  background-color: #4B3FCF;
+  color: #FFFFFF;
+  font-size: 1rem;
+  font-weight: 700;
+  padding: 0.78125rem 1.5rem;
+}
+.HaAuthorizeButton:disabled .HaAuthorizeButtonTitle {
+  background-color: #4B3FCF;
+  color: #9A9DA8;
+}
+.HaAuthorizeButton:not(:disabled):hover .HaAuthorizeButtonTitle,
+.HaAuthorizeButton:not(:disabled):focus .HaAuthorizeButtonTitle {
+  background-color: #4B3FCF;
+}
+</style>

+ 8 - 0
config/abilities/pages/helloasso.yaml

@@ -0,0 +1,8 @@
+helloasso_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['HelloAsso'] }
+    - {
+      function: accessHasAnyRoleAbility,
+      parameters: [{ action: 'read', subject: 'helloasso' }],
+    }

+ 15 - 2
i18n/lang/fr/general.json

@@ -38,6 +38,9 @@
   "add_event": "Ajouter un événement",
   "my_organization": "Ma structure",
   "edit_organization": "Modifier la structure",
+  "dashboard_breadcrumbs": "Tableau de bord",
+  "freemium_breadcrumbs": "Freemium",
+  "helloasso_breadcrumbs": "Helloasso",
   "i_understand": "Je comprends",
   "place_change_everywhere": "Attention : Modification d’un lieu existant",
   "event_categories_choices": "Choisissez à quelles catégories appartient votre événement",
@@ -521,7 +524,8 @@
   "schedule": "Agenda",
   "attendances": "Absences",
   "equipment": "Parc matériel",
-  "basicompta_admin": "Comptabilité BasiCompta",
+  "basicompta_admin": "Comptabilité BasiCompta ®",
+  "helloasso_admin": "HelloAsso ®",
   "education_state": "Suivi pédagogique",
   "criteria_notations": "Critères d'évaluation",
   "education_notation_configs": "Grilles d'évaluation",
@@ -828,5 +832,14 @@
   "information": "Information",
   "refresh_needed": "Actualisation requise",
   "refresh_needed_message": "La page a besoin d'être actualisée pour afficher les dernières modifications.",
-  "refresh_page": "Actualiser la page"
+  "refresh_page": "Actualiser la page",
+  "helloasso_presentation": "HelloAsso aide les associations à collecter des paiements en ligne et propose ses services gratuitement. Elle prend à sa charge tous les frais de transaction pour que vous puissiez bénéficier de la totalité des sommes versées par vos publics, sans frais. Les contributions volontaires laissées par ces derniers sont leur unique source de revenus.",
+  "connect_to_helloasso": "Connecter à HelloAsso",
+  "your_helloasso_account_is_linked": "Votre compte HelloAsso a bien été lié.",
+  "an_error_occured": "Une erreur s'est produite",
+  "please_contact_support": "Veuillez contacter le support technique pour plus d'informations.",
+  "unlink_your_helloasso_account": "Déconnecter votre compte HelloAsso",
+  "your_helloasso_account_was_successfully_connected": "Votre compte HelloAsso a été connecté avec succès.",
+  "your_helloasso_account_was_successfully_unlinked": "Votre compte HelloAsso a été déconnecté avec succès.",
+  "close": "Fermer"
 }

+ 20 - 0
models/HelloAsso/AuthUrl.ts

@@ -0,0 +1,20 @@
+import { Uid, Str } from 'pinia-orm/dist/decorators'
+import ApiResource from '~/models/ApiResource'
+
+/**
+ * AP2i Model : AuthUrl
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/HelloAsso/AuthUrl.php
+ */
+export default class AuthUrl extends ApiResource {
+  static override entity = 'helloasso/auth-url'
+
+  @Uid()
+  declare id: number | string
+
+  @Str('')
+  declare authUrl: string
+
+  @Str('')
+  declare challengeVerifier: string
+}

+ 23 - 0
models/HelloAsso/ConnectionRequest.ts

@@ -0,0 +1,23 @@
+import { Uid, Str } from 'pinia-orm/dist/decorators'
+import { IdField } from '~/models/decorators'
+import { Num } from 'pinia-orm/decorators'
+import ApiResource from '~/models/ApiResource'
+
+/**
+ * AP2i Model : ConnectionRequest
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/HelloAsso/ConnectionRequest.php
+ */
+export default class ConnectionRequest extends ApiResource {
+  static override entity = 'helloasso/connect'
+
+  @Uid()
+  declare id: number | string
+
+  @IdField()
+  @Num(0, { notNullable: true })
+  declare organizationId: number
+
+  @Str('')
+  declare authorizationCode: string
+}

+ 32 - 0
models/HelloAsso/HelloAsso.ts

@@ -0,0 +1,32 @@
+import { Bool, Num, Uid } from 'pinia-orm/dist/decorators'
+import ApiResource from '~/models/ApiResource'
+import { IdField } from '~/models/decorators'
+import { Str } from 'pinia-orm/decorators'
+
+/**
+ * The Mobyt user status of an organization
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResources/Mobyt/MobytUserStatus.php
+ */
+export default class HelloAsso extends ApiResource {
+  static override entity = 'helloasso'
+
+  @Uid()
+  declare id: number | string | null
+
+  @IdField()
+  @Num(0, { notNullable: true })
+  declare organizationId: number
+
+  @Str(null)
+  declare challengeVerifier: string
+
+  @Str(null)
+  declare token: string
+
+  @Str(null)
+  declare refreshToken: string
+
+  @Str(null)
+  declare organizationSlug: string
+}

+ 20 - 0
models/HelloAsso/HelloAssoProfile.ts

@@ -0,0 +1,20 @@
+import { Uid, Str } from 'pinia-orm/dist/decorators'
+import ApiResource from '~/models/ApiResource'
+
+/**
+ * AP2i Resource : HelloAssoProfile
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/HelloAsso/HelloAssoProfile.php
+ */
+export default class HelloAssoProfile extends ApiResource {
+  static override entity = 'helloasso/profile'
+
+  @Uid()
+  declare id: number | string
+
+  @Str('')
+  declare token: string
+
+  @Str('')
+  declare organizationSlug: string
+}

+ 20 - 0
models/HelloAsso/UnlinkRequest.ts

@@ -0,0 +1,20 @@
+import { Uid, Str } from 'pinia-orm/dist/decorators'
+import { IdField } from '~/models/decorators'
+import { Num } from 'pinia-orm/decorators'
+import ApiResource from '~/models/ApiResource'
+
+/**
+ * AP2i Model : UnlinkRequest
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/HelloAsso/UnlinkRequest.php
+ */
+export default class UnlinkRequest extends ApiResource {
+  static override entity = 'helloasso/unlink'
+
+  @Uid()
+  declare id: number | string
+
+  @IdField()
+  @Num(0, { notNullable: true })
+  declare organizationId: number
+}

+ 85 - 0
pages/helloasso/callback.vue

@@ -0,0 +1,85 @@
+<!--
+Page cible du callback après authentification via la mire d'autorisation HelloAsso
+
+@see https://dev.helloasso.com/docs/mire-authorisation
+-->
+<template>
+  <NuxtLayout name="blank">
+    <v-app>
+      <div
+        v-if="!error"
+        class="d-flex flex-column align-center justify-center fill-height theme-secondary"
+      >
+        <v-progress-circular indeterminate size="64" />
+        <span class="mt-3"> {{ $t('please_wait') }}... </span>
+      </div>
+
+      <div v-else class="ma-4">
+        <div>{{ $t('an_error_occured')}}</div>
+        <div>{{ $t('please_contact_support')}}</div>
+      </div>
+    </v-app>
+  </NuxtLayout>
+</template>
+
+<script setup lang="ts">
+/**
+ * Disable the default layout, the page will use the layout defined with <NuxtLayout />
+ * @see https://nuxt.com/docs/guide/directory-structure/layouts#overriding-a-layout-on-a-per-page-basis
+ */
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import ConnectionRequest from '~/models/HelloAsso/ConnectionRequest'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+
+definePageMeta({
+  name: 'helloasso_callback_page',
+  layout: false,
+})
+
+const organizationProfile = useOrganizationProfileStore()
+
+const { em } = useEntityManager()
+
+const route: RouteLocationNormalizedLoaded = useRoute()
+
+if (!route.query.code) {
+  throw new Error('Missing parameter')
+}
+
+const authorizationCode: Ref<string> = ref(route.query.code as string)
+
+const connectionRequest: ConnectionRequest = em.newInstance(
+  ConnectionRequest,
+  {
+    organizationId: organizationProfile.id,
+    authorizationCode: authorizationCode.value,
+  },
+)
+
+const error: Ref<boolean> = ref(false)
+
+onMounted(async () => {
+  try {
+    await em.persist(connectionRequest)
+  } catch (e) {
+    error.value = true
+    throw e
+  }
+
+  // Send a event to the parent window to notify the connection request has been created (in case SSE is not available)
+  window.opener?.postMessage(
+    { code: authorizationCode.value },
+    window.location.origin,
+  )
+
+  // Close the popup
+  window.close()
+})
+</script>
+
+<style scoped lang="scss">
+.background {
+  background-color: var(--v-theme-secondary);
+}
+</style>

+ 185 - 0
pages/helloasso/index.vue

@@ -0,0 +1,185 @@
+<!--
+Administration de la connexion Opentalent / HelloAsso
+-->
+<template>
+  <LayoutContainer>
+    <v-card>
+      <v-row>
+        <v-col cols="12" md="4" class="d-flex justify-center">
+          <v-img src="/images/logos/Logo-HelloAsso.svg" class="logo" />
+        </v-col>
+
+        <v-col cols="12" md="8" class="presentation">
+          {{ $t('helloasso_presentation') }}
+        </v-col>
+      </v-row>
+
+      <v-row>
+        <v-col cols="12" class="d-flex justify-center align-center w-100 mt-6">
+          <v-progress-circular
+            v-if="
+              statusHelloAssoProfile === FETCHING_STATUS.PENDING ||
+              unlinkingPending
+            "
+            indeterminate
+            size="32"
+          />
+
+          <UiButtonHelloAssoConnect
+            v-else-if="!helloAssoProfile || !helloAssoProfile.token"
+            @click="onHelloAssoConnectClicked"
+          />
+
+          <div v-else class="d-flex flex-column align-center">
+            <v-row>
+              <v-icon icon="fas fa-check" color="success" class="mr-3" />
+              {{ $t('your_helloasso_account_is_linked') }}
+            </v-row>
+            <v-row>
+              <v-btn class="theme-warning mt-4" @click="onUnlinkAccountClick">
+                {{ $t('unlink_your_helloasso_account') }}
+              </v-btn>
+            </v-row>
+          </div>
+        </v-col>
+      </v-row>
+    </v-card>
+
+    <v-snackbar v-model="connectedSnackbar" color="success">
+      {{ $t('your_helloasso_account_was_successfully_connected') }}
+
+      <template v-slot:actions>
+        <v-btn variant="text" @click="connectedSnackbar = false">
+          {{ $t('close') }}
+        </v-btn>
+      </template>
+    </v-snackbar>
+
+    <v-snackbar v-model="unlinkedSnackbar" color="success">
+      {{ $t('your_helloasso_account_was_successfully_unlinked') }}
+
+      <template v-slot:actions>
+        <v-btn variant="text" @click="unlinkedSnackbar = false">
+          {{ $t('close') }}
+        </v-btn>
+      </template>
+    </v-snackbar>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import AuthUrl from '~/models/HelloAsso/AuthUrl'
+import HelloAssoProfile from '~/models/HelloAsso/HelloAssoProfile'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { FETCHING_STATUS } from '~/types/enum/data'
+import UnlinkRequest from '~/models/HelloAsso/UnlinkRequest'
+
+const { em } = useEntityManager()
+
+const organizationProfile = useOrganizationProfileStore()
+
+const connectedSnackbar: Ref<boolean> = ref(false)
+const unlinkedSnackbar: Ref<boolean> = ref(false)
+
+const onHelloAssoConnectClicked = async () => {
+  // Important de régénérer une URL avec un nouveau challenge à chaque
+  // essai (entre autres pour supporter le HMR pendant les tests en local,
+  // ou en cas d'erreur et de ré-essai)
+  const authUrl = await em.fetch(AuthUrl)
+
+  navigateTo(authUrl.authUrl, {
+    external: true,
+    open: {
+      target: '_blank',
+      windowFeatures: {
+        popup: true,
+        width: 900,
+        height: 600,
+      },
+    },
+  })
+}
+
+onMounted(() => {
+  window.addEventListener('message', (event) => {
+    if (event.origin !== window.location.origin) {
+      return
+    }
+    if (!event.data || !event.data.code) {
+      return
+    }
+    onHelloAssoConnected()
+  })
+})
+
+const { fetch } = useEntityFetch()
+
+const { status: statusHelloAssoProfile, refresh: refreshHelloAssoProfile } =
+  await fetch(HelloAssoProfile)
+
+const helloAssoProfile: ComputedRef<HelloAssoProfile | null> = computed(() => {
+  if (statusHelloAssoProfile.value !== FETCHING_STATUS.SUCCESS) {
+    return null
+  }
+  return em.find(HelloAssoProfile, 1)
+})
+
+const onHelloAssoConnected = async () => {
+  // On attend 200ms pour laisser en attente du message SSE
+  await new Promise((r) => setTimeout(r, 200))
+
+  if (!helloAssoProfile.value || !helloAssoProfile.value.token) {
+    // Fallback en cas de défaut de fonctionnement du SSE
+    console.log('Helloasso connected (fallback SSE)')
+    await refreshHelloAssoProfile()
+  }
+
+  connectedSnackbar.value = true
+}
+
+const unlinkingPending: Ref<boolean> = ref(false)
+
+const onUnlinkAccountClick = async () => {
+  const unlinkRequest = em.newInstance(UnlinkRequest, {
+    organizationId: organizationProfile.id,
+  })
+
+  unlinkingPending.value = true
+
+  try {
+    await em.persist(unlinkRequest)
+    await refreshHelloAssoProfile()
+
+    unlinkedSnackbar.value = true
+  } finally {
+    unlinkingPending.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.v-card {
+  padding: 48px;
+  max-width: 70%;
+  margin: 36px auto;
+
+  @media (max-width: 600px) {
+    max-width: 90%;
+  }
+}
+
+.logo {
+  max-width: 80%;
+}
+
+.presentation {
+  border-left: 3px solid rgb(var(--v-theme-info));
+  padding: 0 24px;
+  color: rgb(var(--v-theme-on-neutral));
+}
+
+.authDialog {
+  max-width: 90%;
+}
+</style>

+ 1 - 1
prepare/buildIndex.ts

@@ -23,7 +23,7 @@ files.forEach((file) => {
   let entity = null
 
   for (const line of lines) {
-    const match = line.match(/static entity = ['"]([\w-/]+)['"]/)
+    const match = line.match(/static (?:override )?entity = ['"]([\w-/]+)['"]/)
     if (match) {
       // afficher le groupe capturant
       entity = match[1]

+ 21 - 0
public/images/logos/Logo-HelloAsso.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" id="Calque_1" viewBox="0 0 200 43.5">
+  <style>
+    .st0{fill:#2e2f5e}
+  </style>
+  <path d="M71.1 19.3v13.3h-6.6v-12c0-1.4-.4-1.8-1-1.8-.7 0-1.5.6-2.2 1.8v12h-6.6v-25l6.6-.7v9.4c1.5-1.6 3-2.3 5-2.3 3-.1 4.8 1.9 4.8 5.3zM90.3 25.5H79.6c.4 2.6 1.6 3 3.6 3 1.3 0 2.5-.5 4-1.6l2.7 3.7c-2 1.7-4.7 2.7-7.3 2.7-6.5 0-9.6-4.1-9.6-9.6 0-5.3 3-9.7 8.9-9.7 5.2 0 8.7 3.4 8.7 9.4-.1.5-.2 1.4-.3 2.1zm-6.3-4c0-1.8-.4-3.3-2.1-3.3-1.4 0-2.1.8-2.4 3.6H84v-.3zM92.1 27.4V7.5l6.6-.7v20.3c0 .6.3.9.8.9.2 0 .5 0 .7-.2l1.2 4.7c-1.2.4-2.4.6-3.6.6-3.7.2-5.7-2-5.7-5.7zM102.1 27.4V7.5l6.6-.7v20.3c0 .6.3.9.8.9.2 0 .5 0 .7-.2l1.2 4.7c-1.2.4-2.4.6-3.6.6-3.7.2-5.7-2-5.7-5.7zM129.4 23.6c0 5.9-3.5 9.6-9.2 9.6-5.6 0-9.2-3.4-9.2-9.7 0-5.9 3.5-9.6 9.2-9.6 5.6 0 9.2 3.5 9.2 9.7zm-11.5 0c0 3.6.7 4.9 2.4 4.9 1.6 0 2.4-1.4 2.4-4.9 0-3.6-.7-4.9-2.4-4.9s-2.5 1.5-2.4 4.9zM147.8 28.9l-1.3 4.3c-2.3-.2-3.8-.8-4.8-2.5-1.3 2-3.3 2.6-5.4 2.6-3.6 0-5.9-2.4-5.9-5.7 0-4 3-6.2 8.6-6.2h1.3v-.5c0-1.8-.6-2.3-2.6-2.3-1.6.1-3.1.4-4.6.9l-1.4-4.2c2.2-.9 4.6-1.4 7-1.4 5.7 0 8 2.2 8 6.6v6.2c0 1.3.3 1.9 1.1 2.2zm-7.5-1.4v-2.7h-.7c-1.9 0-2.7.6-2.7 2 0 1 .6 1.7 1.5 1.7.7.1 1.5-.3 1.9-1zM163.6 16.3l-2.3 3.6c-1.3-.8-2.7-1.3-4.2-1.3-1.1 0-1.5.3-1.5.8 0 .6.2.9 3.6 1.9 3.4 1.1 5.2 2.5 5.2 5.8 0 3.7-3.5 6.2-8.4 6.2-3.1 0-6-1.1-7.8-2.9l3.1-3.5c1.3 1 2.9 1.8 4.5 1.8 1.2 0 1.9-.4 1.9-1.1 0-.9-.4-1.1-3.4-2-3.3-1-5.2-2.9-5.2-5.8 0-3.2 2.8-5.8 7.7-5.8 2.6-.1 5.2.8 6.8 2.3zM180.1 16.3l-2.3 3.6c-1.3-.8-2.7-1.3-4.2-1.3-1.1 0-1.5.3-1.5.8 0 .6.2.9 3.6 1.9 3.4 1.1 5.2 2.5 5.2 5.8 0 3.7-3.5 6.2-8.4 6.2-3.1 0-6-1.1-7.8-2.9l3.1-3.5c1.3 1 2.9 1.8 4.5 1.8 1.2 0 1.9-.4 1.9-1.1 0-.9-.4-1.1-3.4-2-3.3-1-5.2-2.9-5.2-5.8 0-3.2 2.8-5.8 7.7-5.8 2.6-.1 5.1.8 6.8 2.3zM200 23.6c0 5.9-3.5 9.6-9.2 9.6-5.6 0-9.2-3.4-9.2-9.7 0-5.9 3.5-9.6 9.2-9.6 5.6 0 9.2 3.5 9.2 9.7zm-11.5 0c0 3.6.7 4.9 2.4 4.9 1.6 0 2.4-1.4 2.4-4.9 0-3.6-.7-4.9-2.4-4.9s-2.5 1.5-2.4 4.9z" class="st0"/>
+  <linearGradient id="SVGID_1_" x1="4.322" x2="24.268" y1="33.651" y2="-.503" gradientTransform="matrix(1 0 0 -1 0 44.736)" gradientUnits="userSpaceOnUse">
+    <stop offset="0" stop-color="#498a63"/>
+    <stop offset=".25" stop-color="#61b984"/>
+  </linearGradient>
+  <path fill="url(#SVGID_1_)" d="M12.9 34.9c-6.6-7.6-2.2-26.8.6-26.8C8.1 7.9-1.1 11.5.2 24.4c1.5 12 12.3 20.4 24.1 18.9 3.8-.5 7.3-2 10.3-4.3-10.4 7.5-17.4.8-21.7-4.1z"/>
+  <linearGradient id="SVGID_2_" x1="19.889" x2="40.524" y1="3.627" y2="36.697" gradientTransform="matrix(1 0 0 -1 0 44.736)" gradientUnits="userSpaceOnUse">
+    <stop offset="0" stop-color="#89356d"/>
+    <stop offset=".21" stop-color="#b94794"/>
+  </linearGradient>
+  <path fill="url(#SVGID_2_)" d="M37.2 21.9C31.7 33 14.8 37.7 12.9 34.8c3.3 4.9 11.5 11.6 21.8 4 9.4-7.3 11.1-21 3.8-30.5-2.3-3-5.4-5.3-8.9-6.8 11.7 5.3 10.5 14.6 7.6 20.4z"/>
+  <linearGradient id="SVGID_3_" x1="3.242" x2="37.689" y1="35.782" y2="23.384" gradientTransform="matrix(1 0 0 -1 0 44.736)" gradientUnits="userSpaceOnUse">
+    <stop offset=".6" stop-color="#f59c1c"/>
+    <stop offset="1" stop-color="#c7702b"/>
+  </linearGradient>
+  <path fill="url(#SVGID_3_)" d="M13.5 8.1c11.9-1.3 25.4 11 23.7 13.9 3.3-5.8 4.1-15.1-7.5-20.4C18.6-2.9 6 2.5 1.6 13.7.2 17.2-.3 21 .2 24.7-.6 11.9 9.1 8.5 13.5 8.1z"/>
+<script xmlns=""/></svg>

+ 2 - 2
services/data/entityManager.ts

@@ -113,7 +113,7 @@ class EntityManager {
    * @param iri An IRI of the form .../api/<entity>/...
    */
   public async getModelFromIri(iri: string): Promise<typeof ApiResource> {
-    const matches = iri.match(/^\/api\/(\w+)\/.*/)
+    const matches = iri.match(/^\/api\/([\w-\/]+)(\/\d+)?/)
     if (!matches || !matches[1]) {
       throw new Error('cannot parse the IRI')
     }
@@ -175,7 +175,7 @@ class EntityManager {
    * @param model
    * @param id
    */
-  public find<T extends typeof ApiResource>(model: T, id: number | string): T {
+  public find<T extends typeof ApiResource>(model: T, id: number | string): InstanceType<T> {
     const repository = this.getRepository(model)
     return repository.find(id) as T
   }

+ 0 - 3
services/layout/menuBuilder/basicomptaMenuBuilder.ts

@@ -11,9 +11,6 @@ export default class BasicomptaMenuBuilder extends AbstractMenuBuilder {
   build(): MenuItem | null {
     // cf droit : https://ressources-opentalent.atlassian.net/wiki/spaces/SPEC/pages/32637034/Acc+s+basi+compta+pour+les+structures+de+la+CMF#Acces-a-Basicompta-pour-les-administrateurs
     if (
-      (this.accessProfile.isAdminAccess ||
-        this.accessProfile.isAdministratifManager ||
-        this.accessProfile.isFinancialManager) &&
       this.ability.can('display', 'basicompta_page')
     ) {
       return this.createItem(

+ 24 - 0
services/layout/menuBuilder/helloAssoMenuBuilder.ts

@@ -0,0 +1,24 @@
+import type { MenuItem } from '~/types/layout'
+import { MENU_LINK_TYPE } from '~/types/enum/layout'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
+
+/**
+ * Menu Basicompta
+ */
+export default class BasicomptaMenuBuilder extends AbstractMenuBuilder {
+  static override readonly menuName = 'HelloAsso'
+
+  build(): MenuItem | null {
+    if (
+        this.ability.can('display', 'helloasso_page')
+    ) {
+      return this.createItem(
+        'helloasso_admin',
+        { name: 'fas fa-link' },
+        '/helloasso',
+        MENU_LINK_TYPE.INTERNAL,
+      )
+    }
+    return null
+  }
+}

+ 2 - 0
services/layout/menuBuilder/mainMenuBuilder.ts

@@ -13,6 +13,7 @@ import CotisationsMenuBuilder from '~/services/layout/menuBuilder/cotisationsMen
 import StatsMenuBuilder from '~/services/layout/menuBuilder/statsMenuBuilder'
 import Admin2iosMenuBuilder from '~/services/layout/menuBuilder/admin2iosMenuBuilder'
 import BasicomptaMenuBuilder from '~/services/layout/menuBuilder/basicomptaMenuBuilder'
+import HelloAssoMenuBuilder from '~/services/layout/menuBuilder/helloAssoMenuBuilder'
 
 /**
  * Menu principal (ou menu lateral)
@@ -35,6 +36,7 @@ export default class MainMenuBuilder extends AbstractMenuBuilder {
       this.buildSubmenu(RewardsMenuBuilder),
       this.buildSubmenu(WebsiteAdminMenuBuilder),
       this.buildSubmenu(BasicomptaMenuBuilder),
+      this.buildSubmenu(HelloAssoMenuBuilder),
       this.buildSubmenu(CotisationsMenuBuilder),
       this.buildSubmenu(StatsMenuBuilder),
       this.buildSubmenu(Admin2iosMenuBuilder),

+ 20 - 0
stores/helloasso.ts

@@ -0,0 +1,20 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import type { Ref } from 'vue'
+import type { MenuGroup, MenuItem } from '~/types/layout'
+
+export const useHelloAssoStore = defineStore('helloasso', () => {
+  /**
+   * Le code d'autorisation Helloasso obtenu via la mire d'autorisation
+   */
+  const authorizationCode: Ref<string | null> = ref(null)
+
+  const setAuthorizationCode = (code: string | null) => {
+    authorizationCode.value = code
+  }
+
+  return {
+    authorizationCode,
+    setAuthorizationCode
+  }
+})