Improve and activate groups

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-09-29 09:53:48 +02:00
parent 1ca46a6863
commit 49a5725da3
131 changed files with 16440 additions and 1929 deletions

View File

@@ -45,10 +45,10 @@
</tr>
<tr>
<td>{{ $t("Registrations") }}</td>
<td v-if="config.registrationsOpen && config.registrationsWhitelist">
<td v-if="config.registrationsOpen && config.registrationsAllowlist">
{{ $t("Restricted") }}
</td>
<td v-if="config.registrationsOpen && !config.registrationsWhitelist">
<td v-if="config.registrationsOpen && !config.registrationsAllowlist">
{{ $t("Open") }}
</td>
<td v-else>{{ $t("Closed") }}</td>

View File

@@ -124,6 +124,7 @@ h1 {
<script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import { Route } from "vue-router";
import {
CREATE_PERSON,
CURRENT_ACTOR_CLIENT,
@@ -176,25 +177,25 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
RouteName = RouteName;
get message() {
get message(): string | null {
if (this.isUpdate) return null;
return this.$t("Only alphanumeric characters and underscores are supported.");
return this.$t("Only alphanumeric characters and underscores are supported.") as string;
}
@Watch("isUpdate")
async isUpdateChanged() {
async isUpdateChanged(): Promise<void> {
this.resetFields();
}
@Watch("identityName", { immediate: true })
async onIdentityParamChanged(val: string) {
async onIdentityParamChanged(val: string): Promise<Route | undefined> {
// Only used when we update the identity
if (!this.isUpdate) return;
await this.redirectIfNoIdentitySelected(val);
if (!this.identityName) {
return await this.$router.push({ name: "CreateIdentity" });
return this.$router.push({ name: "CreateIdentity" });
}
if (this.identityName && this.identity) {
@@ -202,7 +203,7 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
}
}
submit() {
submit(): Promise<void> {
if (this.isUpdate) return this.updateIdentity();
return this.createIdentity();
@@ -211,7 +212,7 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
/**
* Delete an identity
*/
async deleteIdentity() {
async deleteIdentity(): Promise<void> {
try {
await this.$apollo.mutate({
mutation: DELETE_PERSON,
@@ -252,7 +253,7 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
}
}
async updateIdentity() {
async updateIdentity(): Promise<void> {
try {
const variables = await this.buildVariables();
@@ -285,7 +286,7 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
}
}
async createIdentity() {
async createIdentity(): Promise<void> {
try {
const variables = await this.buildVariables();
@@ -320,11 +321,12 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
}
}
get getInstanceHost() {
// eslint-disable-next-line class-methods-use-this
get getInstanceHost(): string {
return MOBILIZON_INSTANCE_HOST;
}
openDeleteIdentityConfirmation() {
openDeleteIdentityConfirmation(): void {
this.$buefy.dialog.prompt({
type: "is-danger",
title: this.$t("Delete your identity") as string,

View File

@@ -202,7 +202,6 @@ import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
import { IGroup, MemberRole } from "../../types/actor";
import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
import RouteName from "../../router/name";
import { IEvent } from "../../types/event.model";
import ActorCard from "../../components/Account/ActorCard.vue";
const EVENTS_PER_PAGE = 10;
@@ -248,22 +247,22 @@ export default class AdminGroupProfile extends Vue {
MemberRole = MemberRole;
get metadata(): Array<object> {
get metadata(): Array<Record<string, string>> {
if (!this.group) return [];
const res: object[] = [
const res: Record<string, string>[] = [
{
key: this.$t("Status") as string,
value: this.group.suspended ? this.$t("Suspended") : this.$t("Active"),
value: (this.group.suspended ? this.$t("Suspended") : this.$t("Active")) as string,
},
{
key: this.$t("Domain") as string,
value: this.group.domain ? this.group.domain : this.$t("Local"),
value: (this.group.domain ? this.group.domain : this.$t("Local")) as string,
},
];
return res;
}
confirmSuspendProfile() {
confirmSuspendProfile(): void {
const message = (this.group.domain
? this.$t(
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.",
@@ -284,7 +283,7 @@ export default class AdminGroupProfile extends Vue {
});
}
async suspendProfile() {
async suspendProfile(): Promise<void> {
this.$apollo.mutate<{ suspendProfile: { id: string } }>({
mutation: SUSPEND_PROFILE,
variables: {
@@ -318,9 +317,9 @@ export default class AdminGroupProfile extends Vue {
});
}
async unsuspendProfile() {
async unsuspendProfile(): Promise<void> {
const profileID = this.id;
this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
await this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
mutation: UNSUSPEND_PROFILE,
variables: {
id: this.id,
@@ -336,7 +335,7 @@ export default class AdminGroupProfile extends Vue {
});
}
async refreshProfile() {
async refreshProfile(): Promise<void> {
this.$apollo.mutate<{ refreshProfile: IActor }>({
mutation: REFRESH_PROFILE,
variables: {
@@ -345,7 +344,7 @@ export default class AdminGroupProfile extends Vue {
});
}
async onOrganizedEventsPageChange(page: number) {
async onOrganizedEventsPageChange(page: number): Promise<void> {
this.organizedEventsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
@@ -370,7 +369,7 @@ export default class AdminGroupProfile extends Vue {
});
}
async onMembersPageChange(page: number) {
async onMembersPageChange(page: number): Promise<void> {
this.membersPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {
@@ -395,7 +394,7 @@ export default class AdminGroupProfile extends Vue {
});
}
async onPostsPageChange(page: number) {
async onPostsPageChange(page: number): Promise<void> {
this.postsPage = page;
await this.$apollo.queries.group.fetchMore({
variables: {

View File

@@ -11,7 +11,7 @@
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ `@${group.preferredUsername}` }}</router-link
>{{ group.name }}</router-link
>
</li>
<li class="is-active">

View File

@@ -41,21 +41,31 @@
</b-field>
<subtitle>{{ $t("Organizers") }}</subtitle>
<div class="columns">
<div class="column">
<b-field :label="$t('Organizer')">
<identity-picker-wrapper
v-model="event.organizerActor"
:masked="event.options.hideOrganizerWhenGroupEvent"
@input="resetAttributedToOnOrganizerChange"
/>
</b-field>
</div>
<div class="column" v-if="config && config.features.groups">
<b-field :label="$t('Group')" v-if="event.organizerActor">
<group-picker-wrapper v-model="event.attributedTo" :identity="event.organizerActor" />
</b-field>
</div>
<div v-if="config && config.features.groups">
<b-field>
<organizer-picker-wrapper
v-model="event.attributedTo"
:contacts.sync="event.contacts"
:identity="event.organizerActor"
/>
</b-field>
<p v-if="!event.attributedTo.id || attributedToEqualToOrganizerActor">
{{ $t("The event will show as attributed to your personal profile.") }}
</p>
<p v-else>
<span>{{ $t("The event will show as attributed to this group.") }}</span>
<span
v-if="event.contacts && event.contacts.length"
v-html="
$tc('<b>{contact}</b> will be displayed as contact.', event.contacts.length, {
contact: formatList(
event.contacts.map((contact) => displayNameAndUsername(contact))
),
})
"
/>
</p>
</div>
<!-- <div class="field" v-if="event.attributedTo.id">-->
<!-- <label class="label">{{ $t('Hide the organizer') }}</label>-->
@@ -332,8 +342,9 @@ import TagInput from "@/components/Event/TagInput.vue";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import Subtitle from "@/components/Utils/Subtitle.vue";
import GroupPickerWrapper from "@/components/Group/GroupPickerWrapper.vue";
import { Route } from "vue-router";
import { formatList } from "@/utils/i18n";
import OrganizerPickerWrapper from "../../components/Event/OrganizerPickerWrapper.vue";
import {
CREATE_EVENT,
EDIT_EVENT,
@@ -354,7 +365,7 @@ import {
LOGGED_USER_DRAFTS,
LOGGED_USER_PARTICIPATIONS,
} from "../../graphql/actor";
import { Group, IPerson, Person } from "../../types/actor";
import { IPerson, Person, displayNameAndUsername } from "../../types/actor";
import { TAGS } from "../../graphql/tags";
import { ITag } from "../../types/tag.model";
import { buildFileFromIPicture, buildFileVariable, readFileAsync } from "../../utils/image";
@@ -362,12 +373,13 @@ import RouteName from "../../router/name";
import "intersection-observer";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import { RefetchQueryDescription } from "apollo-client/core/watchQueryOptions";
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
@Component({
components: {
GroupPickerWrapper,
OrganizerPickerWrapper,
Subtitle,
IdentityPickerWrapper,
FullAddressAutoComplete,
@@ -398,6 +410,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: (this.isUpdate ? this.$t("Event edition") : this.$t("Event creation")) as string,
// all titles will be injected into this template
@@ -444,8 +457,12 @@ export default class EditEvent extends Vue {
endsOnNull = false;
displayNameAndUsername = displayNameAndUsername;
formatList = formatList;
@Watch("eventId", { immediate: true })
resetFormForCreation(eventId: string) {
resetFormForCreation(eventId: string): void {
if (eventId === undefined) {
this.event = new EventModel();
}
@@ -467,9 +484,10 @@ export default class EditEvent extends Vue {
this.event.organizerActor = this.event.organizerActor || this.currentActor;
}
async mounted() {
async mounted(): Promise<void> {
this.observer = new IntersectionObserver(
(entries) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
if (entry) {
this.showFixedNavbar = !entry.isIntersecting;
@@ -489,16 +507,16 @@ export default class EditEvent extends Vue {
}
}
createOrUpdateDraft(e: Event) {
createOrUpdateDraft(e: Event): void {
e.preventDefault();
if (this.validateForm()) {
if (this.eventId && !this.isDuplicate) return this.updateEvent();
if (this.eventId && !this.isDuplicate) this.updateEvent();
return this.createEvent();
this.createEvent();
}
}
createOrUpdatePublish(e: Event) {
createOrUpdatePublish(e: Event): void {
if (this.validateForm()) {
this.event.draft = false;
this.createOrUpdateDraft(e);
@@ -506,21 +524,17 @@ export default class EditEvent extends Vue {
}
@Watch("currentActor")
setCurrentActor() {
setCurrentActor(): void {
this.event.organizerActor = this.currentActor;
}
@Watch("event")
setInitialData() {
setInitialData(): void {
if (this.isUpdate && this.unmodifiedEvent === undefined && this.event && this.event.uuid) {
this.unmodifiedEvent = JSON.parse(JSON.stringify(this.event.toEditJSON()));
}
}
resetAttributedToOnOrganizerChange() {
this.event.attributedTo = new Group();
}
// @Watch('event.attributedTo', { deep: true })
// updateHideOrganizerWhenGroupEventOption(attributedTo) {
// if (!attributedTo.preferredUsername) {
@@ -537,7 +551,7 @@ export default class EditEvent extends Vue {
return false;
}
async createEvent() {
async createEvent(): Promise<void> {
const variables = await this.buildVariables();
try {
@@ -565,7 +579,7 @@ export default class EditEvent extends Vue {
}
}
async updateEvent() {
async updateEvent(): Promise<void> {
const variables = await this.buildVariables();
try {
@@ -652,7 +666,8 @@ export default class EditEvent extends Vue {
/**
* Refresh drafts or participation cache depending if the event is still draft or not
*/
private postRefetchQueries(updateEvent: IEvent) {
// eslint-disable-next-line class-methods-use-this
private postRefetchQueries(updateEvent: IEvent): RefetchQueryDescription {
if (updateEvent.draft) {
return [
{
@@ -670,6 +685,12 @@ export default class EditEvent extends Vue {
];
}
get attributedToEqualToOrganizerActor(): boolean {
return (this.event.organizerActor &&
this.event.attributedTo &&
this.event.attributedTo.id === this.event.organizerActor.id) as boolean;
}
/**
* Build variables for Event GraphQL creation query
*/
@@ -680,9 +701,13 @@ export default class EditEvent extends Vue {
organizerActorId: this.event.organizerActor.id,
});
}
if (this.event.attributedTo) {
res = Object.assign(res, { attributedToId: this.event.attributedTo.id });
}
const attributedToId =
this.event.attributedTo &&
!this.attributedToEqualToOrganizerActor &&
this.event.attributedTo.id
? this.event.attributedTo.id
: null;
res = Object.assign(res, { attributedToId });
// eslint-disable-next-line
// @ts-ignore
@@ -736,7 +761,7 @@ export default class EditEvent extends Vue {
}
@Watch("limitedPlaces")
updatedEventCapacityOptions(limitedPlaces: boolean) {
updatedEventCapacityOptions(limitedPlaces: boolean): void {
if (!limitedPlaces) {
this.event.options.maximumAttendeeCapacity = 0;
this.event.options.remainingAttendeeCapacity = 0;
@@ -748,7 +773,7 @@ export default class EditEvent extends Vue {
}
@Watch("needsApproval")
updateEventJoinOptions(needsApproval: boolean) {
updateEventJoinOptions(needsApproval: boolean): void {
if (needsApproval === true) {
this.event.joinOptions = EventJoinOptions.RESTRICTED;
} else {
@@ -756,16 +781,16 @@ export default class EditEvent extends Vue {
}
}
get checkTitleLength() {
get checkTitleLength(): Array<string | undefined> {
return this.event.title.length > 80
? ["is-info", this.$t("The event title will be ellipsed.")]
? ["is-info", this.$t("The event title will be ellipsed.") as string]
: [undefined, undefined];
}
/**
* Confirm cancel
*/
confirmGoElsewhere(callback: (value?: string) => any) {
confirmGoElsewhere(callback: (value?: string) => any): void | Function {
if (!this.isEventModified) {
return callback();
}
@@ -796,11 +821,11 @@ export default class EditEvent extends Vue {
/**
* Confirm cancel
*/
confirmGoBack() {
confirmGoBack(): void {
this.confirmGoElsewhere(() => this.$router.go(-1));
}
beforeRouteLeave(to: Route, from: Route, next: Function) {
beforeRouteLeave(to: Route, from: Route, next: Function): void {
if (to.name === RouteName.EVENT) return next();
this.confirmGoElsewhere(() => next());
}
@@ -814,7 +839,7 @@ export default class EditEvent extends Vue {
}
@Watch("beginsOn", { deep: true })
onBeginsOnChanged(beginsOn: string) {
onBeginsOnChanged(beginsOn: string): void {
if (!this.event.endsOn) return;
const dateBeginsOn = new Date(beginsOn);
const dateEndsOn = new Date(this.event.endsOn);

View File

@@ -36,16 +36,7 @@
</popover-actor-card>
</span>
<span v-else-if="event.organizerActor && event.attributedTo">
<i18n path="By {username} and {group}">
<popover-actor-card
:actor="event.organizerActor"
slot="username"
:inline="true"
>
{{
$t("@{username}", { username: usernameWithDomain(event.organizerActor) })
}}
</popover-actor-card>
<i18n path="By {group}">
<popover-actor-card :actor="event.attributedTo" slot="group" :inline="true">
<router-link
:to="{
@@ -338,11 +329,8 @@
:endsOn="event.endsOn"
/>
</event-metadata-block>
<event-metadata-block :title="$t('Contact')">
<popover-actor-card
:actor="event.organizerActor"
v-if="!event.attributedTo || !event.options.hideOrganizerWhenGroupEvent"
>
<event-metadata-block :title="$tc('Contact', event.contacts.length)">
<popover-actor-card :actor="event.organizerActor" v-if="!event.attributedTo">
<actor-card :actor="event.organizerActor" />
</popover-actor-card>
<router-link
@@ -359,6 +347,14 @@
<actor-card :actor="event.attributedTo" />
</popover-actor-card>
</router-link>
<popover-actor-card
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
>
<actor-card :actor="contact" />
</popover-actor-card>
</event-metadata-block>
<event-metadata-block
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"

View File

@@ -8,7 +8,7 @@
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.preferredUsername }}</router-link
>{{ group.name }}</router-link
>
</li>
<li class="is-active">

View File

@@ -51,7 +51,7 @@
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { Component, Watch } from "vue-property-decorator";
import { Group, IPerson, usernameWithDomain, MemberRole } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { CREATE_GROUP } from "@/graphql/group";
@@ -84,7 +84,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
usernameWithDomain = usernameWithDomain;
async createGroup() {
async createGroup(): Promise<void> {
try {
await this.$apollo.mutate({
mutation: CREATE_GROUP,
@@ -125,6 +125,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
}
}
// eslint-disable-next-line class-methods-use-this
get host(): string {
return window.location.hostname;
}
@@ -152,10 +153,12 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
if (this.bannerFile) {
bannerObj = {
picture: {
name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile,
banner: {
picture: {
name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
@@ -173,18 +176,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
}
private handleError(err: any) {
this.errors.push(
...err.graphQLErrors.map(({ message }: { message: string }) => this.convertMessage(message))
);
}
private convertMessage(message: string): string {
switch (message) {
case "A group with this name already exists":
return this.$t("A group with this name already exists") as string;
default:
return message;
}
this.errors.push(...err.graphQLErrors.map(({ message }: { message: string }) => message));
}
}
</script>

View File

@@ -33,8 +33,8 @@
<header class="block-container presentation">
<div class="block-column media">
<div class="media-left">
<figure class="image rounded is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" />
<figure class="image is-128x128" v-if="group.avatar">
<img class="is-rounded" :src="group.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
@@ -56,13 +56,6 @@
class="button is-outlined"
>{{ $t("Group settings") }}</router-link
>
<b-button
type="is-danger"
v-if="isCurrentActorAGroupMember"
outlined
@click="leaveGroup"
>{{ $t("Leave group") }}</b-button
>
</div>
</div>
</div>
@@ -114,6 +107,7 @@
>{{ $t("Show map") }}</span
>
</div>
<img v-if="group.banner && group.banner.url" :src="group.banner.url" alt="" />
</header>
</div>
<div v-if="isCurrentActorAGroupMember" class="block-container">
@@ -186,50 +180,6 @@
>
</template>
</group-section>
<!-- Todos -->
<group-section
:title="$t('Ongoing tasks')"
icon="checkbox-multiple-marked"
:route="{
name: RouteName.TODO_LISTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>
<template v-slot:default>
<div v-if="group.todoLists.elements.length > 0">
<div v-for="todoList in group.todoLists.elements" :key="todoList.id">
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
<h2 class="is-size-3">
{{
$tc("{title} ({count} todos)", todoList.todos.total, {
count: todoList.todos.total,
title: todoList.title,
})
}}
</h2>
</router-link>
<compact-todo
:todo="todo"
v-for="todo in todoList.todos.elements.slice(0, 3)"
:key="todo.id"
/>
</div>
</div>
<div v-else class="content has-text-grey has-text-centered">
<p>{{ $t("No ongoing todos") }}</p>
</div>
</template>
<template v-slot:create>
<router-link
:to="{
name: RouteName.TODO_LISTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ $t("+ Add a todo") }}</router-link
>
</template>
</group-section>
</div>
<!-- Public things -->
<div class="block-column">
@@ -349,7 +299,7 @@
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { FETCH_GROUP, LEAVE_GROUP } from "@/graphql/group";
import { FETCH_GROUP } from "@/graphql/group";
import {
IActor,
IGroup,
@@ -369,7 +319,6 @@ import FolderItem from "@/components/Resource/FolderItem.vue";
import { Address } from "@/types/address.model";
import Invitations from "@/components/Group/Invitations.vue";
import addMinutes from "date-fns/addMinutes";
import { Route } from "vue-router";
import GroupSection from "../../components/Group/GroupSection.vue";
import RouteName from "../../router/name";
@@ -451,16 +400,6 @@ export default class Group extends Vue {
}
}
async leaveGroup(): Promise<Route> {
await this.$apollo.mutate({
mutation: LEAVE_GROUP,
variables: {
groupId: this.group.id,
},
});
return this.$router.push({ name: RouteName.MY_GROUPS });
}
acceptInvitation(): void {
if (this.groupMember) {
const index = this.person.memberships.elements.findIndex(
@@ -477,7 +416,7 @@ export default class Group extends Vue {
get groupTitle(): undefined | string {
if (!this.group) return undefined;
return this.group.preferredUsername;
return this.group.name || this.group.preferredUsername;
}
get groupSummary(): undefined | string {
@@ -583,6 +522,8 @@ div.container {
&.presentation {
border: 2px solid $purple-2;
padding: 10px 0;
position: relative;
overflow: hidden;
h1 {
color: $purple-1;
@@ -593,6 +534,20 @@ div.container {
.button.is-outlined {
border-color: $purple-2;
}
& > *:not(img) {
position: relative;
z-index: 2;
}
& > img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: auto;
opacity: 0.3;
}
}
.members {

View File

@@ -1,66 +0,0 @@
<template>
<section class="container section">
<h1>{{ $t("Group List") }} ({{ groups.total }})</h1>
<b-loading :active.sync="$apollo.loading" />
<div class="columns">
<GroupMemberCard
v-for="group in groups.elements"
:key="group.uuid"
:group="group"
class="column is-one-quarter-desktop is-half-mobile"
/>
</div>
<router-link class="button" :to="{ name: RouteName.CREATE_GROUP }">{{
$t("Create group")
}}</router-link>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LIST_GROUPS } from "@/graphql/group";
import { Group, IGroup } from "@/types/actor";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import { Paginate } from "@/types/paginate";
import RouteName from "../../router/name";
@Component({
apollo: {
groups: {
query: {
query: LIST_GROUPS,
fetchPolicy: "network-only",
},
},
},
components: {
GroupMemberCard,
},
})
export default class GroupList extends Vue {
groups!: Paginate<IGroup>;
loading = true;
RouteName = RouteName;
//
// usernameWithDomain(actor) {
// return actor.username + (actor.domain === null ? '' : `@${actor.domain}`);
// }
// viewActor(actor) {
// this.$router.push({
// name: RouteName.GROUP,
// params: { name: this.usernameWithDomain(actor) },
// });
// }
//
// joinGroup(group) {
// const router = this.$router;
// // FIXME: remove eventFetch
// // eventFetch(`/groups/${this.usernameWithDomain(group)}/join`, this.$store, { method: 'POST' })
// // .then(response => response.json())
// // .then(() => router.push({ name: 'Group', params: { name: this.usernameWithDomain(group) } }));
// }
}
</script>

View File

@@ -210,14 +210,14 @@ export default class GroupMembers extends Vue {
usernameWithDomain = usernameWithDomain;
mounted() {
mounted(): void {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole;
}
}
async inviteMember() {
async inviteMember(): Promise<void> {
await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER,
variables: {
@@ -253,7 +253,7 @@ export default class GroupMembers extends Vue {
}
@Watch("page")
loadMoreMembers() {
loadMoreMembers(): void {
this.$apollo.queries.event.fetchMore({
// New variables
variables: {
@@ -279,7 +279,7 @@ export default class GroupMembers extends Vue {
});
}
async removeMember(memberId: string) {
async removeMember(memberId: string): Promise<void> {
await this.$apollo.mutate<{ removeMember: IMember }>({
mutation: REMOVE_MEMBER,
variables: {
@@ -310,15 +310,15 @@ export default class GroupMembers extends Vue {
});
}
promoteMember(memberId: string) {
promoteMember(memberId: string): Promise<void> {
return this.updateMember(memberId, MemberRole.ADMINISTRATOR);
}
demoteMember(memberId: string) {
demoteMember(memberId: string): Promise<void> {
return this.updateMember(memberId, MemberRole.MEMBER);
}
async updateMember(memberId: string, role: MemberRole) {
async updateMember(memberId: string, role: MemberRole): Promise<void> {
await this.$apollo.mutate<{ updateMember: IMember }>({
mutation: UPDATE_MEMBER,
variables: {

View File

@@ -37,8 +37,23 @@
<b-input v-model="group.name" />
</b-field>
<b-field :label="$t('Group short description')">
<editor mode="basic" v-model="group.summary"
<editor mode="basic" v-model="group.summary" :maxSize="500"
/></b-field>
<b-field :label="$t('Avatar')">
<picture-upload
:textFallback="$t('Avatar')"
v-model="avatarFile"
:defaultImageSrc="group.avatar ? group.avatar.url : null"
/>
</b-field>
<b-field :label="$t('Banner')">
<picture-upload
:textFallback="$t('Banner')"
v-model="bannerFile"
:defaultImageSrc="group.banner ? group.banner.url : null"
/>
</b-field>
<p class="label">{{ $t("Group visibility") }}</p>
<div class="field">
<b-radio
@@ -106,6 +121,7 @@
import { Component, Vue } from "vue-property-decorator";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { Route } from "vue-router";
import PictureUpload from "@/components/PictureUpload.vue";
import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor";
@@ -129,6 +145,7 @@ import { Group } from "../../types/actor/group.model";
},
components: {
FullAddressAutoComplete,
PictureUpload,
editor: () => import("../../components/Editor.vue"),
},
})
@@ -141,6 +158,10 @@ export default class GroupSettings extends Vue {
newMemberUsername = "";
avatarFile: File | null = null;
bannerFile: File | null = null;
usernameWithDomain = usernameWithDomain;
GroupVisibility = {
@@ -151,19 +172,12 @@ export default class GroupSettings extends Vue {
showCopiedTooltip = false;
async updateGroup(): Promise<void> {
const variables = { ...this.group };
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
if (variables.physicalAddress) {
// eslint-disable-next-line
// @ts-ignore
delete variables.physicalAddress.__typename;
}
const variables = this.buildVariables();
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables,
});
this.$notifier.success(this.$t("Group settings saved") as string);
}
confirmDeleteGroup(): void {
@@ -198,6 +212,52 @@ export default class GroupSettings extends Vue {
}, 2000);
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
const variables = { ...this.group };
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
if (variables.physicalAddress) {
// eslint-disable-next-line
// @ts-ignore
delete variables.physicalAddress.__typename;
}
delete variables.avatar;
delete variables.banner;
if (this.avatarFile) {
avatarObj = {
avatar: {
picture: {
name: this.avatarFile.name,
alt: `${this.group.preferredUsername}'s avatar`,
file: this.avatarFile,
},
},
};
}
if (this.bannerFile) {
bannerObj = {
banner: {
picture: {
name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
return {
...variables,
...avatarObj,
...bannerObj,
};
}
// eslint-disable-next-line class-methods-use-this
get canShowCopyButton(): boolean {
return window.isSecureContext;

View File

@@ -8,7 +8,11 @@
)
}}
</p>
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t("Create group") }}</router-link>
<div class="buttons">
<router-link class="button is-primary" :to="{ name: RouteName.CREATE_GROUP }">{{
$t("Create group")
}}</router-link>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<invitations
:invitations="invitations"
@@ -16,7 +20,13 @@
@rejectInvitation="rejectInvitation"
/>
<section v-if="memberships && memberships.length > 0">
<GroupMemberCard v-for="member in memberships" :key="member.id" :member="member" />
<GroupMemberCard
class="group-member-card"
v-for="member in memberships"
:key="member.id"
:member="member"
@leave="leaveGroup(member.parent)"
/>
</section>
<b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger">
{{ $t("No groups found") }}
@@ -27,10 +37,13 @@
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { LEAVE_GROUP } from "@/graphql/group";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import Invitations from "@/components/Group/Invitations.vue";
import { Paginate } from "@/types/paginate";
import { IMember, MemberRole, usernameWithDomain } from "@/types/actor";
import { IGroup, IMember, MemberRole, usernameWithDomain } from "@/types/actor";
import { Route } from "vue-router";
import { ApolloError } from "apollo-client";
import RouteName from "../../router/name";
@Component({
@@ -64,14 +77,14 @@ export default class MyEvents extends Vue {
RouteName = RouteName;
acceptInvitation(member: IMember) {
acceptInvitation(member: IMember): Promise<Route> {
return this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
}
rejectInvitation({ id: memberId }: { id: string }) {
rejectInvitation({ id: memberId }: { id: string }): void {
const index = this.membershipsPages.elements.findIndex(
(membership) => membership.role === MemberRole.INVITED && membership.id === memberId
);
@@ -81,14 +94,23 @@ export default class MyEvents extends Vue {
}
}
get invitations() {
async leaveGroup(group: IGroup): Promise<void> {
await this.$apollo.mutate({
mutation: LEAVE_GROUP,
variables: {
groupId: group.id,
},
});
}
get invitations(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
}
get memberships() {
get memberships(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => ![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
@@ -114,4 +136,8 @@ section {
text-transform: capitalize;
}
}
.group-member-card {
margin-bottom: 1rem;
}
</style>

View File

@@ -23,10 +23,8 @@
</aside>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { Route } from "vue-router";
import { IGroup, IPerson } from "@/types/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { Component, Vue } from "vue-property-decorator";
import { IGroup } from "@/types/actor";
import RouteName from "../../router/name";
import SettingMenuSection from "../../components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "../../components/Settings/SettingMenuItem.vue";

View File

@@ -42,14 +42,11 @@
import { Component, Vue, Prop } from "vue-property-decorator";
import Editor from "@/components/Editor.vue";
import { GraphQLError } from "graphql";
import { FETCH_GROUP } from "@/graphql/group";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "../../graphql/actor";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST } from "../../graphql/post";
import { FETCH_POST } from "../../graphql/post";
import { IPost, PostVisibility } from "../../types/post.model";
import { IGroup, IMember, usernameWithDomain } from "../../types/actor";
import { IPost } from "../../types/post.model";
import { IMember, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
@@ -90,11 +87,11 @@ import Tag from "../../components/Tag.vue";
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.post ? this.post.title : "",
// all titles will be injected into this template
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
titleTemplate: this.post ? "%s | Mobilizon" : "Mobilizon",
};
@@ -116,7 +113,7 @@ export default class Post extends Vue {
return this.memberships.map(({ parent: { id } }) => id).includes(this.post.attributedTo.id);
}
async handleErrors(errors: GraphQLError[]) {
async handleErrors(errors: GraphQLError[]): Promise<void> {
if (errors[0].message.includes("No such post")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}

View File

@@ -8,7 +8,7 @@
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(resource.actor) },
}"
>{{ resource.actor.preferredUsername }}</router-link
>{{ resource.actor.name }}</router-link
>
</li>
<li>

View File

@@ -177,6 +177,7 @@
<script lang="ts">
import { Component, Vue, Ref } from "vue-property-decorator";
import { Route } from "vue-router";
import { CHANGE_EMAIL, CHANGE_PASSWORD, DELETE_ACCOUNT, LOGGED_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { IUser, IAuthProvider } from "../../types/current-user.model";
@@ -210,7 +211,7 @@ export default class AccountSettings extends Vue {
RouteName = RouteName;
async resetEmailAction() {
async resetEmailAction(): Promise<void> {
this.changeEmailErrors = [];
try {
@@ -234,7 +235,7 @@ export default class AccountSettings extends Vue {
}
}
async resetPasswordAction() {
async resetPasswordAction(): Promise<void> {
this.changePasswordErrors = [];
try {
@@ -252,12 +253,12 @@ export default class AccountSettings extends Vue {
}
}
protected async openDeleteAccountModal() {
protected openDeleteAccountModal(): void {
this.passwordForAccountDeletion = "";
this.isDeleteAccountModalActive = true;
}
async deleteAccount() {
async deleteAccount(): Promise<Route | void> {
try {
await this.$apollo.mutate({
mutation: DELETE_ACCOUNT,
@@ -275,19 +276,19 @@ export default class AccountSettings extends Vue {
return await this.$router.push({ name: RouteName.HOME });
} catch (err) {
this.handleErrors("delete", err);
return this.handleErrors("delete", err);
}
}
get canChangePassword() {
get canChangePassword(): boolean {
return !this.loggedUser.provider;
}
get canChangeEmail() {
get canChangeEmail(): boolean {
return !this.loggedUser.provider;
}
providerName(id: string) {
static providerName(id: string): string {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
@@ -307,31 +308,17 @@ export default class AccountSettings extends Vue {
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "email":
this.changeEmailErrors.push(this.convertMessage(message) as string);
break;
case "password":
this.changePasswordErrors.push(this.convertMessage(message) as string);
this.changePasswordErrors.push(message);
break;
case "email":
default:
this.changeEmailErrors.push(message);
break;
}
});
}
}
private convertMessage(message: string) {
switch (message) {
case "The password provided is invalid":
return this.$t("The password provided is invalid");
case "The new email must be different":
return this.$t("The new email must be different");
case "The new email doesn't seem to be valid":
return this.$t("The new email doesn't seem to be valid");
case "The current password is invalid":
return this.$t("The current password is invalid");
case "The new password must be different":
return this.$t("The new password must be different");
}
}
}
</script>
<style lang="scss">

View File

@@ -8,7 +8,7 @@
name: RouteName.GROUP,
params: { preferredUsername: todo.todoList.actor.preferredUsername },
}"
>{{ todo.todoList.actor.preferredUsername }}</router-link
>{{ todo.todoList.actor.name }}</router-link
>
</li>
<li>

View File

@@ -8,7 +8,7 @@
name: RouteName.GROUP,
params: { preferredUsername: todoList.actor.preferredUsername },
}"
>{{ todoList.actor.preferredUsername }}</router-link
>{{ todoList.actor.name }}</router-link
>
</li>
<li>

View File

@@ -8,7 +8,7 @@
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.preferredUsername }}</router-link
>{{ group.name }}</router-link
>
</li>
<li class="is-active">

View File

@@ -30,33 +30,8 @@
})
}}</b-message
>
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">
<span v-if="error === LoginError.USER_NOT_CONFIRMED">
<span>
{{
$t(
"The user account you're trying to login as has not been confirmed yet. Check your email inbox and eventually your spam folder."
)
}}
</span>
<i18n path="You may also ask to {resend_confirmation_email}.">
<router-link
slot="resend_confirmation_email"
:to="{ name: RouteName.RESEND_CONFIRMATION }"
>{{ $t("resend confirmation email") }}</router-link
>
</i18n>
</span>
<span v-if="error === LoginError.USER_EMAIL_PASSWORD_INVALID">{{
$t("Impossible to login, your email or password seems incorrect.")
}}</span>
<!-- TODO: Shouldn't we hide this information? -->
<span v-if="error === LoginError.USER_DOES_NOT_EXIST">{{
$t("No user account with this email was found. Maybe you made a typo?")
}}</span>
<span v-if="error === LoginError.USER_DISABLED">
{{ $t("This user has been disabled") }}
</span>
<b-message :title="$t('Error')" type="is-danger" v-for="error in errors" :key="error">
{{ error }}
</b-message>
<form @submit="loginAction">
<b-field :label="$t('Email')" label-for="email">
@@ -80,9 +55,10 @@
/>
</b-field>
<p class="control has-text-centered">
<p class="control has-text-centered" v-if="!submitted">
<button class="button is-primary is-large">{{ $t("Login") }}</button>
</p>
<b-loading :is-full-page="false" v-model="submitted" />
<div class="control" v-if="config && config.auth.oauthProviders.length > 0">
<auth-providers :oauthProviders="config.auth.oauthProviders" />
@@ -121,6 +97,7 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { Route } from "vue-router";
import { LOGIN } from "../../graphql/auth";
import { validateEmailField, validateRequiredField } from "../../utils/validators";
import { initializeCurrentActor, NoIdentitiesException, saveUserData } from "../../utils/auth";
@@ -185,7 +162,9 @@ export default class Login extends Vue {
private redirect: string | null = null;
mounted() {
submitted = false;
mounted(): void {
this.credentials.email = this.email;
this.credentials.password = this.password;
@@ -194,12 +173,16 @@ export default class Login extends Vue {
this.redirect = query.redirect as string;
}
async loginAction(e: Event) {
async loginAction(e: Event): Promise<Route | void> {
e.preventDefault();
if (this.submitted) {
return;
}
this.errors = [];
try {
this.submitted = true;
const { data } = await this.$apollo.mutate<{ login: ILogin }>({
mutation: LOGIN,
variables: {
@@ -242,6 +225,7 @@ export default class Login extends Vue {
window.localStorage.setItem("welcome-back", "yes");
return this.$router.push({ name: RouteName.HOME });
} catch (err) {
this.submitted = false;
console.error(err);
err.graphQLErrors.forEach(({ message }: { message: string }) => {
this.errors.push(message);

View File

@@ -44,8 +44,8 @@
</div>
</div>
<div class="column">
<b-message type="is-warning" v-if="config.registrationsWhitelist">
{{ $t("Registrations are restricted by whitelisting.") }}
<b-message type="is-warning" v-if="config.registrationsAllowlist">
{{ $t("Registrations are restricted by allowlisting.") }}
</b-message>
<form v-on:submit.prevent="submit()">
<b-field

View File

@@ -19,14 +19,7 @@
:key="error"
@close="removeError(error)"
>
<span v-if="error == ResetError.USER_IMPOSSIBLE_TO_RESET">
{{
$t(
"You can't reset your password because you use a 3rd-party auth provider to login."
)
}}
</span>
<span v-else>{{ error }}</span>
{{ error }}
</b-message>
<form @submit="sendResetPasswordTokenAction" v-if="!validationSent">
<b-field :label="$t('Email address')">
@@ -59,7 +52,6 @@ import { Component, Prop, Vue } from "vue-property-decorator";
import { validateEmailField, validateRequiredField } from "../../utils/validators";
import { SEND_RESET_PASSWORD } from "../../graphql/auth";
import RouteName from "../../router/name";
import { ResetError } from "../../types/login-error-code.model";
@Component
export default class SendPasswordReset extends Vue {
@@ -75,8 +67,6 @@ export default class SendPasswordReset extends Vue {
errors: string[] = [];
ResetError = ResetError;
state = {
email: {
status: null,
@@ -89,15 +79,15 @@ export default class SendPasswordReset extends Vue {
email: validateEmailField,
};
mounted() {
mounted(): void {
this.credentials.email = this.email;
}
removeError(message: string) {
removeError(message: string): void {
this.errors.splice(this.errors.indexOf(message));
}
async sendResetPasswordTokenAction(e: Event) {
async sendResetPasswordTokenAction(e: Event): Promise<void> {
e.preventDefault();
try {
@@ -119,7 +109,7 @@ export default class SendPasswordReset extends Vue {
}
}
resetState() {
resetState(): void {
this.state = {
email: {
status: null,