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
182 changes: 182 additions & 0 deletions app/src/components/UserDeletionEnvironmentsQuestionModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<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 ? 'Radera 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 ägandet av miljöerna:
</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 miljö ä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 miljö ägandet till annan användare/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 användare 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ändare..."
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 }} ({{ 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 radering & överföring
</button>
</div>
</div>
</div>
</dialog>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';

const props = defineProps<{
modelValue: boolean;
targetUserName: string;
users: Array<{ userId: string; username: string; role: string; email?: string }>;
}>();

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()) ||
user.email?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
user.role.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>
6 changes: 3 additions & 3 deletions app/src/components/layout/DefaultHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<!-- <Breadcrumbs /> -->
<!-- </div> -->
</div>
<div class="grow shrink-0 hidden sm:flex justify-center gap-1">
<div class="grow shrink-0 hidden lg:flex justify-center gap-1">
<!-- <div class="text-xs">
{{ authStore.username }}: {{ authStore.userId }}
</div> -->
Expand All @@ -19,7 +19,7 @@
{{ route.label }}
</RouterLink>
</div>
<div class="sm:flex shrink hidden gap-1 items-center">
<div class="lg:flex shrink hidden gap-1 items-center">
<div class="flex flex-wrap shrink justify-end items-baseline text-xs font-semibold">
<span class="text-base-content/50">{{ authStore.role === 'guest' ? 'Besöker som' : 'Inloggad som'
}}&nbsp;</span>
Expand All @@ -40,7 +40,7 @@
</span>
</button>
</div>
<div class="dropdown dropdown-end sm:hidden">
<div class="dropdown dropdown-end lg:hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<span class="material-icons">menu</span>
</div>
Expand Down
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
63 changes: 61 additions & 2 deletions app/src/views/admin/AdminUserManagerView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<h3>
Skapa ny användare
</h3>
<div class="flex gap-6 items-end">
<div class="flex flex-col gap-6 items-stretch md:flex-row md:items-end">
<div>
<label class="flex items-center gap-2">
<span class="label-text">Användarnamn:</span>
Expand Down 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="adminUsers"
@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/UserDeletionEnvironmentsQuestionModal.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,46 @@ async function makeCallThenResetList(fetchReq: (...p: any) => Promise<any>) {
fetchedUsers.value = await getUsers();
}

const adminUsers = computed(() => {
if (!fetchedUsers.value || !userToDelete.value) return [];
return fetchedUsers.value
.filter(u => ['admin', 'superadmin', 'god', 'user'].includes(u.role))
.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>
Loading