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>