457 lines
17 KiB
Vue
457 lines
17 KiB
Vue
<template>
|
||
<div>
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2>Kullanıcı Yönetimi</h2>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center bg-white">
|
||
<h5 class="mb-0">Kullanıcı Listesi</h5>
|
||
<div class="d-flex align-items-center">
|
||
<div class="form-check form-switch me-3 mb-0">
|
||
<input class="form-check-input" type="checkbox" v-model="showDeleted" id="userDeleted">
|
||
<label class="form-check-label" for="userDeleted">Silinenler</label>
|
||
</div>
|
||
<button class="btn btn-success btn-sm" @click="openModal(null)"><i class="fas fa-plus me-2"></i> Yeni
|
||
Kullanıcı</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover align-middle">
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Avatar</th>
|
||
<th>Kullanıcı Adı</th>
|
||
<th>Email</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="user in users" :key="user.ID">
|
||
<td>{{ user.ID }}</td>
|
||
<td>
|
||
<img :src="getAvatarUrl(user)" alt="Avatar" class="rounded-circle border" width="40" height="40"
|
||
style="object-fit: cover;"
|
||
onerror="this.src='https://ui-avatars.com/api/?name=User&background=random'">
|
||
</td>
|
||
<td>{{ user.username }}</td>
|
||
<td>
|
||
{{ user.email }}
|
||
<span v-if="user.email_verified" class="badge bg-success ms-1"><i class="fas fa-check"></i></span>
|
||
<span v-else class="badge bg-warning ms-1" title="Email Doğrulanmadı"><i
|
||
class="fas fa-exclamation"></i></span>
|
||
</td>
|
||
<td>
|
||
<span v-if="user.profiles && user.profiles.length > 0">
|
||
{{ user.profiles[0]?.first_name }} {{ user.profiles[0]?.last_name }}
|
||
</span>
|
||
<span v-else>-</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge" :class="user.is_admin ? 'bg-primary' : 'bg-secondary'">
|
||
{{ user.is_admin ? 'Admin' : 'User' }}
|
||
</span>
|
||
</td>
|
||
<td class="text-end">
|
||
<div class="btn-group btn-group-sm" role="group">
|
||
<template v-if="!user.DeletedAt">
|
||
<button class="btn btn-outline-primary" @click="openModal(user)" title="Düzenle">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Sil">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<button class="btn btn-outline-success" @click="restoreUser(user.ID)" title="Geri Yükle">
|
||
<i class="fas fa-trash-restore"></i>
|
||
</button>
|
||
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Kalıcı Sil">
|
||
<i class="fas fa-ban"></i>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="users.length === 0">
|
||
<td colspan="6" class="text-center py-4">Kayıt bulunamadı.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- MODAL -->
|
||
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">{{ isEditing ? 'Kullanıcı Düzenle' : 'Yeni Kullanıcı Ekle' }}</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form @submit.prevent="saveUser">
|
||
<div class="mb-3">
|
||
<label class="form-label">Kullanıcı Adı</label>
|
||
<input v-model="formData.username" type="text" class="form-control" required>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Email</label>
|
||
<input v-model="formData.email" type="email" class="form-control" required>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">İsim</label>
|
||
<input v-model="formData.first_name" type="text" class="form-control" required>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">Soyisim</label>
|
||
<input v-model="formData.last_name" type="text" class="form-control" required>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Avatar</label>
|
||
<div class="d-flex align-items-center mb-2" v-if="formData.avatar_url">
|
||
<img :src="config.public.BASE_API_URL + formData.avatar_url" alt="Avatar Preview"
|
||
class="rounded-circle me-3"
|
||
style="width: 50px; height: 50px; object-fit: cover; border: 1px solid #dee2e6;">
|
||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||
@click="formData.avatar_url = ''">Kaldır</button>
|
||
</div>
|
||
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
|
||
<input v-model="formData.avatar_url" type="text" class="form-control mt-2"
|
||
placeholder="veya URL girin (/uploads/...)">
|
||
|
||
<!-- Image Optimization Settings -->
|
||
<div class="mt-3 p-3 border rounded bg-light">
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<small class="fw-bold text-primary"><i class="fas fa-magic me-1"></i> Resim Optimizasyonu</small>
|
||
<span class="badge bg-secondary" style="font-size: 0.7em">İsteğe Bağlı</span>
|
||
</div>
|
||
<div class="row g-2">
|
||
<div class="col-6 col-md-3">
|
||
<label class="form-label small text-muted">Format</label>
|
||
<select v-model="imageSettings.format" class="form-select form-select-sm">
|
||
<option value="avif">AVIF</option>
|
||
<option value="webp">WEBP</option>
|
||
<option value="png">PNG</option>
|
||
<option value="jpg">JPG</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<label class="form-label small text-muted">Kalite (1-100)</label>
|
||
<input v-model="imageSettings.quality" type="number" class="form-control form-control-sm" min="1"
|
||
max="100">
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<label class="form-label small text-muted">Genişlik (px)</label>
|
||
<input v-model="imageSettings.width" type="number" class="form-control form-control-sm">
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<label class="form-label small text-muted">Yükseklik (px)</label>
|
||
<input v-model="imageSettings.height" type="number" class="form-control form-control-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Şifre <span v-if="isEditing" class="text-muted small">(Boş bırakılırsa
|
||
değişmez)</span></label>
|
||
<input v-model="formData.password" type="password" class="form-control" :required="!isEditing"
|
||
minlength="6">
|
||
</div>
|
||
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" v-model="formData.is_admin" id="isAdminCheck">
|
||
<label class="form-check-label" for="isAdminCheck">Admin Yetkisi</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="form-check form-switch">
|
||
<input class="form-check-input" type="checkbox" v-model="formData.email_verified"
|
||
id="emailVerifiedCheck">
|
||
<label class="form-check-label" for="emailVerifiedCheck">Email Onaylı</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-end">
|
||
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">İptal</button>
|
||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||
Kaydet
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { User } from '~~/types/user';
|
||
import Swal from 'sweetalert2';
|
||
|
||
definePageMeta({
|
||
layout: 'admin',
|
||
middleware: 'admin'
|
||
});
|
||
|
||
const config = useRuntimeConfig();
|
||
const { data: authData } = useAuth();
|
||
const loading = ref(false);
|
||
const users = ref<User[]>([]);
|
||
const showDeleted = ref(false);
|
||
const formData = ref<any>({});
|
||
const isEditing = ref(false);
|
||
const avatarFile = ref<File | null>(null);
|
||
|
||
let userModal: any = null;
|
||
|
||
// -- API HANDLERS --
|
||
|
||
const fetchUsers = async () => {
|
||
try {
|
||
const endpoint = showDeleted.value ? '/api/v1/users/list/deleted' : '/api/v1/users/list';
|
||
const res = await $fetch<any>(endpoint, {
|
||
baseURL: config.public.BASE_API_URL,
|
||
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
|
||
});
|
||
users.value = res.users || [];
|
||
} catch (error) {
|
||
console.error('Fetch error:', error);
|
||
users.value = [];
|
||
}
|
||
};
|
||
|
||
watch(showDeleted, () => {
|
||
fetchUsers();
|
||
});
|
||
|
||
const openModal = (user: User | null = null) => {
|
||
if (user) {
|
||
isEditing.value = true;
|
||
// Flatten the structure for the form
|
||
// Check if avatar_url is on user or profile
|
||
const avatarUrl = user.avatar_url || user.profiles?.[0]?.avatar_url || '';
|
||
|
||
formData.value = {
|
||
...user,
|
||
first_name: user.profiles?.[0]?.first_name || '',
|
||
last_name: user.profiles?.[0]?.last_name || '',
|
||
avatar_url: avatarUrl,
|
||
password: '' // Don't prefill password
|
||
};
|
||
} else {
|
||
isEditing.value = false;
|
||
formData.value = { is_admin: false, email_verified: false };
|
||
}
|
||
|
||
const modalEl = document.getElementById('userModal');
|
||
if (modalEl) {
|
||
// Reset image settings
|
||
imageSettings.value = {
|
||
format: 'avif',
|
||
quality: 80,
|
||
width: null,
|
||
height: null
|
||
};
|
||
|
||
// @ts-ignore
|
||
userModal = new bootstrap.Modal(modalEl);
|
||
userModal.show();
|
||
}
|
||
};
|
||
|
||
const getAvatarUrl = (user: User) => {
|
||
// Priority: avatar_url (from User or Profile) -> UI Avatar
|
||
// Check user.avatar_url first (if backend sends it on user object)
|
||
// Then check profiles[0].avatar
|
||
const avatarPath = user.avatar_url || user.profiles?.[0]?.avatar_url;
|
||
|
||
if (avatarPath) {
|
||
// If it's a full URL (e.g. googlelh3...) return as is
|
||
if (avatarPath.startsWith('http')) return avatarPath;
|
||
// If local path, prepend base API URL
|
||
return config.public.BASE_API_URL + avatarPath;
|
||
}
|
||
|
||
// Default: UI Avatars
|
||
const name = (user.profiles?.[0]?.first_name && user.profiles?.[0]?.last_name)
|
||
? `${user.profiles[0].first_name} ${user.profiles[0].last_name}`
|
||
: user.username;
|
||
|
||
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&color=fff`;
|
||
};
|
||
|
||
const handleFileUpload = (event: Event) => {
|
||
const target = event.target as HTMLInputElement;
|
||
if (target.files && target.files[0]) {
|
||
avatarFile.value = target.files[0];
|
||
}
|
||
};
|
||
|
||
const imageSettings = ref({
|
||
format: 'avif',
|
||
quality: 80,
|
||
width: null as number | null,
|
||
height: null as number | null
|
||
});
|
||
|
||
const optimizeImage = async (file: File): Promise<File> => {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('format', imageSettings.value.format);
|
||
fd.append('quality', String(imageSettings.value.quality));
|
||
if (imageSettings.value.width) fd.append('width', String(imageSettings.value.width));
|
||
if (imageSettings.value.height) fd.append('height', String(imageSettings.value.height));
|
||
|
||
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
|
||
method: 'POST',
|
||
body: fd,
|
||
responseType: 'blob'
|
||
});
|
||
|
||
const ext = imageSettings.value.format === 'jpg' ? 'jpeg' : imageSettings.value.format;
|
||
const filename = `avatar-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||
|
||
return new File([optimizedBlob], filename, { type: `image/${ext}` });
|
||
};
|
||
|
||
const saveUser = async () => {
|
||
loading.value = true;
|
||
try {
|
||
if (isEditing.value) {
|
||
// Context: PUT /api/v1/users/:id
|
||
// Using Multipart/Form-Data for strict compliance with backend requirements
|
||
const userId = formData.value.ID;
|
||
const fd = new FormData();
|
||
|
||
// Append Avatar if selected
|
||
if (avatarFile.value) {
|
||
try {
|
||
const optimized = await optimizeImage(avatarFile.value);
|
||
fd.append('avatar', optimized);
|
||
} catch (e) {
|
||
console.error(e);
|
||
fd.append('avatar', avatarFile.value);
|
||
}
|
||
}
|
||
|
||
// Append other fields
|
||
// Explicitly mapping known fields to ensure order and presence matches expectation,
|
||
// or just iterating keys. Iterating keys is safer for "all fields".
|
||
// However, we must ensure boolean -> string conversion.
|
||
|
||
Object.keys(formData.value).forEach(key => {
|
||
const val = formData.value[key];
|
||
// Filter out non-form fields and sensitive/system fields
|
||
if (key !== 'ID' && key !== 'profiles' && key !== 'avatar_url' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
|
||
if (key === 'password' && !val) return; // Skip empty password
|
||
fd.append(key, String(val));
|
||
}
|
||
});
|
||
|
||
await $fetch(`/api/v1/users/${userId}`, {
|
||
method: 'PUT',
|
||
baseURL: config.public.BASE_API_URL,
|
||
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
|
||
body: fd
|
||
});
|
||
|
||
// Reset file
|
||
avatarFile.value = null;
|
||
Swal.fire('Başarılı', 'Kullanıcı güncellendi.', 'success');
|
||
|
||
} else {
|
||
// Context: Create (Register) - POST JSON
|
||
await $fetch('/api/v1/auth/register', {
|
||
method: 'POST',
|
||
baseURL: config.public.BASE_API_URL,
|
||
body: formData.value
|
||
});
|
||
Swal.fire('Başarılı', 'Kullanıcı oluşturuldu.', 'success');
|
||
}
|
||
|
||
if (userModal) userModal.hide();
|
||
fetchUsers();
|
||
} catch (error) {
|
||
console.error(error);
|
||
Swal.fire('Hata', 'İşlem başarısız.', 'error');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const deleteUser = async (id: number) => {
|
||
const isHardDelete = showDeleted.value; // If showing deleted, then next action is hard delete
|
||
|
||
const result = await Swal.fire({
|
||
title: isHardDelete ? 'Kalıcı Silinecek?' : 'Silinecek?',
|
||
text: isHardDelete ? "Bu işlem geri alınamaz!" : "Kullanıcı arşivlenecek.",
|
||
icon: 'warning',
|
||
showCancelButton: true,
|
||
confirmButtonText: isHardDelete ? 'Evet, Kalıcı Sil' : 'Evet, Sil',
|
||
cancelButtonText: 'İptal',
|
||
confirmButtonColor: '#d33'
|
||
});
|
||
|
||
if (!result.isConfirmed) return;
|
||
|
||
try {
|
||
let endpoint = `/api/v1/users/${id}`;
|
||
if (isHardDelete) endpoint += '/hard';
|
||
|
||
await $fetch(endpoint, {
|
||
method: 'DELETE',
|
||
baseURL: config.public.BASE_API_URL,
|
||
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
|
||
});
|
||
|
||
Swal.fire('Silindi!', 'İşlem başarılı.', 'success');
|
||
fetchUsers();
|
||
} catch (error) {
|
||
console.error(error);
|
||
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
|
||
}
|
||
};
|
||
|
||
const restoreUser = async (id: number) => {
|
||
const result = await Swal.fire({
|
||
title: 'Geri Yükle?',
|
||
text: "Kullanıcı aktif edilecek.",
|
||
icon: 'question',
|
||
showCancelButton: true,
|
||
confirmButtonText: 'Evet',
|
||
cancelButtonText: 'İptal'
|
||
});
|
||
|
||
if (!result.isConfirmed) return;
|
||
|
||
try {
|
||
await $fetch(`/api/v1/users/${id}/restore`, {
|
||
method: 'POST',
|
||
baseURL: config.public.BASE_API_URL,
|
||
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
|
||
});
|
||
|
||
Swal.fire('Başarılı', 'Kullanıcı geri yüklendi.', 'success');
|
||
fetchUsers();
|
||
} catch (error) {
|
||
console.error(error);
|
||
Swal.fire('Hata', 'Geri yükleme başarısız.', 'error');
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
fetchUsers();
|
||
});
|
||
</script> |