2026-07-19 22:55:24 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { onMounted, ref } from 'vue';
|
|
|
|
|
import { useTokenStore } from '@/stores/token';
|
|
|
|
|
import MessageBox from '@/components/MessageBox.vue';
|
|
|
|
|
import {
|
|
|
|
|
isAdmin as apiProfileIsAdmin,
|
|
|
|
|
changePassword as apiProfileChangePassword,
|
|
|
|
|
getToken as apiProfileGetToken,
|
|
|
|
|
deleteToken as apiProfileDeleteToken,
|
|
|
|
|
type TokenRow,
|
|
|
|
|
} from '@/api/profile';
|
|
|
|
|
import {
|
|
|
|
|
get as apiAdminGet,
|
|
|
|
|
add as apiAdminAdd,
|
|
|
|
|
update as apiAdminUpdate,
|
|
|
|
|
del as apiAdminDel,
|
|
|
|
|
type AdminUserRow,
|
|
|
|
|
} from '@/api/admin';
|
|
|
|
|
import TokenItem from '@/components/admin/TokenItem.vue';
|
|
|
|
|
import UserItem from '@/components/admin/UserItem.vue';
|
2026-07-21 19:34:19 +08:00
|
|
|
import { i18n } from '@/utils/i18n';
|
2026-07-19 22:55:24 +08:00
|
|
|
|
|
|
|
|
const token = useTokenStore();
|
|
|
|
|
const messagebox = ref<InstanceType<typeof MessageBox>>();
|
|
|
|
|
|
|
|
|
|
// tab control state
|
|
|
|
|
const activeTab = ref<number>(1);
|
|
|
|
|
const showUserTab = ref<boolean>(false);
|
|
|
|
|
|
|
|
|
|
// profile tab state
|
|
|
|
|
const profilePendingPassword = ref<string>("");
|
|
|
|
|
|
|
|
|
|
// token tab state
|
|
|
|
|
const tokenItems = ref<Map<string, TokenRow>>(new Map());
|
|
|
|
|
|
|
|
|
|
// user tab state
|
|
|
|
|
const userItems = ref<Map<string, AdminUserRow>>(new Map());
|
|
|
|
|
const pendingUsername = ref<string>("");
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
// check admin status for user tab visibility
|
|
|
|
|
showUserTab.value = await apiProfileIsAdmin(token.currentToken);
|
|
|
|
|
// auto-refresh both lists
|
|
|
|
|
await refreshTokenList();
|
|
|
|
|
if (showUserTab.value) {
|
|
|
|
|
await refreshUserList();
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// region: Profile Tab
|
|
|
|
|
|
|
|
|
|
const changePassword = async () => {
|
|
|
|
|
const newpassword = profilePendingPassword.value;
|
|
|
|
|
if (newpassword === "") return;
|
|
|
|
|
|
|
|
|
|
const rv = await apiProfileChangePassword(token.currentToken, newpassword);
|
|
|
|
|
if (rv) {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-success'));
|
2026-07-19 22:55:24 +08:00
|
|
|
profilePendingPassword.value = "";
|
|
|
|
|
} else {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-fail-update'));
|
2026-07-19 22:55:24 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
|
|
// region: Token Tab
|
|
|
|
|
|
|
|
|
|
const refreshTokenList = async () => {
|
|
|
|
|
// clean token items
|
|
|
|
|
tokenItems.value.clear();
|
|
|
|
|
|
|
|
|
|
// fetch data
|
|
|
|
|
const rv = await apiProfileGetToken(token.currentToken);
|
|
|
|
|
if (typeof rv === 'undefined') {
|
|
|
|
|
console.error("fail to fetch token list");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// add into dict for rendering
|
|
|
|
|
for (const item of rv) {
|
|
|
|
|
tokenItems.value.set(item[1], item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deleteTokenItem = async (uuid: string) => {
|
|
|
|
|
const rv = await apiProfileDeleteToken(token.currentToken, uuid);
|
|
|
|
|
if (!rv) {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-fail-delete'));
|
2026-07-19 22:55:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: revoking the current session token (the isMe case) is allowed here,
|
|
|
|
|
// but the local tokenStore is not cleared afterwards. The client is left in
|
|
|
|
|
// an inconsistent state until the next failed API call or page reload.
|
|
|
|
|
// Faithfully preserved from legacy for now; revisit later.
|
|
|
|
|
tokenItems.value.delete(uuid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
|
|
// region: User List Tab
|
|
|
|
|
|
|
|
|
|
const refreshUserList = async () => {
|
|
|
|
|
// clean user items
|
|
|
|
|
userItems.value.clear();
|
|
|
|
|
|
|
|
|
|
// fetch data
|
|
|
|
|
const rv = await apiAdminGet(token.currentToken);
|
|
|
|
|
if (typeof rv === 'undefined') {
|
|
|
|
|
console.error("fail to fetch user list");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// add into dict for rendering
|
|
|
|
|
for (const item of rv) {
|
|
|
|
|
userItems.value.set(item[0], item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addUser = async () => {
|
|
|
|
|
const username = pendingUsername.value;
|
|
|
|
|
if (username === "") return;
|
|
|
|
|
|
|
|
|
|
const rv = await apiAdminAdd(token.currentToken, username);
|
|
|
|
|
if (typeof rv === 'undefined') {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-fail-add'));
|
2026-07-19 22:55:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userItems.value.set(rv[0], rv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deleteUser = async (username: string) => {
|
|
|
|
|
const rv = await apiAdminDel(token.currentToken, username);
|
|
|
|
|
if (!rv) {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-fail-delete'));
|
2026-07-19 22:55:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userItems.value.delete(username);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updateUser = async (username: string, newPassword: string, newIsAdmin: boolean) => {
|
|
|
|
|
const savedIsAdmin = userItems.value.get(username)![1];
|
|
|
|
|
const passwordArg = newPassword === "" ? undefined : newPassword;
|
|
|
|
|
const isAdminArg = newIsAdmin === savedIsAdmin ? undefined : newIsAdmin;
|
|
|
|
|
|
|
|
|
|
const rv = await apiAdminUpdate(token.currentToken, username, passwordArg, isAdminArg);
|
|
|
|
|
if (!rv) {
|
2026-07-21 19:34:19 +08:00
|
|
|
messagebox.value?.show(i18n.global.t('js-fail-update'));
|
2026-07-19 22:55:24 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// safely update data
|
|
|
|
|
userItems.value.get(username)![1] = newIsAdmin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// endregion
|
|
|
|
|
|
|
|
|
|
</script>
|
2026-04-28 15:47:32 +08:00
|
|
|
|
|
|
|
|
<template>
|
2026-07-19 22:55:24 +08:00
|
|
|
<div class="container" style="margin-top: 20px;">
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
<ul>
|
2026-07-21 19:34:19 +08:00
|
|
|
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>{{ $t('admin-tabcontrol-tabProfile') }}</a></li>
|
|
|
|
|
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>{{ $t('admin-tabcontrol-tabToken') }}</a></li>
|
|
|
|
|
<li v-if="showUserTab" :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>{{ $t('admin-tabcontrol-tabUserList') }}</a></li>
|
2026-07-19 22:55:24 +08:00
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="activeTab === 1" class="container" style="margin-top: 20px;">
|
2026-07-21 19:34:19 +08:00
|
|
|
<h1 class="title">{{ $t('admin-changePassword') }}</h1>
|
2026-07-19 22:55:24 +08:00
|
|
|
<div class="field has-addons">
|
|
|
|
|
<div class="control">
|
|
|
|
|
<input v-model="profilePendingPassword" class="input" type="password">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="control" @click="changePassword">
|
|
|
|
|
<a class="button is-primary">
|
|
|
|
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-key"></font-awesome-icon></span>
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
|
2026-07-21 19:34:19 +08:00
|
|
|
<h1 class="title">{{ $t('admin-manageToken') }}</h1>
|
|
|
|
|
<h2 class="subtitle">{{ $t('admin-manageToken-desc') }}</h2>
|
2026-07-19 22:55:24 +08:00
|
|
|
<div class="control" @click="refreshTokenList">
|
|
|
|
|
<a class="button is-primary">
|
|
|
|
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
|
|
|
|
<div v-for="item in tokenItems.values()" :key="item[1]"
|
|
|
|
|
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
|
|
|
|
<TokenItem :uuid="item[1]" :ua="item[3]" :ip="item[4]"
|
|
|
|
|
:expire-on="new Date(item[2] * 1000).toLocaleString()" :is-me="token.currentToken === item[1]"
|
|
|
|
|
@delete="deleteTokenItem" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-show="activeTab === 3" class="container" style="margin-top: 20px;">
|
2026-07-21 19:34:19 +08:00
|
|
|
<h1 class="title">{{ $t('admin-userList') }}</h1>
|
2026-07-19 22:55:24 +08:00
|
|
|
<div class="button-list">
|
|
|
|
|
<div class="field has-addons">
|
|
|
|
|
<div class="control">
|
|
|
|
|
<input v-model="pendingUsername" class="input" type="text">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="control" @click="addUser">
|
|
|
|
|
<a class="button is-primary">
|
|
|
|
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-plus"></font-awesome-icon></span>
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="control" @click="refreshUserList">
|
|
|
|
|
<a class="button is-primary">
|
|
|
|
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
|
|
|
|
<div v-for="item in userItems.values()" :key="item[0]"
|
|
|
|
|
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
|
|
|
|
<UserItem :username="item[0]" :is-admin="item[1]" @delete="deleteUser" @update="updateUser" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<MessageBox ref="messagebox" />
|
2026-04-28 15:47:32 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped></style>
|