PronounsPage/components/Account.vue

556 lines
22 KiB
Vue

<template>
<section v-if="logoutInProgress">
<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 class="btn btn-primary" @click.prevent="dismissTermsUpdate">
<Icon v="shield-check" />
<T>confirm.ok</T>
</button>
</p>
</div>
<TabsNav
:tabs="['general', 'cards', 'socials', 'circles', '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="userRef" 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="gravatar(user)" 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 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 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 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 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="code"
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 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>
</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>
<template #socials>
<Loading :value="socialConnections">
<template #header>
<div class="form-check form-switch my-2">
<label>
<input v-model="socialLookup" class="form-check-input" type="checkbox">
<T>user.socialLookup</T>
<br>
<small><T>user.socialLookupWhy</T></small>
</label>
</div>
</template>
<ul v-if="socialConnections !== undefined" class="list-group">
<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 #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>
<script lang="ts">
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 { gravatar } from '../src/helpers.ts';
import { socialProviders } from '../src/socialProviders.ts';
import { usernameRegex } from '../src/username.ts';
import { useMainStore } from '../store/index.ts';
import type { Profile } from '~/src/profile.ts';
export default defineComponent({
async setup() {
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,
});
return {
config: useConfig(),
dialogue,
userRef: user,
user: user.value,
username: ref(user.value.username),
email: ref(user.value.email),
socialLookup: ref(Boolean(user.value.socialLookup)),
accounts,
tokenCookie,
impersonatorCookie,
termsUpdateDismissed3Cookie,
profilesData,
socialConnections,
};
},
data() {
return {
message: '',
messageParams: {},
messageIcon: null as string | null,
error: '',
changeEmailAuthId: null,
code: '' as string | null,
socialProviders,
savingUsername: false,
savingEmail: false,
gravatar,
showCaptcha: false,
captchaToken: null,
universalDomains: this.$config.public.allLocalesUrls.filter((x) => x !== this.$config.public.baseUrl),
logoutInProgress: false,
showTermsUpdate: new Date() < new Date(2023, 3, 6) &&
!this.termsUpdateDismissed3Cookie,
};
},
computed: {
profiles() {
return this.profilesData?.profiles;
},
impersonationActive() {
return !!this.impersonatorCookie;
},
canChangeEmail() {
return this.email && this.captchaToken;
},
usernameError() {
if (!this.username.match(usernameRegex)) {
return this.$t('user.account.changeUsername.invalid');
}
if (this.username !== encodeURIComponent(this.username)) {
return this.$t('user.account.changeUsername.nonascii', { encoded: encodeURIComponent(this.username) });
}
return null;
},
},
watch: {
email(v) {
if (v !== this.user.email) {
this.showCaptcha = true;
}
},
async socialLookup(v) {
const response = await this.dialogue.postWithAlertOnError<any>('/api/user/set-social-lookup', { socialLookup: v });
await this.$setToken(response.token);
},
},
async mounted() {
const user = await $fetch('/api/user/current');
if (user) {
await this.$setToken(user.token);
}
const redirectTo = window.sessionStorage.getItem('after-login');
if (this.user && redirectTo) {
window.sessionStorage.removeItem('after-login');
await this.$router.push(redirectTo);
}
},
methods: {
async changeUsername() {
this.error = '';
if (this.savingUsername) {
return;
}
this.savingUsername = true;
try {
const response = await this.dialogue.postWithAlertOnError<any>('/api/user/change-username', {
username: this.username,
});
if (response.error) {
this.error = response.error;
return;
}
await this.$removeToken(this.user.username);
await this.$setToken(response.token);
this.username = this.user.username;
this.message = 'crud.saved';
this.messageParams = {};
this.messageIcon = 'check-circle';
setTimeout(() => this.message = '', 3000);
} finally {
this.savingUsername = false;
}
},
async changeEmail() {
this.error = '';
if (this.savingEmail) {
return;
}
this.savingEmail = true;
try {
const response = await this.dialogue.postWithAlertOnError<any>('/api/user/change-email', {
email: this.email,
authId: this.changeEmailAuthId,
code: this.code,
captchaToken: this.captchaToken,
});
if (response.error) {
this.error = response.error;
return;
}
if (!this.changeEmailAuthId) {
this.changeEmailAuthId = response.authId;
this.message = 'user.login.emailSent';
this.messageParams = { email: this.addBrackets(this.email) };
this.messageIcon = 'envelope-open-text';
this.$nextTick(() => {
(this.$refs.code as HTMLInputElement).focus();
});
} else {
this.changeEmailAuthId = null;
this.message = '';
this.messageParams = {};
this.code = null;
await this.$setToken(response.token);
this.message = 'crud.saved';
this.messageParams = {};
this.messageIcon = 'check-circle';
setTimeout(() => this.message = '', 3000);
}
} finally {
this.savingEmail = false;
}
},
logout() {
this.logoutInProgress = true;
setTimeout(this.doLogout, 3000);
},
logoutAll() {
window.localStorage.removeItem('account-tokens');
this.logout();
},
async doLogout() {
await this.$removeToken();
this.logoutInProgress = false;
setTimeout(() => window.location.reload(), 300);
},
setProfiles(profiles: Record<string, Partial<Profile>>) {
this.profiles = profiles;
},
async deleteAccount() {
await this.dialogue.confirm(this.$t('user.deleteAccountConfirm'), 'danger');
await this.dialogue.postWithAlertOnError('/api/user/delete');
if (this.impersonationActive) {
this.stopImpersonation();
} else {
this.logout();
}
},
async setAvatar(source: string | null) {
const response = await this.dialogue.postWithAlertOnError<any>('/api/user/set-avatar', { source });
await this.$setToken(response.token);
},
async uploaded(ids: string[]) {
await this.setAvatar(`${this.$config.public.cloudfront}/images/${ids[0]}-avatar.png`);
},
async stopImpersonation() {
await this.$removeToken(this.user.username);
this.tokenCookie = this.impersonatorCookie;
this.impersonatorCookie = null;
setTimeout(() => window.location.reload(), 300);
},
dismissTermsUpdate() {
this.termsUpdateDismissed3Cookie = 'true';
this.showTermsUpdate = false;
},
addBrackets(str: string): string {
return str ? `(${str})` : '';
},
},
});
</script>
<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>