From 5999252e02b4439c7e084d981a2a1fecc650a66e Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 1 Sep 2023 12:14:36 +0200
Subject: [PATCH 1/7] ci: fix Gitlab CI exunit run by separating mix compile
 and tz_world.update

Reference https://github.com/kimlai/tz_world/issues/33

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 .gitlab-ci.yml | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 55396be7e..27029a5a4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -132,7 +132,9 @@ exunit:
   variables:
     MIX_ENV: test
   before_script:
-    - mix deps.get && mix tz_world.update
+    - mix deps.get
+    - mix compile
+    - mix tz_world.update
     - mix ecto.create
     - mix ecto.migrate
   script:

From 84f62cd043d5cf5d186fea6f24a1a9dff5fc64ce Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 1 Sep 2023 12:17:27 +0200
Subject: [PATCH 2/7] fix(front): fix behavior of local toggle for profiles &
 groups view depending on domain value

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/views/Admin/GroupProfiles.vue | 8 ++++----
 js/src/views/Admin/ProfilesView.vue  | 2 +-
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/js/src/views/Admin/GroupProfiles.vue b/js/src/views/Admin/GroupProfiles.vue
index dbd904eea..7b12c9cb1 100644
--- a/js/src/views/Admin/GroupProfiles.vue
+++ b/js/src/views/Admin/GroupProfiles.vue
@@ -129,11 +129,11 @@ const PROFILES_PER_PAGE = 10;
 
 const { restrictions } = useRestrictions();
 
-const preferredUsername = ref("");
-const name = ref("");
-const domain = ref("");
+const preferredUsername = useRouteQuery("preferredUsername", "");
+const name = useRouteQuery("name", "");
+const domain = useRouteQuery("domain", "");
 
-const local = useRouteQuery("local", true, booleanTransformer);
+const local = useRouteQuery("local", domain.value === "", booleanTransformer);
 const suspended = useRouteQuery("suspended", false, booleanTransformer);
 const page = useRouteQuery("page", 1, integerTransformer);
 
diff --git a/js/src/views/Admin/ProfilesView.vue b/js/src/views/Admin/ProfilesView.vue
index fa754642c..27523f265 100644
--- a/js/src/views/Admin/ProfilesView.vue
+++ b/js/src/views/Admin/ProfilesView.vue
@@ -119,7 +119,7 @@ const preferredUsername = useRouteQuery("preferredUsername", "");
 const name = useRouteQuery("name", "");
 const domain = useRouteQuery("domain", "");
 
-const local = useRouteQuery("local", true, booleanTransformer);
+const local = useRouteQuery("local", domain.value === "", booleanTransformer);
 const suspended = useRouteQuery("suspended", false, booleanTransformer);
 const page = useRouteQuery("page", 1, integerTransformer);
 

From 4ce79f5136f42fbbc2a046bd4e604cb7e011e5f5 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 1 Sep 2023 14:08:04 +0200
Subject: [PATCH 3/7] chore(release): release 3.2.0-beta.2

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 CHANGELOG.md    | 10 ++++++++++
 js/package.json |  2 +-
 mix.exs         |  2 +-
 3 files changed, 12 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8058e9310..94180ec57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 3.2.0-beta.2  (2023-09-01)
+
+Fixes a CI issue that prevented 3.2.0-beta.2 being released.
+
+### Bug Fixes
+
+* **front:** fix behavior of local toggle for profiles & groups view depending on domain value ([84f62cd](https://framagit.org/framasoft/mobilizon/commit/84f62cd043d5cf5d186fea6f24a1a9dff5fc64ce))
+
+
+
 ## 3.2.0-beta.1  (2023-09-01)
 
 ### Features
diff --git a/js/package.json b/js/package.json
index 7a082bf77..1603791fc 100644
--- a/js/package.json
+++ b/js/package.json
@@ -1,6 +1,6 @@
 {
   "name": "mobilizon",
-  "version": "3.2.0-beta.1",
+  "version": "3.2.0-beta.2",
   "private": true,
   "scripts": {
     "dev": "vite",
diff --git a/mix.exs b/mix.exs
index 14623353d..a2ee896da 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
 defmodule Mobilizon.Mixfile do
   use Mix.Project
 
-  @version "3.2.0-beta.1"
+  @version "3.2.0-beta.2"
 
   def project do
     [

From 3f60174877bbe05773b1d1b2ceb91749adec7ed7 Mon Sep 17 00:00:00 2001
From: setop <setop@zoocoop.com>
Date: Fri, 1 Sep 2023 14:06:44 +0000
Subject: [PATCH 4/7] improve group creation view

---
 js/src/graphql/group.ts              |   8 ++
 js/src/types/actor/group.model.ts    |   7 +-
 js/src/views/Admin/GroupProfiles.vue |   2 +-
 js/src/views/Group/CreateView.vue    | 145 +++++++++++++++++++++++++--
 lib/graphql/schema/actors/group.ex   |   4 +
 lib/mobilizon/actors/actor.ex        |   3 +-
 6 files changed, 156 insertions(+), 13 deletions(-)

diff --git a/js/src/graphql/group.ts b/js/src/graphql/group.ts
index 0b1d60874..b53ac350a 100644
--- a/js/src/graphql/group.ts
+++ b/js/src/graphql/group.ts
@@ -295,6 +295,10 @@ export const CREATE_GROUP = gql`
     $summary: String
     $avatar: MediaInput
     $banner: MediaInput
+    $physicalAddress: AddressInput
+    $visibility: GroupVisibility
+    $openness: Openness
+    $manuallyApprovesFollowers: Boolean
   ) {
     createGroup(
       preferredUsername: $preferredUsername
@@ -302,6 +306,10 @@ export const CREATE_GROUP = gql`
       summary: $summary
       banner: $banner
       avatar: $avatar
+      physicalAddress: $physicalAddress
+      visibility: $visibility
+      openness: $openness
+      manuallyApprovesFollowers: $manuallyApprovesFollowers
     ) {
       ...ActorFragment
       banner {
diff --git a/js/src/types/actor/group.model.ts b/js/src/types/actor/group.model.ts
index 26e20449a..e96dc485c 100644
--- a/js/src/types/actor/group.model.ts
+++ b/js/src/types/actor/group.model.ts
@@ -5,8 +5,7 @@ import type { IResource } from "../resource";
 import type { IEvent } from "../event.model";
 import type { IDiscussion } from "../discussions";
 import type { IPost } from "../post.model";
-import type { IAddress } from "../address.model";
-import { Address } from "../address.model";
+import { Address, type IAddress } from "../address.model";
 import { ActorType, GroupVisibility, Openness } from "../enums";
 import type { IMember } from "./member.model";
 import type { ITodoList } from "../todolist";
@@ -53,11 +52,11 @@ export class Group extends Actor implements IGroup {
   visibility: GroupVisibility = GroupVisibility.PUBLIC;
   activity: Paginate<IActivity> = { elements: [], total: 0 };
 
-  openness: Openness = Openness.INVITE_ONLY;
+  openness: Openness = Openness.MODERATED;
 
   physicalAddress: IAddress = new Address();
 
-  manuallyApprovesFollowers = true;
+  manuallyApprovesFollowers = false;
 
   patch(hash: IGroup | Record<string, unknown>): void {
     Object.assign(this, hash);
diff --git a/js/src/views/Admin/GroupProfiles.vue b/js/src/views/Admin/GroupProfiles.vue
index 7b12c9cb1..21ab50f32 100644
--- a/js/src/views/Admin/GroupProfiles.vue
+++ b/js/src/views/Admin/GroupProfiles.vue
@@ -120,7 +120,7 @@ import {
 } from "vue-use-route-query";
 import { useI18n } from "vue-i18n";
 import { useHead } from "@vueuse/head";
-import { computed, ref } from "vue";
+import { computed } from "vue";
 import { Paginate } from "@/types/paginate";
 import { IGroup } from "@/types/actor";
 import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
diff --git a/js/src/views/Group/CreateView.vue b/js/src/views/Group/CreateView.vue
index 6d231376b..d3d9aaed8 100644
--- a/js/src/views/Group/CreateView.vue
+++ b/js/src/views/Group/CreateView.vue
@@ -69,11 +69,25 @@
         :message="summaryErrors[0]"
         :type="summaryErrors[1]"
       >
-        <o-input v-model="group.summary" type="textarea" id="group-summary" />
+        <editor
+          v-if="currentActor"
+          id="group-summary"
+          mode="basic"
+          class="mb-3"
+          v-model="group.summary"
+          :maxSize="500"
+          :aria-label="$t('Group description body')"
+          :current-actor="currentActor"
+        />
       </o-field>
 
-      <div>
-        <b>{{ t("Avatar") }}</b>
+      <full-address-auto-complete
+        :label="$t('Group address')"
+        v-model="group.physicalAddress"
+      />
+
+      <div class="field">
+        <b class="field-label">{{ t("Avatar") }}</b>
         <picture-upload
           :textFallback="t('Avatar')"
           v-model="avatarFile"
@@ -81,8 +95,8 @@
         />
       </div>
 
-      <div>
-        <b>{{ t("Banner") }}</b>
+      <div class="field">
+        <b class="field-label">{{ t("Banner") }}</b>
         <picture-upload
           :textFallback="t('Banner')"
           v-model="bannerFile"
@@ -90,7 +104,101 @@
         />
       </div>
 
-      <o-button variant="primary" native-type="submit">
+      <fieldset>
+        <legend class="field-label !mb-0 mt-2">
+          {{ t("Group visibility") }}
+        </legend>
+        <o-radio
+          v-model="group.visibility"
+          name="groupVisibility"
+          :native-value="GroupVisibility.PUBLIC"
+        >
+          {{ $t("Visible everywhere on the web") }}<br />
+          <small>{{
+            $t(
+              "The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
+            )
+          }}</small>
+        </o-radio>
+        <o-radio
+          v-model="group.visibility"
+          name="groupVisibility"
+          :native-value="GroupVisibility.UNLISTED"
+          >{{ $t("Only accessible through link") }}<br />
+          <small>{{
+            $t(
+              "You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
+            )
+          }}</small>
+        </o-radio>
+      </fieldset>
+      <fieldset>
+        <legend class="mt-2">
+          <span class="field-label !mb-0">{{ t("New members") }} </span>
+          <span>
+            {{
+              t(
+                "Members will also access private sections like discussions, resources and restricted posts."
+              )
+            }}
+          </span>
+        </legend>
+        <o-field>
+          <o-radio
+            v-model="group.openness"
+            name="groupOpenness"
+            :native-value="Openness.OPEN"
+          >
+            {{ $t("Anyone can join freely") }}<br />
+            <small>{{
+              $t(
+                "Anyone wanting to be a member from your group will be able to from your group page."
+              )
+            }}</small>
+          </o-radio>
+        </o-field>
+        <o-field>
+          <o-radio
+            v-model="group.openness"
+            name="groupOpenness"
+            :native-value="Openness.MODERATED"
+            >{{ $t("Moderate new members") }}<br />
+            <small>{{
+              $t(
+                "Anyone can request being a member, but an administrator needs to approve the membership."
+              )
+            }}</small>
+          </o-radio>
+        </o-field>
+        <o-field>
+          <o-radio
+            v-model="group.openness"
+            name="groupOpenness"
+            :native-value="Openness.INVITE_ONLY"
+            >{{ $t("Manually invite new members") }}<br />
+            <small>{{
+              $t(
+                "The only way for your group to get new members is if an admininistrator invites them."
+              )
+            }}</small>
+          </o-radio>
+        </o-field>
+      </fieldset>
+      <fieldset>
+        <legend class="mt-2">
+          <span class="field-label !mb-0">
+            {{ t("Followers") }}
+          </span>
+          <span>
+            {{ t("Followers will receive new public events and posts.") }}
+          </span>
+        </legend>
+        <o-checkbox v-model="group.manuallyApprovesFollowers">
+          {{ t("Manually approve new followers") }}
+        </o-checkbox>
+      </fieldset>
+
+      <o-button variant="primary" native-type="submit" class="mt-3">
         {{ t("Create my group") }}
       </o-button>
     </form>
@@ -105,7 +213,14 @@ import PictureUpload from "../../components/PictureUpload.vue";
 import { ErrorResponse } from "@/types/errors.model";
 import { ServerParseError } from "@apollo/client/link/http";
 import { useCurrentActorClient } from "@/composition/apollo/actor";
-import { computed, inject, reactive, ref, watch } from "vue";
+import {
+  computed,
+  defineAsyncComponent,
+  inject,
+  reactive,
+  ref,
+  watch,
+} from "vue";
 import { useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { useCreateGroup } from "@/composition/apollo/group";
@@ -116,6 +231,12 @@ import {
 } from "@/composition/config";
 import { Notifier } from "@/plugins/notifier";
 import { useHead } from "@vueuse/head";
+import { Openness, GroupVisibility } from "@/types/enums";
+import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
+
+const Editor = defineAsyncComponent(
+  () => import("@/components/TextEditor.vue")
+);
 
 const { currentActor } = useCurrentActorClient();
 
@@ -156,10 +277,20 @@ const buildVariables = computed(() => {
   let avatarObj = {};
   let bannerObj = {};
 
+  const cloneGroup = group.value;
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  delete cloneGroup.physicalAddress.__typename;
+  delete cloneGroup.physicalAddress.pictureInfo;
+
   const groupBasic = {
     preferredUsername: group.value.preferredUsername,
     name: group.value.name,
     summary: group.value.summary,
+    physicalAddress: cloneGroup.physicalAddress,
+    visibility: group.value.visibility,
+    openness: group.value.openness,
+    manuallyApprovesFollowers: group.value.manuallyApprovesFollowers,
   };
 
   if (avatarFile.value) {
diff --git a/lib/graphql/schema/actors/group.ex b/lib/graphql/schema/actors/group.ex
index 3b0122a11..eed6b889b 100644
--- a/lib/graphql/schema/actors/group.ex
+++ b/lib/graphql/schema/actors/group.ex
@@ -305,6 +305,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
         description: "Whether the group can be join freely, with approval or is invite-only."
       )
 
+      arg(:manually_approves_followers, :boolean,
+        description: "Whether this group approves new followers manually"
+      )
+
       arg(:avatar, :media_input,
         description:
           "The avatar for the group, either as an object or directly the ID of an existing media"
diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex
index 36ec653b8..9db86aab7 100644
--- a/lib/mobilizon/actors/actor.ex
+++ b/lib/mobilizon/actors/actor.ex
@@ -146,7 +146,8 @@ defmodule Mobilizon.Actors.Actor do
     :domain,
     :summary,
     :visibility,
-    :openness
+    :openness,
+    :manually_approves_followers
   ]
   @group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs
 

From 2de6937407743100daba1d397db4da32d4cb606b Mon Sep 17 00:00:00 2001
From: Luca Eichler <eic.luca@gmail.com>
Date: Tue, 19 Oct 2021 15:56:18 +0200
Subject: [PATCH 5/7] feat: Add option to link an external registration
 provider for events

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 .../components/Event/EventActionSection.vue   | 14 ++++++-
 .../Event/ExternalParticipationButton.vue     | 30 ++++++++++++++
 js/src/graphql/event.ts                       |  6 +++
 js/src/i18n/en_US.json                        |  6 ++-
 js/src/i18n/fr_FR.json                        |  6 ++-
 js/src/types/enums.ts                         |  1 +
 js/src/types/event.model.ts                   |  6 +++
 js/src/views/Event/EditView.vue               | 41 +++++++++++++++++--
 .../activity_stream/converter/event.ex        |  2 +
 lib/graphql/schema/event.ex                   |  6 +++
 lib/mobilizon/events/event.ex                 |  3 ++
 lib/mobilizon/events/events.ex                |  3 +-
 ...0901160000_add_external_url_for_events.exs | 33 +++++++++++++++
 schema.graphql                                | 12 ++++++
 14 files changed, 160 insertions(+), 9 deletions(-)
 create mode 100644 js/src/components/Event/ExternalParticipationButton.vue
 create mode 100644 priv/repo/migrations/20230901160000_add_external_url_for_events.exs

diff --git a/js/src/components/Event/EventActionSection.vue b/js/src/components/Event/EventActionSection.vue
index 7a5dff456..396efc615 100644
--- a/js/src/components/Event/EventActionSection.vue
+++ b/js/src/components/Event/EventActionSection.vue
@@ -1,7 +1,13 @@
 <template>
   <div class="">
+    <external-participation-button
+      v-if="event && event.joinOptions === EventJoinOptions.EXTERNAL"
+      :event="event"
+      :current-actor="currentActor"
+    />
+
     <participation-section
-      v-if="event && anonymousParticipationConfig"
+      v-else-if="event && anonymousParticipationConfig"
       :participation="participations[0]"
       :event="event"
       :anonymousParticipation="anonymousParticipation"
@@ -15,7 +21,10 @@
       @cancel-anonymous-participation="cancelAnonymousParticipation"
     />
     <div class="flex flex-col gap-1 mt-1">
-      <p class="inline-flex gap-2 ml-auto">
+      <p
+        class="inline-flex gap-2 ml-auto"
+        v-if="event.joinOptions !== EventJoinOptions.EXTERNAL"
+      >
         <TicketConfirmationOutline />
         <router-link
           class="participations-link"
@@ -349,6 +358,7 @@ import { useMutation } from "@vue/apollo-composable";
 import { useCreateReport } from "@/composition/apollo/report";
 import { useDeleteEvent } from "@/composition/apollo/event";
 import { useProgrammatic } from "@oruga-ui/oruga-next";
+import ExternalParticipationButton from "./ExternalParticipationButton.vue";
 
 const ShareEventModal = defineAsyncComponent(
   () => import("@/components/Event/ShareEventModal.vue")
diff --git a/js/src/components/Event/ExternalParticipationButton.vue b/js/src/components/Event/ExternalParticipationButton.vue
new file mode 100644
index 000000000..72122d714
--- /dev/null
+++ b/js/src/components/Event/ExternalParticipationButton.vue
@@ -0,0 +1,30 @@
+<template>
+  <o-button
+    tag="a"
+    :href="
+      event.externalParticipationUrl
+        ? encodeURI(`${event.externalParticipationUrl}?uuid=${event.uuid}`)
+        : '#'
+    "
+    rel="noopener ugc"
+    target="_blank"
+    :disabled="!event.externalParticipationUrl"
+    icon-right="OpenInNew"
+  >
+    {{ t("Go to booking") }}
+  </o-button>
+</template>
+
+<script lang="ts" setup>
+import { computed } from "vue";
+import { IEvent } from "../../types/event.model";
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n({ useScope: "global" });
+
+const props = defineProps<{
+  event: IEvent;
+}>();
+
+const event = computed(() => props.event);
+</script>
diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts
index 3e9aaae69..21f6a482c 100644
--- a/js/src/graphql/event.ts
+++ b/js/src/graphql/event.ts
@@ -21,6 +21,7 @@ const FULL_EVENT_FRAGMENT = gql`
     status
     visibility
     joinOptions
+    externalParticipationUrl
     draft
     language
     category
@@ -121,6 +122,7 @@ export const FETCH_EVENT_BASIC = gql`
       id
       uuid
       joinOptions
+      externalParticipationUrl
       participantStats {
         going
         notApproved
@@ -199,6 +201,7 @@ export const CREATE_EVENT = gql`
     $status: EventStatus
     $visibility: EventVisibility
     $joinOptions: EventJoinOptions
+    $externalParticipationUrl: String
     $draft: Boolean
     $tags: [String]
     $picture: MediaInput
@@ -220,6 +223,7 @@ export const CREATE_EVENT = gql`
       status: $status
       visibility: $visibility
       joinOptions: $joinOptions
+      externalParticipationUrl: $externalParticipationUrl
       draft: $draft
       tags: $tags
       picture: $picture
@@ -247,6 +251,7 @@ export const EDIT_EVENT = gql`
     $status: EventStatus
     $visibility: EventVisibility
     $joinOptions: EventJoinOptions
+    $externalParticipationUrl: String
     $draft: Boolean
     $tags: [String]
     $picture: MediaInput
@@ -269,6 +274,7 @@ export const EDIT_EVENT = gql`
       status: $status
       visibility: $visibility
       joinOptions: $joinOptions
+      externalParticipationUrl: $externalParticipationUrl
       draft: $draft
       tags: $tags
       picture: $picture
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index fa50e35d2..304cb02b5 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -1605,5 +1605,9 @@
   "Reported by an unknown actor": "Reported by an unknown actor",
   "Reported at": "Reported at",
   "Updated at": "Updated at",
-  "Suspend the profile?": "Suspend the profile?"
+  "Suspend the profile?": "Suspend the profile?",
+  "Go to booking": "Go to booking",
+  "External registration": "External registration",
+  "I want to manage the registration with an external provider": "I want to manage the registration with an external provider",
+  "External provider URL": "External provider URL"
 }
\ No newline at end of file
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index bb7233e0d..7c4888a05 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1601,5 +1601,9 @@
   "{username} was invited to {group}": "{username} a été invité à {group}",
   "{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
   "{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
-  "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
+  "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
+  "Go to booking": "Aller à la réservation",
+  "External registration": "Inscription externe",
+  "I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
+  "External provider URL": "URL du fournisseur externe"
 }
diff --git a/js/src/types/enums.ts b/js/src/types/enums.ts
index dc551f3f9..ed7e3ee0f 100644
--- a/js/src/types/enums.ts
+++ b/js/src/types/enums.ts
@@ -64,6 +64,7 @@ export enum EventJoinOptions {
   FREE = "FREE",
   RESTRICTED = "RESTRICTED",
   INVITE = "INVITE",
+  EXTERNAL = "EXTERNAL",
 }
 
 export enum EventVisibilityJoinOptions {
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index 11216edc6..f9b520a85 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -43,6 +43,7 @@ interface IEventEditJSON {
   status: EventStatus;
   visibility: EventVisibility;
   joinOptions: EventJoinOptions;
+  externalParticipationUrl: string | null;
   draft: boolean;
   picture?: IMedia | { mediaId: string } | null;
   attributedToId: string | null;
@@ -72,6 +73,7 @@ export interface IEvent {
   status: EventStatus;
   visibility: EventVisibility;
   joinOptions: EventJoinOptions;
+  externalParticipationUrl: string | null;
   draft: boolean;
 
   picture: IMedia | null;
@@ -132,6 +134,8 @@ export class EventModel implements IEvent {
 
   joinOptions = EventJoinOptions.FREE;
 
+  externalParticipationUrl: string | null = null;
+
   status = EventStatus.CONFIRMED;
 
   draft = true;
@@ -197,6 +201,7 @@ export class EventModel implements IEvent {
     this.status = hash.status;
     this.visibility = hash.visibility;
     this.joinOptions = hash.joinOptions;
+    this.externalParticipationUrl = hash.externalParticipationUrl;
     this.draft = hash.draft;
 
     this.picture = hash.picture;
@@ -248,6 +253,7 @@ export function toEditJSON(event: IEditableEvent): IEventEditJSON {
     category: event.category,
     visibility: event.visibility,
     joinOptions: event.joinOptions,
+    externalParticipationUrl: event.externalParticipationUrl,
     draft: event.draft,
     tags: event.tags.map((t) => t.title),
     onlineAddress: event.onlineAddress,
diff --git a/js/src/views/Event/EditView.vue b/js/src/views/Event/EditView.vue
index 608f8883b..44cd754b5 100644
--- a/js/src/views/Event/EditView.vue
+++ b/js/src/views/Event/EditView.vue
@@ -246,8 +246,25 @@
           </o-radio>
         </div>-->
 
+        <o-field :label="t('External registration')">
+          <o-switch v-model="externalParticipation">
+            {{
+              t("I want to manage the registration with an external provider")
+            }}
+          </o-switch>
+        </o-field>
+
+        <o-field v-if="externalParticipation" :label="t('URL')">
+          <o-input
+            icon="link"
+            type="url"
+            v-model="event.externalParticipationUrl"
+            :placeholder="t('External provider URL')"
+          />
+        </o-field>
+
         <o-field
-          v-if="anonymousParticipationConfig?.allowed"
+          v-if="anonymousParticipationConfig?.allowed && !externalParticipation"
           :label="t('Anonymous participations')"
         >
           <o-switch v-model="eventOptions.anonymousParticipation">
@@ -268,19 +285,22 @@
           </o-switch>
         </o-field>
 
-        <o-field :label="t('Participation approval')">
+        <o-field
+          :label="t('Participation approval')"
+          v-show="!externalParticipation"
+        >
           <o-switch v-model="needsApproval">{{
             t("I want to approve every participation request")
           }}</o-switch>
         </o-field>
 
-        <o-field :label="t('Number of places')">
+        <o-field :label="t('Number of places')" v-show="!externalParticipation">
           <o-switch v-model="limitedPlaces">{{
             t("Limited number of places")
           }}</o-switch>
         </o-field>
 
-        <div class="" v-if="limitedPlaces">
+        <div class="" v-if="limitedPlaces && !externalParticipation">
           <o-field :label="t('Number of places')" label-for="number-of-places">
             <o-input
               type="number"
@@ -1308,6 +1328,19 @@ const orderedCategories = computed(() => {
   if (!eventCategories.value) return undefined;
   return sortBy(eventCategories.value, ["label"]);
 });
+
+const externalParticipation = computed({
+  get() {
+    return event.value?.joinOptions === EventJoinOptions.EXTERNAL;
+  },
+  set(newValue) {
+    if (newValue === true) {
+      event.value.joinOptions = EventJoinOptions.EXTERNAL;
+    } else {
+      event.value.joinOptions = EventJoinOptions.FREE;
+    }
+  },
+});
 </script>
 
 <style lang="scss">
diff --git a/lib/federation/activity_stream/converter/event.ex b/lib/federation/activity_stream/converter/event.ex
index e40aab157..d08c61db0 100644
--- a/lib/federation/activity_stream/converter/event.ex
+++ b/lib/federation/activity_stream/converter/event.ex
@@ -78,6 +78,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
           visibility: visibility,
           join_options: Map.get(object, "joinMode", "free"),
           local: is_local?(object["id"]),
+          external_participation_url: object["externalParticipationUrl"],
           options: options,
           metadata: metadata,
           status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(),
@@ -129,6 +130,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
       "mediaType" => "text/html",
       "startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
       "joinMode" => to_string(event.join_options),
+      "externalParticipationUrl" => event.external_participation_url,
       "endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
       "tag" => event.tags |> build_tags(),
       "maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex
index 42b435334..7ace49bb1 100644
--- a/lib/graphql/schema/event.ex
+++ b/lib/graphql/schema/event.ex
@@ -35,6 +35,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
     field(:status, :event_status, description: "Status of the event")
     field(:visibility, :event_visibility, description: "The event's visibility")
     field(:join_options, :event_join_options, description: "The event's visibility")
+    field(:external_participation_url, :string, description: "External URL for participation")
 
     field(:picture, :media,
       description: "The event's picture",
@@ -130,6 +131,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
     value(:free, description: "Anyone can join and is automatically accepted")
     value(:restricted, description: "Manual acceptation")
     value(:invite, description: "Participants must be invited")
+    value(:external, description: "External registration")
   end
 
   @desc "The list of possible options for the event's status"
@@ -398,6 +400,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
         description: "The event's options to join"
       )
 
+      arg(:external_participation_url, :string, description: "External URL for participation")
+
       arg(:tags, list_of(:string),
         default_value: [],
         description: "The list of tags associated to the event"
@@ -469,6 +473,8 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
         description: "The event's options to join"
       )
 
+      arg(:external_participation_url, :string, description: "External URL for participation")
+
       arg(:tags, list_of(:string), description: "The list of tags associated to the event")
 
       arg(:picture, :media_input,
diff --git a/lib/mobilizon/events/event.ex b/lib/mobilizon/events/event.ex
index 541c3d4b9..3d2f8ad7e 100644
--- a/lib/mobilizon/events/event.ex
+++ b/lib/mobilizon/events/event.ex
@@ -47,6 +47,7 @@ defmodule Mobilizon.Events.Event do
           draft: boolean,
           visibility: atom(),
           join_options: atom(),
+          external_participation_url: String.t(),
           publish_at: DateTime.t() | nil,
           uuid: Ecto.UUID.t(),
           online_address: String.t() | nil,
@@ -81,6 +82,7 @@ defmodule Mobilizon.Events.Event do
     :local,
     :visibility,
     :join_options,
+    :external_participation_url,
     :publish_at,
     :online_address,
     :phone_address,
@@ -105,6 +107,7 @@ defmodule Mobilizon.Events.Event do
     field(:draft, :boolean, default: false)
     field(:visibility, EventVisibility, default: :public)
     field(:join_options, JoinOptions, default: :free)
+    field(:external_participation_url, :string)
     field(:publish_at, :utc_datetime)
     field(:uuid, Ecto.UUID, default: Ecto.UUID.generate())
     field(:online_address, :string)
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index ebcdfa533..7653fda97 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -46,7 +46,8 @@ defmodule Mobilizon.Events do
   defenum(JoinOptions, :join_options, [
     :free,
     :restricted,
-    :invite
+    :invite,
+    :external
   ])
 
   defenum(EventStatus, :event_status, [
diff --git a/priv/repo/migrations/20230901160000_add_external_url_for_events.exs b/priv/repo/migrations/20230901160000_add_external_url_for_events.exs
new file mode 100644
index 000000000..fe0f3e73b
--- /dev/null
+++ b/priv/repo/migrations/20230901160000_add_external_url_for_events.exs
@@ -0,0 +1,33 @@
+defmodule Mobilizon.Storage.Repo.Migrations.AddExternalUrlForEvents do
+  use Ecto.Migration
+  alias Mobilizon.Events.JoinOptions
+
+  def up do
+    alter table(:events) do
+      add(:external_participation_url, :string)
+    end
+
+    reset_join_options_enum()
+  end
+
+  def down do
+    alter table(:events) do
+      remove(:external_participation_url, :string)
+    end
+
+    reset_join_options_enum()
+  end
+
+  defp reset_join_options_enum do
+    execute("ALTER TABLE events ALTER COLUMN join_options TYPE VARCHAR USING join_options::text")
+    execute("ALTER TABLE events ALTER COLUMN join_options DROP DEFAULT")
+    JoinOptions.drop_type()
+    JoinOptions.create_type()
+
+    execute(
+      "ALTER TABLE events ALTER COLUMN join_options TYPE join_options USING join_options::join_options"
+    )
+
+    execute("ALTER TABLE events ALTER COLUMN join_options SET DEFAULT 'free'::join_options")
+  end
+end
diff --git a/schema.graphql b/schema.graphql
index 925df234f..fd4111819 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -1551,6 +1551,9 @@ type RootMutationType {
     "The event's options to join"
     joinOptions: EventJoinOptions
 
+    "External URL for participation"
+    externalParticipationUrl: String
+
     "The list of tags associated to the event"
     tags: [String]
 
@@ -1620,6 +1623,9 @@ type RootMutationType {
     "The event's options to join"
     joinOptions: EventJoinOptions
 
+    "External URL for participation"
+    externalParticipationUrl: String
+
     "The list of tags associated to the event"
     tags: [String]
 
@@ -2956,6 +2962,9 @@ type Event implements ActivityObject & Interactable & ActionLogObject {
   "The event's visibility"
   joinOptions: EventJoinOptions
 
+  "External URL for participation"
+  externalParticipationUrl: String
+
   "The event's picture"
   picture: Media
 
@@ -3144,6 +3153,9 @@ enum EventJoinOptions {
 
   "Participants must be invited"
   INVITE
+
+  "External registration"
+  EXTERNAL
 }
 
 type InstanceFeeds {

From af670f39478b11465205fbea9b9268bab401bbb6 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 1 Sep 2023 17:38:11 +0200
Subject: [PATCH 6/7] fix(i18n): add missing translations

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/i18n/en_US.json | 3 ++-
 js/src/i18n/fr_FR.json | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 304cb02b5..312160de2 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -1609,5 +1609,6 @@
   "Go to booking": "Go to booking",
   "External registration": "External registration",
   "I want to manage the registration with an external provider": "I want to manage the registration with an external provider",
-  "External provider URL": "External provider URL"
+  "External provider URL": "External provider URL",
+  "Members will also access private sections like discussions, resources and restricted posts.": "Members will also access private sections like discussions, resources and restricted posts."
 }
\ No newline at end of file
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index 7c4888a05..fb16e932d 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1605,5 +1605,6 @@
   "Go to booking": "Aller à la réservation",
   "External registration": "Inscription externe",
   "I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
-  "External provider URL": "URL du fournisseur externe"
+  "External provider URL": "URL du fournisseur externe",
+  "Members will also access private sections like discussions, resources and restricted posts.": "Les membres auront également accès aux section privées comme les discussions, les ressources et les billets restreints."
 }

From f6611e8eb5a7e12dc0dc0c216b598e04144e07c6 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 1 Sep 2023 18:16:06 +0200
Subject: [PATCH 7/7] feat(back): add admin setting to disable external event
 feature

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 config/config.exs               |  5 ++++-
 js/src/graphql/config.ts        |  2 ++
 js/src/types/config.model.ts    |  1 +
 js/src/views/Event/EditView.vue |  6 +++++-
 lib/graphql/resolvers/config.ex |  1 +
 lib/graphql/resolvers/event.ex  | 27 +++++++++++++++++++++++++++
 lib/graphql/schema/config.ex    |  4 ++++
 lib/mobilizon/config.ex         |  4 ++++
 8 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/config/config.exs b/config/config.exs
index d2a0f9214..8cb7c203a 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -41,7 +41,10 @@ config :mobilizon, :instance,
   email_reply_to: "noreply@localhost"
 
 config :mobilizon, :groups, enabled: true
-config :mobilizon, :events, creation: true
+
+config :mobilizon, :events,
+  creation: true,
+  external: true
 
 config :mobilizon, :restrictions, only_admin_can_create_groups: false
 config :mobilizon, :restrictions, only_groups_can_create_events: false
diff --git a/js/src/graphql/config.ts b/js/src/graphql/config.ts
index 1022b7845..b7e4c7ba3 100644
--- a/js/src/graphql/config.ts
+++ b/js/src/graphql/config.ts
@@ -72,6 +72,7 @@ export const CONFIG = gql`
       features {
         groups
         eventCreation
+        eventExternal
         antispam
       }
       restrictions {
@@ -370,6 +371,7 @@ export const FEATURES = gql`
       features {
         groups
         eventCreation
+        eventExternal
         antispam
       }
     }
diff --git a/js/src/types/config.model.ts b/js/src/types/config.model.ts
index 8548fb13d..e34c39c0a 100644
--- a/js/src/types/config.model.ts
+++ b/js/src/types/config.model.ts
@@ -96,6 +96,7 @@ export interface IConfig {
   timezones: string[];
   features: {
     eventCreation: boolean;
+    eventExternal: boolean;
     groups: boolean;
     antispam: boolean;
   };
diff --git a/js/src/views/Event/EditView.vue b/js/src/views/Event/EditView.vue
index 44cd754b5..a83fb61b3 100644
--- a/js/src/views/Event/EditView.vue
+++ b/js/src/views/Event/EditView.vue
@@ -246,7 +246,10 @@
           </o-radio>
         </div>-->
 
-        <o-field :label="t('External registration')">
+        <o-field
+          :label="t('External registration')"
+          v-if="features?.eventExternal"
+        >
           <o-switch v-model="externalParticipation">
             {{
               t("I want to manage the registration with an external provider")
@@ -260,6 +263,7 @@
             type="url"
             v-model="event.externalParticipationUrl"
             :placeholder="t('External provider URL')"
+            required
           />
         </o-field>
 
diff --git a/lib/graphql/resolvers/config.ex b/lib/graphql/resolvers/config.ex
index 1241872c3..b66afd1a5 100644
--- a/lib/graphql/resolvers/config.ex
+++ b/lib/graphql/resolvers/config.ex
@@ -145,6 +145,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
       features: %{
         groups: Config.instance_group_feature_enabled?(),
         event_creation: Config.instance_event_creation_enabled?(),
+        event_external: Config.instance_event_external_enabled?(),
         antispam: AntiSpam.service().ready?()
       },
       restrictions: %{
diff --git a/lib/graphql/resolvers/event.ex b/lib/graphql/resolvers/event.ex
index aaf18ef8c..16a86aa49 100644
--- a/lib/graphql/resolvers/event.ex
+++ b/lib/graphql/resolvers/event.ex
@@ -254,6 +254,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
 
     with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id),
          {:can_create_event, true} <- can_create_event(args),
+         {:event_external, true} <- edit_event_external_checker(args),
          {:organizer_group_member, true} <-
            {:organizer_group_member, is_organizer_group_member?(args)},
          args_with_organizer <-
@@ -281,6 +282,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
            "Only groups can create events"
          )}
 
+      {:event_external, false} ->
+        {:error,
+         dgettext(
+           "errors",
+           "Providing external registration is not allowed"
+         )}
+
       {:organizer_group_member, false} ->
         {:error,
          dgettext(
@@ -322,6 +330,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
     end
   end
 
+  @spec edit_event_external_checker(map()) :: {:event_external, boolean()}
+  defp edit_event_external_checker(args) do
+    if Config.instance_event_external_enabled?() do
+      {:event_external, true}
+    else
+      {:event_external,
+       Map.get(args, :join_options) != :external and
+         is_nil(Map.get(args, :external_participation_url))}
+    end
+  end
+
   @doc """
   Update an event
   """
@@ -340,6 +359,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
          args <- extract_timezone(args, user.id),
          {:event_can_be_managed, true} <-
            {:event_can_be_managed, can_event_be_updated_by?(event, actor)},
+         {:event_external, true} <- edit_event_external_checker(args),
          {:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <-
            API.Events.update_event(args, event) do
       {:ok, event}
@@ -351,6 +371,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
            "This profile doesn't have permission to update an event on behalf of this group"
          )}
 
+      {:event_external, false} ->
+        {:error,
+         dgettext(
+           "errors",
+           "Providing external registration is not allowed"
+         )}
+
       {:error, :event_not_found} ->
         {:error, dgettext("errors", "Event not found")}
 
diff --git a/lib/graphql/schema/config.ex b/lib/graphql/schema/config.ex
index cc42f1b1a..59579a6e3 100644
--- a/lib/graphql/schema/config.ex
+++ b/lib/graphql/schema/config.ex
@@ -314,6 +314,10 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
       description: "Whether event creation is allowed on this instance"
     )
 
+    field(:event_external, :boolean,
+      description: "Whether redirecting to external providers is authorized in event edition"
+    )
+
     field(:antispam, :boolean, description: "Whether anti-spam is activated on this instance")
   end
 
diff --git a/lib/mobilizon/config.ex b/lib/mobilizon/config.ex
index e27d7c9a2..92a29544e 100644
--- a/lib/mobilizon/config.ex
+++ b/lib/mobilizon/config.ex
@@ -357,6 +357,10 @@ defmodule Mobilizon.Config do
   def instance_event_creation_enabled?,
     do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:creation)
 
+  @spec instance_event_external_enabled? :: boolean
+  def instance_event_external_enabled?,
+    do: :mobilizon |> Application.get_env(:events) |> Keyword.get(:external)
+
   @spec instance_export_formats :: %{event_participants: list(String.t())}
   def instance_export_formats do
     %{