✨ Admin panel & users, users' permissions management
This commit is contained in:
parent
da15c72fb3
commit
46468fae5f
19
.idea/workspace.xml
generated
19
.idea/workspace.xml
generated
@ -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 :sparkles: Audit logs :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>
|
||||
|
164
web/src/components/admin/UserAssignPermsPanel.vue
Normal file
164
web/src/components/admin/UserAssignPermsPanel.vue
Normal 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>
|
46
web/src/components/admin/UserDetailPanel.vue
Normal file
46
web/src/components/admin/UserDetailPanel.vue
Normal 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>
|
@ -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>
|
||||
|
||||
|
30
web/src/layouts/administrator.vue
Normal file
30
web/src/layouts/administrator.vue
Normal 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>
|
@ -25,6 +25,6 @@ import Copyright from "@/components/Copyright.vue"
|
||||
|
||||
<style scoped>
|
||||
.p-container {
|
||||
max-width: 40rem;
|
||||
max-width: 64rem;
|
||||
}
|
||||
</style>
|
@ -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"),
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
|
51
web/src/views/admin/dashboard.vue
Normal file
51
web/src/views/admin/dashboard.vue
Normal 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>
|
123
web/src/views/admin/users.vue
Normal file
123
web/src/views/admin/users.vue
Normal 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>
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user