mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-08-05 12:07:22 -04:00
633 lines
25 KiB
Vue
633 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { computedAsync } from '@vueuse/core';
|
|
import { useCookie, useFetch } from 'nuxt/app';
|
|
|
|
import useConfig from '../composables/useConfig.ts';
|
|
import useDialogue from '../composables/useDialogue.ts';
|
|
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
|
|
import { newDate, gravatar } from '../src/helpers.ts';
|
|
import { socialProviders } from '../src/socialProviders.ts';
|
|
import { usernameRegex } from '../src/username.ts';
|
|
import { useMainStore } from '../store/index.ts';
|
|
|
|
import { getUrlForLocale, getUrlsForAllLocales } from '~/src/domain.ts';
|
|
import type { Profile } from '~/src/profile.ts';
|
|
|
|
const { $translator: translator, $setToken: setToken, $removeToken: removeToken } = useNuxtApp();
|
|
const runtimeConfig = useRuntimeConfig();
|
|
const config = useConfig();
|
|
|
|
const dialogue = useDialogue();
|
|
const { accounts, user } = storeToRefs(useMainStore());
|
|
|
|
if (user.value === null) {
|
|
throw 'no user';
|
|
}
|
|
|
|
const tokenCookie = useCookie('token', longtimeCookieSetting);
|
|
const impersonatorCookie = useCookie('impersonator');
|
|
const termsUpdateDismissed3Cookie = useCookie('termsUpdateDismissed3', longtimeCookieSetting);
|
|
|
|
const { data: profilesData } = useFetch(`/api/profile/get/${user.value.username}?version=2&props=hide`, {
|
|
lazy: true,
|
|
});
|
|
const { data: socialConnections } = useFetch('/api/user/social-connections', {
|
|
lazy: true,
|
|
});
|
|
|
|
const baseUrl = getUrlForLocale(config.locale);
|
|
const universalDomains = getUrlsForAllLocales(config.locale).filter((url) => url !== baseUrl);
|
|
|
|
const username = ref(user.value.username);
|
|
const email = ref(user.value.email);
|
|
const socialLookup = ref(Boolean(user.value.socialLookup));
|
|
|
|
const gravatarSrc = computedAsync(async () => {
|
|
return user.value ? await gravatar(user.value) : undefined;
|
|
});
|
|
|
|
const message = ref('');
|
|
const messageParams = ref({});
|
|
const messageIcon = ref<string | null>(null);
|
|
const error = ref('');
|
|
const changeEmailAuthId = ref<string | null>(null);
|
|
const code = ref<string | null>('');
|
|
|
|
const savingUsername = ref(false);
|
|
const savingEmail = ref(false);
|
|
|
|
const showCaptcha = ref(false);
|
|
const captchaToken = ref<string | null>(null);
|
|
|
|
const logoutInProgress = ref(false);
|
|
|
|
const showTermsUpdate = ref(newDate() < new Date(2023, 3, 6) && !termsUpdateDismissed3Cookie.value);
|
|
|
|
const profiles = computed(() => profilesData.value?.profiles);
|
|
const setProfiles = (profiles: Record<string, Partial<Profile>>) => {
|
|
profilesData.value.profiles = profiles;
|
|
};
|
|
|
|
const impersonationActive = computed(() => {
|
|
return !!impersonatorCookie.value;
|
|
});
|
|
|
|
const canChangeEmail = computed(() => {
|
|
return email.value && captchaToken.value;
|
|
});
|
|
|
|
const usernameError = computed(() => {
|
|
if (!username.value.match(usernameRegex)) {
|
|
return translator.translate('user.account.changeUsername.invalid');
|
|
}
|
|
if (username.value !== encodeURIComponent(username.value)) {
|
|
return translator.translate('user.account.changeUsername.nonascii', { encoded: encodeURIComponent(username.value) });
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const hasEmail = computed(() => {
|
|
return user.value?.email && !user.value.email.endsWith('.oauth');
|
|
});
|
|
|
|
const authMethodsCount = computed(() => {
|
|
if (!user.value || socialConnections.value === null) {
|
|
return null;
|
|
}
|
|
return Object.keys(socialConnections.value)
|
|
.filter((provider) => !socialProviders[provider].deprecated)
|
|
.length +
|
|
(hasEmail.value ? 1 : 0);
|
|
});
|
|
|
|
watch(email, (v) => {
|
|
if (v !== user.value?.email) {
|
|
showCaptcha.value = true;
|
|
}
|
|
});
|
|
watch(socialLookup, async (v) => {
|
|
const response = await dialogue.postWithAlertOnError<any>('/api/user/set-social-lookup', { socialLookup: v });
|
|
|
|
await setToken(response.token);
|
|
});
|
|
|
|
const router = useRouter();
|
|
onMounted(async () => {
|
|
const user = await $fetch('/api/user/current');
|
|
if (user) {
|
|
await setToken(user.token);
|
|
}
|
|
|
|
const redirectTo = window.sessionStorage.getItem('after-login');
|
|
if (user.value && redirectTo) {
|
|
window.sessionStorage.removeItem('after-login');
|
|
await router.push(redirectTo);
|
|
}
|
|
});
|
|
|
|
const changeUsername = async () => {
|
|
error.value = '';
|
|
|
|
if (savingUsername.value || !user.value) {
|
|
return;
|
|
}
|
|
savingUsername.value = true;
|
|
try {
|
|
const response = await dialogue.postWithAlertOnError<any>('/api/user/change-username', {
|
|
username: username.value,
|
|
});
|
|
|
|
if (response.error) {
|
|
error.value = response.error;
|
|
return;
|
|
}
|
|
|
|
await removeToken(user.value.username);
|
|
await setToken(response.token);
|
|
username.value = user.value.username;
|
|
message.value = 'crud.saved';
|
|
messageParams.value = {};
|
|
messageIcon.value = 'check-circle';
|
|
setTimeout(() => message.value = '', 3000);
|
|
} finally {
|
|
savingUsername.value = false;
|
|
}
|
|
};
|
|
|
|
const codeInput = useTemplateRef('codeInput');
|
|
const changeEmail = async () => {
|
|
error.value = '';
|
|
|
|
if (savingEmail.value) {
|
|
return;
|
|
}
|
|
savingEmail.value = true;
|
|
try {
|
|
const response = await dialogue.postWithAlertOnError<any>('/api/user/change-email', {
|
|
email: email.value,
|
|
authId: changeEmailAuthId.value,
|
|
code: code.value,
|
|
captchaToken: captchaToken.value,
|
|
});
|
|
|
|
if (response.error) {
|
|
error.value = response.error;
|
|
return;
|
|
}
|
|
|
|
if (!changeEmailAuthId.value) {
|
|
changeEmailAuthId.value = response.authId;
|
|
message.value = 'user.login.emailSent';
|
|
messageParams.value = { email: addBrackets(email.value) };
|
|
messageIcon.value = 'envelope-open-text';
|
|
await nextTick();
|
|
codeInput.value?.focus();
|
|
} else {
|
|
changeEmailAuthId.value = null;
|
|
message.value = '';
|
|
messageParams.value = {};
|
|
code.value = null;
|
|
|
|
await setToken(response.token);
|
|
message.value = 'crud.saved';
|
|
messageParams.value = {};
|
|
messageIcon.value = 'check-circle';
|
|
setTimeout(() => message.value = '', 3000);
|
|
}
|
|
} finally {
|
|
savingEmail.value = false;
|
|
}
|
|
};
|
|
|
|
const logout = () => {
|
|
logoutInProgress.value = true;
|
|
setTimeout(doLogout, 3000);
|
|
};
|
|
const logoutAll = () => {
|
|
window.localStorage.removeItem('account-tokens');
|
|
logout();
|
|
};
|
|
const doLogout = async () => {
|
|
await removeToken();
|
|
logoutInProgress.value = false;
|
|
setTimeout(() => window.location.reload(), 300);
|
|
};
|
|
|
|
const deleteAccount = async () => {
|
|
await dialogue.confirm(translator.translate('user.deleteAccountConfirm'), 'danger');
|
|
|
|
await dialogue.postWithAlertOnError('/api/user/delete');
|
|
|
|
if (impersonationActive.value) {
|
|
stopImpersonation();
|
|
} else {
|
|
logout();
|
|
}
|
|
};
|
|
const setAvatar = async (source: string | null) => {
|
|
const response = await dialogue.postWithAlertOnError<any>('/api/user/set-avatar', { source });
|
|
|
|
await setToken(response.token);
|
|
};
|
|
const uploaded = async (ids: string[]) => {
|
|
await setAvatar(`${runtimeConfig.public.cloudfront}/images/${ids[0]}-avatar.png`);
|
|
};
|
|
const stopImpersonation = async () => {
|
|
if (!user.value) {
|
|
return;
|
|
}
|
|
|
|
await removeToken(user.value.username);
|
|
tokenCookie.value = impersonatorCookie.value;
|
|
impersonatorCookie.value = null;
|
|
setTimeout(() => window.location.reload(), 300);
|
|
};
|
|
const dismissTermsUpdate = () => {
|
|
termsUpdateDismissed3Cookie.value = 'true';
|
|
showTermsUpdate.value = false;
|
|
};
|
|
const addBrackets = (str: string): string => {
|
|
return str ? `(${str})` : '';
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<section v-if="logoutInProgress || !user">
|
|
<p class="text-center">
|
|
<Spinner size="5rem" />
|
|
</p>
|
|
<div v-if="!impersonationActive">
|
|
<iframe
|
|
v-for="domain in universalDomains"
|
|
:src="`${domain}/api/user/logout-universal`"
|
|
style="width: 1px; height: 1px; opacity: .01"
|
|
>
|
|
</iframe>
|
|
</div>
|
|
</section>
|
|
<section v-else>
|
|
<div v-if="showTermsUpdate" class="alert alert-info container my-4 small">
|
|
<h4 class="mb-3">
|
|
<Icon v="info-circle" />
|
|
<T>terms.update.header</T>
|
|
</h4>
|
|
<p>
|
|
<T>terms.update.intro</T>
|
|
</p>
|
|
<ul>
|
|
<li v-for="change in $t('terms.update.changes')">
|
|
<LinkedText :text="change" />
|
|
</li>
|
|
</ul>
|
|
<p class="text-center">
|
|
<button type="button" class="btn btn-primary" @click="dismissTermsUpdate">
|
|
<Icon v="shield-check" />
|
|
<T>confirm.ok</T>
|
|
</button>
|
|
</p>
|
|
</div>
|
|
|
|
<TabsNav
|
|
:tabs="['general', 'cards', 'socials', 'circles', 'blocks', 'backup']"
|
|
pills
|
|
showheaders
|
|
navclass="mb-3 border-bottom-0"
|
|
anchors
|
|
>
|
|
<template #general-header>
|
|
<Icon v="user" />
|
|
<T>user.headerLong</T>
|
|
</template>
|
|
<template #general>
|
|
<div class="card mb-3">
|
|
<div class="card-body row">
|
|
<div class="col-12 col-md-3 text-center">
|
|
<p v-if="$isGranted('panel') || $isGranted('users') || $isGranted('community')">
|
|
<nuxt-link to="/admin" class="badge bg-primary text-white">
|
|
<Icon v="collective-logo.svg" class="inverted" />
|
|
<T>contact.team.member</T>
|
|
</nuxt-link>
|
|
</p>
|
|
<p class="mb-0">
|
|
<Avatar :user="user" validate />
|
|
</p>
|
|
<div>
|
|
<p class="mt-3 mb-1">
|
|
<strong><T>user.avatar.change</T><T>quotation.colon</T></strong>
|
|
</p>
|
|
<div v-if="user.avatarSource === 'gravatar'" class="mt-3">
|
|
<a href="https://gravatar.com" target="_blank" rel="noopener" class="small">
|
|
<Icon v="external-link" />
|
|
Gravatar
|
|
</a>
|
|
</div>
|
|
<div v-else class="mt-3">
|
|
Gravatar:
|
|
<a href="#" @click.prevent="setAvatar('gravatar')">
|
|
<Avatar :user="user" :src="gravatarSrc" dsize="2rem" />
|
|
</a>
|
|
</div>
|
|
<div v-if="user.avatarSource">
|
|
<a href="#" class="small" @click.prevent="setAvatar(null)">
|
|
<Icon v="trash" />
|
|
<T>crud.remove</T>
|
|
</a>
|
|
</div>
|
|
<ImageUploader small sizes="avatar" @uploaded="uploaded" />
|
|
<p class="small my-2 avatar-social-hint">
|
|
<T>user.avatar.social</T>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 col-md-9">
|
|
<Alert type="danger" :message="error" />
|
|
|
|
<div v-if="message" class="alert alert-success">
|
|
<p class="mb-0 narrow-message">
|
|
<Icon :v="messageIcon" />
|
|
<T :params="messageParams">{{ message }}</T>
|
|
</p>
|
|
</div>
|
|
|
|
<form :inert="savingUsername" @submit.prevent="changeUsername">
|
|
<h3 class="h6">
|
|
<T>user.account.changeUsername.header</T>
|
|
</h3>
|
|
<input
|
|
v-model="username"
|
|
type="text"
|
|
class="form-control mb-3"
|
|
required
|
|
minlength="4"
|
|
maxlength="16"
|
|
>
|
|
<p v-if="usernameError" class="small text-danger">
|
|
<Icon v="exclamation-triangle" />
|
|
<span class="ml-1">{{ usernameError }}</span>
|
|
</p>
|
|
<div class="d-none d-md-block mt-3">
|
|
<button
|
|
type="submit"
|
|
class="btn btn-outline-primary"
|
|
:disabled="username === user.username"
|
|
>
|
|
<T>user.account.changeUsername.action</T>
|
|
</button>
|
|
</div>
|
|
<div class="d-block-force d-md-none mt-3">
|
|
<button
|
|
type="submit"
|
|
class="btn btn-outline-primary w-100"
|
|
:disabled="username === user.username"
|
|
>
|
|
<T>user.account.changeUsername.action</T>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<hr>
|
|
|
|
<Alert v-if="$user() && $user()!.email.endsWith('.oauth')" type="warning" message="user.emailMissing" />
|
|
|
|
<form :inert="savingEmail" @submit.prevent="changeEmail">
|
|
<h3 class="h6">
|
|
<T>user.account.changeEmail.header</T>
|
|
</h3>
|
|
<div v-if="!changeEmailAuthId" class="">
|
|
<input v-model="email" type="email" class="form-control mb-3" required>
|
|
<div class="d-flex flex-column flex-md-row">
|
|
<Captcha v-if="showCaptcha" v-model="captchaToken" />
|
|
<div :class="['d-none', 'd-md-block', showCaptcha ? 'ms-3' : '']">
|
|
<button
|
|
type="submit"
|
|
class="btn btn-outline-primary"
|
|
:disabled="!canChangeEmail"
|
|
>
|
|
<T>user.account.changeEmail.action</T>
|
|
</button>
|
|
</div>
|
|
<div class="d-block-force d-md-none mt-3">
|
|
<button
|
|
type="submit"
|
|
class="btn btn-outline-primary w-100"
|
|
:disabled="!canChangeEmail"
|
|
>
|
|
<T>user.account.changeEmail.action</T>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="input-group mb-3">
|
|
<input
|
|
ref="codeInput"
|
|
v-model="code"
|
|
type="text"
|
|
class="form-control text-center"
|
|
placeholder="000000"
|
|
autofocus
|
|
required
|
|
minlength="0"
|
|
maxlength="6"
|
|
inputmode="numeric"
|
|
pattern="[0-9]{6}"
|
|
autocomplete="one-time-code"
|
|
>
|
|
<button type="submit" class="btn btn-outline-primary">
|
|
<Icon v="key" />
|
|
<T>user.code.action</T>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AdPlaceholder :phkey="['content-0', 'content-mobile-0']" />
|
|
|
|
<section class="mt-5">
|
|
<a href="#" class="btn btn-outline-danger" @click.prevent="logout">
|
|
<Icon v="sign-out" />
|
|
<T :params="{ username: user.username }">user.logout</T>
|
|
</a>
|
|
|
|
<a
|
|
v-if="accounts && Object.keys(accounts).length > 1"
|
|
href="#"
|
|
class="btn btn-outline-danger"
|
|
@click.prevent="logoutAll"
|
|
>
|
|
<Icon v="sign-out" />
|
|
<T>user.logoutAll</T>
|
|
</a>
|
|
|
|
<a v-if="user.username !== 'example'" href="#" class="btn btn-outline-danger" @click.prevent="deleteAccount">
|
|
<Icon v="trash-alt" />
|
|
<T :params="{ username: user.username }">user.deleteAccount</T>
|
|
</a>
|
|
|
|
<a v-if="impersonationActive" href="#" class="btn btn-outline-primary" @click.prevent="stopImpersonation">
|
|
<Icon v="user-secret" />
|
|
Stop impersonation
|
|
</a>
|
|
</section>
|
|
</template>
|
|
|
|
<template #cards-header>
|
|
<Icon v="id-card" />
|
|
<T>profile.list</T>
|
|
<span v-if="profiles !== undefined" class="badge text-bg-light">
|
|
{{ Object.keys(profiles).length }}
|
|
</span>
|
|
</template>
|
|
<template #cards>
|
|
<Loading :value="profiles">
|
|
<ul v-if="profiles !== undefined" class="list-group">
|
|
<li
|
|
v-for="locale in Object.keys($locales)"
|
|
:key="locale"
|
|
:class="['list-group-item', locale === config.locale ? 'profile-current' : '']"
|
|
>
|
|
<ProfileOverview :username="username" :profile="profiles[locale]" :locale="locale" @update="setProfiles" />
|
|
</li>
|
|
</ul>
|
|
</Loading>
|
|
|
|
<AdPlaceholder :phkey="['content-1', 'content-mobile-1']" />
|
|
</template>
|
|
|
|
<template #socials-header>
|
|
<Icon v="sign-in-alt" />
|
|
<T>user.socialConnection.header</T>
|
|
<template v-if="authMethodsCount !== null">
|
|
<span v-if="authMethodsCount < 2" class="badge text-bg-warning">
|
|
<Icon v="exclamation-triangle" />
|
|
</span>
|
|
<span v-else class="badge text-bg-light">
|
|
{{ authMethodsCount }}
|
|
</span>
|
|
</template>
|
|
</template>
|
|
<template #socials>
|
|
<Loading :value="socialConnections">
|
|
<template #header>
|
|
<div v-if="authMethodsCount !== null && authMethodsCount < 2" class="alert alert-warning">
|
|
<Icon v="exclamation-triangle" />
|
|
<T>user.tooFewAuthMethods</T>
|
|
</div>
|
|
<div class="form-check form-switch my-2">
|
|
<label>
|
|
<input v-model="socialLookup" class="form-check-input" type="checkbox" role="switch">
|
|
<T>user.socialLookup</T>
|
|
<br>
|
|
<small><T>user.socialLookupWhy</T></small>
|
|
</label>
|
|
</div>
|
|
</template>
|
|
<ul v-if="socialConnections !== null" class="list-group">
|
|
<li :class="['list-group-item', hasEmail ? 'profile-current' : '']">
|
|
<div class="d-md-flex justify-content-between align-items-center">
|
|
<h4 class="my-md-0 d-flex align-items-center gap-2">
|
|
<Icon v="envelope" />
|
|
<T>user.account.changeEmail.header</T>
|
|
</h4>
|
|
|
|
<span v-if="hasEmail">
|
|
<span class="me-2">
|
|
{{ user.email }}
|
|
</span>
|
|
<a
|
|
href="#general"
|
|
class="btn btn-light border"
|
|
>
|
|
<Icon v="edit" />
|
|
<T>user.account.changeEmail.action</T>
|
|
</a>
|
|
</span>
|
|
<span v-else>
|
|
<a
|
|
href="#general"
|
|
class="btn btn-light border border-warning"
|
|
>
|
|
<Icon v="mailbox" />
|
|
<T>user.account.changeEmail.confirm</T>
|
|
</a>
|
|
</span>
|
|
</div>
|
|
</li>
|
|
<li v-for="(providerOptions, provider) in socialProviders" :key="provider" :class="['list-group-item', socialConnections[provider] !== undefined ? 'profile-current' : '']">
|
|
<SocialConnection
|
|
:provider="provider"
|
|
:provider-options="providerOptions"
|
|
:connection="socialConnections[provider]"
|
|
@disconnected="socialConnections[provider] = undefined"
|
|
@set-avatar="setAvatar"
|
|
/>
|
|
</li>
|
|
<li :class="['list-group-item', user.mfa ? 'profile-current' : '']">
|
|
<MfaConnection />
|
|
</li>
|
|
</ul>
|
|
</Loading>
|
|
</template>
|
|
|
|
<template #circles-header>
|
|
<Icon v="heart-circle" />
|
|
<T>profile.circles.header</T>
|
|
</template>
|
|
<template #circles>
|
|
<h5><T>profile.circles.yourMentions.header</T><T>quotation.colon</T></h5>
|
|
<CircleMentions />
|
|
<nuxt-link to="/editor#circle" class="btn btn-primary">
|
|
<Icon v="user-plus" />
|
|
<T>profile.circles.add</T>
|
|
</nuxt-link>
|
|
</template>
|
|
|
|
<template #blocks-header>
|
|
<Icon v="ban" />
|
|
<T>profile.blocks.header</T>
|
|
</template>
|
|
<template #blocks>
|
|
<BlocksList />
|
|
</template>
|
|
|
|
<template #backup-header>
|
|
<Icon v="copy" />
|
|
<T>profile.backup.headerShort</T>
|
|
</template>
|
|
<template #backup>
|
|
<CardsBackup v-if="!user.bannedReason" />
|
|
</template>
|
|
</TabsNav>
|
|
|
|
<AdPlaceholder :phkey="['content-2', 'content-mobile-2']" />
|
|
|
|
<div>
|
|
<iframe
|
|
v-for="domain in universalDomains"
|
|
:src="`${domain}/api/user/init-universal/${tokenCookie}`"
|
|
style="width: 1px; height: 1px; opacity: .01"
|
|
>
|
|
</iframe>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@import "assets/variables";
|
|
|
|
.profile-current {
|
|
border-inline-start: 3px solid $primary;
|
|
}
|
|
|
|
.narrow-message {
|
|
max-width: 56ch;
|
|
}
|
|
|
|
@include media-breakpoint-up('md') {
|
|
.avatar-social-hint {
|
|
max-width: 200px;
|
|
}
|
|
}
|
|
</style>
|