mirror of
https://github.com/directus/directus.git
synced 2026-01-28 07:48:04 -05:00
Users nav (#400)
* Add filters-to-query util * Add all users string * Suppot filters in tabular layout * Sync filters with collection presets * Support filters in use-items composition * Render users nav + use skeleton loader * Add list-item-icon type to skeleton loader * Fix missing loader indicator on table * Cleanup has click check * Dont route based on id * Fix loading state in table * Revert "Dont route based on id" This reverts commit 6de7cbe1b801d5e5e267f09a6e77dc73dcc60a37. * Fix table loading state * Fix routing for users module * Force role field to be fetched * Add roles store * Dont render avatar until user is known * Speed up hydration absurd much * Rely on roles store to prevent nav from loading * Fix tests
This commit is contained in:
@@ -15,7 +15,7 @@ export const basic = () =>
|
||||
defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
default: select('Type', ['input', 'input-tall'], 'input'),
|
||||
default: select('Type', ['input', 'input-tall', 'list-item-icon'], 'input'),
|
||||
},
|
||||
},
|
||||
template: `
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template functional>
|
||||
<transition name="fade">
|
||||
<div class="v-skeleton-loader" :class="props.type" />
|
||||
<div class="v-skeleton-loader" :class="props.type">
|
||||
<template v-if="props.type === 'list-item-icon'">
|
||||
<div class="icon" />
|
||||
<div class="text" />
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +17,7 @@ export default defineComponent({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'input',
|
||||
validator: (type: string) => ['input', 'input-tall', 'list-item-icon'].includes(type),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -19,10 +25,18 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-skeleton-loader {
|
||||
--v-skeleton-loader-color: var(--background-page);
|
||||
--v-skeleton-loader-background-color: var(--background-subdued);
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--background-subdued);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@mixin loader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--v-skeleton-loader-background-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
@@ -31,8 +45,14 @@ export default defineComponent({
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--v-skeleton-loader-color),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
opacity: 0.5;
|
||||
animation: loading 1.5s infinite;
|
||||
content: '';
|
||||
}
|
||||
@@ -48,14 +68,41 @@ export default defineComponent({
|
||||
.input-tall {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border: var(--border-width) solid var(--background-subdued);
|
||||
border: var(--border-width) solid var(--v-skeleton-loader-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@include loader;
|
||||
}
|
||||
|
||||
.input-tall {
|
||||
height: var(--input-height-tall);
|
||||
}
|
||||
|
||||
.list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
|
||||
@include loader;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex-grow: 1;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@include loader;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
|
||||
@@ -374,7 +374,7 @@ export default defineComponent({
|
||||
&.sticky th {
|
||||
position: sticky;
|
||||
top: 48px;
|
||||
z-index: +1;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
src/compositions/use-collection-preset/types.ts
Normal file
6
src/compositions/use-collection-preset/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Filter = {
|
||||
locked?: boolean;
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string | number;
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import useCollectionPresetStore from '@/stores/collection-presets';
|
||||
import { ref, Ref, computed, watch } from '@vue/composition-api';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { Filter } from './types';
|
||||
|
||||
export function useCollectionPreset(collection: Ref<string>) {
|
||||
const collectionPresetsStore = useCollectionPresetStore();
|
||||
|
||||
@@ -48,5 +50,18 @@ export function useCollectionPreset(collection: Ref<string>) {
|
||||
},
|
||||
});
|
||||
|
||||
return { viewOptions, viewQuery };
|
||||
const filters = computed<Filter[]>({
|
||||
get() {
|
||||
return localPreset.value.filters || [];
|
||||
},
|
||||
set(val) {
|
||||
localPreset.value = {
|
||||
...localPreset.value,
|
||||
filters: val,
|
||||
};
|
||||
savePreset(localPreset.value);
|
||||
},
|
||||
});
|
||||
|
||||
return { viewOptions, viewQuery, filters };
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@ import useProjectsStore from '@/stores/projects';
|
||||
import useCollection from '@/compositions/use-collection';
|
||||
import Vue from 'vue';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
import filtersToQuery from '@/utils/filters-to-query';
|
||||
|
||||
type Options = {
|
||||
limit: Ref<number>;
|
||||
fields: Ref<readonly string[]>;
|
||||
sort: Ref<string>;
|
||||
page: Ref<number>;
|
||||
filters: Ref<readonly Filter[]>;
|
||||
};
|
||||
|
||||
export function useItems(collection: Ref<string>, options: Options) {
|
||||
const projectsStore = useProjectsStore();
|
||||
const { primaryKeyField } = useCollection(collection);
|
||||
|
||||
const { limit, fields, sort, page } = options;
|
||||
const { limit, fields, sort, page, filters } = options;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items = ref<any>([]);
|
||||
@@ -61,7 +64,32 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NOTE
|
||||
* Ignore sorting through the API when the full set of items is already loaded. This requires
|
||||
* layouts to support client side sorting when the itemcount is less than the total items.
|
||||
*/
|
||||
if (limit.value > (itemCount.value || 0)) return;
|
||||
|
||||
await Vue.nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
}
|
||||
});
|
||||
|
||||
watch([filters], async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NOTE
|
||||
*
|
||||
* When the filters change, we have to re-calculate the total amount of items too, as the
|
||||
* total amount of available items is based on the filter query.
|
||||
*/
|
||||
itemCount.value = null;
|
||||
|
||||
await Vue.nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
@@ -76,6 +104,7 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
|
||||
const fieldsToFetch = [...fields.value];
|
||||
|
||||
// Make sure the primary key is always fetched
|
||||
if (
|
||||
fields.value !== ['*'] &&
|
||||
primaryKeyField.value &&
|
||||
@@ -84,6 +113,15 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
fieldsToFetch.push(primaryKeyField.value.field);
|
||||
}
|
||||
|
||||
// Make sure all fields that are used to filter are fetched
|
||||
if (fields.value !== ['*']) {
|
||||
filters.value.forEach((filter) => {
|
||||
if (fieldsToFetch.includes(filter.field) === false) {
|
||||
fieldsToFetch.push(filter.field);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = collection.value.startsWith('directus_')
|
||||
? `/${currentProjectKey}/${collection.value.substring(9)}`
|
||||
@@ -95,6 +133,7 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
fields: fieldsToFetch,
|
||||
sort: sort.value,
|
||||
page: page.value,
|
||||
...filtersToQuery(filters.value),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings/';
|
||||
import { useProjectsStore } from '@/stores/projects/';
|
||||
import { useLatencyStore } from '@/stores/latency';
|
||||
import { usePermissionsStore } from '@/stores/permissions';
|
||||
import { useRolesStore } from '@/stores/roles';
|
||||
|
||||
type GenericStore = {
|
||||
id: string;
|
||||
@@ -28,6 +29,7 @@ export function useStores(
|
||||
useProjectsStore,
|
||||
useLatencyStore,
|
||||
usePermissionsStore,
|
||||
useRolesStore,
|
||||
]
|
||||
) {
|
||||
return stores.map((useStore) => useStore()) as GenericStore[];
|
||||
@@ -45,12 +47,15 @@ export async function hydrate(stores = useStores()) {
|
||||
try {
|
||||
/**
|
||||
* @NOTE
|
||||
* This will fetch the store data sequential. While this does prevent rate limiteres from
|
||||
* kicking in, we could optimize it by running (some of) the requests in parallel
|
||||
* Multiple stores rely on the userStore to be set, so they can fetch user specific data. The
|
||||
* following makes sure that the user store is always fetched first, before we hydrate anything
|
||||
* else.
|
||||
*/
|
||||
for (const store of stores) {
|
||||
await store.hydrate?.();
|
||||
}
|
||||
await useUserStore().hydrate();
|
||||
|
||||
await Promise.all(
|
||||
stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.())
|
||||
);
|
||||
} catch (error) {
|
||||
appStore.state.error = error;
|
||||
} finally {
|
||||
|
||||
@@ -177,6 +177,8 @@
|
||||
"create_new_project_copy": "Make sure you have your database information handy, then enter your API's Super-Admin password to continue.",
|
||||
"super_admin_token": "Super-Admin Token",
|
||||
|
||||
"all_users": "All Users",
|
||||
|
||||
|
||||
"about_directus": "About Directus",
|
||||
"activity_log": "Activity Log",
|
||||
|
||||
@@ -101,6 +101,7 @@ import Draggable from 'vuedraggable';
|
||||
import useCollection from '@/compositions/use-collection';
|
||||
import useItems from '@/compositions/use-items';
|
||||
import { render } from 'micromustache';
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
|
||||
type ViewOptions = {
|
||||
widths?: {
|
||||
@@ -134,6 +135,10 @@ export default defineComponent({
|
||||
type: Object as PropType<ViewQuery>,
|
||||
default: null,
|
||||
},
|
||||
filters: {
|
||||
type: Array as PropType<Filter[]>,
|
||||
default: () => [],
|
||||
},
|
||||
selectMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -152,6 +157,7 @@ export default defineComponent({
|
||||
const _selection = useSync(props, 'selection', emit);
|
||||
const _viewOptions = useSync(props, 'viewOptions', emit);
|
||||
const _viewQuery = useSync(props, 'viewQuery', emit);
|
||||
const _filters = useSync(props, 'filters', emit);
|
||||
|
||||
const { collection } = toRefs(props);
|
||||
const { primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
|
||||
@@ -167,6 +173,7 @@ export default defineComponent({
|
||||
limit,
|
||||
page,
|
||||
fields,
|
||||
filters: _filters,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -369,6 +376,7 @@ export default defineComponent({
|
||||
project: currentProjectKey.value,
|
||||
collection: collection.value,
|
||||
primaryKey,
|
||||
item,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
<template>
|
||||
<div>users nav</div>
|
||||
<v-list nav>
|
||||
<v-list-item to="/my-project/users/all">
|
||||
<v-list-item-icon><v-icon name="people" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ $t('all_users') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-for="{ name, id } in roles" :key="id" :to="`/my-project/users/${id}`">
|
||||
<v-list-item-icon><v-icon name="people" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ name }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import useRolesStore from '@/stores/roles';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const rolesStore = useRolesStore();
|
||||
|
||||
return { roles: rolesStore.state.roles };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .v-skeleton-loader {
|
||||
--v-skeleton-loader-background-color: var(--background-normal-alt);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineModule } from '@/modules/define';
|
||||
|
||||
import UsersBrowse from './routes/browse/';
|
||||
import UsersDetail from './routes/detail/';
|
||||
|
||||
@@ -8,14 +9,31 @@ export default defineModule(({ i18n }) => ({
|
||||
icon: 'people',
|
||||
routes: [
|
||||
{
|
||||
name: 'users-browse',
|
||||
path: '/',
|
||||
redirect: '/all',
|
||||
},
|
||||
{
|
||||
name: 'users-browse-all',
|
||||
path: '/all',
|
||||
component: UsersBrowse,
|
||||
},
|
||||
{
|
||||
name: 'users-detail-add-new',
|
||||
path: '/+',
|
||||
component: UsersDetail,
|
||||
props: {
|
||||
primaryKey: '+',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'users-browse-role',
|
||||
path: '/:role',
|
||||
component: UsersBrowse,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
name: 'users-detail',
|
||||
path: '/:primaryKey',
|
||||
path: '/:role/:primaryKey',
|
||||
component: UsersDetail,
|
||||
props: true,
|
||||
},
|
||||
|
||||
@@ -56,7 +56,9 @@
|
||||
:selection.sync="selection"
|
||||
:view-options.sync="viewOptions"
|
||||
:view-query.sync="viewQuery"
|
||||
:detail-route="'/{{project}}/users/{{primaryKey}}'"
|
||||
:detail-route="'/{{project}}/users/{{item.role}}/{{primaryKey}}'"
|
||||
:filters="_filters"
|
||||
@update:filters="filters = $event"
|
||||
/>
|
||||
</private-view>
|
||||
</template>
|
||||
@@ -78,18 +80,50 @@ type Item = {
|
||||
export default defineComponent({
|
||||
name: 'users-browse',
|
||||
components: { UsersNavigation },
|
||||
props: {},
|
||||
setup() {
|
||||
props: {
|
||||
role: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const layout = ref<LayoutComponent>(null);
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const selection = ref<Item[]>([]);
|
||||
|
||||
const { viewOptions, viewQuery } = useCollectionPreset(ref('directus_users'));
|
||||
const { viewOptions, viewQuery, filters } = useCollectionPreset(ref('directus_users'));
|
||||
const { addNewLink, batchLink } = useLinks();
|
||||
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
|
||||
const { breadcrumb } = useBreadcrumb();
|
||||
|
||||
const _filters = computed(() => {
|
||||
if (props.role !== null) {
|
||||
return [
|
||||
{
|
||||
locked: 1,
|
||||
field: 'role',
|
||||
operator: 'eq',
|
||||
value: props.role,
|
||||
},
|
||||
...filters.value,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
// This filter is basically a no-op. Every user has a role. However, by filtering on
|
||||
// this field, we can ensure that the field data is fetched, which is needed to build
|
||||
// out the navigation links
|
||||
{
|
||||
locked: 1,
|
||||
field: 'role',
|
||||
operator: 'nnull',
|
||||
value: 1,
|
||||
},
|
||||
...filters.value,
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
addNewLink,
|
||||
batchLink,
|
||||
@@ -101,6 +135,7 @@ export default defineComponent({
|
||||
layout,
|
||||
viewOptions,
|
||||
viewQuery,
|
||||
_filters,
|
||||
};
|
||||
|
||||
function useBatchDelete() {
|
||||
|
||||
4
src/stores/roles/index.ts
Normal file
4
src/stores/roles/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useRolesStore } from './roles';
|
||||
|
||||
export { useRolesStore };
|
||||
export default useRolesStore;
|
||||
25
src/stores/roles/roles.ts
Normal file
25
src/stores/roles/roles.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createStore } from 'pinia';
|
||||
import api from '@/api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
|
||||
import { Role } from './types';
|
||||
|
||||
export const useRolesStore = createStore({
|
||||
id: 'rolesStore',
|
||||
state: () => ({
|
||||
roles: [] as Role[],
|
||||
}),
|
||||
actions: {
|
||||
async hydrate() {
|
||||
const projectsStore = useProjectsStore();
|
||||
const currentProjectKey = projectsStore.state.currentProjectKey;
|
||||
|
||||
const rolesResponse = await api.get(`/${currentProjectKey}/roles`);
|
||||
|
||||
this.state.roles = rolesResponse.data.data;
|
||||
},
|
||||
async dehydrate() {
|
||||
this.reset();
|
||||
},
|
||||
},
|
||||
});
|
||||
10
src/stores/roles/types.ts
Normal file
10
src/stores/roles/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type Role = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
collection_listing: null;
|
||||
module_listing: null;
|
||||
enforce_2fa: null | boolean;
|
||||
external_id: null | string;
|
||||
ip_whitelist: string[];
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Role } from '@/stores/roles/types';
|
||||
|
||||
export type Avatar = {
|
||||
data: {
|
||||
thumbnails: Thumbnail[];
|
||||
@@ -24,16 +26,7 @@ export type User = {
|
||||
external_id: string;
|
||||
'2fa_secret': string;
|
||||
theme: 'auto' | 'dark' | 'light';
|
||||
role: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
collection_listing: null;
|
||||
module_listing: null;
|
||||
enforce_2fa: null | boolean;
|
||||
external_id: null | string;
|
||||
ip_whitelist: string[];
|
||||
};
|
||||
role: Role;
|
||||
password_reset_token: string | null;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
|
||||
12
src/utils/filters-to-query/filters-to-query.ts
Normal file
12
src/utils/filters-to-query/filters-to-query.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
|
||||
export default function filtersToQuery(filters: readonly Filter[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const query: Record<string, any> = {};
|
||||
|
||||
filters.forEach((filter) => {
|
||||
query[`filter[${filter.field}][${filter.operator}]`] = filter.value;
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
4
src/utils/filters-to-query/index.ts
Normal file
4
src/utils/filters-to-query/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import filtersToQuery from './filters-to-query';
|
||||
|
||||
export { filtersToQuery };
|
||||
export default filtersToQuery;
|
||||
27
src/utils/filters-to-query/readme.md
Normal file
27
src/utils/filters-to-query/readme.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Filters to Query
|
||||
|
||||
Converts an array of filter objects to an Axios compatible object of query params.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
field: 'title',
|
||||
operator: 'contains',
|
||||
value: 'directus',
|
||||
},
|
||||
{
|
||||
field: 'author',
|
||||
operator: 'eq',
|
||||
value: 1,
|
||||
},
|
||||
];
|
||||
|
||||
filtersToQuery(filters);
|
||||
|
||||
// {
|
||||
// 'filter[title][contains]': 'directus',
|
||||
// 'filter[author][eq]: 1
|
||||
// }
|
||||
```
|
||||
@@ -127,6 +127,7 @@ describe('Views / Private / Module Bar Avatar', () => {
|
||||
userStore.state.currentUser = {
|
||||
id: 1,
|
||||
avatar: null,
|
||||
role: { id: 15 },
|
||||
} as any;
|
||||
|
||||
const component = shallowMount(ModuleBarAvatar, {
|
||||
@@ -137,7 +138,7 @@ describe('Views / Private / Module Bar Avatar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).userProfileLink).toBe('/my-project/users/1');
|
||||
expect((component.vm as any).userProfileLink).toBe('/my-project/users/15/1');
|
||||
expect((component.vm as any).signOutLink).toBe('/my-project/logout');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,11 +52,10 @@ export default defineComponent({
|
||||
|
||||
const userProfileLink = computed<string>(() => {
|
||||
const project = projectsStore.state.currentProjectKey;
|
||||
// This is rendered in the private view, which is only accessible as a logged in user
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const id = userStore.state.currentUser!.id;
|
||||
const id = userStore.state.currentUser?.id;
|
||||
const role = userStore.state.currentUser?.role?.id;
|
||||
|
||||
return `/${project}/users/${id}`;
|
||||
return `/${project}/users/${role}/${id}`;
|
||||
});
|
||||
|
||||
const signOutLink = computed<string>(() => {
|
||||
|
||||
Reference in New Issue
Block a user