mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1008 lines
38 KiB
1008 lines
38 KiB
<template>
|
|
<w-layout view="hHh lpR fFf" container>
|
|
<w-header class="card-header px-4 py-2">
|
|
<w-icon name="img:/_assets/icons/fluent-account.svg" left size="md" />
|
|
<div>
|
|
<span>{{ t(`admin.users.edit`) }}</span>
|
|
<div class="text-caption">{{ state.user.name }}</div>
|
|
</div>
|
|
<w-space />
|
|
<w-btn-group push>
|
|
<w-btn
|
|
push
|
|
color="grey-6"
|
|
text-color="white"
|
|
:aria-label="t(`common.actions.refresh`)"
|
|
icon="la:redo-alt"
|
|
@click="fetchUser"
|
|
:loading="state.loading > 0">
|
|
<w-tooltip anchor="center left" self="center right">{{
|
|
t(`common.actions.refresh`)
|
|
}}</w-tooltip>
|
|
</w-btn>
|
|
<w-btn
|
|
push
|
|
color="white"
|
|
text-color="grey-7"
|
|
:label="t(`common.actions.close`)"
|
|
:aria-label="t(`common.actions.close`)"
|
|
icon="la:times"
|
|
@click="close" />
|
|
<w-btn
|
|
v-if="canManage"
|
|
push
|
|
color="positive"
|
|
text-color="white"
|
|
:label="t(`common.actions.save`)"
|
|
:aria-label="t(`common.actions.save`)"
|
|
icon="la:check"
|
|
@click="save()"
|
|
:disabled="state.loading > 0" />
|
|
</w-btn-group>
|
|
</w-header>
|
|
<w-drawer class="bg-dark-6" :model-value="true" :width="250" dark>
|
|
<w-list padding dark v-if="state.loading < 1">
|
|
<template v-for="sc of sections" :key="`section-` + sc.key">
|
|
<w-item
|
|
v-if="!sc.disabled || flagsStore.experimental"
|
|
clickable
|
|
:to="{ params: { section: sc.key } }"
|
|
active-class="bg-primary text-white"
|
|
:disabled="sc.disabled">
|
|
<w-item-section side><w-icon :name="sc.icon" color="white" /></w-item-section>
|
|
<w-item-section>{{ sc.text }}</w-item-section>
|
|
</w-item>
|
|
</template>
|
|
<!--
|
|
A new tab rather than a router link: this overlay is an edit form with its own Save button,
|
|
and routing away from it unmounts it -- taking whatever has been typed into it and not saved
|
|
with it. `href` is what says so, since `to` would have the router resolve the path in place.
|
|
|
|
Left out for a system account, which has no profile to view: the guest every anonymous reader
|
|
is appears in the user list like any other row, and its profile answers 404.
|
|
-->
|
|
<template v-if="!state.user.isSystem">
|
|
<w-separator class="my-2" dark inset />
|
|
<w-item :href="`/_user/${state.user.id}`" target="_blank">
|
|
<w-item-section side><w-icon name="la:id-card" color="white" /></w-item-section>
|
|
<w-item-section>{{ t('profile.viewPublicProfile') }}</w-item-section>
|
|
</w-item>
|
|
</template>
|
|
</w-list>
|
|
</w-drawer>
|
|
<w-page-container>
|
|
<w-page v-if="state.loading > 0">
|
|
<div class="flex p-6 items-center">
|
|
<w-spinner color="primary" size="32px" />
|
|
<div class="text-caption text-primary pl-4">
|
|
<strong>{{ t('admin.users.loading') }}</strong>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
<w-page v-else-if="route.params.section === `overview`">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-12 gap-4">
|
|
<div class="col-span-12 lg:col-span-8">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.profile') }}</w-card-header>
|
|
<w-item>
|
|
<blueprint-icon icon="contact" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.name`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.nameHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.name"
|
|
dense
|
|
:rules="[
|
|
(val) => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
|
|
]"
|
|
hide-bottom-space
|
|
:aria-label="t(`admin.users.name`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="envelope" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.email`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.emailHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.email"
|
|
dense
|
|
:aria-label="t(`admin.users.email`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<template v-if="state.user.meta">
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="address" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.location`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.locationHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.meta.location"
|
|
dense
|
|
:aria-label="t(`admin.users.location`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="new-job" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.jobTitle`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.jobTitleHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.meta.jobTitle"
|
|
dense
|
|
:aria-label="t(`admin.users.jobTitle`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="gender" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.pronouns`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.pronounsHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.meta.pronouns"
|
|
dense
|
|
:aria-label="t(`admin.users.pronouns`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</template>
|
|
</w-card>
|
|
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
|
|
<w-card-header>{{ t('admin.users.preferences') }}</w-card-header>
|
|
<w-item>
|
|
<blueprint-icon icon="timezone" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.timezone`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.timezoneHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-select
|
|
outlined
|
|
v-model="state.user.prefs.timezone"
|
|
:options="timezones"
|
|
option-value="value"
|
|
option-label="text"
|
|
emit-value
|
|
map-options
|
|
dense
|
|
options-dense
|
|
:aria-label="t(`admin.users.timezone`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="calendar" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.dateFormat`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.dateFormatHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section>
|
|
<w-select
|
|
outlined
|
|
v-model="state.user.prefs.dateFormat"
|
|
emit-value
|
|
map-options
|
|
dense
|
|
:aria-label="t(`admin.users.dateFormat`)"
|
|
:options="[
|
|
{ label: t('profile.localeDefault'), value: '' },
|
|
{ label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' },
|
|
{ label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
|
|
{ label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
|
|
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
|
|
{ label: 'YYYY/MM/DD', value: 'YYYY/MM/DD' }
|
|
]" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="clock" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.timeFormat`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.timeFormatHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section class="flex-none">
|
|
<w-btn-toggle
|
|
v-model="state.user.prefs.timeFormat"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="[
|
|
{ label: t('profile.timeFormat12h'), value: '12h' },
|
|
{ label: t('profile.timeFormat24h'), value: '24h' }
|
|
]" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="light-on" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.appearance`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.darkModeHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section class="flex-none">
|
|
<w-btn-toggle
|
|
v-model="state.user.prefs.appearance"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="[
|
|
{ label: t('profile.appearanceDefault'), value: 'site' },
|
|
{ label: t('profile.appearanceLight'), value: 'light' },
|
|
{ label: t('profile.appearanceDark'), value: 'dark' }
|
|
]" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="visualy-impaired" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`profile.cvd`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`profile.cvdHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section class="flex-none">
|
|
<w-btn-toggle
|
|
v-model="state.user.prefs.cvd"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="[
|
|
{ value: 'none', label: t('profile.cvdNone') },
|
|
{ value: 'protanopia', label: t('profile.cvdProtanopia') },
|
|
{ value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
|
|
{ value: 'tritanopia', label: t('profile.cvdTritanopia') }
|
|
]" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
</div>
|
|
<div class="col-span-12 lg:col-span-4">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.info') }}</w-card-header>
|
|
<w-item>
|
|
<blueprint-icon icon="person" :hue-rotate="-45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`common.field.id`) }}</w-item-label>
|
|
<w-item-label
|
|
><strong>{{ state.user.id }}</strong></w-item-label
|
|
>
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="calendar-plus" :hue-rotate="-45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`common.field.createdOn`) }}</w-item-label>
|
|
<w-item-label>
|
|
<strong>{{ formattedDate(state.user.createdAt) }}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="summertime" :hue-rotate="-45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`common.field.lastUpdated`) }}</w-item-label>
|
|
<w-item-label>
|
|
<strong>{{ formattedDate(state.user.updatedAt) }}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="enter" :hue-rotate="-45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.lastLoginAt`) }}</w-item-label>
|
|
<w-item-label>
|
|
<strong>{{ formattedDate(state.user.lastLoginAt) }}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
|
|
<w-card-header>{{ t('admin.users.notes') }}</w-card-header>
|
|
<w-card-section class="pt-0">
|
|
<w-input
|
|
outlined
|
|
v-model="state.user.meta.notes"
|
|
type="textarea"
|
|
:aria-label="t(`admin.users.notes`)"
|
|
input-style="min-height: 243px"
|
|
:hint="t(`admin.users.noteHint`)" />
|
|
</w-card-section>
|
|
</w-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
<w-page v-else-if="route.params.section === `activity`"><span>---</span></w-page>
|
|
<w-page v-else-if="route.params.section === `auth`">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-12 gap-4">
|
|
<div class="col-span-12 lg:col-span-7">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.passAuth') }}</w-card-header>
|
|
<w-item>
|
|
<blueprint-icon icon="password" :hue-rotate="45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.changePassword`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.changePasswordHint`) }}</w-item-label>
|
|
<w-item-label caption>
|
|
<strong
|
|
:class="localAuth.isPasswordSet ? `text-positive` : `text-negative`"
|
|
>{{
|
|
localAuth.isPasswordSet
|
|
? t(`admin.users.pwdSet`)
|
|
: t(`admin.users.pwdNotSet`)
|
|
}}</strong
|
|
>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="changePassword"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item tag="label">
|
|
<blueprint-icon icon="password-reset" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.mustChangePwd`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.mustChangePwdHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section avatar>
|
|
<w-toggle
|
|
v-model="localAuth.mustChangePwd"
|
|
color="primary"
|
|
checked-icon="la:check"
|
|
unchecked-icon="la:times"
|
|
:aria-label="t(`admin.users.mustChangePwd`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item tag="label">
|
|
<blueprint-icon icon="key" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.pwdAuthRestrict`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.pwdAuthRestrictHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section avatar>
|
|
<w-toggle
|
|
v-model="localAuth.restrictLogin"
|
|
color="primary"
|
|
checked-icon="la:check"
|
|
unchecked-icon="la:times"
|
|
:aria-label="t(`admin.users.pwdAuthRestrict`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="shadow-1 pb-2 mt-4">
|
|
<w-card-header>{{ t('admin.users.tfa') }}</w-card-header>
|
|
<w-item tag="label">
|
|
<blueprint-icon icon="key" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.tfaRequired`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.tfaRequiredHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section avatar>
|
|
<w-toggle
|
|
v-model="localAuth.isTfaRequired"
|
|
color="primary"
|
|
checked-icon="la:check"
|
|
unchecked-icon="la:times"
|
|
:aria-label="t(`admin.users.tfaRequired`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="password" :hue-rotate="45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.tfaInvalidate`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.tfaInvalidateHint`) }}</w-item-label>
|
|
<w-item-label caption>
|
|
<strong :class="localAuth.isTfaSetup ? `text-positive` : `text-negative`">{{
|
|
localAuth.isTfaSetup ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)
|
|
}}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="invalidateTFA"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
</div>
|
|
<div class="col-span-12 lg:col-span-5">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.linkedProviders') }}</w-card-header>
|
|
<w-card-section v-if="linkedAuthProviders.length < 1" class="pt-0">
|
|
<w-banner
|
|
rounded
|
|
:class="dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`"
|
|
>{{ t('admin.users.noLinkedProviders') }}</w-banner
|
|
>
|
|
</w-card-section>
|
|
<template v-for="(prv, idx) in linkedAuthProviders" :key="prv.authId">
|
|
<w-separator class="my-2" inset v-if="idx > 0" />
|
|
<w-item>
|
|
<blueprint-icon :icon="prv.strategyIcon" :hue-rotate="-45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ prv.authName }}</w-item-label>
|
|
<w-item-label caption>{{ prv.config.key }}</w-item-label>
|
|
</w-item-section>
|
|
</w-item>
|
|
</template>
|
|
</w-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
<w-page v-else-if="route.params.section === `groups`">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-12 gap-4">
|
|
<div class="col-span-12 lg:col-span-8">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.groups') }}</w-card-header>
|
|
<template v-for="(grp, idx) of state.user.groups" :key="grp.id">
|
|
<w-separator class="my-2" inset v-if="idx > 0" />
|
|
<w-item>
|
|
<blueprint-icon icon="team" :hue-rotate="-45" />
|
|
<w-item-section
|
|
><w-item-label>{{ grp.name }}</w-item-label></w-item-section
|
|
>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:times"
|
|
color="accent"
|
|
v-if="canManage"
|
|
@click="unassignGroup(grp.id)"
|
|
:aria-label="t(`admin.users.unassignGroup`)">
|
|
<w-tooltip anchor="center left" self="center right">{{
|
|
t('admin.users.unassignGroup')
|
|
}}</w-tooltip>
|
|
</w-btn>
|
|
</w-item-section>
|
|
</w-item>
|
|
</template>
|
|
</w-card>
|
|
<w-card class="shadow-1 py-2 mt-4">
|
|
<w-item>
|
|
<blueprint-icon icon="join" />
|
|
<w-item-section>
|
|
<w-select
|
|
outlined
|
|
:options="state.groups"
|
|
v-model="state.groupToAdd"
|
|
map-options
|
|
emit-value
|
|
option-value="id"
|
|
option-label="name"
|
|
options-dense
|
|
dense
|
|
hide-bottom-space
|
|
:label="t(`admin.users.groups`)"
|
|
:aria-label="t(`admin.users.groups`)"
|
|
:loading="state.loading > 0" />
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
unelevated
|
|
icon="la:plus"
|
|
:label="t(`admin.users.assignGroup`)"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="assignGroup" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
<w-page v-else-if="route.params.section === `metadata`">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-12 gap-4">
|
|
<div class="col-span-12 lg:col-span-8">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>
|
|
{{ t('admin.users.metadata') }}
|
|
<template #action>
|
|
<w-badge v-if="state.metadataInvalidJSON" color="negative">
|
|
<w-icon class="mr-1" name="la:exclamation-triangle" size="20px" />
|
|
<span>{{ t('admin.users.invalidJSON') }}</span>
|
|
</w-badge>
|
|
<w-badge class="py-1" v-else label="JSON" color="positive" />
|
|
</template>
|
|
</w-card-header>
|
|
<w-item>
|
|
<w-item-section>
|
|
<util-code-editor
|
|
v-model="metadata"
|
|
language="json"
|
|
:min-height="500"
|
|
aria-label="Metadata (JSON)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
<w-page v-else-if="route.params.section === `operations`">
|
|
<div class="p-4">
|
|
<div class="grid grid-cols-12 gap-4">
|
|
<div class="col-span-12 lg:col-span-8">
|
|
<w-card class="shadow-1 pb-2">
|
|
<w-card-header>{{ t('admin.users.operations') }}</w-card-header>
|
|
<w-item>
|
|
<blueprint-icon icon="email-open" :hue-rotate="45" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.sendWelcomeEmail`) }}</w-item-label>
|
|
<w-item-label caption>{{
|
|
t(`admin.users.sendWelcomeEmailAltHint`)
|
|
}}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="sendWelcomeEmail"
|
|
:loading="state.welcomeEmailLoading"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="apply" :hue-rotate="45" />
|
|
<w-item-section>
|
|
<w-item-label>{{
|
|
state.user.isVerified ? t(`admin.users.unverify`) : t(`admin.users.verify`)
|
|
}}</w-item-label>
|
|
<w-item-label caption>{{
|
|
state.user.isVerified
|
|
? t(`admin.users.unverifyHint`)
|
|
: t(`admin.users.verifyHint`)
|
|
}}</w-item-label>
|
|
<w-item-label caption>
|
|
<strong :class="state.user.isVerified ? `text-positive` : `text-negative`">{{
|
|
state.user.isVerified
|
|
? t(`admin.users.verified`)
|
|
: t(`admin.users.unverified`)
|
|
}}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="toggleVerified"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<w-item>
|
|
<blueprint-icon icon="unfriend" :hue-rotate="45" />
|
|
<w-item-section>
|
|
<w-item-label>{{
|
|
state.user.isActive ? t(`admin.users.ban`) : t(`admin.users.unban`)
|
|
}}</w-item-label>
|
|
<w-item-label caption>{{
|
|
state.user.isActive ? t(`admin.users.banHint`) : t(`admin.users.unbanHint`)
|
|
}}</w-item-label>
|
|
<w-item-label caption>
|
|
<strong :class="state.user.isActive ? `text-positive` : `text-negative`">{{
|
|
state.user.isActive ? t(`admin.users.active`) : t(`admin.users.banned`)
|
|
}}</strong>
|
|
</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="primary"
|
|
v-if="canManage"
|
|
@click="toggleBan"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="shadow-1 py-2 mt-4">
|
|
<w-item>
|
|
<blueprint-icon icon="denied" :hue-rotate="140" />
|
|
<w-item-section>
|
|
<w-item-label>{{ t(`admin.users.delete`) }}</w-item-label>
|
|
<w-item-label caption>{{ t(`admin.users.deleteHint`) }}</w-item-label>
|
|
</w-item-section>
|
|
<w-item-section side>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:arrow-circle-right"
|
|
color="negative"
|
|
v-if="canManage"
|
|
@click="deleteUser"
|
|
:label="t(`common.actions.proceed`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
</w-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</w-page>
|
|
</w-page-container>
|
|
</w-layout>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { useI18n } from 'vue-i18n'
|
|
import { computed, onMounted, reactive, watch } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
|
import { confirm, dialog } from '@/composables/dialog'
|
|
import { loading } from '@/composables/loading'
|
|
import { useDark } from '@/composables/dark'
|
|
import { notify } from '@/composables/notify'
|
|
|
|
import { useAdminStore } from '@/stores/admin'
|
|
import { useFlagsStore } from '@/stores/flags'
|
|
import { useUserStore } from '@/stores/user'
|
|
|
|
import { apiErrorMessage } from '@/helpers/apiError'
|
|
|
|
import UserChangePwdDialog from './UserChangePwdDialog.vue'
|
|
import UtilCodeEditor from './UtilCodeEditor.vue'
|
|
|
|
// COMPOSABLES
|
|
|
|
const dark = useDark()
|
|
|
|
// STORES
|
|
|
|
const adminStore = useAdminStore()
|
|
const flagsStore = useFlagsStore()
|
|
const userStore = useUserStore()
|
|
|
|
// ROUTER
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
// I18N
|
|
|
|
const { t } = useI18n()
|
|
|
|
// DATA
|
|
|
|
const state = reactive({
|
|
invalidCharsRegex: /^[^<>"]+$/,
|
|
user: {
|
|
meta: {},
|
|
prefs: {},
|
|
groups: []
|
|
},
|
|
groups: [],
|
|
groupToAdd: null,
|
|
loading: 0,
|
|
welcomeEmailLoading: false,
|
|
metadataInvalidJSON: false
|
|
})
|
|
|
|
const sections = [
|
|
{ key: 'overview', text: t('admin.users.overview'), icon: 'la:user' },
|
|
{ key: 'activity', text: t('admin.users.activity'), icon: 'la:chart-area', disabled: true },
|
|
{ key: 'auth', text: t('admin.users.auth'), icon: 'la:key' },
|
|
{ key: 'groups', text: t('admin.users.groups'), icon: 'la:users' },
|
|
{ key: 'metadata', text: t('admin.users.metadata'), icon: 'la:clipboard-list' },
|
|
{ key: 'operations', text: t('admin.users.operations'), icon: 'la:tools' }
|
|
]
|
|
|
|
const timezones = Intl.supportedValuesOf('timeZone')
|
|
|
|
// COMPUTED
|
|
|
|
/*
|
|
`read:users` opens this overlay read-only: every write below needs `manage:users` (see
|
|
`api/users.ts`), so the actions that perform one are hidden rather than left to fail at the API.
|
|
The fields stay as they are -- without Save there is nowhere for a typed change to go.
|
|
*/
|
|
const canManage = computed(() => userStore.can('manage:users'))
|
|
|
|
const metadata = computed({
|
|
get() {
|
|
return JSON.stringify(state.user.meta ?? {}, null, 2)
|
|
},
|
|
set(val) {
|
|
try {
|
|
state.user.meta = JSON.parse(val)
|
|
state.metadataInvalidJSON = false
|
|
} catch (err) {
|
|
state.metadataInvalidJSON = true
|
|
}
|
|
}
|
|
})
|
|
|
|
const localAuth = computed({
|
|
get() {
|
|
return state.user?.auth?.find((prv) => prv.strategyKey === 'local')?.config ?? {}
|
|
},
|
|
set(val) {
|
|
if (localAuth.value.authId) {
|
|
state.user.auth.find((prv) => prv.strategyKey === 'local').config = val
|
|
}
|
|
}
|
|
})
|
|
|
|
const linkedAuthProviders = computed(() => {
|
|
if (!state.user?.auth) {
|
|
return []
|
|
}
|
|
|
|
return state.user.auth.filter((prv) => prv.strategyKey !== 'local')
|
|
})
|
|
|
|
// WATCHERS
|
|
|
|
watch(() => route.params.section, checkRoute)
|
|
|
|
// METHODS
|
|
|
|
async function fetchUser() {
|
|
state.loading++
|
|
loading.show()
|
|
try {
|
|
const [groups, user] = await Promise.all([
|
|
API_CLIENT.get('groups').json(),
|
|
API_CLIENT.get(`users/${adminStore.overlayOpts.id}`).json()
|
|
])
|
|
state.groups = (groups ?? []).filter((g) => g.id !== '10000000-0000-4000-8000-000000000001')
|
|
if (!user?.id) {
|
|
throw new Error('An unexpected error occured while fetching user details.')
|
|
}
|
|
state.user = user
|
|
} catch (err) {
|
|
notify({
|
|
type: 'negative',
|
|
message: err.message
|
|
})
|
|
}
|
|
loading.hide()
|
|
state.loading--
|
|
}
|
|
|
|
function close() {
|
|
adminStore.$patch({ overlay: '' })
|
|
}
|
|
|
|
function checkRoute() {
|
|
if (!route.params.section) {
|
|
router.replace({ params: { section: 'overview' } })
|
|
}
|
|
if (route.params.section === 'metadata') {
|
|
state.metadataInvalidJSON = false
|
|
}
|
|
}
|
|
|
|
function formattedDate(val) {
|
|
if (!val) {
|
|
return '---'
|
|
}
|
|
return Temporal.Instant.from(val).toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZoneName: 'short'
|
|
})
|
|
}
|
|
|
|
function assignGroup() {
|
|
if (!state.groupToAdd) {
|
|
notify({
|
|
type: 'negative',
|
|
message: t('admin.users.noGroupSelected')
|
|
})
|
|
} else if (state.user.groups.some((gr) => gr.id === state.groupToAdd)) {
|
|
notify({
|
|
type: 'warning',
|
|
message: t('admin.users.groupAlreadyAssigned')
|
|
})
|
|
} else {
|
|
const newGroup = state.groups.find((gr) => gr.id === state.groupToAdd)
|
|
state.user.groups = [...state.user.groups, newGroup]
|
|
}
|
|
}
|
|
|
|
function unassignGroup(id) {
|
|
if (state.user.groups.length <= 1) {
|
|
notify({
|
|
type: 'negative',
|
|
message: t('admin.users.minimumGroupRequired')
|
|
})
|
|
} else {
|
|
state.user.groups = state.user.groups.filter((gr) => gr.id === id)
|
|
}
|
|
}
|
|
|
|
async function save(patch, { silent, keepOpen } = { silent: false, keepOpen: false }) {
|
|
loading.show()
|
|
if (!patch) {
|
|
patch = {
|
|
name: state.user.name,
|
|
email: state.user.email,
|
|
isVerified: state.user.isVerified,
|
|
isActive: state.user.isActive,
|
|
meta: state.user.meta,
|
|
prefs: state.user.prefs,
|
|
groups: state.user.groups.map((gr) => gr.id),
|
|
auth: {
|
|
tfaRequired: localAuth.value.isTfaRequired,
|
|
mustChangePwd: localAuth.value.mustChangePwd,
|
|
restrictLogin: localAuth.value.restrictLogin
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
const resp = await API_CLIENT.put(`users/${adminStore.overlayOpts.id}`, {
|
|
json: patch
|
|
}).json()
|
|
if (!resp?.ok) {
|
|
throw new Error(
|
|
t(`admin.users.${resp?.error}`, resp?.message || 'An unexpected error occured.')
|
|
)
|
|
}
|
|
if (!silent) {
|
|
notify({
|
|
type: 'positive',
|
|
message: t('admin.users.saveSuccess')
|
|
})
|
|
}
|
|
if (!keepOpen) {
|
|
close()
|
|
}
|
|
} catch (err) {
|
|
// -> ky throws above 400 with the reason in the body, which is where the server explains itself
|
|
notify({
|
|
type: 'negative',
|
|
message: apiErrorMessage(err, 'An unexpected error occured.')
|
|
})
|
|
}
|
|
loading.hide()
|
|
}
|
|
|
|
function changePassword() {
|
|
dialog({
|
|
component: UserChangePwdDialog,
|
|
componentProps: {
|
|
userId: adminStore.overlayOpts.id
|
|
}
|
|
}).onOk(({ mustChangePassword }) => {
|
|
localAuth.value = {
|
|
...localAuth.value,
|
|
mustChangePwd: mustChangePassword
|
|
}
|
|
})
|
|
}
|
|
|
|
function invalidateTFA() {
|
|
confirm({
|
|
title: t('admin.users.tfaInvalidate'),
|
|
message: t('admin.users.tfaInvalidateConfirm'),
|
|
cancel: true,
|
|
persistent: true,
|
|
okLabel: t('common.actions.confirm')
|
|
}).onOk(() => {
|
|
// TODO: invalidate user 2FA
|
|
notify({
|
|
type: 'positive',
|
|
message: t('admin.users.tfaInvalidateSuccess')
|
|
})
|
|
})
|
|
}
|
|
|
|
async function sendWelcomeEmail() {
|
|
// -> Not `state.loading`, which the whole overlay hides itself behind: this is one row's button
|
|
state.welcomeEmailLoading = true
|
|
try {
|
|
// -> The site the admin area is being used on decides what the mail calls this wiki and where it
|
|
// points; the endpoint takes that from the request when nothing is named
|
|
const resp = await API_CLIENT.post(`users/${adminStore.overlayOpts.id}/send-welcome-email`, {
|
|
json: {
|
|
siteId: adminStore.currentSiteId
|
|
}
|
|
}).json()
|
|
if (!resp?.ok) {
|
|
throw new Error(resp?.message || 'An unexpected error occured.')
|
|
}
|
|
notify({
|
|
type: 'positive',
|
|
message: t('admin.users.sendWelcomeEmailSuccess')
|
|
})
|
|
} catch (err) {
|
|
notify({
|
|
type: 'negative',
|
|
message: t('admin.users.sendWelcomeEmailFailed'),
|
|
caption: apiErrorMessage(err)
|
|
})
|
|
}
|
|
state.welcomeEmailLoading = false
|
|
}
|
|
|
|
function toggleVerified() {
|
|
state.user.isVerified = !state.user.isVerified
|
|
save(
|
|
{
|
|
isVerified: state.user.isVerified
|
|
},
|
|
{ silent: true, keepOpen: true }
|
|
)
|
|
}
|
|
|
|
function toggleBan() {
|
|
state.user.isActive = !state.user.isActive
|
|
save(
|
|
{
|
|
isActive: state.user.isActive
|
|
},
|
|
{ silent: true, keepOpen: true }
|
|
)
|
|
}
|
|
|
|
async function deleteUser() {}
|
|
|
|
// MOUNTED
|
|
|
|
onMounted(() => {
|
|
checkRoute()
|
|
fetchUser()
|
|
})
|
|
</script>
|
|
|
|
<!--
|
|
-> The `.metadata-codemirror` rules that were here targeted `.cm-editor`, a CodeMirror 6 class this
|
|
app never had, from a class no element in this file carries. Dead twice over.
|
|
-->
|