From cde9f8873e87d2d2211108239026884171dbcb92 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 26 Mar 2021 19:01:55 +0100
Subject: [PATCH 1/2] Expose personal tokened feeds

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/Settings/SettingsMenu.vue   |   2 +-
 js/src/graphql/user.ts                        |  14 ++
 js/src/i18n/en_US.json                        |  10 +-
 js/src/i18n/fr_FR.json                        |  10 +-
 .../views/Account/children/EditIdentity.vue   | 139 ++++++++++++++++
 js/src/views/Settings/Notifications.vue       | 151 +++++++++++++++++-
 lib/graphql/resolvers/feed_token.ex           |  14 +-
 lib/graphql/schema/actors/person.ex           |  10 +-
 lib/graphql/schema/events/feed_token.ex       |   2 +-
 lib/graphql/schema/user.ex                    |  10 +-
 lib/service/export/common.ex                  |   5 +-
 lib/web/controllers/feed_controller.ex        |   4 +-
 test/graphql/resolvers/feed_token_test.exs    |   8 +-
 test/service/export/icalendar_test.exs        |   2 +-
 test/web/controllers/feed_controller_test.exs |   8 +-
 15 files changed, 363 insertions(+), 26 deletions(-)

diff --git a/js/src/components/Settings/SettingsMenu.vue b/js/src/components/Settings/SettingsMenu.vue
index e6aa18ba3..bbf96cdcb 100644
--- a/js/src/components/Settings/SettingsMenu.vue
+++ b/js/src/components/Settings/SettingsMenu.vue
@@ -14,7 +14,7 @@
           :to="{ name: RouteName.PREFERENCES }"
         />
         <SettingMenuItem
-          :title="this.$t('Email notifications')"
+          :title="this.$t('Notifications')"
           :to="{ name: RouteName.NOTIFICATIONS }"
         />
       </SettingMenuSection>
diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts
index b794ef1a8..1eba90fbb 100644
--- a/js/src/graphql/user.ts
+++ b/js/src/graphql/user.ts
@@ -241,3 +241,17 @@ export const UPDATE_USER_LOCALE = gql`
     }
   }
 `;
+
+export const FEED_TOKENS_LOGGED_USER = gql`
+  query {
+    loggedUser {
+      id
+      feedTokens {
+        token
+        actor {
+          id
+        }
+      }
+    }
+  }
+`;
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 0953e0c0b..a5410a33e 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -969,5 +969,13 @@
   "You replied to a comment on the event {event}.": "You replied to a comment on the event {event}.",
   "{profile} replied to a comment on the event {event}.": "{profile} replied to a comment on the event {event}.",
   "New post": "New post",
-  "Comment text can't be empty": "Comment text can't be empty"
+  "Comment text can't be empty": "Comment text can't be empty",
+  "Notifications": "Notifications",
+  "Profile feeds": "Profile feeds",
+  "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.",
+  "Regenerate new links": "Regenerate new links",
+  "Create new links": "Create new links",
+  "You'll need to change the URLs where there were previously entered.": "You'll need to change the URLs where there were previously entered.",
+  "Personal feeds": "Personal feeds",
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index 992fc10ea..cdbfda87c 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1063,5 +1063,13 @@
   "You replied to a comment on the event {event}.": "Vous avez répondu à un commentaire sur l'événement {event}.",
   "{profile} replied to a comment on the event {event}.": "{profile} a répondu à un commentaire sur l'événement {event}.",
   "New post": "Nouveau billet",
-  "Comment text can't be empty": "Le texte du commentaire ne peut être vide"
+  "Comment text can't be empty": "Le texte du commentaire ne peut être vide",
+  "Notifications": "Notifications",
+  "Profile feeds": "Flux du profil",
+  "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
+  "Regenerate new links": "Regénérer de nouveaux liens",
+  "Create new links": "Créer de nouveaux liens",
+  "You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
+  "Personal feeds": "Flux personnels",
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils."
 }
diff --git a/js/src/views/Account/children/EditIdentity.vue b/js/src/views/Account/children/EditIdentity.vue
index bb6c44dc5..5bf8670a4 100644
--- a/js/src/views/Account/children/EditIdentity.vue
+++ b/js/src/views/Account/children/EditIdentity.vue
@@ -98,6 +98,77 @@
           $t("Delete this identity")
         }}</span>
       </div>
+
+      <section v-if="isUpdate">
+        <div class="setting-title">
+          <h2>{{ $t("Profile feeds") }}</h2>
+        </div>
+        <p>
+          {{
+            $t(
+              "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings."
+            )
+          }}
+        </p>
+        <div v-if="identity.feedTokens && identity.feedTokens.length > 0">
+          <div
+            class="buttons"
+            v-for="feedToken in identity.feedTokens"
+            :key="feedToken.token"
+          >
+            <b-tooltip
+              :label="$t('URL copied to clipboard')"
+              :active="showCopiedTooltip.atom"
+              always
+              type="is-success"
+              position="is-left"
+            >
+              <b-button
+                tag="a"
+                icon-left="rss"
+                @click="
+                  (e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
+                "
+                :href="tokenToURL(feedToken.token, 'atom')"
+                target="_blank"
+                >{{ $t("RSS/Atom Feed") }}</b-button
+              >
+            </b-tooltip>
+            <b-tooltip
+              :label="$t('URL copied to clipboard')"
+              :active="showCopiedTooltip.ics"
+              always
+              type="is-success"
+              position="is-left"
+            >
+              <b-button
+                tag="a"
+                @click="
+                  (e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
+                "
+                icon-left="calendar-sync"
+                :href="tokenToURL(feedToken.token, 'ics')"
+                target="_blank"
+                >{{ $t("ICS/WebCal Feed") }}</b-button
+              >
+            </b-tooltip>
+            <b-button
+              icon-left="refresh"
+              type="is-text"
+              @click="openRegenerateFeedTokensConfirmation"
+              >{{ $t("Regenerate new links") }}</b-button
+            >
+          </div>
+        </div>
+        <div v-else>
+          <b-button
+            icon-left="refresh"
+            type="is-text"
+            @click="generateFeedTokens"
+            >{{ $t("Create new links") }}</b-button
+          >
+        </div>
+      </section>
     </div>
   </div>
 </template>
@@ -131,6 +202,10 @@ h1 {
 .username-field + .field {
   margin-bottom: 0;
 }
+
+::v-deep .buttons > *:not(:last-child) .button {
+  margin-right: 0.5rem;
+}
 </style>
 
 <script lang="ts">
@@ -151,6 +226,11 @@ import RouteName from "../../../router/name";
 import { buildFileVariable } from "../../../utils/image";
 import { changeIdentity } from "../../../utils/auth";
 import identityEditionMixin from "../../../mixins/identityEdition";
+import {
+  CREATE_FEED_TOKEN_ACTOR,
+  DELETE_FEED_TOKEN,
+} from "@/graphql/feed_tokens";
+import { IFeedToken } from "@/types/feedtoken.model";
 
 @Component({
   components: {
@@ -191,6 +271,8 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
 
   RouteName = RouteName;
 
+  showCopiedTooltip = { ics: false, atom: false };
+
   get message(): string | null {
     if (this.isUpdate) return null;
     return this.$t(
@@ -353,6 +435,63 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
     return MOBILIZON_INSTANCE_HOST;
   }
 
+  tokenToURL(token: string, format: string): string {
+    return `${window.location.origin}/events/going/${token}/${format}`;
+  }
+
+  copyURL(e: Event, url: string, format: "ics" | "atom"): void {
+    if (navigator.clipboard) {
+      e.preventDefault();
+      navigator.clipboard.writeText(url);
+      this.showCopiedTooltip[format] = true;
+      setTimeout(() => {
+        this.showCopiedTooltip[format] = false;
+      }, 2000);
+    }
+  }
+
+  async generateFeedTokens(): Promise<void> {
+    const newToken = await this.createNewFeedToken();
+    this.identity.feedTokens.push(newToken);
+  }
+
+  async regenerateFeedTokens(): Promise<void> {
+    if (this.identity?.feedTokens.length < 1) return;
+    await this.deleteFeedToken(this.identity.feedTokens[0].token);
+    const newToken = await this.createNewFeedToken();
+    this.identity.feedTokens.pop();
+    this.identity.feedTokens.push(newToken);
+  }
+
+  private async deleteFeedToken(token: string): Promise<void> {
+    await this.$apollo.mutate({
+      mutation: DELETE_FEED_TOKEN,
+      variables: { token },
+    });
+  }
+
+  private async createNewFeedToken(): Promise<IFeedToken> {
+    const { data } = await this.$apollo.mutate({
+      mutation: CREATE_FEED_TOKEN_ACTOR,
+      variables: { actor_id: this.identity?.id },
+    });
+
+    return data.createFeedToken;
+  }
+
+  openRegenerateFeedTokensConfirmation(): void {
+    this.$buefy.dialog.confirm({
+      type: "is-warning",
+      title: this.$t("Regenerate new links") as string,
+      message: this.$t(
+        "You'll need to change the URLs where there were previously entered."
+      ) as string,
+      confirmText: this.$t("Regenerate new links") as string,
+      cancelText: this.$t("Cancel") as string,
+      onConfirm: () => this.regenerateFeedTokens(),
+    });
+  }
+
   openDeleteIdentityConfirmation(): void {
     this.$buefy.dialog.prompt({
       type: "is-danger",
diff --git a/js/src/views/Settings/Notifications.vue b/js/src/views/Settings/Notifications.vue
index c204471a9..c6e697b1d 100644
--- a/js/src/views/Settings/Notifications.vue
+++ b/js/src/views/Settings/Notifications.vue
@@ -9,7 +9,7 @@
         </li>
         <li class="is-active">
           <router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
-            $t("Email notifications")
+            $t("Notifications")
           }}</router-link>
         </li>
       </ul>
@@ -118,23 +118,108 @@
         </b-select>
       </div>
     </section>
+    <section>
+      <div class="setting-title">
+        <h2>{{ $t("Personal feeds") }}</h2>
+      </div>
+      <p>
+        {{
+          $t(
+            "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
+          )
+        }}
+      </p>
+      <div v-if="feedTokens && feedTokens.length > 0">
+        <div
+          class="buttons"
+          v-for="feedToken in feedTokens"
+          :key="feedToken.token"
+        >
+          <b-tooltip
+            :label="$t('URL copied to clipboard')"
+            :active="showCopiedTooltip.atom"
+            always
+            type="is-success"
+            position="is-left"
+          >
+            <b-button
+              tag="a"
+              icon-left="rss"
+              @click="
+                (e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
+              "
+              :href="tokenToURL(feedToken.token, 'atom')"
+              target="_blank"
+              >{{ $t("RSS/Atom Feed") }}</b-button
+            >
+          </b-tooltip>
+          <b-tooltip
+            :label="$t('URL copied to clipboard')"
+            :active="showCopiedTooltip.ics"
+            always
+            type="is-success"
+            position="is-left"
+          >
+            <b-button
+              tag="a"
+              @click="
+                (e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
+              "
+              icon-left="calendar-sync"
+              :href="tokenToURL(feedToken.token, 'ics')"
+              target="_blank"
+              >{{ $t("ICS/WebCal Feed") }}</b-button
+            >
+          </b-tooltip>
+          <b-button
+            icon-left="refresh"
+            type="is-text"
+            @click="openRegenerateFeedTokensConfirmation"
+            >{{ $t("Regenerate new links") }}</b-button
+          >
+        </div>
+      </div>
+      <div v-else>
+        <b-button
+          icon-left="refresh"
+          type="is-text"
+          @click="generateFeedTokens"
+          >{{ $t("Create new links") }}</b-button
+        >
+      </div>
+    </section>
   </div>
 </template>
 <script lang="ts">
 import { Component, Vue, Watch } from "vue-property-decorator";
 import { INotificationPendingEnum } from "@/types/enums";
-import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
+import {
+  USER_SETTINGS,
+  SET_USER_SETTINGS,
+  FEED_TOKENS_LOGGED_USER,
+} from "../../graphql/user";
 import { IUser } from "../../types/current-user.model";
 import RouteName from "../../router/name";
+import { IFeedToken } from "@/types/feedtoken.model";
+import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
 
 @Component({
   apollo: {
     loggedUser: USER_SETTINGS,
+    feedTokens: {
+      query: FEED_TOKENS_LOGGED_USER,
+      update: (data) =>
+        data.loggedUser.feedTokens.filter(
+          (token: IFeedToken) => token.actor === null
+        ),
+    },
   },
 })
 export default class Notifications extends Vue {
   loggedUser!: IUser;
 
+  feedTokens: IFeedToken[] = [];
+
   notificationOnDay: boolean | undefined = true;
 
   notificationEachWeek: boolean | undefined = false;
@@ -148,6 +233,8 @@ export default class Notifications extends Vue {
 
   RouteName = RouteName;
 
+  showCopiedTooltip = { ics: false, atom: false };
+
   mounted(): void {
     this.notificationPendingParticipationValues = {
       [INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
@@ -176,6 +263,62 @@ export default class Notifications extends Vue {
       refetchQueries: [{ query: USER_SETTINGS }],
     });
   }
+
+  tokenToURL(token: string, format: string): string {
+    return `${window.location.origin}/events/going/${token}/${format}`;
+  }
+
+  copyURL(e: Event, url: string, format: "ics" | "atom"): void {
+    if (navigator.clipboard) {
+      e.preventDefault();
+      navigator.clipboard.writeText(url);
+      this.showCopiedTooltip[format] = true;
+      setTimeout(() => {
+        this.showCopiedTooltip[format] = false;
+      }, 2000);
+    }
+  }
+
+  openRegenerateFeedTokensConfirmation(): void {
+    this.$buefy.dialog.confirm({
+      type: "is-warning",
+      title: this.$t("Regenerate new links") as string,
+      message: this.$t(
+        "You'll need to change the URLs where there were previously entered."
+      ) as string,
+      confirmText: this.$t("Regenerate new links") as string,
+      cancelText: this.$t("Cancel") as string,
+      onConfirm: () => this.regenerateFeedTokens(),
+    });
+  }
+
+  async regenerateFeedTokens(): Promise<void> {
+    if (this.feedTokens.length < 1) return;
+    await this.deleteFeedToken(this.feedTokens[0].token);
+    const newToken = await this.createNewFeedToken();
+    this.feedTokens.pop();
+    this.feedTokens.push(newToken);
+  }
+
+  async generateFeedTokens(): Promise<void> {
+    const newToken = await this.createNewFeedToken();
+    this.feedTokens.push(newToken);
+  }
+
+  private async deleteFeedToken(token: string): Promise<void> {
+    await this.$apollo.mutate({
+      mutation: DELETE_FEED_TOKEN,
+      variables: { token },
+    });
+  }
+
+  private async createNewFeedToken(): Promise<IFeedToken> {
+    const { data } = await this.$apollo.mutate({
+      mutation: CREATE_FEED_TOKEN,
+    });
+
+    return data.createFeedToken;
+  }
 }
 </script>
 
@@ -193,4 +336,8 @@ export default class Notifications extends Vue {
     margin-left: 5px;
   }
 }
+
+::v-deep .buttons > *:not(:last-child) .button {
+  margin-right: 0.5rem;
+}
 </style>
diff --git a/lib/graphql/resolvers/feed_token.ex b/lib/graphql/resolvers/feed_token.ex
index ed545ed3a..196da066c 100644
--- a/lib/graphql/resolvers/feed_token.ex
+++ b/lib/graphql/resolvers/feed_token.ex
@@ -22,7 +22,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
       ) do
     with {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id),
          {:ok, feed_token} <- Events.create_feed_token(%{user_id: id, actor_id: actor_id}) do
-      {:ok, feed_token}
+      {:ok, to_short_uuid(feed_token)}
     else
       {:is_owned, nil} ->
         {:error, dgettext("errors", "Profile is not owned by authenticated user")}
@@ -32,7 +32,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
   @spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()}
   def create_feed_token(_parent, %{}, %{context: %{current_user: %User{id: id}}}) do
     with {:ok, feed_token} <- Events.create_feed_token(%{user_id: id}) do
-      {:ok, feed_token}
+      {:ok, to_short_uuid(feed_token)}
     end
   end
 
@@ -50,7 +50,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
         %{token: token},
         %{context: %{current_user: %User{id: id} = _user}}
       ) do
-    with {:ok, token} <- Ecto.UUID.cast(token),
+    with {:ok, token} <- ShortUUID.decode(token),
+         {:ok, token} <- Ecto.UUID.cast(token),
          {:no_token, %FeedToken{actor: actor, user: %User{} = user} = feed_token} <-
            {:no_token, Events.get_feed_token(token)},
          {:token_from_user, true} <- {:token_from_user, id == user.id},
@@ -65,6 +66,9 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
       :error ->
         {:error, dgettext("errors", "Token is not a valid UUID")}
 
+      {:error, "Invalid input"} ->
+        {:error, dgettext("errors", "Token is not a valid UUID")}
+
       {:no_token, _} ->
         {:error, dgettext("errors", "Token does not exist")}
 
@@ -77,4 +81,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
   def delete_feed_token(_parent, _args, %{}) do
     {:error, dgettext("errors", "You are not allowed to delete a feed token if not connected")}
   end
+
+  defp to_short_uuid(%FeedToken{token: token} = feed_token) do
+    %FeedToken{feed_token | token: ShortUUID.encode!(token)}
+  end
 end
diff --git a/lib/graphql/schema/actors/person.ex b/lib/graphql/schema/actors/person.ex
index fd8aedf03..42743316f 100644
--- a/lib/graphql/schema/actors/person.ex
+++ b/lib/graphql/schema/actors/person.ex
@@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
   """
   use Absinthe.Schema.Notation
 
-  import Absinthe.Resolution.Helpers, only: [dataloader: 1]
+  import Absinthe.Resolution.Helpers, only: [dataloader: 2]
 
   alias Mobilizon.Events
   alias Mobilizon.GraphQL.Resolvers.{Media, Person}
@@ -53,7 +53,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
     )
 
     field(:feed_tokens, list_of(:feed_token),
-      resolve: dataloader(Events),
+      resolve:
+        dataloader(
+          Events,
+          callback: fn feed_tokens, _parent, _args ->
+            {:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
+          end
+        ),
       description: "A list of the feed tokens for this person"
     )
 
diff --git a/lib/graphql/schema/events/feed_token.ex b/lib/graphql/schema/events/feed_token.ex
index 44ea96289..c50880574 100644
--- a/lib/graphql/schema/events/feed_token.ex
+++ b/lib/graphql/schema/events/feed_token.ex
@@ -31,7 +31,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.FeedTokenType do
       description: "The actor that participates to the event"
     )
 
-    field(:token, :string, description: "The role of this actor at this event")
+    field(:token, :string, description: "A ShortUUID private token")
   end
 
   @desc "Represents a deleted feed_token"
diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex
index af62f798b..e8236c897 100644
--- a/lib/graphql/schema/user.ex
+++ b/lib/graphql/schema/user.ex
@@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
   """
   use Absinthe.Schema.Notation
 
-  import Absinthe.Resolution.Helpers, only: [dataloader: 1]
+  import Absinthe.Resolution.Helpers, only: [dataloader: 2]
 
   alias Mobilizon.Events
   alias Mobilizon.GraphQL.Resolvers.{Media, User}
@@ -43,7 +43,13 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
     )
 
     field(:feed_tokens, list_of(:feed_token),
-      resolve: dataloader(Events),
+      resolve:
+        dataloader(
+          Events,
+          callback: fn feed_tokens, _parent, _args ->
+            {:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
+          end
+        ),
       description: "A list of the feed tokens for this user"
     )
 
diff --git a/lib/service/export/common.ex b/lib/service/export/common.ex
index 26efe9b38..2b9a35692 100644
--- a/lib/service/export/common.ex
+++ b/lib/service/export/common.ex
@@ -25,8 +25,9 @@ defmodule Mobilizon.Service.Export.Common do
   # Only events, not posts
   @spec fetch_events_from_token(String.t()) :: String.t()
   def fetch_events_from_token(token) do
-    with {:ok, _uuid} <- Ecto.UUID.cast(token),
-         %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do
+    with {:ok, uuid} <- ShortUUID.decode(token),
+         {:ok, _uuid} <- Ecto.UUID.cast(uuid),
+         %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(uuid) do
       case actor do
         %Actor{} = actor ->
           %{
diff --git a/lib/web/controllers/feed_controller.ex b/lib/web/controllers/feed_controller.ex
index 87b8181df..4de8e61b4 100644
--- a/lib/web/controllers/feed_controller.ex
+++ b/lib/web/controllers/feed_controller.ex
@@ -26,7 +26,7 @@ defmodule Mobilizon.Web.FeedController do
   end
 
   def event(conn, %{"uuid" => uuid, "format" => "ics"}) do
-    return_data(conn, "ics", "event_" <> uuid, "event.ics")
+    return_data(conn, "ics", "event_" <> uuid, "event")
   end
 
   def event(_conn, _) do
@@ -34,7 +34,7 @@ defmodule Mobilizon.Web.FeedController do
   end
 
   def going(conn, %{"token" => token, "format" => format}) when format in @formats do
-    return_data(conn, format, "token_" <> token, "events.#{format}")
+    return_data(conn, format, "token_" <> token, "events")
   end
 
   def going(_conn, _) do
diff --git a/test/graphql/resolvers/feed_token_test.exs b/test/graphql/resolvers/feed_token_test.exs
index 931c8c342..bc2d353ce 100644
--- a/test/graphql/resolvers/feed_token_test.exs
+++ b/test/graphql/resolvers/feed_token_test.exs
@@ -186,7 +186,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
                %{
                  "feedTokens" => [
                    %{
-                     "token" => feed_token.token
+                     "token" => ShortUUID.encode!(feed_token.token)
                    }
                  ]
                }
@@ -194,7 +194,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{feed_token.token}",
+              token: "#{ShortUUID.encode!(feed_token.token)}",
             ) {
                 actor {
                   id
@@ -270,7 +270,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{feed_token.token}",
+              token: "#{ShortUUID.encode!(feed_token.token)}",
             ) {
                 actor {
                   id
@@ -320,7 +320,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
       mutation = """
           mutation {
             deleteFeedToken(
-              token: "#{uuid}"
+              token: "#{ShortUUID.encode!(uuid)}"
             ) {
                 actor {
                   id
diff --git a/test/service/export/icalendar_test.exs b/test/service/export/icalendar_test.exs
index c3aceed1c..b50351e12 100644
--- a/test/service/export/icalendar_test.exs
+++ b/test/service/export/icalendar_test.exs
@@ -58,7 +58,7 @@ defmodule Mobilizon.Service.ICalendarTest do
       event = insert(:event)
       insert(:participant, event: event, actor: actor, role: :participant)
 
-      {:commit, ics} = ICalendarService.create_cache("token_#{token}")
+      {:commit, ics} = ICalendarService.create_cache("token_#{ShortUUID.encode!(token)}")
       assert ics =~ event.title
     end
   end
diff --git a/test/web/controllers/feed_controller_test.exs b/test/web/controllers/feed_controller_test.exs
index 1b56dabc3..c4335dfbc 100644
--- a/test/web/controllers/feed_controller_test.exs
+++ b/test/web/controllers/feed_controller_test.exs
@@ -225,7 +225,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         conn
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "atom")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
           |> URI.decode()
         )
 
@@ -260,7 +260,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "application/atom+xml")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "atom")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
           |> URI.decode()
         )
 
@@ -307,7 +307,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "text/calendar")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "ics")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
           |> URI.decode()
         )
 
@@ -338,7 +338,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
         |> put_req_header("accept", "text/calendar")
         |> get(
           Endpoint
-          |> Routes.feed_url(:going, feed_token.token, "ics")
+          |> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
           |> URI.decode()
         )
 

From 13c80800975037202b9c6893e66add98b1cd83e6 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 29 Mar 2021 10:33:19 +0200
Subject: [PATCH 2/2] Allow to create an event from a group preconfigured with
 the organizer

Refactored the organizer-picker components a lot

Close #464

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/Event/OrganizerPicker.vue   |  58 +++---
 .../Event/OrganizerPickerWrapper.vue          | 178 +++++++++---------
 js/src/graphql/actor.ts                       |  52 +----
 js/src/i18n/en_US.json                        |   4 +-
 js/src/i18n/fr_FR.json                        |   4 +-
 js/src/views/Event/Edit.vue                   |  80 +++++---
 js/src/views/Group/Group.vue                  |   1 +
 lib/graphql/resolvers/person.ex               |   5 +-
 8 files changed, 194 insertions(+), 188 deletions(-)

diff --git a/js/src/components/Event/OrganizerPicker.vue b/js/src/components/Event/OrganizerPicker.vue
index 9b3bb50e3..41f61b1c0 100644
--- a/js/src/components/Event/OrganizerPicker.vue
+++ b/js/src/components/Event/OrganizerPicker.vue
@@ -1,11 +1,11 @@
 <template>
   <div class="list is-hoverable">
     <b-radio-button
-      v-model="currentActor"
+      v-model="selectedActor"
       :native-value="availableActor"
       class="list-item"
       v-for="availableActor in actualAvailableActors"
-      :class="{ 'is-active': availableActor.id === currentActor.id }"
+      :class="{ 'is-active': availableActor.id === selectedActor.id }"
       :key="availableActor.id"
     >
       <div class="media">
@@ -31,9 +31,13 @@
   </div>
 </template>
 <script lang="ts">
-import { Component, Prop, Vue, Watch } from "vue-property-decorator";
+import { Component, Prop, Vue } from "vue-property-decorator";
 import { IPerson, IActor, Actor } from "@/types/actor";
-import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
+import {
+  CURRENT_ACTOR_CLIENT,
+  IDENTITIES,
+  LOGGED_USER_MEMBERSHIPS,
+} from "@/graphql/actor";
 import { Paginate } from "@/types/paginate";
 import { IMember } from "@/types/actor/member.model";
 import { MemberRole } from "@/types/enums";
@@ -41,29 +45,37 @@ import { MemberRole } from "@/types/enums";
 @Component({
   apollo: {
     groupMemberships: {
-      query: PERSON_MEMBERSHIPS,
-      variables() {
-        return {
-          id: this.identity.id,
-        };
-      },
-      update: (data) => data.person.memberships,
-      skip() {
-        return !this.identity.id;
-      },
+      query: LOGGED_USER_MEMBERSHIPS,
+      update: (data) => data.loggedUser.memberships,
     },
+    identities: IDENTITIES,
+    currentActor: CURRENT_ACTOR_CLIENT,
   },
 })
 export default class OrganizerPicker extends Vue {
   @Prop() value!: IActor;
 
-  @Prop() identity!: IPerson;
-
   @Prop({ required: false, default: false }) restrictModeratorLevel!: boolean;
 
   groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
 
-  currentActor: IActor = this.value;
+  currentActor!: IPerson;
+
+  get selectedActor(): IActor | undefined {
+    if (this.value?.id) {
+      return this.value;
+    }
+    if (this.currentActor) {
+      return this.currentActor;
+    }
+    return undefined;
+  }
+
+  set selectedActor(actor: IActor | undefined) {
+    this.$emit("input", actor);
+  }
+
+  identities: IActor[] = [];
 
   Actor = Actor;
 
@@ -82,14 +94,12 @@ export default class OrganizerPicker extends Vue {
 
   get actualAvailableActors(): IActor[] {
     return [
-      this.identity,
+      this.currentActor,
+      ...this.identities.filter(
+        (identity: IActor) => identity.id !== this.currentActor?.id
+      ),
       ...this.actualMemberships.map((member) => member.parent),
-    ];
-  }
-
-  @Watch("currentActor")
-  async fetchMembersForGroup(): Promise<void> {
-    this.$emit("input", this.currentActor);
+    ].filter((elem) => elem);
   }
 }
 </script>
diff --git a/js/src/components/Event/OrganizerPickerWrapper.vue b/js/src/components/Event/OrganizerPickerWrapper.vue
index 1f7b766c7..e1af655dc 100644
--- a/js/src/components/Event/OrganizerPickerWrapper.vue
+++ b/js/src/components/Event/OrganizerPickerWrapper.vue
@@ -1,30 +1,30 @@
 <template>
-  <div class="organizer-picker">
+  <div class="organizer-picker" v-if="selectedActor">
     <!-- If we have a current actor (inline) -->
     <div
-      v-if="inline && currentActor.id"
+      v-if="inline && selectedActor.id"
       class="inline box"
       @click="isComponentModalActive = true"
     >
       <div class="media">
         <div class="media-left">
-          <figure class="image is-48x48" v-if="currentActor.avatar">
+          <figure class="image is-48x48" v-if="selectedActor.avatar">
             <img
               class="image is-rounded"
-              :src="currentActor.avatar.url"
-              :alt="currentActor.avatar.alt"
+              :src="selectedActor.avatar.url"
+              :alt="selectedActor.avatar.alt"
             />
           </figure>
           <b-icon v-else size="is-large" icon="account-circle" />
         </div>
-        <div class="media-content" v-if="currentActor.name">
-          <p class="is-4">{{ currentActor.name }}</p>
+        <div class="media-content" v-if="selectedActor.name">
+          <p class="is-4">{{ selectedActor.name }}</p>
           <p class="is-6 has-text-grey">
-            {{ `@${currentActor.preferredUsername}` }}
+            {{ `@${selectedActor.preferredUsername}` }}
           </p>
         </div>
         <div class="media-content" v-else>
-          {{ `@${currentActor.preferredUsername}` }}
+          {{ `@${selectedActor.preferredUsername}` }}
         </div>
         <b-button type="is-text" @click="isComponentModalActive = true">
           {{ $t("Change") }}
@@ -33,45 +33,18 @@
     </div>
     <!-- If we have a current actor -->
     <span
-      v-else-if="currentActor.id"
+      v-else-if="selectedActor.id"
       class="block"
       @click="isComponentModalActive = true"
     >
       <img
         class="image is-48x48"
-        v-if="currentActor.avatar"
-        :src="currentActor.avatar.url"
-        :alt="currentActor.avatar.alt"
+        v-if="selectedActor.avatar"
+        :src="selectedActor.avatar.url"
+        :alt="selectedActor.avatar.alt"
       />
       <b-icon v-else size="is-large" icon="account-circle" />
     </span>
-    <!-- If we have no current actor -->
-    <div v-if="groupMemberships.total === 0 || !currentActor.id" class="box">
-      <div class="media">
-        <div class="media-left">
-          <figure class="image is-48x48" v-if="identity.avatar">
-            <img
-              class="image is-rounded"
-              :src="identity.avatar.url"
-              :alt="identity.avatar.alt"
-            />
-          </figure>
-          <b-icon v-else size="is-large" icon="account-circle" />
-        </div>
-        <div class="media-content" v-if="identity.name">
-          <p class="is-4">{{ identity.name }}</p>
-          <p class="is-6 has-text-grey">
-            {{ `@${identity.preferredUsername}` }}
-          </p>
-        </div>
-        <div class="media-content" v-else>
-          {{ `@${identity.preferredUsername}` }}
-        </div>
-        <b-button type="is-text" @click="isComponentModalActive = true">
-          {{ $t("Change") }}
-        </b-button>
-      </div>
-    </div>
     <b-modal :active.sync="isComponentModalActive" has-modal-card>
       <div class="modal-card">
         <header class="modal-card-head">
@@ -81,20 +54,15 @@
           <div class="columns">
             <div class="column">
               <organizer-picker
-                v-model="currentActor"
-                :identity.sync="identity"
+                v-model="selectedActor"
                 @input="relay"
                 :restrict-moderator-level="true"
               />
             </div>
             <div class="column">
-              <div v-if="actorMembersForCurrentActor.length > 0">
+              <div v-if="actorMembers.length > 0">
                 <p>{{ $t("Add a contact") }}</p>
-                <p
-                  class="field"
-                  v-for="actor in actorMembersForCurrentActor"
-                  :key="actor.id"
-                >
+                <p class="field" v-for="actor in actorMembers" :key="actor.id">
                   <b-checkbox v-model="actualContacts" :native-value="actor.id">
                     <div class="media">
                       <div class="media-left">
@@ -138,79 +106,121 @@
 <script lang="ts">
 import { Component, Prop, Vue, Watch } from "vue-property-decorator";
 import { IMember } from "@/types/actor/member.model";
-import { IActor, IGroup, IPerson } from "../../types/actor";
+import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
 import OrganizerPicker from "./OrganizerPicker.vue";
-import { PERSON_MEMBERSHIPS_WITH_MEMBERS } from "../../graphql/actor";
+import {
+  CURRENT_ACTOR_CLIENT,
+  LOGGED_USER_MEMBERSHIPS,
+} from "../../graphql/actor";
 import { Paginate } from "../../types/paginate";
+import { GROUP_MEMBERS } from "@/graphql/member";
+import { ActorType, MemberRole } from "@/types/enums";
+
+const MEMBER_ROLES = [
+  MemberRole.CREATOR,
+  MemberRole.ADMINISTRATOR,
+  MemberRole.MODERATOR,
+  MemberRole.MEMBER,
+];
 
 @Component({
   components: { OrganizerPicker },
   apollo: {
-    groupMemberships: {
-      query: PERSON_MEMBERSHIPS_WITH_MEMBERS,
+    members: {
+      query: GROUP_MEMBERS,
       variables() {
         return {
-          id: this.identity.id,
+          name: usernameWithDomain(this.selectedActor),
+          page: this.membersPage,
+          limit: 10,
+          roles: MEMBER_ROLES.join(","),
         };
       },
-      update: (data) => data.person.memberships,
+      update: (data) => data.group.members,
       skip() {
-        return !this.identity.id;
+        return (
+          !this.selectedActor || this.selectedActor.type !== ActorType.GROUP
+        );
       },
     },
+    currentActor: CURRENT_ACTOR_CLIENT,
+    userMemberships: {
+      query: LOGGED_USER_MEMBERSHIPS,
+      variables: {
+        page: 1,
+        limit: 100,
+      },
+      update: (data) => data.loggedUser.memberships,
+    },
   },
 })
 export default class OrganizerPickerWrapper extends Vue {
-  @Prop({ type: Object, required: true }) value!: IActor;
+  @Prop({ type: Object, required: false }) value!: IActor;
 
   @Prop({ default: true, type: Boolean }) inline!: boolean;
 
-  @Prop({ type: Object, required: true }) identity!: IPerson;
+  currentActor!: IPerson;
 
   isComponentModalActive = false;
 
-  currentActor: IActor = this.value;
-
-  groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
-
   @Prop({ type: Array, required: false, default: () => [] })
   contacts!: IActor[];
+  members: Paginate<IMember> = { elements: [], total: 0 };
 
-  actualContacts: (string | undefined)[] = this.contacts.map(({ id }) => id);
+  membersPage = 1;
 
-  @Watch("contacts")
-  updateActualContacts(contacts: IActor[]): void {
-    this.actualContacts = contacts.map(({ id }) => id);
+  userMemberships: Paginate<IMember> = { elements: [], total: 0 };
+
+  get actualContacts(): (string | undefined)[] {
+    return this.contacts.map(({ id }) => id);
   }
 
-  @Watch("value")
-  updateCurrentActor(value: IGroup): void {
-    this.currentActor = value;
+  set actualContacts(contactsIds: (string | undefined)[]) {
+    this.$emit(
+      "update:contacts",
+      this.actorMembers.filter(({ id }) => contactsIds.includes(id))
+    );
+  }
+
+  @Watch("userMemberships")
+  setInitialActor(): void {
+    if (this.$route.query?.actorId) {
+      const actorId = this.$route.query?.actorId as string;
+      this.$router.replace({ query: undefined });
+      const actor = this.userMemberships.elements.find(
+        ({ parent: { id }, role }) =>
+          actorId === id && MEMBER_ROLES.includes(role)
+      )?.parent as IActor;
+      this.selectedActor = actor;
+    }
+  }
+
+  get selectedActor(): IActor | undefined {
+    if (this.value?.id) {
+      return this.value;
+    }
+    if (this.currentActor) {
+      return this.currentActor;
+    }
+    return undefined;
+  }
+
+  set selectedActor(selectedActor: IActor | undefined) {
+    this.$emit("input", selectedActor);
   }
 
   async relay(group: IGroup): Promise<void> {
-    this.currentActor = group;
+    this.actualContacts = [];
+    this.selectedActor = group;
   }
 
   pickActor(): void {
-    this.$emit(
-      "update:contacts",
-      this.actorMembersForCurrentActor.filter(({ id }) =>
-        this.actualContacts.includes(id)
-      )
-    );
-    this.$emit("input", this.currentActor);
     this.isComponentModalActive = false;
   }
 
-  get actorMembersForCurrentActor(): IActor[] {
-    const currentMembership = this.groupMemberships.elements.find(
-      ({ parent: { id } }) => id === this.currentActor.id
-    );
-    if (currentMembership) {
-      return currentMembership.parent.members.elements.map(
-        ({ actor }: { actor: IActor }) => actor
-      );
+  get actorMembers(): IActor[] {
+    if (this.selectedActor?.type === ActorType.GROUP) {
+      return this.members.elements.map(({ actor }: { actor: IActor }) => actor);
     }
     return [];
   }
diff --git a/js/src/graphql/actor.ts b/js/src/graphql/actor.ts
index 19f7d1bc4..197fbd25a 100644
--- a/js/src/graphql/actor.ts
+++ b/js/src/graphql/actor.ts
@@ -319,6 +319,7 @@ export const LOGGED_USER_MEMBERSHIPS = gql`
             preferredUsername
             domain
             name
+            type
             avatar {
               id
               url
@@ -359,6 +360,7 @@ export const IDENTITIES = gql`
         id
         url
       }
+      type
       preferredUsername
       name
     }
@@ -379,6 +381,7 @@ export const PERSON_MEMBERSHIPS = gql`
             preferredUsername
             name
             domain
+            type
             avatar {
               id
               url
@@ -397,55 +400,6 @@ export const PERSON_MEMBERSHIPS = gql`
   }
 `;
 
-export const PERSON_MEMBERSHIPS_WITH_MEMBERS = gql`
-  query PersonMembershipsWithMembers($id: ID!) {
-    person(id: $id) {
-      id
-      memberships {
-        total
-        elements {
-          id
-          role
-          parent {
-            id
-            preferredUsername
-            name
-            domain
-            avatar {
-              id
-              url
-            }
-            members {
-              total
-              elements {
-                id
-                role
-                actor {
-                  id
-                  preferredUsername
-                  name
-                  domain
-                  avatar {
-                    id
-                    url
-                  }
-                }
-              }
-            }
-          }
-          invitedBy {
-            id
-            preferredUsername
-            name
-          }
-          insertedAt
-          updatedAt
-        }
-      }
-    }
-  }
-`;
-
 export const PERSON_MEMBERSHIP_GROUP = gql`
   query PersonMembershipGroup($id: ID!, $group: String!) {
     person(id: $id) {
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index a5410a33e..6bc987ab3 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -977,5 +977,7 @@
   "Create new links": "Create new links",
   "You'll need to change the URLs where there were previously entered.": "You'll need to change the URLs where there were previously entered.",
   "Personal feeds": "Personal feeds",
-  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.",
+  "The event will show as attributed to this profile.": "The event will show as attributed to this profile.",
+  "You may show some members as contacts.": "You may show some members as contacts."
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index cdbfda87c..c3861f301 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -1071,5 +1071,7 @@
   "Create new links": "Créer de nouveaux liens",
   "You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
   "Personal feeds": "Flux personnels",
-  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils."
+  "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
+  "The event will show as attributed to this profile.": "L'événement sera affiché comme attribué à ce profil.",
+  "You may show some members as contacts.": "Vous pouvez afficher certain⋅es membres en tant que contacts."
 }
diff --git a/js/src/views/Event/Edit.vue b/js/src/views/Event/Edit.vue
index 11eb96924..f67ba5dc2 100644
--- a/js/src/views/Event/Edit.vue
+++ b/js/src/views/Event/Edit.vue
@@ -81,19 +81,21 @@
 
         <subtitle>{{ $t("Organizers") }}</subtitle>
 
-        <div v-if="config && config.features.groups">
+        <div v-if="config && config.features.groups && organizerActor.id">
           <b-field>
             <organizer-picker-wrapper
-              v-model="event.attributedTo"
+              v-model="organizerActor"
               :contacts.sync="event.contacts"
-              :identity="event.organizerActor"
             />
           </b-field>
-          <p v-if="!event.attributedTo.id || attributedToEqualToOrganizerActor">
+          <p v-if="!attributedToAGroup && organizerActorEqualToCurrentActor">
             {{
               $t("The event will show as attributed to your personal profile.")
             }}
           </p>
+          <p v-else-if="!attributedToAGroup">
+            {{ $t("The event will show as attributed to this profile.") }}
+          </p>
           <p v-else>
             <span>{{
               $t("The event will show as attributed to this group.")
@@ -101,6 +103,7 @@
             <span
               v-if="event.contacts && event.contacts.length"
               v-html="
+                ' ' +
                 $tc(
                   '<b>{contact}</b> will be displayed as contact.',
                   event.contacts.length,
@@ -114,6 +117,9 @@
                 )
               "
             />
+            <span v-else>
+              {{ $t("You may show some members as contacts.") }}
+            </span>
           </p>
         </div>
         <subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
@@ -432,6 +438,7 @@ import Subtitle from "@/components/Utils/Subtitle.vue";
 import { Route } from "vue-router";
 import { formatList } from "@/utils/i18n";
 import {
+  ActorType,
   CommentModeration,
   EventJoinOptions,
   EventStatus,
@@ -448,10 +455,11 @@ import {
 import { EventModel, IEvent } from "../../types/event.model";
 import {
   CURRENT_ACTOR_CLIENT,
+  IDENTITIES,
   LOGGED_USER_DRAFTS,
   LOGGED_USER_PARTICIPATIONS,
 } from "../../graphql/actor";
-import { IPerson, Person, displayNameAndUsername } from "../../types/actor";
+import { displayNameAndUsername, IActor, IGroup } from "../../types/actor";
 import { TAGS } from "../../graphql/tags";
 import { ITag } from "../../types/tag.model";
 import {
@@ -480,6 +488,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
     currentActor: CURRENT_ACTOR_CLIENT,
     tags: TAGS,
     config: CONFIG,
+    identities: IDENTITIES,
     event: {
       query: FETCH_EVENT,
       variables() {
@@ -513,12 +522,14 @@ export default class EditEvent extends Vue {
 
   @Prop({ type: Boolean, default: false }) isDuplicate!: boolean;
 
-  currentActor = new Person();
+  currentActor!: IActor;
 
   tags: ITag[] = [];
 
   event: IEvent = new EventModel();
 
+  identities: IActor[] = [];
+
   config!: IConfig;
 
   unmodifiedEvent!: IEvent;
@@ -573,16 +584,32 @@ export default class EditEvent extends Vue {
 
     this.event.beginsOn = now;
     this.event.endsOn = end;
-    this.event.organizerActor = this.getDefaultActor();
   }
 
-  private getDefaultActor() {
-    if (this.event.organizerActor?.id) {
+  get organizerActor(): IActor {
+    if (this.event?.attributedTo?.id) {
+      return this.event.attributedTo;
+    }
+    if (this.event?.organizerActor?.id) {
       return this.event.organizerActor;
     }
     return this.currentActor;
   }
 
+  set organizerActor(actor: IActor) {
+    if (actor?.type === ActorType.GROUP) {
+      this.event.attributedTo = actor as IGroup;
+      this.event.organizerActor = this.currentActor;
+    } else {
+      this.event.attributedTo = undefined;
+      this.event.organizerActor = actor;
+    }
+  }
+
+  get attributedToAGroup(): boolean {
+    return this.event.attributedTo?.id !== undefined;
+  }
+
   async mounted(): Promise<void> {
     this.observer = new IntersectionObserver(
       (entries) => {
@@ -724,8 +751,10 @@ export default class EditEvent extends Vue {
     return !(
       this.eventId &&
       this.event.organizerActor?.id !== undefined &&
-      this.currentActor.id !== this.event.organizerActor.id
-    ) as boolean;
+      !this.identities
+        .map(({ id }) => id)
+        .includes(this.event.organizerActor?.id)
+    );
   }
 
   get updateEventMessage(): string {
@@ -752,8 +781,7 @@ export default class EditEvent extends Vue {
    */
   private postCreateOrUpdate(store: any, updateEvent: IEvent) {
     const resultEvent: IEvent = { ...updateEvent };
-    const organizerActor: IPerson = this.event.organizerActor as Person;
-    resultEvent.organizerActor = organizerActor;
+    resultEvent.organizerActor = this.event.organizerActor;
     resultEvent.relatedEvents = [];
 
     store.writeQuery({
@@ -766,12 +794,12 @@ export default class EditEvent extends Vue {
         query: EVENT_PERSON_PARTICIPATION,
         variables: {
           eventId: updateEvent.id,
-          name: organizerActor.preferredUsername,
+          name: this.event.organizerActor?.preferredUsername,
         },
         data: {
           person: {
             __typename: "Person",
-            id: organizerActor.id,
+            id: this.event?.organizerActor?.id,
             participations: {
               __typename: "PaginatedParticipantList",
               total: 1,
@@ -782,7 +810,7 @@ export default class EditEvent extends Vue {
                   role: ParticipantRole.CREATOR,
                   actor: {
                     __typename: "Actor",
-                    id: organizerActor.id,
+                    id: this.event?.organizerActor?.id,
                   },
                   event: {
                     __typename: "Event",
@@ -819,30 +847,26 @@ export default class EditEvent extends Vue {
     ];
   }
 
-  get attributedToEqualToOrganizerActor(): boolean {
-    return (this.event.organizerActor?.id !== undefined &&
-      this.event.attributedTo?.id === this.event.organizerActor?.id) as boolean;
+  get organizerActorEqualToCurrentActor(): boolean {
+    return (
+      this.currentActor?.id !== undefined &&
+      this.organizerActor?.id === this.currentActor?.id
+    );
   }
 
   /**
    * Build variables for Event GraphQL creation query
    */
   private async buildVariables() {
-    this.event.organizerActor = this.event.organizerActor?.id
-      ? this.event.organizerActor
-      : this.currentActor;
     let res = this.event.toEditJSON();
     if (this.event.organizerActor) {
       res = Object.assign(res, {
         organizerActorId: this.event.organizerActor.id,
       });
     }
-    const attributedToId =
-      this.event.attributedTo &&
-      !this.attributedToEqualToOrganizerActor &&
-      this.event.attributedTo.id
-        ? this.event.attributedTo.id
-        : null;
+    const attributedToId = this.event.attributedTo?.id
+      ? this.event.attributedTo.id
+      : null;
     res = Object.assign(res, { attributedToId });
 
     // eslint-disable-next-line
diff --git a/js/src/views/Group/Group.vue b/js/src/views/Group/Group.vue
index e4d566943..a1f64a3c1 100644
--- a/js/src/views/Group/Group.vue
+++ b/js/src/views/Group/Group.vue
@@ -327,6 +327,7 @@
               v-if="isCurrentActorAGroupModerator"
               :to="{
                 name: RouteName.CREATE_EVENT,
+                query: { actorId: group.id },
               }"
               class="button is-primary"
               >{{ $t("+ Create an event") }}</router-link
diff --git a/lib/graphql/resolvers/person.ex b/lib/graphql/resolvers/person.ex
index a1588263f..21f686c12 100644
--- a/lib/graphql/resolvers/person.ex
+++ b/lib/graphql/resolvers/person.ex
@@ -315,7 +315,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
         context: %{current_user: user}
       }) do
     with {:is_owned, %Actor{id: actor_id}} <- User.owns_actor(user, actor_id),
-         %Actor{id: group_id} <- Actors.get_actor_by_name(group, :Group),
+         {:group, %Actor{id: group_id}} <- {:group, Actors.get_actor_by_name(group, :Group)},
          {:ok, %Member{} = membership} <- Actors.get_member(actor_id, group_id),
          memberships <- %Page{
            total: 1,
@@ -326,6 +326,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
       {:error, :member_not_found} ->
         {:ok, %Page{total: 0, elements: []}}
 
+      {:group, nil} ->
+        {:error, :group_not_found}
+
       {:is_owned, nil} ->
         {:error, dgettext("errors", "Profile is not owned by authenticated user")}
     end