497 lines
14 KiB
Vue
497 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import { onBeforeRouteLeave } from 'vue-router'
|
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
|
import AuthorProfileCard from '@/components/AuthorProfileCard.vue'
|
|
import CharacterCounter from '@/components/CharacterCounter.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import DangerZoneCard from '@/components/DangerZoneCard.vue'
|
|
import DataExportCard from '@/components/DataExportCard.vue'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import EmailChangeForm from '@/components/EmailChangeForm.vue'
|
|
import PendingEmailNotice from '@/components/PendingEmailNotice.vue'
|
|
import ProfileCard from '@/components/ProfileCard.vue'
|
|
import YourRoleCard from '@/components/YourRoleCard.vue'
|
|
import { ApiError, authRedirectPending } from '@/composables/useApi'
|
|
import { useEmailChange } from '@/composables/useEmailChange'
|
|
import { useProfile } from '@/composables/useProfile'
|
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
|
import { usePermissionsStore } from '@/stores/permissions'
|
|
import { useSessionStore, type Session } from '@/stores/session'
|
|
|
|
const DISPLAY_NAME_MAX = 60
|
|
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
|
|
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
|
|
const SAVE_FAILED_COPY =
|
|
"Couldn't save your display name. Your text is still here. Try again in a moment."
|
|
const RESET_FALLBACK_SECONDS = 300
|
|
const PASSWORD_MASK = '••••••••••••'
|
|
const SKELETON_CARDS = [
|
|
{ titleWidth: 'w-30', inputs: 1 },
|
|
{ titleWidth: 'w-22.5', inputs: 2 },
|
|
{ titleWidth: 'w-28', inputs: 1 },
|
|
{ titleWidth: 'w-20', inputs: 1 },
|
|
{ titleWidth: 'w-25', inputs: 1 },
|
|
]
|
|
|
|
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
|
|
|
|
const session = useSessionStore()
|
|
const permissions = usePermissionsStore()
|
|
const toast = useLocalizedToast()
|
|
const overlay = useLocalizedOverlay()
|
|
const { updateDisplayName, sendPasswordReset } = useProfile()
|
|
const retryAfter = useRetryAfter()
|
|
const emailChange = useEmailChange()
|
|
|
|
const account = ref<Session | null>(session.session)
|
|
const field = ref('')
|
|
const saving = ref(false)
|
|
const saveFailed = ref(false)
|
|
const serverLimitError = ref(false)
|
|
const resetState = ref<ResetState>('idle')
|
|
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
|
|
|
|
watch(
|
|
() => session.session,
|
|
(value) => {
|
|
if (value) {
|
|
account.value = value
|
|
}
|
|
},
|
|
)
|
|
|
|
watch(
|
|
account,
|
|
(value, previous) => {
|
|
if (value && !previous) {
|
|
field.value = value.displayName ?? ''
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(field, () => {
|
|
serverLimitError.value = false
|
|
})
|
|
|
|
watch(retryAfter.remaining, (value) => {
|
|
if (value === 0 && resetState.value === 'limited') {
|
|
resetState.value = 'idle'
|
|
}
|
|
})
|
|
|
|
const username = computed(() => account.value?.username ?? '')
|
|
const email = computed(() => account.value?.email ?? '')
|
|
const savedName = computed(() => account.value?.displayName ?? '')
|
|
const draft = computed(() => field.value.trim())
|
|
const count = computed(() => Array.from(draft.value).length)
|
|
const dirty = computed(() => draft.value !== savedName.value)
|
|
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
|
|
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
|
|
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
|
|
const showUnsaved = computed(() => dirty.value && !saving.value)
|
|
|
|
const statusText = computed(() => {
|
|
if (showUnsaved.value) {
|
|
return t('Unsaved changes')
|
|
}
|
|
return savedName.value
|
|
? t('You appear as “{0}”.', [savedName.value])
|
|
: t('You appear as “{0}”, your username.', [username.value])
|
|
})
|
|
|
|
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
|
|
const readOnlyUi = {
|
|
base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base',
|
|
}
|
|
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
|
|
const changeEmailLinkClass =
|
|
'h-auto min-h-11 p-0 font-medium text-highlighted underline underline-offset-3 hover:underline lg:min-h-5'
|
|
|
|
const resetNotes = {
|
|
sent: {
|
|
color: 'success',
|
|
icon: 'i-lucide-mail-check',
|
|
get title() {
|
|
return t('Reset link sent')
|
|
},
|
|
},
|
|
limited: {
|
|
color: 'warning',
|
|
icon: 'i-lucide-clock-alert',
|
|
get title() {
|
|
return t('Too many requests')
|
|
},
|
|
get description() {
|
|
return t("You've asked for several links recently. Wait a few minutes, then try again.")
|
|
},
|
|
},
|
|
error: {
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("Couldn't send the link")
|
|
},
|
|
get description() {
|
|
return t('Check your connection and try again.')
|
|
},
|
|
},
|
|
} as const
|
|
|
|
const resetNote = computed(() => {
|
|
if (resetState.value === 'sent') {
|
|
return {
|
|
...resetNotes.sent,
|
|
get description() {
|
|
return t('Check {0}. The link works once, within 1 hour.', [email.value])
|
|
},
|
|
}
|
|
}
|
|
if (resetState.value === 'limited' || resetState.value === 'error') {
|
|
return resetNotes[resetState.value]
|
|
}
|
|
return null
|
|
})
|
|
|
|
const resetButtonLabel = computed(() => {
|
|
if (resetState.value === 'sending') {
|
|
return t('Sending…')
|
|
}
|
|
return resetState.value === 'sent' ? t('Send again') : t('Send reset link')
|
|
})
|
|
|
|
const resetBusy = computed(() => resetState.value === 'sending')
|
|
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
|
|
|
|
function focusField() {
|
|
nextTick(() => inputRef.value?.inputRef?.focus())
|
|
}
|
|
|
|
function announceSaved(displayName: string) {
|
|
toast.add(() => ({
|
|
id: 'display-name-saved',
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return displayName ? t('Display name saved') : t('Display name removed')
|
|
},
|
|
get description() {
|
|
return displayName
|
|
? t('You now appear as {0} across RenewCMS and in emails.', [displayName])
|
|
: t('You now appear as {0}, your username.', [username.value])
|
|
},
|
|
}))
|
|
}
|
|
|
|
function isHandledGlobally(error: unknown) {
|
|
return error instanceof ApiError && (error.status === 401 || error.status === 403)
|
|
}
|
|
|
|
async function onSave() {
|
|
if (saving.value || !saveEnabled.value) {
|
|
return
|
|
}
|
|
saving.value = true
|
|
saveFailed.value = false
|
|
serverLimitError.value = false
|
|
try {
|
|
const updated = await updateDisplayName(draft.value)
|
|
const displayName = updated.display_name ?? ''
|
|
session.setDisplayName(displayName)
|
|
field.value = displayName
|
|
announceSaved(displayName)
|
|
} catch (error) {
|
|
if (isHandledGlobally(error)) {
|
|
return
|
|
}
|
|
if (
|
|
error instanceof ApiError &&
|
|
error.status === 400 &&
|
|
error.body === DISPLAY_NAME_LIMIT_BODY
|
|
) {
|
|
serverLimitError.value = true
|
|
} else {
|
|
saveFailed.value = true
|
|
}
|
|
focusField()
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function onSendReset() {
|
|
if (resetDisabled.value) {
|
|
return
|
|
}
|
|
resetState.value = 'sending'
|
|
try {
|
|
await sendPasswordReset(email.value)
|
|
resetState.value = 'sent'
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 429) {
|
|
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
|
|
resetState.value = 'limited'
|
|
return
|
|
}
|
|
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
|
|
}
|
|
}
|
|
|
|
onBeforeRouteLeave(async () => {
|
|
if (authRedirectPending.value || !dirty.value) {
|
|
return true
|
|
}
|
|
const keepEditing = await overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Leave without saving?')
|
|
},
|
|
get description() {
|
|
return t("Your display name change hasn't been saved.")
|
|
},
|
|
get confirmLabel() {
|
|
return t('Keep editing')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Discard changes')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Keep editing')
|
|
},
|
|
tone: 'neutral',
|
|
action: async () => {},
|
|
dismissResolvesTo: 'confirm',
|
|
},
|
|
})
|
|
.open()
|
|
return !keepEditing
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell compact-padding render-while-checking>
|
|
<div class="flex max-w-168 flex-col gap-4">
|
|
<div class="flex flex-col gap-0.5">
|
|
<h1 class="text-xl font-semibold text-highlighted">
|
|
{{ t('Profile') }}
|
|
</h1>
|
|
<p class="text-muted">
|
|
{{ t('Your account on this RenewCMS workspace.') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
|
|
<div
|
|
v-for="(card, cardIndex) in SKELETON_CARDS"
|
|
:key="cardIndex"
|
|
data-testid="profile-skeleton-card"
|
|
class="flex flex-col gap-3 border border-default bg-default p-4"
|
|
>
|
|
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
|
|
<USkeleton
|
|
v-for="inputIndex in card.inputs"
|
|
:key="inputIndex"
|
|
class="h-10 rounded-none bg-accented"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<UForm :state="{ displayName: field }" @submit="onSave">
|
|
<ProfileCard
|
|
:title="t('Display name')"
|
|
:description="
|
|
t(
|
|
'Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username.',
|
|
)
|
|
"
|
|
>
|
|
<UAlert
|
|
v-if="saveFailed"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
icon="i-lucide-circle-x"
|
|
:description="t(SAVE_FAILED_COPY)"
|
|
:close="false"
|
|
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
|
/>
|
|
|
|
<UFormField name="displayName" :error="showFieldError">
|
|
<template #label>
|
|
{{ t('Display name') }}
|
|
<span class="font-normal text-muted">{{ t('(optional)') }}</span>
|
|
</template>
|
|
<UInput
|
|
ref="inputRef"
|
|
v-model="field"
|
|
:placeholder="t('e.g. Marta Alves')"
|
|
autocomplete="off"
|
|
:disabled="saving"
|
|
class="w-full"
|
|
:ui="inputUi"
|
|
@keydown.enter.prevent="onSave"
|
|
/>
|
|
<div class="mt-1.5 flex items-start gap-3">
|
|
<span
|
|
v-if="showFieldError"
|
|
data-testid="display-name-error"
|
|
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
|
|
>
|
|
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
|
|
{{ t(DISPLAY_NAME_LIMIT_COPY) }}
|
|
</span>
|
|
<span v-else class="flex-1" />
|
|
<CharacterCounter
|
|
data-testid="display-name-counter"
|
|
variant="field"
|
|
:value="count"
|
|
:max="DISPLAY_NAME_MAX"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
|
|
<template #footer>
|
|
<span
|
|
data-testid="display-name-status"
|
|
class="min-w-0 flex-1 text-xs/[18px]"
|
|
:class="showUnsaved ? 'text-warning' : 'text-muted'"
|
|
>{{ statusText }}</span
|
|
>
|
|
<UButton
|
|
type="submit"
|
|
:loading="saving"
|
|
:disabled="!saveEnabled && !saving"
|
|
class="h-11 shrink-0 px-3.5 lg:h-8"
|
|
>
|
|
{{ saving ? t('Saving…') : t('Save') }}
|
|
</UButton>
|
|
</template>
|
|
</ProfileCard>
|
|
</UForm>
|
|
|
|
<ProfileCard
|
|
:title="t('Account')"
|
|
:description="
|
|
t(
|
|
'Set when your account was created. You log in with your username, even after adding a display name.',
|
|
)
|
|
"
|
|
>
|
|
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
|
<UFormField :label="t('Username')">
|
|
<UInput
|
|
:model-value="username"
|
|
disabled
|
|
trailing-icon="i-lucide-lock"
|
|
class="w-full"
|
|
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
|
/>
|
|
</UFormField>
|
|
<UFormField :label="t('Email')">
|
|
<template v-if="emailChange.showChangeLink.value" #hint>
|
|
<UButton
|
|
variant="link"
|
|
color="neutral"
|
|
:class="changeEmailLinkClass"
|
|
@click="emailChange.openForm"
|
|
>
|
|
{{ t('Change email') }}
|
|
</UButton>
|
|
</template>
|
|
<UInput
|
|
:model-value="email"
|
|
disabled
|
|
trailing-icon="i-lucide-lock"
|
|
class="w-full"
|
|
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
|
/>
|
|
</UFormField>
|
|
</div>
|
|
<EmailChangeForm
|
|
v-if="emailChange.formOpen.value"
|
|
v-model:email="emailChange.newEmail.value"
|
|
v-model:password="emailChange.password.value"
|
|
:email-error="emailChange.emailError.value"
|
|
:password-error="emailChange.passwordError.value"
|
|
:notice="emailChange.notice.value"
|
|
:sending="emailChange.sending.value"
|
|
:send-disabled="emailChange.sendLimited.value || emailChange.sending.value"
|
|
:focus-request="emailChange.focusRequest.value"
|
|
@submit="emailChange.submit"
|
|
@cancel="emailChange.resetForm"
|
|
@blur-email="emailChange.checkEmailOnBlur"
|
|
/>
|
|
<PendingEmailNotice
|
|
v-else-if="emailChange.pendingEmail.value"
|
|
:pending-email="emailChange.pendingEmail.value"
|
|
:current-email="email"
|
|
:resend-state="emailChange.resendState.value"
|
|
:resend-countdown="emailChange.resendCountdown.value"
|
|
:note="emailChange.pendingNote.value"
|
|
:busy="emailChange.cancelling.value"
|
|
@resend="emailChange.resend"
|
|
@cancel-change="emailChange.cancelChange"
|
|
/>
|
|
</ProfileCard>
|
|
|
|
<YourRoleCard v-if="permissions.role" />
|
|
|
|
<AuthorProfileCard />
|
|
|
|
<ProfileCard
|
|
:title="t('Password')"
|
|
:description="
|
|
t(
|
|
'To change it, we\'ll email a reset link to {0}. The link works once, within 1 hour.',
|
|
[email],
|
|
)
|
|
"
|
|
>
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
|
|
<UFormField :label="t('Password')" class="min-w-0 flex-1">
|
|
<UInput
|
|
:model-value="PASSWORD_MASK"
|
|
disabled
|
|
trailing-icon="i-lucide-lock"
|
|
class="w-full"
|
|
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
|
|
/>
|
|
</UFormField>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
:icon="resetBusy ? undefined : 'i-lucide-mail'"
|
|
:loading="resetBusy"
|
|
:disabled="resetDisabled"
|
|
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
|
|
@click="onSendReset"
|
|
>
|
|
{{ resetButtonLabel }}
|
|
</UButton>
|
|
</div>
|
|
<UAlert
|
|
v-if="resetNote"
|
|
data-testid="reset-note"
|
|
role="status"
|
|
variant="soft"
|
|
:color="resetNote.color"
|
|
:icon="resetNote.icon"
|
|
:title="resetNote.title"
|
|
:description="resetNote.description"
|
|
:close="false"
|
|
/>
|
|
</ProfileCard>
|
|
|
|
<DataExportCard />
|
|
|
|
<DangerZoneCard />
|
|
</template>
|
|
</div>
|
|
</DashboardShell>
|
|
</template>
|