mirror of https://github.com/halo-dev/halo
refactor: user detail page structure (#4664)
#### What type of PR is this? /area console /kind improvement /milestone 2.10.x #### What this PR does / why we need it: 重构 Console 端用户详情页面的结构。 1. 提高代码可读性。 2. 使用问号参数来区分不同的选项卡。 3. 封装头像修改相关的代码为组件。 #### Special notes for your reviewer: 测试用户详情页面的所有功能是否正常。 #### Does this PR introduce a user-facing change? ```release-note 重构 Console 端用户详情页面的代码结构。 ```pull/4665/head
parent
a5a69780a3
commit
470b0de70d
|
@ -1,168 +1,199 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
IconUserSettings,
|
|
||||||
VButton,
|
|
||||||
VDescription,
|
|
||||||
VDescriptionItem,
|
|
||||||
VTag,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import type { ComputedRef, Ref } from "vue";
|
|
||||||
import { inject, computed } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
|
|
||||||
import { rbacAnnotations } from "@/constants/annotations";
|
|
||||||
import { formatDatetime } from "@/utils/date";
|
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import axios from "axios";
|
import {
|
||||||
|
VButton,
|
||||||
|
VTabbar,
|
||||||
|
VDropdown,
|
||||||
|
VDropdownItem,
|
||||||
|
VLoading,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
type ComputedRef,
|
||||||
|
type Ref,
|
||||||
|
} from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import type { DetailedUser } from "@halo-dev/api-client";
|
||||||
|
import UserEditingModal from "./components/UserEditingModal.vue";
|
||||||
|
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
|
||||||
|
import { usePermission } from "@/utils/permission";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { rbacAnnotations } from "@/constants/annotations";
|
||||||
|
import { onBeforeRouteUpdate } from "vue-router";
|
||||||
|
import UserAvatar from "./components/UserAvatar.vue";
|
||||||
|
import type { Raw } from "vue";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
import DetailTab from "./tabs/Detail.vue";
|
||||||
|
import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue";
|
||||||
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
|
||||||
const user = inject<Ref<DetailedUser | undefined>>("user");
|
const { currentUserHasPermission } = usePermission();
|
||||||
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
|
interface UserTab {
|
||||||
queryKey: ["user-auth-providers"],
|
id: string;
|
||||||
|
label: string;
|
||||||
|
component: Raw<Component>;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
permissions?: string[];
|
||||||
|
priority: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingModal = ref(false);
|
||||||
|
const passwordChangeModal = ref(false);
|
||||||
|
|
||||||
|
const { params } = useRoute();
|
||||||
|
const name = ref();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
name.value = params.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update name when route change
|
||||||
|
onBeforeRouteUpdate((to, _, next) => {
|
||||||
|
name.value = to.params.name;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["user-detail", name],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.authProvider.listAuthProviders();
|
if (name.value === "-") {
|
||||||
|
const { data } = await apiClient.user.getCurrentUserDetail();
|
||||||
return data;
|
return data;
|
||||||
},
|
} else {
|
||||||
enabled: isCurrentUser,
|
const { data } = await apiClient.user.getUserDetail({
|
||||||
});
|
name: name.value,
|
||||||
|
|
||||||
const availableAuthProviders = computed(() => {
|
|
||||||
return authProviders.value?.filter(
|
|
||||||
(authProvider) => authProvider.enabled && authProvider.supportsBinding
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUnbindAuth = (authProvider: ListedAuthProvider) => {
|
|
||||||
Dialog.warning({
|
|
||||||
title: t("core.user.detail.operations.unbind.title", {
|
|
||||||
display_name: authProvider.displayName,
|
|
||||||
}),
|
|
||||||
confirmText: t("core.common.buttons.confirm"),
|
|
||||||
cancelText: t("core.common.buttons.cancel"),
|
|
||||||
onConfirm: async () => {
|
|
||||||
await axios.put(
|
|
||||||
`${import.meta.env.VITE_API_URL}${authProvider.unbindingUrl}`,
|
|
||||||
{
|
|
||||||
withCredentials: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
return data;
|
||||||
|
|
||||||
const handleBindAuth = (authProvider: ListedAuthProvider) => {
|
|
||||||
if (!authProvider.bindingUrl) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
window.location.href = `${
|
},
|
||||||
authProvider.bindingUrl
|
refetchInterval: (data) => {
|
||||||
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
|
const annotations = data?.user.metadata.annotations;
|
||||||
};
|
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
||||||
|
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
||||||
|
? 1000
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
enabled: computed(() => !!name.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCurrentUser = computed(() => {
|
||||||
|
if (name.value === "-") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
user.value?.user.metadata.name === userStore.currentUser?.metadata.name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
provide<Ref<DetailedUser | undefined>>("user", user);
|
||||||
|
provide<ComputedRef<boolean>>("isCurrentUser", isCurrentUser);
|
||||||
|
|
||||||
|
const tabs = computed((): UserTab[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "detail",
|
||||||
|
label: t("core.user.detail.tabs.detail"),
|
||||||
|
component: markRaw(DetailTab),
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pat",
|
||||||
|
label: t("core.user.detail.tabs.pat"),
|
||||||
|
component: markRaw(PersonalAccessTokensTab),
|
||||||
|
priority: 20,
|
||||||
|
hidden: !isCurrentUser.value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id, {
|
||||||
|
mode: "push",
|
||||||
|
});
|
||||||
|
provide<Ref<string>>("activeTab", activeTab);
|
||||||
|
|
||||||
|
const tabbarItems = computed(() => {
|
||||||
|
return tabs.value
|
||||||
|
.filter((tab) => !tab.hidden)
|
||||||
|
.map((tab) => ({ id: tab.id, label: tab.label }));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="border-t border-gray-100">
|
<UserEditingModal v-model:visible="editingModal" :user="user?.user" />
|
||||||
<VDescription>
|
|
||||||
<VDescriptionItem
|
<UserPasswordChangeModal
|
||||||
:label="$t('core.user.detail.fields.display_name')"
|
v-model:visible="passwordChangeModal"
|
||||||
:content="user?.user.spec.displayName"
|
:user="user?.user"
|
||||||
class="!px-2"
|
@close="refetch"
|
||||||
/>
|
/>
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.user.detail.fields.username')"
|
<header class="bg-white">
|
||||||
:content="user?.user.metadata.name"
|
<div class="p-4">
|
||||||
class="!px-2"
|
<div class="flex items-center justify-between">
|
||||||
/>
|
<div class="flex flex-row items-center gap-5">
|
||||||
<VDescriptionItem
|
<div class="group relative h-20 w-20">
|
||||||
:label="$t('core.user.detail.fields.email')"
|
<VLoading v-if="isFetching" class="h-full w-full" />
|
||||||
:content="user?.user.spec.email || $t('core.common.text.none')"
|
<UserAvatar v-else />
|
||||||
class="!px-2"
|
</div>
|
||||||
/>
|
<div class="block">
|
||||||
<VDescriptionItem
|
<h1 class="truncate text-lg font-bold text-gray-900">
|
||||||
:label="$t('core.user.detail.fields.roles')"
|
{{ user?.user.spec.displayName }}
|
||||||
class="!px-2"
|
</h1>
|
||||||
>
|
<span v-if="!isLoading" class="text-sm text-gray-600">
|
||||||
<VTag
|
@{{ user?.user.metadata.name }}
|
||||||
v-for="(role, index) in user?.roles"
|
</span>
|
||||||
:key="index"
|
</div>
|
||||||
@click="
|
</div>
|
||||||
router.push({
|
<div
|
||||||
name: 'RoleDetail',
|
v-if="
|
||||||
params: { name: role.metadata.name },
|
currentUserHasPermission(['system:users:manage']) || isCurrentUser
|
||||||
})
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #leftIcon>
|
<VDropdown>
|
||||||
<IconUserSettings />
|
<VButton type="default">
|
||||||
</template>
|
{{ $t("core.common.buttons.edit") }}
|
||||||
{{
|
|
||||||
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
|
|
||||||
role.metadata.name
|
|
||||||
}}
|
|
||||||
</VTag>
|
|
||||||
</VDescriptionItem>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.user.detail.fields.bio')"
|
|
||||||
:content="user?.user.spec?.bio || $t('core.common.text.none')"
|
|
||||||
class="!px-2"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.user.detail.fields.creation_time')"
|
|
||||||
:content="formatDatetime(user?.user.metadata?.creationTimestamp)"
|
|
||||||
class="!px-2"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem
|
|
||||||
v-if="!isFetching && isCurrentUser && availableAuthProviders?.length"
|
|
||||||
:label="$t('core.user.detail.fields.identity_authentication')"
|
|
||||||
class="!px-2"
|
|
||||||
>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<template v-for="(authProvider, index) in authProviders">
|
|
||||||
<li
|
|
||||||
v-if="authProvider.supportsBinding && authProvider.enabled"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-full cursor-pointer flex-wrap justify-between gap-y-3 rounded border p-5 hover:border-primary sm:w-1/2"
|
|
||||||
>
|
|
||||||
<div class="inline-flex items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<img class="h-7 w-7 rounded" :src="authProvider.logo" />
|
|
||||||
</div>
|
|
||||||
<div class="text-sm font-medium text-gray-900">
|
|
||||||
{{ authProvider.displayName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex items-center">
|
|
||||||
<VButton
|
|
||||||
v-if="authProvider.isBound"
|
|
||||||
size="sm"
|
|
||||||
@click="handleUnbindAuth(authProvider)"
|
|
||||||
>
|
|
||||||
{{ $t("core.user.detail.operations.unbind.button") }}
|
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton
|
<template #popper>
|
||||||
v-else
|
<VDropdownItem @click="editingModal = true">
|
||||||
size="sm"
|
{{ $t("core.user.detail.actions.update_profile.title") }}
|
||||||
type="secondary"
|
</VDropdownItem>
|
||||||
@click="handleBindAuth(authProvider)"
|
<VDropdownItem @click="passwordChangeModal = true">
|
||||||
>
|
{{ $t("core.user.detail.actions.change_password.title") }}
|
||||||
{{ $t("core.user.detail.operations.bind.button") }}
|
</VDropdownItem>
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</VDropdown>
|
||||||
</VDescriptionItem>
|
|
||||||
</VDescription>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="bg-white p-4">
|
||||||
|
<VTabbar
|
||||||
|
v-model:active-id="activeTab"
|
||||||
|
:items="tabbarItems"
|
||||||
|
class="w-full"
|
||||||
|
type="outline"
|
||||||
|
></VTabbar>
|
||||||
|
<div class="mt-2">
|
||||||
|
<template v-for="tab in tabs" :key="tab.id">
|
||||||
|
<component
|
||||||
|
:is="tab.component"
|
||||||
|
v-if="activeTab === tab.id && !tab.hidden"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,186 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import {
|
||||||
|
IconRiPencilFill,
|
||||||
|
VButton,
|
||||||
|
VAvatar,
|
||||||
|
VDropdown,
|
||||||
|
VDropdownItem,
|
||||||
|
VModal,
|
||||||
|
VSpace,
|
||||||
|
Toast,
|
||||||
|
Dialog,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { ref, defineAsyncComponent, type ComputedRef, type Ref } from "vue";
|
||||||
|
import type { DetailedUser } from "@halo-dev/api-client";
|
||||||
|
import { usePermission } from "@/utils/permission";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useFileDialog } from "@vueuse/core";
|
||||||
|
import { inject } from "vue";
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { currentUserHasPermission } = usePermission();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const UserAvatarCropper = defineAsyncComponent(
|
||||||
|
() => import("../components/UserAvatarCropper.vue")
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IUserAvatarCropperType
|
||||||
|
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
||||||
|
getCropperFile(): Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { open, reset, onChange } = useFileDialog({
|
||||||
|
accept: ".jpg, .jpeg, .png",
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = inject<Ref<DetailedUser | undefined>>("user");
|
||||||
|
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
||||||
|
|
||||||
|
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
||||||
|
const showAvatarEditor = ref(false);
|
||||||
|
const visibleCropperModal = ref(false);
|
||||||
|
const originalFile = ref<File>() as Ref<File>;
|
||||||
|
|
||||||
|
onChange((files) => {
|
||||||
|
if (!files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
originalFile.value = files[0];
|
||||||
|
visibleCropperModal.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadSaving = ref(false);
|
||||||
|
const handleUploadAvatar = () => {
|
||||||
|
userAvatarCropper.value?.getCropperFile().then((file) => {
|
||||||
|
if (!user?.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadSaving.value = true;
|
||||||
|
|
||||||
|
apiClient.user
|
||||||
|
.uploadUserAvatar({
|
||||||
|
name: user.value.user.metadata.name,
|
||||||
|
file: file,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
|
||||||
|
handleCloseCropperModal();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
uploadSaving.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCurrentAvatar = () => {
|
||||||
|
Dialog.warning({
|
||||||
|
title: t("core.user.detail.avatar.remove.title"),
|
||||||
|
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||||
|
confirmType: "danger",
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
if (!user?.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.user
|
||||||
|
.deleteUserAvatar({
|
||||||
|
name: user.value.user.metadata.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCropperModal = () => {
|
||||||
|
visibleCropperModal.value = false;
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeUploadAvatar = () => {
|
||||||
|
reset();
|
||||||
|
open();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="h-full w-full"
|
||||||
|
@mouseover="showAvatarEditor = true"
|
||||||
|
@mouseout="showAvatarEditor = false"
|
||||||
|
>
|
||||||
|
<VAvatar
|
||||||
|
:src="user?.user.spec.avatar"
|
||||||
|
:alt="user?.user.spec.displayName"
|
||||||
|
circle
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
class="ring-4 ring-white drop-shadow-md"
|
||||||
|
/>
|
||||||
|
<VDropdown
|
||||||
|
v-if="currentUserHasPermission(['system:users:manage']) || isCurrentUser"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="showAvatarEditor"
|
||||||
|
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<IconRiPencilFill
|
||||||
|
class="inline-block w-full self-center text-2xl text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #popper>
|
||||||
|
<VDropdownItem @click="open()">
|
||||||
|
{{ $t("core.common.buttons.upload") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
||||||
|
{{ $t("core.common.buttons.delete") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VModal
|
||||||
|
:visible="visibleCropperModal"
|
||||||
|
:width="1200"
|
||||||
|
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
||||||
|
mount-to-body
|
||||||
|
@update:visible="handleCloseCropperModal"
|
||||||
|
>
|
||||||
|
<UserAvatarCropper
|
||||||
|
ref="userAvatarCropper"
|
||||||
|
:file="originalFile"
|
||||||
|
@change-file="changeUploadAvatar"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
v-if="visibleCropperModal"
|
||||||
|
:loading="uploadSaving"
|
||||||
|
type="secondary"
|
||||||
|
@click="handleUploadAvatar"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.submit") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="handleCloseCropperModal">
|
||||||
|
{{ $t("core.common.buttons.cancel") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
|
@ -1,355 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
import {
|
|
||||||
IconRiPencilFill,
|
|
||||||
VButton,
|
|
||||||
VTabbar,
|
|
||||||
VAvatar,
|
|
||||||
VDropdown,
|
|
||||||
VDropdownItem,
|
|
||||||
VModal,
|
|
||||||
VSpace,
|
|
||||||
Toast,
|
|
||||||
VLoading,
|
|
||||||
Dialog,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
onMounted,
|
|
||||||
provide,
|
|
||||||
ref,
|
|
||||||
watch,
|
|
||||||
defineAsyncComponent,
|
|
||||||
type ComputedRef,
|
|
||||||
type Ref,
|
|
||||||
} from "vue";
|
|
||||||
import { useRoute, useRouter } from "vue-router";
|
|
||||||
import type { DetailedUser } from "@halo-dev/api-client";
|
|
||||||
import UserEditingModal from "../components/UserEditingModal.vue";
|
|
||||||
import UserPasswordChangeModal from "../components/UserPasswordChangeModal.vue";
|
|
||||||
import { usePermission } from "@/utils/permission";
|
|
||||||
import { useUserStore } from "@/stores/user";
|
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { useFileDialog } from "@vueuse/core";
|
|
||||||
import { rbacAnnotations } from "@/constants/annotations";
|
|
||||||
import { onBeforeRouteUpdate } from "vue-router";
|
|
||||||
|
|
||||||
const UserAvatarCropper = defineAsyncComponent(
|
|
||||||
() => import("../components/UserAvatarCropper.vue")
|
|
||||||
);
|
|
||||||
|
|
||||||
interface IUserAvatarCropperType
|
|
||||||
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
|
||||||
getCropperFile(): Promise<File>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { open, reset, onChange } = useFileDialog({
|
|
||||||
accept: ".jpg, .jpeg, .png",
|
|
||||||
multiple: false,
|
|
||||||
});
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const tabs = ref([
|
|
||||||
{
|
|
||||||
id: "detail",
|
|
||||||
label: t("core.user.detail.tabs.detail"),
|
|
||||||
routeName: "UserDetail",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const editingModal = ref(false);
|
|
||||||
const passwordChangeModal = ref(false);
|
|
||||||
|
|
||||||
const { params } = useRoute();
|
|
||||||
const name = ref();
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
name.value = params.name;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update name when route change
|
|
||||||
onBeforeRouteUpdate((to, _, next) => {
|
|
||||||
name.value = to.params.name;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: user,
|
|
||||||
isFetching,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["user-detail", name],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (name.value === "-") {
|
|
||||||
const { data } = await apiClient.user.getCurrentUserDetail();
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
const { data } = await apiClient.user.getUserDetail({
|
|
||||||
name: name.value,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchInterval: (data) => {
|
|
||||||
const annotations = data?.user.metadata.annotations;
|
|
||||||
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
|
||||||
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
|
||||||
? 1000
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
enabled: computed(() => !!name.value),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCurrentUser = computed(() => {
|
|
||||||
if (name.value === "-") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
user.value?.user.metadata.name === userStore.currentUser?.metadata.name
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
provide<Ref<DetailedUser | undefined>>("user", user);
|
|
||||||
provide<ComputedRef<boolean>>("isCurrentUser", isCurrentUser);
|
|
||||||
|
|
||||||
// fixme: refactor this component to simplify the logic
|
|
||||||
watch(
|
|
||||||
() => isCurrentUser.value,
|
|
||||||
(value) => {
|
|
||||||
if (value) {
|
|
||||||
tabs.value.push({
|
|
||||||
id: "tokens",
|
|
||||||
label: t("core.user.detail.tabs.pat"),
|
|
||||||
routeName: "PersonalAccessTokens",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTab = ref();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// set default active tab
|
|
||||||
onMounted(() => {
|
|
||||||
const tab = tabs.value.find((tab) => tab.routeName === route.name);
|
|
||||||
activeTab.value = tab ? tab.id : tabs.value[0].id;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
async (newRouteName) => {
|
|
||||||
const tab = tabs.value.find((tab) => tab.routeName === newRouteName);
|
|
||||||
activeTab.value = tab ? tab.id : tabs.value[0].id;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabChange = (id: string) => {
|
|
||||||
const tab = tabs.value.find((tab) => tab.id === id);
|
|
||||||
if (tab) {
|
|
||||||
router.push({ name: tab.routeName });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
|
||||||
const showAvatarEditor = ref(false);
|
|
||||||
const visibleCropperModal = ref(false);
|
|
||||||
const originalFile = ref<File>() as Ref<File>;
|
|
||||||
onChange((files) => {
|
|
||||||
if (!files) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (files.length > 0) {
|
|
||||||
originalFile.value = files[0];
|
|
||||||
visibleCropperModal.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadSaving = ref(false);
|
|
||||||
const handleUploadAvatar = () => {
|
|
||||||
userAvatarCropper.value?.getCropperFile().then((file) => {
|
|
||||||
uploadSaving.value = true;
|
|
||||||
apiClient.user
|
|
||||||
.uploadUserAvatar({
|
|
||||||
name: name.value,
|
|
||||||
file: file,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
handleCloseCropperModal();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
uploadSaving.value = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCurrentAvatar = () => {
|
|
||||||
Dialog.warning({
|
|
||||||
title: t("core.user.detail.avatar.remove.title"),
|
|
||||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
|
||||||
confirmType: "danger",
|
|
||||||
confirmText: t("core.common.buttons.confirm"),
|
|
||||||
cancelText: t("core.common.buttons.cancel"),
|
|
||||||
onConfirm: async () => {
|
|
||||||
apiClient.user
|
|
||||||
.deleteUserAvatar({
|
|
||||||
name: name.value as string,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseCropperModal = () => {
|
|
||||||
visibleCropperModal.value = false;
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeUploadAvatar = () => {
|
|
||||||
reset();
|
|
||||||
open();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<BasicLayout>
|
|
||||||
<UserEditingModal v-model:visible="editingModal" :user="user?.user" />
|
|
||||||
|
|
||||||
<UserPasswordChangeModal
|
|
||||||
v-model:visible="passwordChangeModal"
|
|
||||||
:user="user?.user"
|
|
||||||
@close="refetch"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<header class="bg-white">
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex flex-row items-center gap-5">
|
|
||||||
<div class="group relative h-20 w-20">
|
|
||||||
<VLoading v-if="isFetching" class="h-full w-full" />
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-full w-full"
|
|
||||||
@mouseover="showAvatarEditor = true"
|
|
||||||
@mouseout="showAvatarEditor = false"
|
|
||||||
>
|
|
||||||
<VAvatar
|
|
||||||
v-if="user"
|
|
||||||
:src="user.user.spec.avatar"
|
|
||||||
:alt="user.user.spec.displayName"
|
|
||||||
circle
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
class="ring-4 ring-white drop-shadow-md"
|
|
||||||
/>
|
|
||||||
<VDropdown
|
|
||||||
v-if="
|
|
||||||
currentUserHasPermission(['system:users:manage']) ||
|
|
||||||
isCurrentUser
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-show="showAvatarEditor"
|
|
||||||
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<IconRiPencilFill
|
|
||||||
class="inline-block w-full self-center text-2xl text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<template #popper>
|
|
||||||
<VDropdownItem @click="open()">
|
|
||||||
{{ $t("core.common.buttons.upload") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
|
||||||
{{ $t("core.common.buttons.delete") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
</template>
|
|
||||||
</VDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="block">
|
|
||||||
<h1 class="truncate text-lg font-bold text-gray-900">
|
|
||||||
{{ user?.user.spec.displayName }}
|
|
||||||
</h1>
|
|
||||||
<span v-if="!isLoading" class="text-sm text-gray-600">
|
|
||||||
@{{ user?.user.metadata.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
currentUserHasPermission(['system:users:manage']) || isCurrentUser
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<VDropdown>
|
|
||||||
<VButton type="default">
|
|
||||||
{{ $t("core.common.buttons.edit") }}
|
|
||||||
</VButton>
|
|
||||||
<template #popper>
|
|
||||||
<VDropdownItem @click="editingModal = true">
|
|
||||||
{{ $t("core.user.detail.actions.update_profile.title") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownItem @click="passwordChangeModal = true">
|
|
||||||
{{ $t("core.user.detail.actions.change_password.title") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
</template>
|
|
||||||
</VDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<section class="bg-white p-4">
|
|
||||||
<VTabbar
|
|
||||||
v-model:active-id="activeTab"
|
|
||||||
:items="tabs"
|
|
||||||
class="w-full"
|
|
||||||
type="outline"
|
|
||||||
@change="handleTabChange"
|
|
||||||
></VTabbar>
|
|
||||||
<div class="mt-2">
|
|
||||||
<RouterView></RouterView>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<VModal
|
|
||||||
:visible="visibleCropperModal"
|
|
||||||
:width="1200"
|
|
||||||
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
|
||||||
@update:visible="handleCloseCropperModal"
|
|
||||||
>
|
|
||||||
<UserAvatarCropper
|
|
||||||
ref="userAvatarCropper"
|
|
||||||
:file="originalFile"
|
|
||||||
@change-file="changeUploadAvatar"
|
|
||||||
/>
|
|
||||||
<template #footer>
|
|
||||||
<VSpace>
|
|
||||||
<VButton
|
|
||||||
v-if="visibleCropperModal"
|
|
||||||
:loading="uploadSaving"
|
|
||||||
type="secondary"
|
|
||||||
@click="handleUploadAvatar"
|
|
||||||
>
|
|
||||||
{{ $t("core.common.buttons.submit") }}
|
|
||||||
</VButton>
|
|
||||||
<VButton @click="handleCloseCropperModal">
|
|
||||||
{{ $t("core.common.buttons.cancel") }}
|
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
|
||||||
</VModal>
|
|
||||||
</BasicLayout>
|
|
||||||
</template>
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { definePlugin } from "@halo-dev/console-shared";
|
import { definePlugin } from "@halo-dev/console-shared";
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||||
import UserProfileLayout from "./layouts/UserProfileLayout.vue";
|
|
||||||
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
|
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
|
||||||
import UserList from "./UserList.vue";
|
import UserList from "./UserList.vue";
|
||||||
import UserDetail from "./UserDetail.vue";
|
import UserDetail from "./UserDetail.vue";
|
||||||
import PersonalAccessTokens from "./PersonalAccessTokens.vue";
|
|
||||||
import Login from "./Login.vue";
|
import Login from "./Login.vue";
|
||||||
import { IconUserSettings } from "@halo-dev/components";
|
import { IconUserSettings } from "@halo-dev/components";
|
||||||
import { markRaw } from "vue";
|
import { markRaw } from "vue";
|
||||||
|
@ -61,25 +59,17 @@ export default definePlugin({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":name",
|
path: ":name",
|
||||||
component: UserProfileLayout,
|
component: BasicLayout,
|
||||||
name: "User",
|
name: "User",
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "detail",
|
path: "",
|
||||||
name: "UserDetail",
|
name: "UserDetail",
|
||||||
component: UserDetail,
|
component: UserDetail,
|
||||||
meta: {
|
meta: {
|
||||||
title: "core.user.detail.title",
|
title: "core.user.detail.title",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "tokens",
|
|
||||||
name: "PersonalAccessTokens",
|
|
||||||
component: PersonalAccessTokens,
|
|
||||||
meta: {
|
|
||||||
title: "个人令牌",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,168 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
IconUserSettings,
|
||||||
|
VButton,
|
||||||
|
VDescription,
|
||||||
|
VDescriptionItem,
|
||||||
|
VTag,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import type { ComputedRef, Ref } from "vue";
|
||||||
|
import { inject, computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
|
||||||
|
import { rbacAnnotations } from "@/constants/annotations";
|
||||||
|
import { formatDatetime } from "@/utils/date";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const user = inject<Ref<DetailedUser | undefined>>("user");
|
||||||
|
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
|
||||||
|
queryKey: ["user-auth-providers"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.authProvider.listAuthProviders();
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: isCurrentUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableAuthProviders = computed(() => {
|
||||||
|
return authProviders.value?.filter(
|
||||||
|
(authProvider) => authProvider.enabled && authProvider.supportsBinding
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUnbindAuth = (authProvider: ListedAuthProvider) => {
|
||||||
|
Dialog.warning({
|
||||||
|
title: t("core.user.detail.operations.unbind.title", {
|
||||||
|
display_name: authProvider.displayName,
|
||||||
|
}),
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await axios.put(
|
||||||
|
`${import.meta.env.VITE_API_URL}${authProvider.unbindingUrl}`,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBindAuth = (authProvider: ListedAuthProvider) => {
|
||||||
|
if (!authProvider.bindingUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = `${
|
||||||
|
authProvider.bindingUrl
|
||||||
|
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="border-t border-gray-100">
|
||||||
|
<VDescription>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.display_name')"
|
||||||
|
:content="user?.user.spec.displayName"
|
||||||
|
class="!px-2"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.username')"
|
||||||
|
:content="user?.user.metadata.name"
|
||||||
|
class="!px-2"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.email')"
|
||||||
|
:content="user?.user.spec.email || $t('core.common.text.none')"
|
||||||
|
class="!px-2"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.roles')"
|
||||||
|
class="!px-2"
|
||||||
|
>
|
||||||
|
<VTag
|
||||||
|
v-for="(role, index) in user?.roles"
|
||||||
|
:key="index"
|
||||||
|
@click="
|
||||||
|
router.push({
|
||||||
|
name: 'RoleDetail',
|
||||||
|
params: { name: role.metadata.name },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<IconUserSettings />
|
||||||
|
</template>
|
||||||
|
{{
|
||||||
|
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
|
||||||
|
role.metadata.name
|
||||||
|
}}
|
||||||
|
</VTag>
|
||||||
|
</VDescriptionItem>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.bio')"
|
||||||
|
:content="user?.user.spec?.bio || $t('core.common.text.none')"
|
||||||
|
class="!px-2"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.user.detail.fields.creation_time')"
|
||||||
|
:content="formatDatetime(user?.user.metadata?.creationTimestamp)"
|
||||||
|
class="!px-2"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
v-if="!isFetching && isCurrentUser && availableAuthProviders?.length"
|
||||||
|
:label="$t('core.user.detail.fields.identity_authentication')"
|
||||||
|
class="!px-2"
|
||||||
|
>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<template v-for="(authProvider, index) in authProviders">
|
||||||
|
<li
|
||||||
|
v-if="authProvider.supportsBinding && authProvider.enabled"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full cursor-pointer flex-wrap justify-between gap-y-3 rounded border p-5 hover:border-primary sm:w-1/2"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<img class="h-7 w-7 rounded" :src="authProvider.logo" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
{{ authProvider.displayName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<VButton
|
||||||
|
v-if="authProvider.isBound"
|
||||||
|
size="sm"
|
||||||
|
@click="handleUnbindAuth(authProvider)"
|
||||||
|
>
|
||||||
|
{{ $t("core.user.detail.operations.unbind.button") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
v-else
|
||||||
|
size="sm"
|
||||||
|
type="secondary"
|
||||||
|
@click="handleBindAuth(authProvider)"
|
||||||
|
>
|
||||||
|
{{ $t("core.user.detail.operations.bind.button") }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</VDescriptionItem>
|
||||||
|
</VDescription>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -10,9 +10,9 @@ import { ref } from "vue";
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import type { PersonalAccessToken } from "@halo-dev/api-client";
|
import type { PersonalAccessToken } from "@halo-dev/api-client";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import PersonalAccessTokenCreationModal from "./components/PersonalAccessTokenCreationModal.vue";
|
import PersonalAccessTokenCreationModal from "../components/PersonalAccessTokenCreationModal.vue";
|
||||||
import { nextTick } from "vue";
|
import { nextTick } from "vue";
|
||||||
import PersonalAccessTokenListItem from "./components/PersonalAccessTokenListItem.vue";
|
import PersonalAccessTokenListItem from "../components/PersonalAccessTokenListItem.vue";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pats,
|
data: pats,
|
Loading…
Reference in New Issue