From 2c08f1bdbdf55d62193a57ef6f10f804c10031ed Mon Sep 17 00:00:00 2001 From: andrewlalis <andrewlalisofficial@gmail.com> Date: Wed, 26 Feb 2025 14:17:42 -0500 Subject: [PATCH] Added admin view-as mode. --- api/source/api_modules/auth.d | 13 ++ api/source/app.d | 2 +- app/src/App.vue | 14 +- app/src/api/base.ts | 6 +- .../components/AdminAnnouncementCreator.vue | 45 ++++++ app/src/components/AdminUsersTable.vue | 140 ++++++++++++++++++ app/src/components/AnnouncementsBanner.vue | 3 +- app/src/stores/auth.ts | 15 +- app/src/views/AdminDashboardView.vue | 127 +--------------- app/src/views/MyAccountView.vue | 22 ++- 10 files changed, 254 insertions(+), 133 deletions(-) create mode 100644 app/src/components/AdminAnnouncementCreator.vue create mode 100644 app/src/components/AdminUsersTable.vue diff --git a/api/source/api_modules/auth.d b/api/source/api_modules/auth.d index f6b4359..f93ea65 100644 --- a/api/source/api_modules/auth.d +++ b/api/source/api_modules/auth.d @@ -58,6 +58,7 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connectio import std.string : startsWith; import std.digest.sha; import std.algorithm : countUntil; + import std.conv : to; string headerStr = ctx.request.headers.getFirst("Authorization").orElse(""); if (headerStr.length == 0 || !startsWith(headerStr, "Basic ")) { @@ -83,6 +84,18 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connectio ) { return Optional!User.empty; } + // Check if an admin user is requesting to view the application as a given user. + if (optUser.value.isAdmin && ctx.request.headers.contains("X-Admin-As-User")) { + string userAsIdHeader = ctx.request.headers.getFirst("X-Admin-As-User").orElse(""); + ulong userId = userAsIdHeader.to!ulong; + infoF!"Admin user %s is viewing the application as user %d."(optUser.value.username, userId); + return findOne( + conn, + "SELECT * FROM auth_user WHERE id = ?", + &User.parse, + userId + ); + } return optUser; } diff --git a/api/source/app.d b/api/source/app.d index c233c56..ef34935 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -17,7 +17,7 @@ void main() { config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; config.defaultHeaders["Access-Control-Request-Method"] = "*"; - config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization, Content-Length, Content-Type"; + config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization, Content-Length, Content-Type, X-Admin-As-User"; if (env == "PROD") { config.port = 8107; diff --git a/app/src/App.vue b/app/src/App.vue index d4f5971..069a16c 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -15,6 +15,12 @@ async function logOut() { authStore.logOut() await router.replace('/') } + +async function exitAdminViewAsUser() { + if (!authStore.state) return + authStore.state.adminAsUser = null + await router.replace('/admin-dashboard') +} </script> <template> @@ -25,7 +31,7 @@ async function logOut() { <span style="font-weight: bold; font-size: large;">Teacher Tools</span> <RouterLink class="link" to="/">Apps</RouterLink> <RouterLink class="link" to="/my-account" v-if="authStore.state">My Account</RouterLink> - <RouterLink class="link" to="/admin-dashboard" v-if="authStore.admin">Admin</RouterLink> + <RouterLink class="link" to="/admin-dashboard" v-if="authStore.isAdmin()">Admin</RouterLink> </div> <div> @@ -34,6 +40,12 @@ async function logOut() { Logged in as <span v-text="authStore.state.username" style="font-weight: bold; font-style: normal; font-size: medium;"></span> </span> + <span v-if="authStore.state && authStore.state.adminAsUser !== null" + style="margin-right: 0.25em; font-style: italic; font-size: smaller"> + Viewing as <span v-text="authStore.state.adminAsUser.username" + style="font-weight: bold; font-style: normal; font-size: medium;"></span> + <button type="button" @click="exitAdminViewAsUser" style="margin-left: 0.25em;">Exit Admin View-As</button> + </span> <button type="button" @click="logOut" v-if="authStore.state">Log out</button> </div> </nav> diff --git a/app/src/api/base.ts b/app/src/api/base.ts index e1d7a62..e394af1 100644 --- a/app/src/api/base.ts +++ b/app/src/api/base.ts @@ -112,9 +112,13 @@ export abstract class APIClient { protected getAuthHeaders(): HeadersInit { if (this.authStore !== null && this.authStore.state) { - return { + const headers: HeadersInit = { Authorization: 'Basic ' + this.authStore.getBasicAuth(), } + if (this.authStore.state.adminAsUser !== null) { + headers['X-Admin-As-User'] = '' + this.authStore.state.adminAsUser.id + } + return headers } return {} } diff --git a/app/src/components/AdminAnnouncementCreator.vue b/app/src/components/AdminAnnouncementCreator.vue new file mode 100644 index 0000000..c9ad2e8 --- /dev/null +++ b/app/src/components/AdminAnnouncementCreator.vue @@ -0,0 +1,45 @@ +<script setup lang="ts"> +import { showAlert } from '@/alerts' +import { AnnouncementAPIClient } from '@/api/announcement' +import { useAuthStore } from '@/stores/auth' +import { type Ref, ref } from 'vue' + +interface CreateAnnouncementFormData { + type: string + message: string +} +const createAnnouncementFormData: Ref<CreateAnnouncementFormData> = ref({ type: 'INFO', message: '' }) + +const authStore = useAuthStore() + +async function doCreateAnnouncement() { + const client = new AnnouncementAPIClient(authStore) + const a = await client.createAnnouncement( + createAnnouncementFormData.value.type, createAnnouncementFormData.value.message + ).handleErrorsWithAlert() + if (a !== null) { + await showAlert('Created announcement. It will appear shortly.') + createAnnouncementFormData.value = { type: 'INFO', message: '' } + } +} +</script> +<template> + <form @submit.prevent="doCreateAnnouncement"> + <h3>Create Announcement</h3> + <div> + <label for="create-announcement-type">Type</label> + <select id="create-announcement-type" v-model="createAnnouncementFormData.type"> + <option value="INFO" selected>INFO</option> + <option value="ERROR">ERROR</option> + </select> + </div> + <div> + <label for="create-announcement-message">Message</label> + <textarea id="create-announcement-message" v-model="createAnnouncementFormData.message" maxlength="2000" + minlength="1" required style="min-width: 300px; min-height: 100px;"></textarea> + </div> + <div class="button-bar"> + <button type="submit">Create</button> + </div> + </form> +</template> diff --git a/app/src/components/AdminUsersTable.vue b/app/src/components/AdminUsersTable.vue new file mode 100644 index 0000000..9b6d59c --- /dev/null +++ b/app/src/components/AdminUsersTable.vue @@ -0,0 +1,140 @@ +<script setup lang="ts"> +import { AuthenticationAPIClient, type User, type UserUpdatePayload } from '@/api/auth'; +import { useAuthStore } from '@/stores/auth'; +import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'; +import ConfirmDialog from './ConfirmDialog.vue'; +import { useRouter } from 'vue-router'; + + +const authStore = useAuthStore() +const router = useRouter() +const apiClient = new AuthenticationAPIClient(authStore) + +const users: Ref<User[]> = ref([]) +const usersPage: Ref<number> = ref(0) +const usersPageSize: Ref<number> = ref(50) +const loading: Ref<boolean> = ref(true) + +const deleteUserConfirmDialog = useTemplateRef('deleteUserConfirmDialog') + +onMounted(() => { + watch([usersPage, usersPageSize], fetchUsers) + fetchUsers() +}) + +function fetchUsers() { + loading.value = true + apiClient.getUsers(usersPage.value, usersPageSize.value).handleErrorsWithAlert() + .then(result => { + if (result !== null) { + users.value = result + } + }) + .finally(() => loading.value = false) +} + +async function deleteUser(user: User) { + const confirm = await deleteUserConfirmDialog.value?.show() + if (!confirm) return + await apiClient.deleteUser(user.id).handleErrorsWithAlert() + fetchUsers() // Refresh the list of users. +} + +async function toggleLocked(user: User) { + const payload: UserUpdatePayload = { + isLocked: !user.isLocked + } + await apiClient.updateUser(user.id, payload).handleErrorsWithAlert() + fetchUsers() +} + +function viewAsUser(user: User) { + if (!authStore.state) return + authStore.state.adminAsUser = user + router.replace('/') +} + +function booleanCellClass(b: boolean) { + return { + 'color-false': !b, + 'color-true': b, + 'text-mono': true + } +} + +defineExpose({ fetchUsers }) + +</script> +<template> + <div> + <h3 style="margin-bottom: 0.25em;">Users</h3> + <div class="button-bar"> + <button type="button" :disabled="loading || usersPage < 1" @click="usersPage -= 1">Previous Page</button> + <span>Page: {{ usersPage + 1 }}</span> + <button type="button" @click="usersPage += 1" :disabled="loading">Next Page</button> + <label style="display: inline;">Page Size:</label> + <select v-model="usersPageSize" :disabled="loading"> + <option value="5">5</option> + <option value="10">10</option> + <option value="50" selected>50</option> + <option value="100">100</option> + </select> + </div> + <table class="users-table"> + <thead> + <tr> + <th>ID</th> + <th>Username</th> + <th>Created At</th> + <th>Admin</th> + <th>Locked</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + <tr v-for="user in users" :key="user.id"> + <td class="text-mono">{{ user.id }}</td> + <td>{{ user.username }}</td> + <td>{{ new Date(user.createdAt).toLocaleString() }}</td> + <td :class="booleanCellClass(user.isAdmin)">{{ user.isAdmin }} + </td> + <td :class="booleanCellClass(user.isLocked)">{{ user.isLocked + }}</td> + <td> + <div> + <button type="button" @click="deleteUser(user)">Delete</button> + <button type="button" @click="toggleLocked(user)" v-text="user.isLocked ? 'Unlock' : 'Lock'"></button> + <button type="button" @click="viewAsUser(user)" v-if="user.id !== authStore.state?.user.id">View + as</button> + </div> + </td> + </tr> + </tbody> + </table> + + <ConfirmDialog ref="deleteUserConfirmDialog"> + <p> + Are you sure you want to delete this user? + </p> + </ConfirmDialog> + </div> +</template> +<style scoped> +.users-table { + border-collapse: collapse; +} + +.users-table th, +td { + border: 1px solid gray; + padding: 0.5em; +} + +.color-false { + color: red; +} + +.color-true { + color: lime; +} +</style> diff --git a/app/src/components/AnnouncementsBanner.vue b/app/src/components/AnnouncementsBanner.vue index 2c5bb60..98a8246 100644 --- a/app/src/components/AnnouncementsBanner.vue +++ b/app/src/components/AnnouncementsBanner.vue @@ -58,7 +58,8 @@ async function deleteAnnouncement(a: Announcement) { <div v-for="a in getVisibleAnnouncements()" :key="a.id" class="announcement-banner" :class="getBannerClasses(a)"> <p class="announcement-banner-message">{{ a.message }}</p> <button class="announcement-banner-button" @click="dismiss(a)">Dismiss</button> - <button v-if="authStore.admin" class="announcement-banner-button" @click="deleteAnnouncement(a)">Delete</button> + <button v-if="authStore.isAdmin()" class="announcement-banner-button" + @click="deleteAnnouncement(a)">Delete</button> </div> <ConfirmDialog ref="deleteAnnouncementConfirmDialog"> diff --git a/app/src/stores/auth.ts b/app/src/stores/auth.ts index 29a718b..35ba247 100644 --- a/app/src/stores/auth.ts +++ b/app/src/stores/auth.ts @@ -1,20 +1,20 @@ import type { User } from '@/api/auth' import { defineStore } from 'pinia' -import { computed, ref, type Ref } from 'vue' +import { ref, type Ref } from 'vue' export interface Authenticated { username: string password: string user: User + adminAsUser: User | null } export type AuthenticationState = Authenticated | null export const useAuthStore = defineStore('auth', () => { const state: Ref<AuthenticationState> = ref(null) - const admin = computed(() => state.value && state.value.user.isAdmin) function logIn(username: string, password: string, user: User) { - state.value = { username: username, password: password, user: user } + state.value = { username: username, password: password, user: user, adminAsUser: null } } function logOut() { state.value = null @@ -23,5 +23,12 @@ export const useAuthStore = defineStore('auth', () => { if (!state.value) throw new Error('User is not authenticated.') return btoa(state.value.username + ':' + state.value.password) } - return { state, admin, logIn, logOut, getBasicAuth } + function isAdmin() { + return ( + state.value && + state.value.user.isAdmin && + (state.value.adminAsUser === null || state.value.adminAsUser.isAdmin) + ) + } + return { state, isAdmin, logIn, logOut, getBasicAuth } }) diff --git a/app/src/views/AdminDashboardView.vue b/app/src/views/AdminDashboardView.vue index db9bec9..7529ee4 100644 --- a/app/src/views/AdminDashboardView.vue +++ b/app/src/views/AdminDashboardView.vue @@ -1,10 +1,9 @@ <script setup lang="ts"> -import { showAlert } from '@/alerts'; -import { AnnouncementAPIClient } from '@/api/announcement'; -import { AuthenticationAPIClient, type User, type UserUpdatePayload } from '@/api/auth'; -import ConfirmDialog from '@/components/ConfirmDialog.vue'; +import { AuthenticationAPIClient } from '@/api/auth'; +import AdminAnnouncementCreator from '@/components/AdminAnnouncementCreator.vue'; +import AdminUsersTable from '@/components/AdminUsersTable.vue'; import { useAuthStore } from '@/stores/auth'; -import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'; +import { ref, useTemplateRef, type Ref } from 'vue'; const authStore = useAuthStore() const apiClient = new AuthenticationAPIClient(authStore) @@ -15,44 +14,8 @@ interface CreateUserData { } const createUserFormData: Ref<CreateUserData> = ref({ username: '', password: '' }) -interface CreateAnnouncementFormData { - type: string - message: string -} -const createAnnouncementFormData: Ref<CreateAnnouncementFormData> = ref({ type: 'INFO', message: '' }) -const users: Ref<User[]> = ref([]) -const usersPage: Ref<number> = ref(0) -const usersPageSize: Ref<number> = ref(50) - -const deleteUserConfirmDialog = useTemplateRef('deleteUserConfirmDialog') - -onMounted(() => { - watch([usersPage, usersPageSize], fetchUsers) - fetchUsers() -}) - -async function fetchUsers() { - const result = await apiClient.getUsers(usersPage.value, usersPageSize.value).handleErrorsWithAlert() - if (result !== null) { - users.value = result - } -} - -async function deleteUser(user: User) { - const confirm = await deleteUserConfirmDialog.value?.show() - if (!confirm) return - await apiClient.deleteUser(user.id).handleErrorsWithAlert() - fetchUsers() // Refresh the list of users. -} - -async function toggleLocked(user: User) { - const payload: UserUpdatePayload = { - isLocked: !user.isLocked - } - await apiClient.updateUser(user.id, payload).handleErrorsWithAlert() - fetchUsers() -} +const usersTable = useTemplateRef('admin-users-table') async function doCreateUser() { const user = await apiClient.createUser(createUserFormData.value.username, createUserFormData.value.password) @@ -62,18 +25,7 @@ async function doCreateUser() { username: '', password: '' } - fetchUsers(); - } -} - -async function doCreateAnnouncement() { - const client = new AnnouncementAPIClient(authStore) - const a = await client.createAnnouncement( - createAnnouncementFormData.value.type, createAnnouncementFormData.value.message - ).handleErrorsWithAlert() - if (a !== null) { - await showAlert('Created announcement. It will appear shortly.') - createAnnouncementFormData.value = { type: 'INFO', message: '' } + usersTable.value?.fetchUsers() } } </script> @@ -84,47 +36,7 @@ async function doCreateAnnouncement() { On this page, you'll find tools for managing users and checking audit information. </p> - <h3>Users</h3> - <div class="button-bar"> - <button type="button" :disabled="usersPage < 1" @click="usersPage -= 1">Previous Page</button> - <span>Page: {{ usersPage }}</span> - <button type="button" @click="usersPage += 1">Next Page</button> - <select v-model="usersPageSize"> - <option value="5">5</option> - <option value="10">10</option> - <option value="50" selected>50</option> - <option value="100">100</option> - </select> - </div> - <table> - <thead> - <tr> - <th>ID</th> - <th>Username</th> - <th>Created At</th> - <th>Admin</th> - <th>Locked</th> - <th>Actions</th> - </tr> - </thead> - <tbody> - <tr v-for="user in users" :key="user.id"> - <td>{{ user.id }}</td> - <td>{{ user.username }}</td> - <td>{{ new Date(user.createdAt).toLocaleString() }}</td> - <td>{{ user.isAdmin }}</td> - <td>{{ user.isLocked }}</td> - <td> - <div> - <button type="button" @click="deleteUser(user)">Delete</button> - - <button type="button" @click="toggleLocked(user)" v-text="user.isLocked ? 'Unlock' : 'Lock'"></button> - - </div> - </td> - </tr> - </tbody> - </table> + <AdminUsersTable ref="admin-users-table" /> <form @submit.prevent="doCreateUser"> <h3>Create User</h3> @@ -141,29 +53,6 @@ async function doCreateAnnouncement() { </div> </form> - <form @submit.prevent="doCreateAnnouncement"> - <h3>Create Announcement</h3> - <div> - <label for="create-announcement-type">Type</label> - <select id="create-announcement-type" v-model="createAnnouncementFormData.type"> - <option value="INFO" selected>INFO</option> - <option value="ERROR">ERROR</option> - </select> - </div> - <div> - <label for="create-announcement-message">Message</label> - <textarea id="create-announcement-message" v-model="createAnnouncementFormData.message" maxlength="2000" - minlength="1" required style="min-width: 300px; min-height: 100px;"></textarea> - </div> - <div> - <button type="submit">Create</button> - </div> - </form> - - <ConfirmDialog ref="deleteUserConfirmDialog"> - <p> - Are you sure you want to delete this user? - </p> - </ConfirmDialog> + <AdminAnnouncementCreator /> </main> </template> diff --git a/app/src/views/MyAccountView.vue b/app/src/views/MyAccountView.vue index 26f3c14..6e5bac0 100644 --- a/app/src/views/MyAccountView.vue +++ b/app/src/views/MyAccountView.vue @@ -1,32 +1,42 @@ <script setup lang="ts"> +import type { User } from '@/api/auth'; import { useAuthStore } from '@/stores/auth'; +import { computed, type Ref } from 'vue'; const authStore = useAuthStore() + +const user: Ref<User | null> = computed(() => { + if (!authStore.state) return null + if (authStore.state.adminAsUser) { + return authStore.state.adminAsUser + } + return authStore.state.user +}) </script> <template> - <main v-if="authStore.state" class="centered-content"> + <main v-if="user" class="centered-content"> <h1 class="align-center">My Account</h1> <table class="account-properties-table"> <tbody> <tr> <th>Internal ID</th> - <td>{{ authStore.state.user.id }}</td> + <td>{{ user.id }}</td> </tr> <tr> <th>Username</th> - <td>{{ authStore.state.user.username }}</td> + <td>{{ user.username }}</td> </tr> <tr> <th>Created At</th> - <td>{{ new Date(authStore.state.user.createdAt).toLocaleString() }}</td> + <td>{{ new Date(user.createdAt).toLocaleString() }}</td> </tr> <tr> <th>Account Locked</th> - <td>{{ authStore.state.user.isLocked }}</td> + <td>{{ user.isLocked }}</td> </tr> <tr> <th>Administrator</th> - <td>{{ authStore.state.user.isAdmin }}</td> + <td>{{ user.isAdmin }}</td> </tr> </tbody> </table>