Admin panel & users, users' permissions management

This commit is contained in:
LittleSheep 2024-07-04 22:58:34 +08:00
parent da15c72fb3
commit 46468fae5f
10 changed files with 451 additions and 12 deletions

19
.idea/workspace.xml generated
View File

@ -4,12 +4,17 @@
<option name="autoReloadType" value="ALL" />
</component>
<component name="ChangeListManager">
<list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":sparkles: Admin notify one user">
<change afterPath="$PROJECT_DIR$/pkg/internal/server/admin/factors_api.go" afterDir="false" />
<list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":sparkles: Admin check users' auth factor">
<change afterPath="$PROJECT_DIR$/web/src/components/admin/UserAssignPermsPanel.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/web/src/components/admin/UserDetailPanel.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/web/src/layouts/administrator.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/web/src/views/admin/dashboard.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/web/src/views/admin/users.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/internal/server/admin/index.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/admin/index.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/internal/server/admin/user_api.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/admin/users_api.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/settings.toml" beforeDir="false" afterPath="$PROJECT_DIR$/settings.toml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/components/navigation/AppBar.vue" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/components/navigation/AppBar.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/layouts/user-center.vue" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/layouts/user-center.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/router/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/router/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/views/security.vue" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/views/security.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -155,7 +160,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value=":sparkles: Last seen at" />
<MESSAGE value=":sparkles: Edit, delete current status" />
<MESSAGE value=":bug: Fix clear status affected the statutes cleared before" />
<MESSAGE value=":sparkles: Get self-current status API" />
@ -180,7 +184,8 @@
<MESSAGE value=":zap: Optimized audit, event logging system&#10;:sparkles: Audit logs&#10;:sparkles: Admin edit user permissions" />
<MESSAGE value=":sparkles: Admin force confirm account" />
<MESSAGE value=":sparkles: Admin notify one user" />
<option name="LAST_COMMIT_MESSAGE" value=":sparkles: Admin notify one user" />
<MESSAGE value=":sparkles: Admin check users' auth factor" />
<option name="LAST_COMMIT_MESSAGE" value=":sparkles: Admin check users' auth factor" />
</component>
<component name="VgoProject">
<settings-migrated>true</settings-migrated>

View File

@ -0,0 +1,164 @@
<template>
<v-dialog class="max-w-[720px]" :model-value="data != null" @update:model-value="(val) => !val && emits('close')">
<template v-slot:default="{ isActive }">
<v-card title="Assign permissions" :subtitle="`To user @${props.data?.name}`" :loading="submitting">
<v-card-text>
<v-sheet elevation="2" rounded="lg">
<v-table density="comfortable">
<thead>
<tr>
<th class="text-left">
Key
</th>
<th class="text-left">
Value
</th>
</tr>
</thead>
<tbody>
<tr
v-for="[key, val] in Object.entries(perms)"
:key="key"
>
<td class="w-1/2">
<div>
<p>{{ key }}</p>
<div class="flex mx-[-8px]">
<v-btn color="error" text="Delete" variant="plain" size="x-small"
@click="() => deleteNode(key)" />
<v-btn class="ms-[-8px]" color="info" text="Change" variant="plain" size="x-small"
@click="() => changeNodeType(key)" />
</div>
</div>
</td>
<td class="w-1/2">
<div class="w-full flex items-center">
<v-checkbox v-if="typeof val === 'boolean'" class="my-1" density="comfortable"
:hide-details="true"
v-model="perms[key]" />
<v-number-input v-else-if="typeof val === 'number'"
controlVariant="default"
:reverse="false"
:hideInput="false"
:inset="false"
class="font-mono my-2"
density="compact" :hide-details="true"
v-model="perms[key]" />
<v-text-field v-else class="font-mono my-2" density="compact" :hide-details="true"
v-model="perms[key]" />
</div>
</td>
</tr>
<tr>
<td>
<v-text-field class="my-3.5" label="Key" density="compact" variant="solo-filled"
v-model="pendingNodeKey"
:hide-details="true" />
</td>
<td>
<div class="w-full flex justify-center">
<v-btn prepend-icon="mdi-plus-circle" text="Add one" block rounded="md" @click="addNode" />
</div>
</td>
</tr>
</tbody>
</v-table>
</v-sheet>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="submitting"
text="Cancel"
color="grey"
@click="isActive.value = false"
></v-btn>
<v-btn
:disabled="submitting"
text="Apply Changes"
@click="saveNode"
></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
const perms = ref<any>({})
const pendingNodeKey = ref("")
const props = defineProps<{ data: any }>()
const emits = defineEmits(["close", "success", "error"])
watch(props, (v) => {
if (v.data != null) {
perms.value = v.data["perm_nodes"]
}
}, { immediate: true, deep: true })
function addNode() {
if (pendingNodeKey.value) {
perms.value[pendingNodeKey.value] = false
pendingNodeKey.value = ""
}
}
function deleteNode(key: string) {
delete perms.value[key]
}
function changeNodeType(key: string) {
const typelist = [
"boolean",
"number",
"string",
]
const idx = typelist.indexOf(typeof perms.value[key])
if (idx == -1 || idx == typelist.length - 1) {
perms.value[key] = false
return
}
switch (typelist[idx + 1]) {
case "boolean":
perms.value[key] = false
break
case "number":
perms.value[key] = 0
break
default:
perms.value[key] = ""
break
}
}
const submitting = ref(false)
async function saveNode() {
submitting.value = true
const res = await request(`/api/admin/users/${props.data.id}/permissions`, {
method: 'PUT',
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${getAtk()}`,
},
body: JSON.stringify({
'perm_nodes': perms.value,
}),
})
if (res.status !== 200) {
emits("error", await res.text())
} else {
emits("success")
emits("close")
}
submitting.value = false
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<v-dialog class="max-w-[720px]" :model-value="data != null" @update:model-value="(val) => !val && emits('close')">
<template v-slot:default="{ isActive }">
<v-card :title="`User @${props.data?.name}`">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<h4 class="field-title">Name</h4>
<p>{{ props.data?.name }}</p>
</v-col>
<v-col cols="12" md="6">
<h4 class="field-title">Nick</h4>
<p>{{ props.data?.nick }}</p>
</v-col>
<v-col cols="12">
<h4 class="field-title">Entire Payload</h4>
<v-code class="font-mono overflow-x-scroll max-h-[360px]">
<pre>{{ JSON.stringify(props.data, null, 4) }}</pre>
</v-code>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
text="Close"
@click="isActive.value = false"
></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
const props = defineProps<{ data: any }>()
const emits = defineEmits(["close"])
</script>
<style scoped>
.field-title {
font-weight: bold;
}
</style>

View File

@ -3,7 +3,7 @@
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
<router-link :to="{ name: 'dashboard' }" class="flex gap-1 ms-0.5">
<img src="/favicon.png" alt="logo" width="27" height="24" class="icon-filter" />
<h2 class="ml-2 text-lg font-500">Solarpass</h2>
<h2 class="ml-2 text-lg font-500">{{ props.title ?? "Solarpass" }}</h2>
</router-link>
<v-spacer />
@ -23,7 +23,7 @@
</div>
</div>
<template #extension>
<template v-if="slots.extension" #extension>
<slot name="extension" />
</template>
</v-app-bar>
@ -35,10 +35,13 @@
import NotificationList from "@/components/NotificationList.vue"
import UserMenu from "@/components/UserMenu.vue"
import { useNotifications } from "@/stores/notifications"
import { ref } from "vue"
import { ref, useSlots } from "vue"
const notify = useNotifications()
const slots = useSlots()
const props = defineProps<{ title?: String }>()
const openNotify = ref(false)
</script>

View File

@ -0,0 +1,30 @@
<template>
<app-bar title="Solarpass Administration" />
<v-main>
<router-view />
</v-main>
</template>
<script setup lang="ts">
import { useUserinfo } from "@/stores/userinfo"
import { useRouter } from "vue-router"
import { onMounted } from "vue"
import AppBar from "@/components/navigation/AppBar.vue"
const id = useUserinfo()
const router = useRouter()
onMounted(async () => {
await id.readProfiles()
if (!id.userinfo.data.perm_nodes["AdminView"]) {
await router.push({ name: "dashboard" })
}
})
</script>
<style scoped>
.icon-filter {
filter: invert(100%) sepia(100%) saturate(14%) hue-rotate(212deg) brightness(104%) contrast(104%);
}
</style>

View File

@ -25,6 +25,6 @@ import Copyright from "@/components/Copyright.vue"
<style scoped>
.p-container {
max-width: 40rem;
max-width: 64rem;
}
</style>

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from "vue-router"
import { useUserinfo } from "@/stores/userinfo"
import UserCenterLayout from "@/layouts/user-center.vue"
import AdministratorLayout from "@/layouts/administrator.vue"
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -74,6 +75,22 @@ const router = createRouter({
},
],
},
{
path: "/admin",
component: AdministratorLayout,
children: [
{
path: "",
name: "admin.dashboard",
component: () => import("@/views/admin/dashboard.vue"),
},
{
path: "users",
name: "admin.users",
component: () => import("@/views/admin/users.vue"),
},
]
}
],
})

View File

@ -0,0 +1,51 @@
<template>
<div class="w-full h-full flex justify-center items-center">
<v-empty-state
headline="Administration"
icon="mdi-cog"
title="What would you like to do today?"
>
<v-container>
<v-row>
<v-col cols="12" md="6">
<v-card
:to="{ name: 'admin.users' }"
prepend-icon="mdi-account-group"
text="Manage to help users do something they can't"
title="Users"
></v-card>
</v-col>
<v-col cols="12" md="6">
<v-card
disabled
prepend-icon="mdi-comment-quote"
text="Manage the content on the platform"
title="Posts & Articles"
></v-card>
</v-col>
<v-col cols="12" md="6">
<v-card
disabled
prepend-icon="mdi-file-cabinet"
text="Manage attachments on the platform"
title="Attachments"
></v-card>
</v-col>
<v-col cols="12" md="6">
<v-card
disabled
prepend-icon="mdi-ticket"
text="Solve the tickets issued by users"
title="Tickets"
></v-card>
</v-col>
</v-row>
</v-container>
</v-empty-state>
</div>
</template>
<script setup lang="ts">
</script>

View File

@ -0,0 +1,123 @@
<template>
<div>
<v-data-table-server
fixed-header
class="h-full"
density="compact"
:headers="dataDefinitions.users"
:items="users"
:items-length="pagination.total"
:loading="reverting"
v-model:items-per-page="pagination.pageSize"
@update:options="readUsers"
item-value="id"
>
<template v-slot:top>
<v-toolbar color="secondary">
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
<v-btn class="me-2" icon="mdi-account-group" density="compact" :to="{ name: 'admin.dashboard' }" exact />
<h3 class="ml-2 text-lg font-500">Users</h3>
</div>
</v-toolbar>
</template>
<template v-slot:item="{ item }: { item: any }">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.nick }}</td>
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
<td>
<v-tooltip text="Details">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
size="x-small"
color="info"
icon="mdi-dots-horizontal"
@click="viewingUser = item"
/>
</template>
</v-tooltip>
<v-tooltip text="Assign Permissions">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
size="x-small"
color="teal"
icon="mdi-code-block-braces"
@click="assigningPermUser = item"
/>
</template>
</v-tooltip>
</td>
</tr>
</template>
</v-data-table-server>
<user-detail-panel :data="viewingUser" @close="viewingUser = null" />
<user-assign-perms-panel :data="assigningPermUser" @close="assigningPermUser = null"
@success="readUsers(pagination)"
@error="val => error = val" />
<v-snackbar :timeout="3000" :model-value="error != null" @update:model-value="_ => error = null">
{{ error }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
import UserDetailPanel from "@/components/admin/UserDetailPanel.vue"
import UserAssignPermsPanel from "@/components/admin/UserAssignPermsPanel.vue"
const error = ref<string | null>(null)
const users = ref<any[]>([])
const viewingUser = ref<any>(null)
const assigningPermUser = ref<any>(null)
const dataDefinitions: { [id: string]: any[] } = {
users: [
{ align: "start", key: "id", title: "ID" },
{ align: "start", key: "name", title: "Name" },
{ align: "start", key: "nick", title: "Nick" },
{ align: "start", key: "created_at", title: "Created At" },
{ align: "start", key: "actions", title: "Actions", sortable: false },
],
}
const reverting = ref(true)
const pagination = reactive({
page: 1, pageSize: 5, total: 0,
})
async function readUsers({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
reverting.value = true
const res = await request(
"/api/admin/users?" +
new URLSearchParams({
take: pagination.pageSize.toString(),
offset: ((pagination.page - 1) * pagination.pageSize).toString(),
}),
{
headers: { Authorization: `Bearer ${getAtk()}` },
},
)
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
users.value = data["data"]
pagination.total = data["count"]
}
reverting.value = false
}
onMounted(() => readUsers({}))
</script>

View File

@ -29,7 +29,7 @@
</td>
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
<td>
<v-tooltip text="Sign out">
<v-tooltip text="Sign Out">
<template #activator="{ props }">
<v-btn
v-bind="props"