Skip to content
Open
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
184 changes: 184 additions & 0 deletions app/src/components/UserDeletionVRSpacesQuestionModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<template>
<dialog
class="modal"
:class="{ 'modal-open': modelValue }"
@click.self="onCancel"
>
<div class="modal-box">
<button @click="onCancel" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
<h2 class="text-lg font-semibold mb-4">
{{ ownershipStep === 1 ? 'Ta bort användare' : 'Välj ny ägare' }}
</h2>

<div v-if="ownershipStep === 1" class="space-y-4">
<p class="text-sm text-error">
Användaren äger VR-miljöer. Välj vad som ska hända med dessa miljöer:
</p>

<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="ownership-action"
class="radio radio-primary"
:value="'take-over'"
v-model="selectedAction"
/>
<span class="label-text">Ta över ägandet själv</span>
</label>
</div>

<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="radio"
name="ownership-action"
class="radio radio-primary"
:value="'transfer'"
v-model="selectedAction"
/>
<span class="label-text">Överför ägandet till en annan användare eller admin</span>
</label>
</div>

<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="onCancel">Avbryt</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedAction"
@click="goToStep2"
>
Nästa
</button>
</div>
</div>

<div v-else-if="ownershipStep === 2" class="space-y-4">
<p class="text-sm">
Välj vem som ska ta över ägandet av {{ targetUserName }}s VR-miljöer:
</p>

<div class="form-control">
<input
v-model="searchQuery"
type="text"
placeholder="Sök användarnamn..."
class="input input-bordered w-full"
/>
</div>

<div class="max-h-60 overflow-y-auto space-y-2">
<label
v-for="user in filteredUsers"
:key="user.userId"
class="label cursor-pointer justify-start gap-3 p-2 hover:bg-base-200 rounded"
>
<input
type="radio"
name="target-user"
class="radio radio-primary"
:value="user.userId"
v-model="selectedUserId"
/>
<span>{{ user.username }} ({{ translateUserRole(user.role) }})</span>
</label>
<p v-if="filteredUsers.length === 0" class="text-sm text-warning p-4 text-center">
Inga användare matchar sökningen.
</p>
</div>

<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="goBackToStep1">Tillbaka</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedUserId"
@click="onConfirmDelete"
>
Bekräfta borttagning och överföring
</button>
</div>
</div>
</div>
</dialog>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { translateUserRole} from 'schemas';
import { type getUsers } from '@/modules/authClient'

type FetchedUser = Awaited<ReturnType<typeof getUsers>>[number];

const props = defineProps<{
modelValue: boolean;
targetUserName: string;
users: Array<FetchedUser>;
}>();

const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', payload: {
action: 'take-over' | 'transfer';
targetUserId?: string;
}): void;
}>();

const ownershipStep = ref(1);
const selectedAction = ref<'take-over' | 'transfer' | ''>('');
const selectedUserId = ref('');
const searchQuery = ref('');

const filteredUsers = computed(() =>
props.users.filter(user =>
user.username.toLowerCase().includes(searchQuery.value.toLowerCase())
)
);

watch(
() => props.modelValue,
(open) => {
if (open) {
resetForm();
}
},
);

const resetForm = () => {
ownershipStep.value = 1;
selectedAction.value = '';
selectedUserId.value = '';
searchQuery.value = '';
};

function close() {
emit('update:modelValue', false);
}

function onCancel() {
close();
}

function goToStep2() {
if (selectedAction.value === 'take-over') {
// Immediate confirm for take-over (no step 2 needed)
emit('confirm', { action: 'take-over' });
close();
return;
}
ownershipStep.value = 2;
}

function goBackToStep1() {
ownershipStep.value = 1;
}

function onConfirmDelete() {
emit('confirm', {
action: 'transfer',
targetUserId: selectedUserId.value,
});
close();
}
</script>
10 changes: 10 additions & 0 deletions app/src/stores/vrSpaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ export const useVrSpaceStore = defineStore('vrSpace', () => {
return vrSpaceId;
}

async function transferVrSpaceOwnership(fromUserId: string, toUserId: string) {
await connection.client.vr.transferVrSpaceOwnership.mutate({ fromUserId, toUserId });
}

async function doesUserHaveVrSpaces(userId: string) {
return await connection.client.vr.doesUserHaveVrSpaces.mutate({ userId });
}

async function deleteVrSpace(vrSpaceId?: VrSpaceId) {
if (!vrSpaceId) vrSpaceId = currentVrSpace.value?.dbData.vrSpaceId;
if (!vrSpaceId) {
Expand Down Expand Up @@ -237,6 +245,8 @@ export const useVrSpaceStore = defineStore('vrSpace', () => {
navMeshUrl,
worldModelScaleString,
panoramicPreviewUrl,
doesUserHaveVrSpaces,
transferVrSpaceOwnership,
createVrSpace,
deleteVrSpace,
enterVrSpace,
Expand Down
60 changes: 59 additions & 1 deletion app/src/views/admin/AdminUserManagerView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@
class="btn">
<span class="material-icons">edit</span>
</button>
<button @click="makeCallThenResetList(() => deleteUser(user.userId))" class="btn btn-error">
<button
@click="openDeleteModal(user)"
class="btn btn-error"
>
<span class="material-icons">delete</span>
</button>
</template>
Expand All @@ -104,25 +107,39 @@
Du saknar behörighet till den här sidan.
</div>
</div>
<UserDeleteOwnershipModal
v-model="showDeleteModal"
:target-user-name="userToDelete?.username || ''"
:users="assignableUsers"
@confirm="handleUserDelete"
/>
</MaxWidth7xl>
</template>

<script setup lang="ts">
import { useAuthStore } from '@/stores/authStore';
import { useConnectionStore } from '@/stores/connectionStore';
import { computed, onBeforeMount, reactive, ref } from 'vue';
import { createUser, getAdmins, updateUser, deleteUser, getUsers } from '@/modules/authClient';
import { useVrSpaceStore } from '@/stores/vrSpaceStore';
import { allRolesBelow, hasAtLeastSecurityRole, roleHierarchy, translateUserRole, type UserRole } from 'schemas';
import MaxWidth7xl from '@/components/layout/MaxWidth7xl.vue';
import UserDeleteOwnershipModal from '@/components/UserDeletionVRSpacesQuestionModal.vue';

// Use imports
const authStore = useAuthStore();
const vrSpaceStore = useVrSpaceStore();
const connection = useConnectionStore();
const creatableRoles = computed(() => {
if (!authStore.role) return [];
const rolesBelowUser = allRolesBelow(authStore.role);
const unuseRolesExcluded = rolesBelowUser.filter(r => r !== 'moderator' && r !== 'guest')
return unuseRolesExcluded.map(r => ({ value: r, label: translateUserRole(r) })).reverse();
})

const showDeleteModal = ref(false);
const userToDelete = ref<{ userId: string; username: string } | null>(null);

function getClassForRole(role: UserRole) {
switch (role) {
case 'guest':
Expand Down Expand Up @@ -217,4 +234,45 @@ async function makeCallThenResetList(fetchReq: (...p: any) => Promise<any>) {
fetchedUsers.value = await getUsers();
}

const assignableUsers = computed(() => {
if (!fetchedUsers.value || !userToDelete.value) return [];
return fetchedUsers.value
.filter(u => u.userId !== userToDelete.value!.userId)
.filter(u => u.userId !== authStore.userId);
});

async function openDeleteModal(user: Awaited<ReturnType<typeof getUsers>>[number]) {
try {
const hasVrSpaces = await vrSpaceStore.doesUserHaveVrSpaces(user.userId);

if (hasVrSpaces) {
userToDelete.value = { userId: user.userId, username: user.username };
showDeleteModal.value = true;
} else {
await deleteUser(user.userId);
await makeCallThenResetList(async () => {});
}
} catch (error) {
console.error('Check failed:', error);
}
}

async function handleUserDelete(payload: { action: 'take-over' | 'transfer'; targetUserId?: string }) {
if (!userToDelete.value) return;

const newOwnerId = payload.action === 'transfer'
? payload.targetUserId
: authStore.userId;

try {
await vrSpaceStore.transferVrSpaceOwnership(userToDelete.value.userId, newOwnerId);

await deleteUser(userToDelete.value.userId);

await makeCallThenResetList(async () => {});
} catch (error) {
console.error('Delete failed:', error);
}
}

</script>
53 changes: 52 additions & 1 deletion backend/mediaserver/src/routers/vrRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ process.env.DEBUG = 'Router:VR*, ' + process.env.DEBUG;
log.enable(process.env.DEBUG);

import { ClientRealtimeDataSchema, hasAtLeastSecurityRole, PlacedObjectIdSchema, PlacedObjectInsertSchema, ScreenShareSchema, TransformSchema, VrSpaceIdSchema, VrSpaceUpdateSchema } from 'schemas';
import { procedure as p, router, isUserClientM, userClientP, atLeastUserP, userInVrSpaceP, userWithEditRightsToVrSpace, userWithAdminRightsToVrSpace } from '../trpc/trpc.js';
import { procedure as p, router, isUserClientM, userClientP, atLeastUserP, atLeastAdminP, userInVrSpaceP, userWithEditRightsToVrSpace, userWithAdminRightsToVrSpace } from '../trpc/trpc.js';
import { VrSpace } from 'classes/VrSpace.js';
import { z } from 'zod';
import { db, schema, } from 'database';
Expand Down Expand Up @@ -89,6 +89,57 @@ export const vrRouter = router({
ctx.client.leaveCurrentVrSpace();
// ctx.vrSpace.removeClient(ctx.client);
}),
transferVrSpaceOwnership: atLeastUserP.input(z.object({
fromUserId: z.string(),
toUserId: z.string(),
})).mutation(async ({ ctx, input }) => {
if (!hasAtLeastSecurityRole(ctx.role, 'admin')) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Admin rights required' });
}
const { fromUserId, toUserId } = input;

log.info(`Transferring VR spaces from ${fromUserId} to ${toUserId}`);

const result = await db
.update(schema.vrSpaces)
.set({ ownerUserId: toUserId })
.where(eq(schema.vrSpaces.ownerUserId, fromUserId))
.returning({
vrSpaceId: schema.vrSpaces.vrSpaceId,
name: schema.vrSpaces.name
});

log.info(`Transferred ${result.length} VR spaces`);

for (const space of result) {
const vrSpace = VrSpace.getVrSpace(space.vrSpaceId);
if (vrSpace) {
vrSpace.reloadDbData('ownership transferred');
}
}

return {
transferredCount: result.length,
spaces: result
};
}),
doesUserHaveVrSpaces: atLeastAdminP.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {

const { userId } = input;
log.info(`Checking if user ${userId} owns VR spaces`);


const result = await db
.select({ count: sql<number>`count(*)`.mapWith(Number) })
.from(schema.vrSpaces)
.where(eq(schema.vrSpaces.ownerUserId, userId));

const hasSpaces = result[0]?.count > 0;
log.info(`User ${userId} ${hasSpaces ? 'HAS' : 'has NO'} VR spaces`);

return hasSpaces;
}),
updateVrSpace: userWithEditRightsToVrSpace.input(VrSpaceUpdateSchema).mutation(async ({ ctx, input }) => {
log.info('updating vrSpace', input);
const { vrSpaceId, reason, ...data } = input;
Expand Down