Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions client-v3/e2e/tests/03-system-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
waitForAppReady,
waitForModal,
waitForModalClosed,
confirmModal,
confirmDialog,
} from '../helpers.js';
import { registerRetryHooks } from '../db-snapshot.js';
Expand Down Expand Up @@ -172,6 +173,48 @@ test('can configure RBAC permissions for testuser', async () => {
await waitForModalClosed(page);
});

test('can edit a user to promote to admin', async () => {
const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') });

// Verify the badge shows "User" before edit
await expect(userRow.locator('.badge:has-text("User")')).toBeVisible();

await userRow.locator('button:has-text("Edit")').click();
await waitForModal(page, 'Edit User');

// Toggle the admin switch on
await page.locator('.modal.show .form-check-input').click();
await expect(page.locator('.modal.show')).toContainText('Admin');

await confirmModal(page);
await waitForModalClosed(page);

// Badge should now show "Admin"
await expect(userRow.locator('.badge:has-text("Admin")')).toBeVisible({ timeout: 5_000 });
});

test('can edit a user to demote from admin', async () => {
const userRow = page.locator('tr', { has: page.locator('td:has-text("testuser")') });

await userRow.locator('button:has-text("Edit")').click();
await waitForModal(page, 'Edit User');

// Toggle the admin switch off
await page.locator('.modal.show .form-check-input').click();
await expect(page.locator('.modal.show')).toContainText('Standard User');

await confirmModal(page);
await waitForModalClosed(page);

// Badge should show "User" again
await expect(userRow.locator('.badge:has-text("User")')).toBeVisible({ timeout: 5_000 });
});

test('edit button is disabled for the current user', async () => {
const adminRow = page.locator('tr', { has: page.locator('td:has-text("admin")') });
await expect(adminRow.locator('button:has-text("Edit")')).toBeDisabled();
});

test('resets the non-admin user password', async () => {
// Ensure testuser row is stable before interacting
await expect(page.locator('td:has-text("testuser")')).toBeVisible({ timeout: 5_000 });
Expand Down
4 changes: 2 additions & 2 deletions client-v3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client-v3/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "client-v3",
"version": "0.30.8",
"version": "0.31.0",
"description": "DigiScript front end (Vue 3)",
"author": "DreamTeamProd",
"private": true,
Expand Down
7 changes: 3 additions & 4 deletions client-v3/src/components/config/ConfigLogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
/>
</BFormGroup>

<BFormGroup v-if="source === 'client'" label="User:" label-cols="auto" class="mb-0">
<BFormGroup label="User:" label-cols="auto" class="mb-0">
<BFormInput
v-model="usernameInput"
placeholder="Filter by username…"
Expand Down Expand Up @@ -182,8 +182,7 @@ async function fetchLogs(): Promise<void> {
limit: String(limit.value),
offset: '0',
});
if (source.value === 'client' && usernameInput.value)
params.set('username', usernameInput.value);
if (usernameInput.value) params.set('username', usernameInput.value);

const response = await fetch(`${makeURL('/api/v1/logs/view')}?${params}`);
if (!response.ok) {
Expand All @@ -209,7 +208,7 @@ function buildStreamUrl(): string {
level: levelFilter.value,
search: searchInput.value,
});
if (source.value === 'client' && usernameInput.value) params.set('username', usernameInput.value);
if (usernameInput.value) params.set('username', usernameInput.value);
return `${makeURL('/api/v1/logs/stream')}?${params}`;
}

Expand Down
44 changes: 44 additions & 0 deletions client-v3/src/components/config/ConfigUsers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
</BButtonGroup>
</template>
<template #head(is_admin)>User Type</template>
<template #cell(created_on)="data">
{{ data.item.created_on ?? 'N/A' }}
</template>
<template #cell(last_login)="data">
{{ data.item.last_login ?? 'Never' }}
</template>
Expand All @@ -29,6 +32,13 @@
>
RBAC
</BButton>
<BButton
variant="secondary"
:disabled="data.item.id === currentUser?.id"
@click.stop="openEditUser(data.item)"
>
Edit
</BButton>
<BButton
variant="info"
:disabled="data.item.id === currentUser?.id"
Expand Down Expand Up @@ -68,6 +78,22 @@
<ConfigRbac v-if="selectedUserId != null" :user-id="selectedUserId" />
</BModal>

<BModal
ref="editUserModal"
title="Edit User"
size="sm"
@ok="submitEditUser"
@hidden="clearEditUser"
>
<BForm v-if="editFormState">
<BFormGroup label="User Type">
<BFormCheckbox v-model="editFormState.is_admin" switch>
{{ editFormState.is_admin ? 'Admin' : 'Standard User' }}
</BFormCheckbox>
</BFormGroup>
</BForm>
</BModal>

<BModal ref="resetPasswordModal" title="Reset User Password" size="md" no-footer>
<ResetPassword
v-if="selectedUser"
Expand Down Expand Up @@ -98,13 +124,16 @@ const { users, currentUser } = storeToRefs(userStore);
const newUserModal = ref<InstanceType<typeof BModal>>();
const newAdminModal = ref<InstanceType<typeof BModal>>();
const rbacModal = ref<InstanceType<typeof BModal>>();
const editUserModal = ref<InstanceType<typeof BModal>>();
const resetPasswordModal = ref<InstanceType<typeof BModal>>();

const selectedUserId = ref<number | null>(null);
const selectedUser = ref<{ id: number; username: string } | null>(null);
const editFormState = ref<Record<string, unknown> | null>(null);

const userFields = [
'username',
'created_on',
'last_login',
'last_seen',
{ key: 'is_admin', label: 'User Type' },
Expand All @@ -128,6 +157,21 @@ function openResetPassword(user: { id: number; username: string }): void {
resetPasswordModal.value?.show();
}

function openEditUser(user: Record<string, unknown>): void {
editFormState.value = { ...user };
editUserModal.value?.show();
}

function clearEditUser(): void {
editFormState.value = null;
}

async function submitEditUser(): Promise<void> {
if (editFormState.value) {
await userStore.editUser(editFormState.value as { id: number });
}
}

async function deleteUser(item: { id: number; username: string }): Promise<void> {
const confirmed = await confirm(`Are you sure you want to delete ${item.username}?`, {
title: 'Delete User',
Expand Down
6 changes: 3 additions & 3 deletions client-v3/src/js/http-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ export default function setupHttpInterceptor(): void {
const userStore = useUserStore();

const newOptions = buildAuthenticatedOptions(options, userStore.authToken);
const isLogoutRequest = resource.endsWith('/api/v1/auth/logout');
const isLoginRequest = resource.endsWith('/api/v1/auth/login');
const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token');
const isLogoutRequest = resource.includes('/api/v1/auth/logout');
const isLoginRequest = resource.includes('/api/v1/auth/login');
const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token');

try {
const response = await originalFetch(resource, newOptions);
Expand Down
38 changes: 26 additions & 12 deletions client-v3/src/stores/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const useUserStore = defineStore('user', {

async getUsers(): Promise<void> {
if (!this.currentUser?.is_admin) return;
const response = await fetch(makeURL('/api/v1/auth/users'));
const response = await fetch(makeURL('/api/v2/users'));
if (response.ok) {
const data = await response.json();
this.users = data.users;
Expand All @@ -187,7 +187,7 @@ export const useUserStore = defineStore('user', {
},

async createUser(user: Record<string, unknown>): Promise<void> {
const response = await fetch(makeURL('/api/v1/auth/create'), {
const response = await fetch(makeURL('/api/v2/users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
Expand All @@ -203,10 +203,9 @@ export const useUserStore = defineStore('user', {
},

async deleteUser(userId: number): Promise<void> {
const response = await fetch(makeURL('/api/v1/auth/delete'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: userId }),
const params = new URLSearchParams({ id: String(userId) });
const response = await fetch(makeURL(`/api/v2/users?${params}`), {
method: 'DELETE',
});
if (response.ok) {
await this.getUsers();
Expand Down Expand Up @@ -264,7 +263,7 @@ export const useUserStore = defineStore('user', {
},

async generateApiToken(): Promise<Record<string, unknown> | null> {
const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
const response = await fetch(makeURL('/api/v2/users/token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
Expand All @@ -279,10 +278,8 @@ export const useUserStore = defineStore('user', {
},

async revokeApiToken(): Promise<boolean> {
const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
const response = await fetch(makeURL('/api/v2/users/token'), {
method: 'DELETE',
});
if (response.ok) {
toast.success('API token revoked successfully!');
Expand All @@ -294,12 +291,29 @@ export const useUserStore = defineStore('user', {
},

async getApiToken(): Promise<Record<string, unknown> | null> {
const response = await fetch(makeURL('/api/v1/auth/api-token'));
const response = await fetch(makeURL('/api/v2/users/token'));
if (response.ok) return response.json();
toast.error('Unable to get API token!');
return null;
},

async editUser(user: { id: number; [key: string]: unknown }): Promise<void> {
const params = new URLSearchParams({ id: String(user.id) });
const response = await fetch(makeURL(`/api/v2/users?${params}`), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (response.ok) {
await this.getUsers();
toast.success('User updated!');
} else {
const body = await response.json();
log.error('Unable to update user');
toast.error(`Unable to update user: ${body.message || 'Unknown error'}`);
}
},

async getStageDirectionStyleOverrides(): Promise<void> {
const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'));
if (response.ok) {
Expand Down
1 change: 1 addition & 0 deletions client-v3/src/types/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface User {
id: number;
username: string | null;
is_admin: boolean | null;
created_on: string | null;
last_login: string | null;
last_seen: string | null;
requires_password_change: boolean;
Expand Down
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "client",
"version": "0.30.8",
"version": "0.31.0",
"description": "DigiScript front end",
"author": "DreamTeamProd",
"private": true,
Expand Down
4 changes: 2 additions & 2 deletions client/src/js/http-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export default function setupHttpInterceptor(): void {
// Only intercept our own API requests
if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) {
const token = store.getters.AUTH_TOKEN;
const isLogoutRequest = resource.endsWith('/api/v1/auth/logout');
const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token');
const isLogoutRequest = resource.includes('/api/v1/auth/logout');
const isRefreshRequest = resource.includes('/api/v1/auth/refresh-token');

// Clone the options
const newOptions = {
Expand Down
Loading
Loading