summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-10 12:15:01 -0400
committerHazelnoot <acomputerdog@gmail.com>2025-05-16 17:40:06 -0400
commitd717df938b916ddfa0312164f243fd30672bceea (patch)
treedbf48f01e8653bc41495bda960f60ec2245b4d25
parentadd "followStats" property to admin/show-user response (diff)
downloadsharkey-d717df938b916ddfa0312164f243fd30672bceea.tar.gz
sharkey-d717df938b916ddfa0312164f243fd30672bceea.tar.bz2
sharkey-d717df938b916ddfa0312164f243fd30672bceea.zip
cleanup admin user UI
* hide inaccessible controls * hide irrelevant controls * remove duplicate components * collapse details behind sections * group all buttons * apply semantic "warning" styles to buttons * add follow stats * translate untranslated strings * group related controls * resolve some lint errors
-rw-r--r--locales/index.d.ts42
-rw-r--r--packages/frontend/src/pages/admin-user.vue157
-rw-r--r--sharkey-locales/en-US.yml12
3 files changed, 151 insertions, 60 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts
index a83f5f317a..fd5c5fd845 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -2447,7 +2447,7 @@ export interface Locale extends ILocale {
*/
"disablePagesScript": string;
/**
- * リモートユーザー情報の更新
+ * Refresh remote data
*/
"updateRemoteUser": string;
/**
@@ -13090,6 +13090,46 @@ export interface Locale extends ILocale {
*/
"popularUsersLocal": ParameterizedString<"name">;
/**
+ * Silenced
+ */
+ "silenced": string;
+ /**
+ * Total followers
+ */
+ "totalFollowers": string;
+ /**
+ * Total following
+ */
+ "totalFollowing": string;
+ /**
+ * Local followers
+ */
+ "localFollowers": string;
+ /**
+ * Local following
+ */
+ "localFollowing": string;
+ /**
+ * Remote followers
+ */
+ "remoteFollowers": string;
+ /**
+ * Remote following
+ */
+ "remoteFollowing": string;
+ /**
+ * Activity Pub
+ */
+ "activityPub": string;
+ /**
+ * IP
+ */
+ "ip": string;
+ /**
+ * The date is the IP address was first acknowledged.
+ */
+ "ipTip": string;
+ /**
* Translation timeout
*/
"translationTimeoutLabel": string;
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index efe547ff21..100fb34655 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -7,38 +7,49 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
- <div v-if="tab === 'overview'" class="_gaps_m">
- <div class="aeakzknw">
+ <div v-if="tab === 'overview'" class="_gaps">
+ <div v-if="user" class="aeakzknw">
<MkAvatar class="avatar" :user="user" indicator link preview/>
<div class="body">
<span class="name"><MkUserName class="name" :user="user"/></span>
- <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+ <span class="sub">
+ <span class="acct _monospace">@{{ acct(user) }}</span>
+ <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard('@' + acct(user))"><i class="ti ti-copy"></i></button>
+ </span>
+ <span class="sub">
+ <span class="_monospace">{{ user.id }}</span>
+ <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button>
+ </span>
<span class="state">
<span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span>
<span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span>
- <span v-if="suspended" class="suspended">Suspended</span>
- <span v-if="silenced" class="silenced">Silenced</span>
- <span v-if="moderator" class="moderator">Moderator</span>
+ <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span>
+ <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span>
+ <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span>
</span>
</div>
</div>
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
- <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
-
- <div style="display: flex; flex-direction: column; gap: 1em;">
- <MkKeyValue :copy="user.id" oneline>
- <template #key>ID</template>
- <template #value><span class="_monospace">{{ user.id }}</span></template>
- </MkKeyValue>
- <!-- 要る?
- <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
- <template #key>IP (recent)</template>
- <template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
- </MkKeyValue>
- -->
- <template v-if="!isSystem">
+ <MkFolder v-if="!isSystem">
+ <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template>
+ <template #label>{{ i18n.ts.details }}</template>
+ <div style="display: flex; flex-direction: column; gap: 1em;">
+ <MkKeyValue v-if="user" :copy="user.id" oneline>
+ <template #key>{{ i18n.ts.id }}</template>
+ <template #value><span class="_monospace">{{ user.id }}</span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline>
+ <template #key>{{ i18n.ts.username }}</template>
+ <template #value><span class="_monospace">@{{ acct(user) }}</span></template>
+ </MkKeyValue>
+ <!-- 要る?
+ <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
+ <template #key>IP (recent)</template>
+ <template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
+ </MkKeyValue>
+ -->
<MkKeyValue oneline>
<template #key>{{ i18n.ts.createdAt }}</template>
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
@@ -51,16 +62,62 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #key>{{ i18n.ts.email }}</template>
<template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.totalFollowers }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowers"></MkNumber></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.totalFollowing }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowing"></MkNumber></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.remoteFollowers }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowers"></MkNumber></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.remoteFollowing }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowing"></MkNumber></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.localFollowers }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowers"></MkNumber></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.localFollowing }}</template>
+ <template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowing"></MkNumber></span></template>
+ </MkKeyValue>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="info">
+ <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
+ <template #label>{{ i18n.ts._role.policies }}</template>
+ <div class="_gaps">
+ <div v-for="policy in Object.keys(info.policies)" :key="policy">
+ {{ policy }} ... {{ info.policies[policy] }}
+ </div>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="iAmAdmin && ips && ips.length > 0">
+ <template #icon><i class="ph-network ph-bold ph-lg"></i></template>
+ <template #label>{{ i18n.ts.ip }}</template>
+ <MkInfo>{{ i18n.ts.ipTip }}</MkInfo>
+ <template>
+ <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
+ <span class="date">{{ record.createdAt }}</span>
+ <span class="ip">{{ record.ip }}</span>
+ </div>
</template>
- </div>
+ </MkFolder>
<MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged">
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
- <FormSection v-if="user.host">
- <template #label>ActivityPub</template>
+ <FormSection v-if="user?.host">
+ <template #label>{{ i18n.ts.activityPub }}</template>
<div class="_gaps_m">
<div style="display: flex; flex-direction: column; gap: 1em;">
@@ -73,12 +130,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template>
</MkKeyValue>
</div>
-
- <MkButton @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
</div>
</FormSection>
- <FormSection v-if="!isSystem">
+ <FormSection v-if="!isSystem && user && iAmModerator">
<div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
@@ -90,40 +145,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
- <div>
- <MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
+ <div :class="$style.buttonStrip">
+ <MkButton v-if="user.host != null" inline @click="updateRemoteUser"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
+ <MkButton v-if="user.host == null" accent @click="resetPassword"><i class="ph-password ph-bold ph-lg"></i> {{ i18n.ts.resetPassword }}</MkButton>
+ <MkButton inline accent @click="unsetUserAvatar"><i class="ph-camera-slash ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
+ <MkButton inline accent @click="unsetUserBanner"><i class="ph-image-broken ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
+ <MkButton inline danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
+ <MkButton v-if="iAmAdmin" inline danger @click="deleteAccount"><i class="ph-skull ph-bold ph-lg"></i> {{ i18n.ts.deleteAccount }}</MkButton>
</div>
-
- <MkFolder>
- <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template>
- <template #label>{{ i18n.ts._role.policies }}</template>
- <div class="_gaps">
- <div v-for="policy in Object.keys(info.policies)" :key="policy">
- {{ policy }} ... {{ info.policies[policy] }}
- </div>
- </div>
- </MkFolder>
-
- <MkFolder>
- <template #icon><i class="ti ti-password"></i></template>
- <template #label>IP</template>
- <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
- <!-- TODO translate -->
- <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
- <template v-if="iAmAdmin && ips">
- <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
- <span class="date">{{ record.createdAt }}</span>
- <span class="ip">{{ record.ip }}</span>
- </div>
- </template>
- </MkFolder>
-
- <div>
- <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ti ti-user-circle"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
- <MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserBanner"><i class="ti ti-photo"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
- <MkButton v-if="iAmModerator" inline danger @click="deleteAllFiles"><i class="ph-cloud ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
- </div>
- <MkButton v-if="$i.isAdmin && !isSystem" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
</div>
</FormSection>
</div>
@@ -231,6 +260,8 @@ import { iAmAdmin, $i, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue';
+import MkNumber from '@/components/MkNumber.vue';
+import { copyToClipboard } from '@/utility/copy-to-clipboard';
const props = withDefaults(defineProps<{
userId: string;
@@ -740,4 +771,12 @@ definePage(() => ({
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
+
+.buttonStrip {
+ margin: calc(var(--MI-margin) / 2 * -1);
+
+ >* {
+ margin: calc(var(--MI-margin) / 2);
+ }
+}
</style>
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index bf746533af..165f477f21 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -569,5 +569,17 @@ bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, an
popularUsersGlobal: "Users popular on the global network"
popularUsersLocal: "Users popular on {name}"
+silenced: "Silenced"
+totalFollowers: "Total followers"
+totalFollowing: "Total following"
+localFollowers: "Local followers"
+localFollowing: "Local following"
+remoteFollowers: "Remote followers"
+remoteFollowing: "Remote following"
+updateRemoteUser: "Refresh remote data"
+activityPub: "Activity Pub"
+ip: "IP"
+ipTip: "The date is the IP address was first acknowledged."
+
translationTimeoutLabel: "Translation timeout"
translationTimeoutCaption: "Timeout in milliseconds for translation API requests."