build: switch from yarn to npm to manage js dependencies and move js contents to root

yarn v1 is being deprecated and starts to have some issues

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-11-14 17:24:42 +01:00
parent 32055122c3
commit 2e72f6faf4
595 changed files with 12078 additions and 7843 deletions

View File

@@ -0,0 +1,418 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: t('Account'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: t('General'),
},
]"
/>
<section>
<h2>{{ t("Email") }}</h2>
<i18n-t
tag="p"
class="prose dark:prose-invert"
v-if="loggedUser"
keypath="Your current email is {email}. You use it to log in."
>
<template #email>
<b>{{ loggedUser.email }}</b>
</template>
</i18n-t>
<o-notification
v-if="!canChangeEmail && loggedUser.provider"
variant="warning"
:closable="false"
>
{{
t(
"Your email address was automatically set based on your {provider} account.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changeEmailErrors"
>{{ error }}</o-notification
>
<form
@submit.prevent="resetEmailAction"
ref="emailForm"
class="form"
v-if="canChangeEmail"
>
<o-field :label="t('New email')" label-for="account-email">
<o-input
aria-required="true"
required
type="email"
id="account-email"
v-model="newEmail"
/>
</o-field>
<p class="help">{{ t("You'll receive a confirmation email.") }}</p>
<o-field :label="t('Password')" label-for="account-password">
<o-input
aria-required="true"
required
type="password"
id="account-password"
password-reveal
minlength="6"
v-model="passwordForEmailChange"
/>
</o-field>
<o-button class="mt-2" variant="primary" nativeType="submit">
{{ t("Change my email") }}
</o-button>
</form>
<h2 class="mt-2">{{ t("Password") }}</h2>
<o-notification
v-if="!canChangePassword && loggedUser.provider"
variant="warning"
:closable="false"
>
{{
t(
"You can't change your password because you are registered through {provider}.",
{
provider: providerName(loggedUser.provider),
}
)
}}
</o-notification>
<o-notification
variant="danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in changePasswordErrors"
>{{ error }}</o-notification
>
<form
@submit.prevent="resetPasswordAction"
ref="passwordForm"
class="form"
v-if="canChangePassword"
>
<o-field :label="t('Old password')" label-for="account-old-password">
<o-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
id="account-old-password"
v-model="oldPassword"
/>
</o-field>
<o-field :label="t('New password')" label-for="account-new-password">
<o-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
id="account-new-password"
v-model="newPassword"
/>
</o-field>
<o-button class="mt-2" variant="primary" nativeType="submit">
{{ t("Change my password") }}
</o-button>
</form>
<h2 class="mt-2">{{ t("Delete account") }}</h2>
<p class="prose dark:prose-invert">
{{ t("Deleting my account will delete all of my identities.") }}
</p>
<o-button @click="openDeleteAccountModal" variant="danger" class="mb-4">
{{ t("Delete my account") }}
</o-button>
<o-modal
:close-button-aria-label="t('Close')"
v-model:active="isDeleteAccountModalActive"
has-modal-card
full-screen
:can-cancel="false"
>
<section class="">
<div class="">
<div class="container mx-auto max-w-md">
<div class="">
<div class="">
<h1 class="title">
{{ t("Deleting your Mobilizon account") }}
</h1>
<p class="prose dark:prose-invert">
{{
t(
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever."
)
}}
<br />
<b>{{ t("There will be no way to recover your data.") }}</b>
</p>
<p class="prose dark:prose-invert" v-if="hasUserGotAPassword">
{{
t("Please enter your password to confirm this action.")
}}
</p>
<form @submit.prevent="deleteAccount">
<o-field
:type="deleteAccountPasswordFieldType"
v-if="hasUserGotAPassword"
label-for="account-deletion-password"
>
<o-input
type="password"
v-model="passwordForAccountDeletion"
password-reveal
id="account-deletion-password"
:aria-label="t('Password')"
icon="lock"
:placeholder="t('Password')"
/>
<template #message>
<o-notification
class="mt-2 not-italic text-base"
variant="danger"
v-for="message in deletePasswordErrors"
:key="message"
>
{{ message }}
</o-notification>
</template>
</o-field>
<div class="flex items-center justify-center">
<o-button
class="mt-2"
native-type="submit"
variant="danger"
size="large"
>
{{ t("Delete everything") }}
</o-button>
</div>
</form>
<div class="mt-4 text-center">
<o-button
variant="light"
@click="isDeleteAccountModalActive = false"
>
{{ t("Cancel") }}
</o-button>
</div>
</div>
</div>
</div>
</div>
</section>
</o-modal>
</section>
</div>
</template>
<script lang="ts" setup>
import { useLoggedUser } from "@/composition/apollo/user";
import { Notifier } from "@/plugins/notifier";
import { IAuthProvider } from "@/types/enums";
import { useMutation } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { GraphQLError } from "graphql/error/GraphQLError";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import {
CHANGE_EMAIL,
CHANGE_PASSWORD,
DELETE_ACCOUNT,
} from "../../graphql/user";
import RouteName from "../../router/name";
import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
import { useProgrammatic } from "@oruga-ui/oruga-next";
const { t } = useI18n({ useScope: "global" });
const { loggedUser } = useLoggedUser();
useHead({
title: computed(() => t("General settings")),
});
const passwordForm = ref<HTMLFormElement>();
const emailForm = ref<HTMLFormElement>();
const passwordForEmailChange = ref("");
const newEmail = ref("");
const changeEmailErrors = ref<string[]>([]);
const oldPassword = ref("");
const newPassword = ref("");
const changePasswordErrors = ref<string[]>([]);
const deletePasswordErrors = ref<string[]>([]);
const isDeleteAccountModalActive = ref(false);
const passwordForAccountDeletion = ref("");
const notifier = inject<Notifier>("notifier");
const {
mutate: changeEmailMutation,
onDone: changeEmailMutationDone,
onError: changeEmailMutationError,
} = useMutation(CHANGE_EMAIL);
changeEmailMutationDone(() => {
notifier?.info(
t(
"The account's email address was changed. Check your emails to verify it."
)
);
newEmail.value = "";
passwordForEmailChange.value = "";
});
changeEmailMutationError((err) => {
handleErrors("email", err);
});
const resetEmailAction = async (): Promise<void> => {
if (emailForm.value?.reportValidity()) {
changeEmailErrors.value = [];
changeEmailMutation({
email: newEmail.value,
password: passwordForEmailChange.value,
});
}
};
const {
mutate: changePasswordMutation,
onDone: onChangePasswordMutationDone,
onError: onChangePasswordMutationError,
} = useMutation(CHANGE_PASSWORD);
onChangePasswordMutationDone(() => {
oldPassword.value = "";
newPassword.value = "";
notifier?.success(t("The password was successfully changed"));
});
onChangePasswordMutationError((err) => {
handleErrors("password", err);
});
const resetPasswordAction = async (): Promise<void> => {
if (passwordForm.value?.reportValidity()) {
changePasswordErrors.value = [];
changePasswordMutation({
oldPassword: oldPassword.value,
newPassword: newPassword.value,
});
}
};
const openDeleteAccountModal = (): void => {
passwordForAccountDeletion.value = "";
isDeleteAccountModalActive.value = true;
};
const router = useRouter();
const {
mutate: deleteAccountMutation,
onDone: deleteAccountMutationDone,
onError: deleteAccountMutationError,
} = useMutation<{ deleteAccount: { id: string } }, { password?: string }>(
DELETE_ACCOUNT
);
const { oruga } = useProgrammatic();
deleteAccountMutationDone(async () => {
console.debug("Deleted account, logging out client...");
await logout(false);
oruga.notification.open({
message: t("Your account has been successfully deleted"),
variant: "success",
position: "bottom-right",
duration: 5000,
});
return router.push({ name: RouteName.HOME });
});
deleteAccountMutationError((err) => {
deletePasswordErrors.value = err.graphQLErrors.map(
({ message }: GraphQLError) => message
);
});
const deleteAccount = () => {
deletePasswordErrors.value = [];
console.debug("Asking to delete account...");
deleteAccountMutation({
password: hasUserGotAPassword.value
? passwordForAccountDeletion.value
: undefined,
});
};
const canChangePassword = computed((): boolean => {
return !loggedUser.value?.provider;
});
const canChangeEmail = computed((): boolean => {
return !loggedUser.value?.provider;
});
const providerName = (id: string): string => {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
};
const hasUserGotAPassword = computed((): boolean => {
return (
loggedUser.value?.provider == null ||
loggedUser.value?.provider === IAuthProvider.LDAP
);
});
const deleteAccountPasswordFieldType = computed((): string | null => {
return deletePasswordErrors.value.length > 0 ? "is-danger" : null;
});
const handleErrors = (type: string, err: any) => {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => {
switch (type) {
case "password":
changePasswordErrors.value.push(message);
break;
case "email":
default:
changeEmailErrors.value.push(message);
break;
}
});
}
};
</script>

View File

@@ -0,0 +1,153 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.AUTHORIZED_APPS,
text: t('Apps'),
},
{
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
text: t('General'),
},
]"
/>
<section>
<h2>{{ t("Apps") }}</h2>
<p>
{{
t(
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access."
)
}}
</p>
<div v-if="authAuthorizedApplications.length > 0">
<div
class="flex justify-between items-center rounded-lg bg-white shadow-xl my-6"
v-for="authAuthorizedApplication in authAuthorizedApplications"
:key="authAuthorizedApplication.id"
>
<div class="p-4">
<p class="text-3xl font-bold">
{{ authAuthorizedApplication.application.name }}
</p>
<a
v-if="authAuthorizedApplication.application.website"
target="_blank"
:href="authAuthorizedApplication.application.website"
>{{
urlToHostname(authAuthorizedApplication.application.website)
}}</a
>
<p>
<span v-if="authAuthorizedApplication.lastUsedAt">{{
t("Last used on {last_used_date}", {
last_used_date: formatDateString(
authAuthorizedApplication.lastUsedAt
),
})
}}</span>
<span v-else>{{ t("Never used") }}</span>
{{
t("Authorized on {authorization_date}", {
authorization_date: formatDateString(
authAuthorizedApplication.insertedAt
),
})
}}
</p>
</div>
<div class="p-4">
<o-button
@click="
() => revoke({ appTokenId: authAuthorizedApplication.id })
"
variant="danger"
>{{ t("Revoke") }}</o-button
>
</div>
</div>
</div>
<EmptyContent v-else icon="apps" inline>
{{ t("No apps authorized yet") }}
</EmptyContent>
</section>
</div>
</template>
<script lang="ts" setup>
import { useLoggedUser } from "@/composition/apollo/user";
import {
AUTH_AUTHORIZED_APPLICATIONS,
REVOKED_AUTHORIZED_APPLICATION,
} from "@/graphql/application";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
import RouteName from "../../router/name";
import { IUser } from "@/types/current-user.model";
import { formatDateString } from "@/filters/datetime";
import { Notifier } from "@/plugins/notifier";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
const { t } = useI18n({ useScope: "global" });
const { loggedUser } = useLoggedUser();
const { result: authAuthorizedApplicationsResult } = useQuery<{
loggedUser: Pick<IUser, "authAuthorizedApplications">;
}>(AUTH_AUTHORIZED_APPLICATIONS);
const authAuthorizedApplications = computed(
() =>
authAuthorizedApplicationsResult.value?.loggedUser
?.authAuthorizedApplications ?? []
);
const urlToHostname = (url: string | undefined): string | null => {
if (!url) return null;
try {
return new URL(url).hostname;
} catch (e) {
return null;
}
};
const { mutate: revoke, onDone: onRevokedApplication } = useMutation<
{ revokeApplicationToken: { id: string } },
{ appTokenId: string }
>(REVOKED_AUTHORIZED_APPLICATION, {
update: (cache, { data: returnedData }) => {
const data = cache.readQuery<{
loggedUser: Pick<IUser, "authAuthorizedApplications">;
}>({ query: AUTH_AUTHORIZED_APPLICATIONS });
if (!data) return;
if (!returnedData) return;
const authorizedApplications =
data.loggedUser.authAuthorizedApplications.filter(
(app) => app.id !== returnedData.revokeApplicationToken.id
);
cache.writeQuery({
query: AUTH_AUTHORIZED_APPLICATIONS,
data: {
...data,
loggedUser: {
...data.loggedUser,
authAuthorizedApplications: authorizedApplications,
},
},
});
},
});
const notifier = inject<Notifier>("notifier");
onRevokedApplication(() => {
notifier?.success(t("Application was revoked"));
});
useHead({
title: computed(() => t("Apps")),
});
</script>

View File

@@ -0,0 +1,858 @@
<template>
<div v-if="loggedUser">
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: $t('Account'),
},
{
name: RouteName.NOTIFICATIONS,
text: $t('Notifications'),
},
]"
/>
<section class="my-4">
<h2>{{ $t("Browser notifications") }}</h2>
<o-button
v-if="subscribed"
@click="unsubscribeToWebPush()"
@keyup.enter="unsubscribeToWebPush()"
>{{ $t("Unsubscribe to browser push notifications") }}</o-button
>
<o-button
icon-left="rss"
@click="subscribeToWebPush"
@keyup.enter="subscribeToWebPush"
v-else-if="canShowWebPush && webPushEnabled"
>{{ $t("Activate browser push notifications") }}</o-button
>
<o-notification variant="warning" v-else-if="!webPushEnabled">
{{ $t("This instance hasn't got push notifications enabled.") }}
<i18n-t keypath="Ask your instance admin to {enable_feature}.">
<template #enable_feature>
<a
href="https://docs.joinmobilizon.org/administration/configure/push/"
target="_blank"
rel="noopener noreferer"
>{{ $t("enable the feature") }}</a
>
</template>
</i18n-t>
</o-notification>
<o-notification variant="danger" v-else>{{
$t("You can't use push notifications in this browser.")
}}</o-notification>
</section>
<section class="my-4">
<h2>{{ $t("Notification settings") }}</h2>
<p>
{{
$t(
"Select the activities for which you wish to receive an email or a push notification."
)
}}
</p>
<table class="table table-auto">
<tbody>
<template
v-for="notificationType in notificationTypes"
:key="notificationType"
>
<tr>
<th colspan="3">
{{ notificationType.label }}
</th>
</tr>
<tr>
<th v-for="(method, key) in notificationMethods" :key="key">
{{ method }}
</th>
<th>{{ $t("Description") }}</th>
</tr>
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
<td v-for="(method, key) in notificationMethods" :key="key">
<o-checkbox
:modelValue="notificationValues?.[subType.id]?.[key]?.enabled"
@update:modelValue="
(e: boolean) =>
updateNotificationValue({
key: subType.id,
method: key,
enabled: e,
})
"
:disabled="notificationValues?.[subType.id]?.[key]?.disabled"
/>
</td>
<td>
{{ subType.label }}
</td>
</tr>
</template>
</tbody>
</table>
<o-field
:label="$t('Send notification e-mails')"
label-for="groupNotifications"
:message="
$t(
'Announcements and mentions notifications are always sent straight away.'
)
"
>
<o-select
v-model="groupNotifications"
@update:modelValue="updateSetting({ groupNotifications })"
id="groupNotifications"
>
<option
v-for="(value, key) in groupNotificationsValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</o-field>
</section>
<section class="my-4">
<h2>{{ $t("Participation notifications") }}</h2>
<div class="field">
<strong>{{
$t(
"Mobilizon will send you an email when the events you are attending have important changes: date and time, address, confirmation or cancellation, etc."
)
}}</strong>
</div>
<p>
{{ $t("Other notification options:") }}
</p>
<div class="field">
<o-checkbox
v-model="notificationOnDay"
@input="updateSetting({ notificationOnDay })"
>
<strong>{{ $t("Notification on the day of the event") }}</strong>
<p>
{{
$t(
"We'll use your timezone settings to send a recap of the morning of the event."
)
}}
</p>
<div v-if="loggedUser.settings && loggedUser.settings.timezone">
<em>{{
$t("Your timezone is currently set to {timezone}.", {
timezone: loggedUser.settings.timezone,
})
}}</em>
<router-link
class="change-timezone"
:to="{ name: RouteName.PREFERENCES }"
>{{ $t("Change timezone") }}</router-link
>
</div>
<span v-else>{{
$t("You can pick your timezone into your preferences.")
}}</span>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationEachWeek"
@input="updateSetting({ notificationEachWeek })"
>
<strong>{{ $t("Recap every week") }}</strong>
<p>
{{
$t(
"You'll get a weekly recap every Monday for upcoming events, if you have any."
)
}}
</p>
</o-checkbox>
</div>
<div class="field">
<o-checkbox
v-model="notificationBeforeEvent"
@input="updateSetting({ notificationBeforeEvent })"
>
<strong>{{ $t("Notification before the event") }}</strong>
<p>
{{
$t(
"We'll send you an email one hour before the event begins, to be sure you won't forget about it."
)
}}
</p>
</o-checkbox>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Organizer notifications") }}</h2>
<div class="field is-primary">
<label
class="has-text-weight-bold"
for="notificationPendingParticipation"
>{{
$t("Notifications for manually approved participations to an event")
}}</label
>
<p>
{{
$t(
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below."
)
}}
</p>
<o-select
v-model="notificationPendingParticipation"
id="notificationPendingParticipation"
@input="updateSetting({ notificationPendingParticipation })"
>
<option
v-for="(value, key) in notificationPendingParticipationValues"
:value="key"
:key="key"
>
{{ value }}
</option>
</o-select>
</div>
</section>
<section class="my-4">
<h2>{{ $t("Personal feeds") }}</h2>
<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="flex gap-2"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
variant="success"
position="left"
>
<o-button
tag="a"
icon-left="rss"
@click="
(e: Event) =>
copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
@keyup.enter="
(e: Event) =>
copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</o-button
>
</o-tooltip>
<o-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
variant="success"
position="left"
>
<o-button
tag="a"
@click="
(e: Event) =>
copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
@keyup.enter="
(e: Event) =>
copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</o-button
>
</o-tooltip>
<o-button
icon-left="refresh"
variant="text"
@click="openRegenerateFeedTokensConfirmation"
@keyup.enter="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</o-button
>
</div>
</div>
<div v-else>
<o-button
icon-left="refresh"
variant="text"
@click="generateFeedTokens"
@keyup.enter="generateFeedTokens"
>{{ $t("Create new links") }}</o-button
>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { INotificationPendingEnum } from "@/types/enums";
import {
SET_USER_SETTINGS,
USER_NOTIFICATIONS,
UPDATE_ACTIVITY_SETTING,
USER_FRAGMENT_FEED_TOKENS,
} from "../../graphql/user";
import {
IActivitySetting,
IActivitySettingMethod,
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";
import {
subscribeUserToPush,
unsubscribeUserToPush,
} from "../../services/push-subscription";
import {
REGISTER_PUSH_MUTATION,
UNREGISTER_PUSH_MUTATION,
} from "@/graphql/webPush";
import merge from "lodash/merge";
import { WEB_PUSH } from "@/graphql/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
computed,
inject,
onBeforeMount,
onMounted,
reactive,
ref,
watch,
} from "vue";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import { Dialog } from "@/plugins/dialog";
type NotificationSubType = { label: string; id: string };
type NotificationType = { label: string; subtypes: NotificationSubType[] };
const { result: loggedUserResult } = useQuery<{ loggedUser: IUser }>(
USER_NOTIFICATIONS
);
const loggedUser = computed(() => loggedUserResult.value?.loggedUser);
const feedTokens = computed(
() =>
loggedUser.value?.feedTokens.filter(
(token: IFeedToken) => token.actor === null
)
);
const { result: webPushEnabledResult } = useQuery<{
config: Pick<IConfig, "webPush">;
}>(WEB_PUSH);
const webPushEnabled = computed(
() => webPushEnabledResult.value?.config?.webPush.enabled
);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Notification settings")),
});
const notificationOnDay = ref<boolean | undefined>(true);
const notificationEachWeek = ref<boolean | undefined>(false);
const notificationBeforeEvent = ref<boolean | undefined>(false);
const notificationPendingParticipation = ref<
INotificationPendingEnum | undefined
>(INotificationPendingEnum.NONE);
const groupNotifications = ref<INotificationPendingEnum | undefined>(
INotificationPendingEnum.ONE_DAY
);
const notificationPendingParticipationValues = ref<Record<string, unknown>>({});
const groupNotificationsValues = ref<Record<string, unknown>>({});
const showCopiedTooltip = reactive({ ics: false, atom: false });
const subscribed = ref(false);
const canShowWebPush = ref(false);
const notificationMethods = {
email: t("Email"),
push: t("Push"),
};
const defaultNotificationValues = {
participation_event_updated: {
email: { enabled: true, disabled: true },
push: { enabled: true, disabled: true },
},
participation_event_comment: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_pending_participation: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
event_new_participation: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
event_created: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
discussion_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_published: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
post_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
resource_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
member_request: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
member_updated: {
email: { enabled: false, disabled: false },
push: { enabled: false, disabled: false },
},
user_email_password_updated: {
email: { enabled: true, disabled: true },
push: { enabled: false, disabled: true },
},
event_comment_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
conversation_mention: {
email: { enabled: true, disabled: false },
push: { enabled: true, disabled: false },
},
discussion_mention: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
event_new_comment: {
email: { enabled: true, disabled: false },
push: { enabled: false, disabled: false },
},
};
const notificationTypes: NotificationType[] = [
{
label: t("Mentions") as string,
subtypes: [
{
id: "conversation_mention",
label: t("I've been mentionned in a conversation") as string,
},
{
id: "event_comment_mention",
label: t("I've been mentionned in a comment under an event") as string,
},
{
id: "discussion_mention",
label: t("I've been mentionned in a group discussion") as string,
},
],
},
{
label: t("Participations") as string,
subtypes: [
{
id: "participation_event_updated",
label: t("An event I'm going to has been updated") as string,
},
{
id: "participation_event_comment",
label: t("An event I'm going to has posted an announcement") as string,
},
],
},
{
label: t("Organizers") as string,
subtypes: [
{
id: "event_new_pending_participation",
label: t(
"An event I'm organizing has a new pending participation"
) as string,
},
{
id: "event_new_participation",
label: t("An event I'm organizing has a new participation") as string,
},
{
id: "event_new_comment",
label: t("An event I'm organizing has a new comment") as string,
},
],
},
{
label: t("Group activity") as string,
subtypes: [
{
id: "event_created",
label: t("An event from one of my groups has been published") as string,
},
{
id: "event_updated",
label: t(
"An event from one of my groups has been updated or deleted"
) as string,
},
{
id: "discussion_updated",
label: t("A discussion has been created or updated") as string,
},
{
id: "post_published",
label: t("A post has been published") as string,
},
{
id: "post_updated",
label: t("A post has been updated") as string,
},
{
id: "resource_updated",
label: t("A resource has been created or updated") as string,
},
{
id: "member_request",
label: t("A member requested to join one of my groups") as string,
},
{
id: "member_updated",
label: t("A member has been updated") as string,
},
],
},
{
label: t("User settings") as string,
subtypes: [
{
id: "user_email_password_updated",
label: t("You changed your email or password") as string,
},
],
},
];
const userNotificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
return (loggedUser.value?.activitySettings ?? []).reduce(
(acc, activitySetting) => {
acc[activitySetting.key] = acc[activitySetting.key] || {};
acc[activitySetting.key][activitySetting.method] =
acc[activitySetting.key][activitySetting.method] || {};
acc[activitySetting.key][activitySetting.method].enabled =
activitySetting.enabled;
return acc;
},
{} as Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
>
);
}
);
const notificationValues = computed(
(): Record<
string,
Record<IActivitySettingMethod, { enabled: boolean; disabled: boolean }>
> => {
const values = merge(
defaultNotificationValues,
userNotificationValues.value
);
for (const value in values) {
if (!canShowWebPush.value) {
values[value].push.disabled = true;
}
}
return values;
}
);
onMounted(async () => {
notificationPendingParticipationValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email per request"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
groupNotificationsValues.value = {
[INotificationPendingEnum.NONE]: t("Do not receive any mail"),
[INotificationPendingEnum.DIRECT]: t("Receive one email for each activity"),
[INotificationPendingEnum.ONE_HOUR]: t("Hourly email summary"),
[INotificationPendingEnum.ONE_DAY]: t("Daily email summary"),
[INotificationPendingEnum.ONE_WEEK]: t("Weekly email summary"),
};
canShowWebPush.value = await checkCanShowWebPush();
});
watch(loggedUser, () => {
if (loggedUser.value?.settings) {
notificationOnDay.value = loggedUser.value.settings.notificationOnDay;
notificationEachWeek.value = loggedUser.value.settings.notificationEachWeek;
notificationBeforeEvent.value =
loggedUser.value.settings.notificationBeforeEvent;
notificationPendingParticipation.value =
loggedUser.value.settings.notificationPendingParticipation;
groupNotifications.value = loggedUser.value.settings.groupNotifications;
}
});
const { mutate: updateSetting } = useMutation<{ setUserSettings: string }>(
SET_USER_SETTINGS,
() => ({ refetchQueries: [{ query: USER_NOTIFICATIONS }] })
);
const tokenToURL = (token: string, format: string): string => {
return `${window.location.origin}/events/going/${token}/${format}`;
};
const copyURL = (e: Event, url: string, format: "ics" | "atom"): void => {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
showCopiedTooltip[format] = true;
setTimeout(() => {
showCopiedTooltip[format] = false;
}, 2000);
}
};
const dialog = inject<Dialog>("dialog");
const openRegenerateFeedTokensConfirmation = () => {
dialog?.confirm({
variant: "warning",
title: t("Regenerate new links") as string,
message: t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: t("Regenerate new links") as string,
cancelText: t("Cancel") as string,
onConfirm: () => regenerateFeedTokens(),
});
};
const regenerateFeedTokens = async (): Promise<void> => {
if (!feedTokens.value || feedTokens.value?.length < 1) return;
await deleteFeedToken({ token: feedTokens.value[0].token });
await createNewFeedToken(
{},
{
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Remove the old token
cachedData = {
id: cachedData?.id,
feedTokens: [
...(cachedData?.feedTokens ?? []).slice(0, -1),
{ token: newFeedToken },
],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}
);
};
const generateFeedTokens = async (): Promise<void> => {
await createNewFeedToken();
};
const {
mutate: registerPushMutation,
onDone: registerPushMutationDone,
onError: registerPushMutationError,
} = useMutation(REGISTER_PUSH_MUTATION);
registerPushMutationDone(() => {
subscribed.value = true;
});
registerPushMutationError((err) => {
console.error(err);
});
const subscribeToWebPush = async (): Promise<void> => {
if (canShowWebPush.value) {
const subscription = await subscribeUserToPush();
if (subscription) {
const subscriptionJSON = subscription?.toJSON();
registerPushMutation({
endpoint: subscriptionJSON.endpoint,
auth: subscriptionJSON?.keys?.auth,
p256dh: subscriptionJSON?.keys?.p256dh,
});
subscribed.value = true;
} else {
// tnotifier.error(
// t("Error while subscribing to push notifications") as string
// );
}
} else {
console.error("can't do webpush");
}
};
const {
mutate: unregisterPushMutation,
onDone: onUnregisterPushMutationDone,
onError: onUnregisterPushMutationError,
} = useMutation(UNREGISTER_PUSH_MUTATION);
onUnregisterPushMutationDone(({ data }) => {
console.debug(data);
subscribed.value = false;
});
onUnregisterPushMutationError((e) => {
console.error(e);
});
const unsubscribeToWebPush = async (): Promise<void> => {
const endpoint = await unsubscribeUserToPush();
if (endpoint) {
unregisterPushMutation({
endpoint,
});
}
};
const checkCanShowWebPush = async (): Promise<boolean> => {
try {
if (!window.isSecureContext || !("serviceWorker" in navigator))
return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return registration !== undefined;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
onBeforeMount(async () => {
subscribed.value = await isSubscribed();
});
const { mutate: updateNotificationValue } = useMutation<
{
updateActivitySetting: IActivitySetting;
},
{
key: string;
method: IActivitySettingMethod;
enabled: boolean;
}
>(UPDATE_ACTIVITY_SETTING);
const isSubscribed = async (): Promise<boolean> => {
try {
if (!("serviceWorker" in navigator)) return Promise.resolve(false);
const registration = await navigator.serviceWorker.getRegistration();
return (await registration?.pushManager?.getSubscription()) != null;
} catch (e) {
console.error(e);
return Promise.resolve(false);
}
};
const { mutate: deleteFeedToken } = useMutation(DELETE_FEED_TOKEN);
const { mutate: createNewFeedToken } = useMutation(CREATE_FEED_TOKEN, () => ({
update(cache, { data }) {
const userId = data?.createFeedToken.user?.id;
const newFeedToken = data?.createFeedToken.token;
if (!newFeedToken) return;
let cachedData = cache.readFragment<{
id: string | undefined;
feedTokens: { token: string }[];
}>({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
});
// Add the new token to the list
cachedData = {
id: cachedData?.id,
feedTokens: [...(cachedData?.feedTokens ?? []), { token: newFeedToken }],
};
cache.writeFragment({
id: `User:${userId}`,
fragment: USER_FRAGMENT_FEED_TOKENS,
data: cachedData,
});
},
}));
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.field {
&:not(:last-child) {
margin-bottom: 1.5rem;
}
a.change-timezone {
text-decoration: underline;
text-decoration-thickness: 2px;
@include margin-left(5px);
}
}
:deep(.buttons > *:not(:last-child) .button) {
margin-right: 0.5rem;
@include margin-right(0.5rem);
}
</style>

View File

@@ -0,0 +1,351 @@
<template>
<div>
<breadcrumbs-nav
:links="[
{
name: RouteName.ACCOUNT_SETTINGS,
text: t('Account'),
},
{
name: RouteName.PREFERENCES,
text: t('Preferences'),
},
]"
/>
<div>
<o-field :label="t('Theme')" addonsClass="flex flex-col">
<o-field>
<o-checkbox v-model="systemTheme">{{
t("Adapt to system theme")
}}</o-checkbox>
</o-field>
<o-field>
<fieldset>
<legend class="sr-only">{{ t("Theme") }}</legend>
<o-radio
:class="{ 'border-mbz-bluegreen': theme === 'light' }"
class="p-4 bg-white text-zinc-800 rounded-md mt-2 mr-2 border-2"
:disabled="systemTheme"
v-model="theme"
name="theme"
native-value="light"
>{{ t("Light") }}</o-radio
>
<o-radio
:class="{ 'border-mbz-bluegreen': theme === 'dark' }"
class="p-4 bg-zinc-800 rounded-md text-white mt-2 ml-2 border-2"
:disabled="systemTheme"
v-model="theme"
name="theme"
native-value="dark"
>{{ t("Dark") }}</o-radio
>
</fieldset>
</o-field>
</o-field>
<o-field :label="t('Language')" label-for="setting-language">
<o-select
:loading="loadingTimezones || loadingUserSettings"
v-model="$i18n.locale"
@update:modelValue="updateLanguage"
:placeholder="t('Select a language')"
id="setting-language"
>
<option v-for="(language, lang) in langs" :value="lang" :key="lang">
{{ language }}
</option>
</o-select>
</o-field>
<o-field
:label="t('Timezone')"
v-if="selectedTimezone"
label-for="setting-timezone"
>
<o-select
:placeholder="t('Select a timezone')"
:loading="loadingTimezones || loadingUserSettings"
v-model="selectedTimezone"
id="setting-timezone"
>
<optgroup
:label="group"
v-for="(groupTimezones, group) in timezones"
:key="group"
>
<option
v-for="timezone in groupTimezones"
:value="`${group}/${timezone}`"
:key="timezone"
>
{{ sanitize(timezone) }}
</option>
</optgroup>
</o-select>
</o-field>
<em v-if="Intl.DateTimeFormat().resolvedOptions().timeZone">{{
t("Timezone detected as {timezone}.", {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
}}</em>
<o-notification v-else variant="danger">{{
t("Unable to detect timezone.")
}}</o-notification>
<hr role="presentation" />
<o-field grouped>
<o-field :label="t('City or region')" expanded label-for="setting-city">
<full-address-auto-complete
v-if="loggedUser?.settings"
:resultType="AddressSearchType.ADMINISTRATIVE"
v-model="address"
:default-text="address?.description"
id="setting-city"
class="grid"
:hideMap="true"
:hideSelected="true"
labelClass="sr-only"
:placeholder="t('e.g. Nantes, Berlin, Cork, …')"
/>
</o-field>
<o-field :label="t('Radius')" label-for="setting-radius">
<o-select
:placeholder="t('Select a radius')"
v-model="locationRange"
id="setting-radius"
>
<option
v-for="index in [1, 5, 10, 25, 50, 100]"
:key="index"
:value="index"
>
{{ t("{count} km", { count: index }, index) }}
</option>
</o-select>
</o-field>
<o-button
:disabled="address == undefined"
@click="resetArea"
@keyup.enter="resetArea"
class="reset-area self-center"
icon-left="close"
:aria-label="t('Reset')"
/>
</o-field>
<p>
{{
t(
"Your city or region and the radius will only be used to suggest you events nearby. The event radius will consider the administrative center of the area."
)
}}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import ngeohash from "ngeohash";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import langs from "../../i18n/langs.json";
import RouteName from "../../router/name";
import { AddressSearchType } from "@/types/enums";
import { Address, IAddress } from "@/types/address.model";
import { useTimezones } from "@/composition/apollo/config";
import { useUserSettings, updateLocale } from "@/composition/apollo/user";
import { useHead } from "@vueuse/head";
import { computed, defineAsyncComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation } from "@vue/apollo-composable";
const FullAddressAutoComplete = defineAsyncComponent(
() => import("@/components/Event/FullAddressAutoComplete.vue")
);
const { timezones: serverTimezones, loading: loadingTimezones } =
useTimezones();
const { loggedUser, loading: loadingUserSettings } = useUserSettings();
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Preferences")),
});
// langs: Record<string, string> = langs;
const theme = ref(localStorage.getItem("theme"));
const systemTheme = ref(!("theme" in localStorage));
const { mutate: doUpdateLocale } = updateLocale();
const updateLanguage = (newLocale: string) => {
doUpdateLocale({ locale: newLocale });
};
watch(systemTheme, (newSystemTheme) => {
console.debug("changing system theme", newSystemTheme);
if (newSystemTheme) {
theme.value = null;
localStorage.removeItem("theme");
} else {
theme.value = "light";
localStorage.setItem("theme", theme.value);
}
changeTheme();
});
watch(theme, (newTheme) => {
console.debug("changing theme value", newTheme);
if (newTheme) {
localStorage.setItem("theme", newTheme);
}
changeTheme();
});
const changeTheme = () => {
console.debug("changing theme to apply");
if (
localStorage.getItem("theme") === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
console.debug("applying dark theme");
document.documentElement.classList.add("dark");
} else {
console.debug("removing dark theme");
document.documentElement.classList.remove("dark");
}
};
const selectedTimezone = computed({
get() {
if (loggedUser.value?.settings?.timezone) {
return loggedUser.value.settings.timezone;
}
const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (loggedUser.value?.settings?.timezone === null) {
updateUserSettings({ timezone: detectedTimezone });
}
return detectedTimezone;
},
set(newSelectedTimezone: string) {
if (newSelectedTimezone !== loggedUser.value?.settings?.timezone) {
updateUserSettings({ timezone: newSelectedTimezone });
}
},
});
const sanitize = (timezone: string): string => {
return timezone
.split("_")
.join(" ")
.replace("St ", "St. ")
.split("/")
.join(" - ");
};
const timezones = computed((): Record<string, string[]> => {
if (!serverTimezones.value) return {};
return serverTimezones.value.reduce(
(acc: { [key: string]: Array<string> }, val: string) => {
const components = val.split("/");
const [prefix, suffix] = [
components.shift() as string,
components.join("/"),
];
const pushOrCreate = (
acc2: { [key: string]: Array<string> },
prefix2: string,
suffix2: string
) => {
// eslint-disable-next-line no-param-reassign
(acc2[prefix2] = acc2[prefix2] || []).push(suffix2);
return acc2;
};
if (suffix) {
return pushOrCreate(acc, prefix, suffix);
}
return pushOrCreate(acc, t("Other") as string, prefix);
},
{}
);
});
const address = computed({
get(): IAddress | null {
if (
loggedUser.value?.settings?.location?.name &&
loggedUser.value?.settings?.location?.geohash
) {
const { latitude, longitude } = ngeohash.decode(
loggedUser.value?.settings?.location?.geohash
);
const name = loggedUser.value?.settings?.location?.name;
return {
description: name,
locality: "",
type: "administrative",
geom: `${longitude};${latitude}`,
street: "",
postalCode: "",
region: "",
country: "",
};
}
return null;
},
set(newAddress: IAddress | null) {
if (newAddress?.geom) {
const { geom } = newAddress;
const addressObject = new Address(newAddress);
const queryText = addressObject.poiInfos.name;
const [lon, lat] = geom.split(";");
const geohash = ngeohash.encode(lat, lon, 6);
if (queryText && geom) {
updateUserSettings({
location: {
geohash,
name: queryText,
},
});
}
}
},
});
const locationRange = computed({
get(): number | undefined | null {
return loggedUser.value?.settings?.location?.range;
},
set(newLocationRange: number | undefined | null) {
if (newLocationRange) {
updateUserSettings({
location: {
range: newLocationRange,
},
});
}
},
});
const resetArea = (): void => {
updateUserSettings({
location: {
geohash: null,
name: null,
range: null,
},
});
};
const { mutate: updateUserSettings } = useMutation<{ setUserSetting: string }>(
SET_USER_SETTINGS,
() => ({
refetchQueries: [{ query: USER_SETTINGS }],
})
);
</script>
<style lang="scss" scoped>
.reset-area {
align-self: center;
position: relative;
top: 10px;
}
</style>