Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and Docker Compose setup for local and production deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
179
resources/js/pages/admin/Analytics.vue
Normal file
179
resources/js/pages/admin/Analytics.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatTile from '../../components/charts/StatTile.vue';
|
||||
import BarList from '../../components/charts/BarList.vue';
|
||||
import DailyLoginsChart from '../../components/charts/DailyLoginsChart.vue';
|
||||
import type { AnalyticsSummary } from '../../types/models';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const loading = ref(true);
|
||||
const days = ref(30);
|
||||
const summary = ref<AnalyticsSummary | null>(null);
|
||||
const showTable = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<AnalyticsSummary>('/web-api/admin/analytics/summary', { params: { days: days.value } });
|
||||
summary.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(days, load);
|
||||
onMounted(load);
|
||||
|
||||
const appItems = computed(() => (summary.value?.logins_by_app ?? []).map((a) => ({ label: a.client_name, value: a.count })));
|
||||
const userItems = computed(() => (summary.value?.top_users ?? []).map((u) => ({ label: u.name, value: u.count })));
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analytics">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('analytics.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('analytics.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
|
||||
<select v-model.number="days" class="pill-select">
|
||||
<option :value="7">{{ t('analytics.last7') }}</option>
|
||||
<option :value="30">{{ t('analytics.last30') }}</option>
|
||||
<option :value="90">{{ t('analytics.last90') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="loading && !summary" class="analytics__loading">{{ t('common.loading') }}</p>
|
||||
|
||||
<template v-else-if="summary">
|
||||
<div class="analytics__kpis" :class="{ 'analytics__section--loading': loading }">
|
||||
<StatTile :label="t('analytics.totalUsers')" :value="summary.kpis.total_users" />
|
||||
<StatTile :label="t('analytics.activeSessions')" :value="summary.kpis.active_sessions" :hint="t('analytics.activeSessionsHint')" />
|
||||
<StatTile :label="t('analytics.loginsToday')" :value="summary.kpis.logins_today" />
|
||||
<StatTile :label="t('analytics.logins7d')" :value="summary.kpis.logins_7d" />
|
||||
</div>
|
||||
|
||||
<div class="analytics__chart" :class="{ 'analytics__section--loading': loading }">
|
||||
<DailyLoginsChart :data="summary.logins_by_day" />
|
||||
</div>
|
||||
|
||||
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
|
||||
<BarList :title="t('analytics.loginsByApp')" :items="appItems" :empty-text="t('analytics.noAppLogins')" />
|
||||
<BarList :title="t('analytics.topUsers')" :items="userItems" :empty-text="t('analytics.noUserActivity')" />
|
||||
</div>
|
||||
|
||||
<div class="card analytics__recent" :class="{ 'analytics__section--loading': loading }">
|
||||
<div class="analytics__recent-header">
|
||||
<h3 class="card__title">{{ t('analytics.recentActivity') }}</h3>
|
||||
<button @click="showTable = !showTable" class="link analytics__toggle">
|
||||
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showTable" class="analytics__table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('analytics.colUser') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colApp') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colType') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colIp') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colWhen') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="(event, i) in summary.recent" :key="i">
|
||||
<td class="table__cell table__cell--primary">{{ event.user_name ?? t('analytics.serviceNoUser') }}</td>
|
||||
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : (event.client_name ?? t('analytics.deletedApp')) }}</td>
|
||||
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : 'OAuth' }}</td>
|
||||
<td class="table__cell">{{ event.ip_address }}</td>
|
||||
<td class="table__cell analytics__when">{{ formatDateTime(event.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="analytics__recent-count">{{ t('analytics.recentCount', { count: summary.recent.length }) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analytics {
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
}
|
||||
|
||||
.analytics__loading {
|
||||
margin-top: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.analytics__kpis {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics__chart {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.analytics__bar-lists {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics__recent {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.analytics__recent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.analytics__toggle {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics__table-wrap {
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.analytics__when {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.analytics__recent-count {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.analytics__section--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.analytics__kpis {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analytics__bar-lists {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
567
resources/js/pages/admin/Users.vue
Normal file
567
resources/js/pages/admin/Users.vue
Normal file
@@ -0,0 +1,567 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import type { AppRoleGroup, OAuthApp, User } from '../../types/models';
|
||||
|
||||
type View = 'active' | 'pending' | 'trashed';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const pendingUsers = ref<User[]>([]);
|
||||
const trashedUsers = ref<User[]>([]);
|
||||
const view = ref<View>('active');
|
||||
const apps = ref<OAuthApp[]>([]);
|
||||
const appRoles = ref<AppRoleGroup[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
const createForm = reactive({ name: '', email: '', password: '', role: 'user' });
|
||||
const editForm = reactive({ name: '', email: '', password: '', role: 'user', is_active: true });
|
||||
const newAppRole = reactive({ client_id: '', role: 'user' });
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (view.value === 'pending') return t('adminUsers.pendingTitle');
|
||||
if (view.value === 'trashed') return t('adminUsers.trashTitle');
|
||||
return t('adminUsers.title');
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const [usersRes, appsRes, pendingRes] = await Promise.all([
|
||||
axios.get<User[]>('/web-api/admin/users'),
|
||||
axios.get<OAuthApp[]>('/web-api/admin/apps'),
|
||||
axios.get<User[]>('/web-api/admin/users/pending'),
|
||||
]);
|
||||
users.value = usersRes.data;
|
||||
apps.value = appsRes.data;
|
||||
pendingUsers.value = pendingRes.data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadPending(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<User[]>('/web-api/admin/users/pending');
|
||||
pendingUsers.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadTrashed(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<User[]>('/web-api/admin/users/trashed');
|
||||
trashedUsers.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function setView(next: View): Promise<void> {
|
||||
view.value = next;
|
||||
editingId.value = null;
|
||||
|
||||
if (next === 'pending') await loadPending();
|
||||
if (next === 'trashed') await loadTrashed();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function approveUser(user: User): Promise<void> {
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/approve`);
|
||||
await loadPending();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.approveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectUser(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.rejectConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}`);
|
||||
await loadPending();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.rejectFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreUser(user: User): Promise<void> {
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/restore`);
|
||||
await loadTrashed();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.restoreFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function forceDeleteUser(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.forceDeleteConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/force`);
|
||||
await loadTrashed();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.forceDeleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppRoles(user: User): Promise<void> {
|
||||
const { data } = await axios.get<AppRoleGroup[]>(`/web-api/admin/users/${user.id}/app-roles`);
|
||||
appRoles.value = data;
|
||||
}
|
||||
|
||||
async function addAppRole(user: User): Promise<void> {
|
||||
if (!newAppRole.client_id) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/app-roles`, newAppRole);
|
||||
newAppRole.client_id = '';
|
||||
newAppRole.role = 'user';
|
||||
await loadAppRoles(user);
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.assignFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAppRole(user: User, clientId: string, role: string): Promise<void> {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/app-roles/${clientId}/${role}`);
|
||||
await loadAppRoles(user);
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
await axios.post('/web-api/admin/users', createForm);
|
||||
createForm.name = '';
|
||||
createForm.email = '';
|
||||
createForm.password = '';
|
||||
createForm.role = 'user';
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(user: User): void {
|
||||
editingId.value = user.id;
|
||||
editForm.name = user.name;
|
||||
editForm.email = user.email;
|
||||
editForm.password = '';
|
||||
editForm.role = user.roles[0] ?? 'user';
|
||||
editForm.is_active = user.is_active;
|
||||
errors.value = {};
|
||||
appRoles.value = [];
|
||||
loadAppRoles(user);
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingId.value = null;
|
||||
errors.value = {};
|
||||
}
|
||||
|
||||
async function saveEdit(user: User): Promise<void> {
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = { ...editForm };
|
||||
if (!payload.password) delete payload.password;
|
||||
|
||||
await axios.put(`/web-api/admin/users/${user.id}`, payload);
|
||||
editingId.value = null;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
if (e.response?.status === 422 && e.response.data.message) {
|
||||
alert(e.response.data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.deleteConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
<p class="page-subtitle">{{ t('adminUsers.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="users__tabs">
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'active' }" @click="setView('active')">
|
||||
{{ t('adminUsers.viewActive') }}
|
||||
</button>
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'pending' }" @click="setView('pending')">
|
||||
{{ t('adminUsers.viewPending') }}
|
||||
<span v-if="pendingUsers.length" class="users__tab-count">{{ pendingUsers.length }}</span>
|
||||
</button>
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'trashed' }" @click="setView('trashed')">
|
||||
{{ t('adminUsers.viewTrash') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="view === 'active'">
|
||||
<form class="card users__form" @submit.prevent="create">
|
||||
<h2 class="card__title users__form-title">{{ t('adminUsers.newUser') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.name') }}</label>
|
||||
<input v-model="createForm.name" required class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.email') }}</label>
|
||||
<input v-model="createForm.email" type="email" required class="field__input" />
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.password') }}</label>
|
||||
<input v-model="createForm.password" type="password" required class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="createForm.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="users__form-title">
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('adminUsers.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colRole') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colStatus') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<template v-for="user in users" :key="user.id">
|
||||
<tr v-if="editingId !== user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell">{{ user.roles.join(', ') || t('adminUsers.noRole') }}</td>
|
||||
<td class="table__cell">
|
||||
<span class="badge" :class="user.is_active ? 'badge--active' : 'badge--inactive'">
|
||||
<span class="badge__dot"></span>
|
||||
{{ user.is_active ? t('adminUsers.active') : t('adminUsers.inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="startEdit(user)" class="link">{{ t('adminUsers.edit') }}</button>
|
||||
<button v-if="user.id !== auth.user?.id" @click="destroy(user)" class="link link--danger users__row-action">{{ t('adminUsers.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else class="table__row--edit">
|
||||
<td class="table__cell" colspan="5">
|
||||
<div class="users__edit-grid">
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.name') }}</label>
|
||||
<input v-model="editForm.name" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.email') }}</label>
|
||||
<input v-model="editForm.email" type="email" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.newPassword') }}</label>
|
||||
<input v-model="editForm.password" type="password" :placeholder="t('adminUsers.noChange')" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="editForm.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field__checkbox-row">
|
||||
<input type="checkbox" v-model="editForm.is_active" class="field__checkbox" />
|
||||
{{ t('adminUsers.active') }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
<div class="users__edit-actions">
|
||||
<button @click="saveEdit(user)" class="btn btn--accent">{{ t('adminUsers.save') }}</button>
|
||||
<button @click="cancelEdit" class="btn btn--secondary">{{ t('adminUsers.cancel') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="users__app-roles">
|
||||
<h3 class="users__app-roles-title">{{ t('adminUsers.appRolesTitle') }}</h3>
|
||||
<p class="users__app-roles-desc">
|
||||
{{ t('adminUsers.appRolesDesc', { name: user.name }) }}
|
||||
</p>
|
||||
|
||||
<div v-if="!appRoles.length" class="users__app-roles-empty">{{ t('adminUsers.noAppRoles') }}</div>
|
||||
<ul v-else class="users__chip-list">
|
||||
<li v-for="group in appRoles" :key="group.client_id" class="users__chip-group">
|
||||
<span v-for="role in group.roles" :key="role" class="chip">
|
||||
{{ group.client_name }} — {{ role }}
|
||||
<button @click="removeAppRole(user, group.client_id, role)" class="chip__remove">✕</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="users__assign-row">
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">App</label>
|
||||
<select v-model="newAppRole.client_id" class="field__select">
|
||||
<option value="">{{ t('adminUsers.chooseApp') }}</option>
|
||||
<option v-for="app in apps" :key="app.id" :value="app.id">{{ app.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="newAppRole.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="addAppRole(user)" class="btn btn--dark">
|
||||
{{ t('adminUsers.assign') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view === 'pending'">
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!pendingUsers.length" class="table__empty">{{ t('adminUsers.pendingEmpty') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="user in pendingUsers" :key="user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell users__deleted-at">{{ t('adminUsers.registeredAt', { date: formatDate(user.created_at) }) }}</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="approveUser(user)" class="link">{{ t('adminUsers.approve') }}</button>
|
||||
<button @click="rejectUser(user)" class="link link--danger users__row-action">{{ t('adminUsers.reject') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!trashedUsers.length" class="table__empty">{{ t('adminUsers.trashEmpty') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="user in trashedUsers" :key="user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell users__deleted-at">{{ t('adminUsers.deletedAt', { date: formatDate(user.deleted_at!) }) }}</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="restoreUser(user)" class="link">{{ t('adminUsers.restore') }}</button>
|
||||
<button @click="forceDeleteUser(user)" class="link link--danger users__row-action">{{ t('adminUsers.forceDelete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.users__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.users__tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users__tab--active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.users__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.users__form {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users__form-title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.users__table-card {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.users__row-action {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.users__edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.users__edit-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.users__app-roles {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.users__app-roles-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.users__app-roles-desc {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users__app-roles-empty {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users__chip-list {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users__chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.users__assign-row {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users__deleted-at {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.users__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.users__edit-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user