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:
70
resources/js/pages/Authorize.vue
Normal file
70
resources/js/pages/Authorize.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const data = window.__OAUTH_AUTHORIZE__ ?? null;
|
||||
const approveForm = ref<HTMLFormElement | null>(null);
|
||||
const denyForm = ref<HTMLFormElement | null>(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card auth-card--wide">
|
||||
<template v-if="data">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('authorize.title') }}</h1>
|
||||
<p class="auth-card__subtitle">
|
||||
<i18n-t keypath="authorize.wantsAccess" tag="span">
|
||||
<template #client><strong>{{ data.client.name }}</strong></template>
|
||||
<template #email>{{ data.user.email }}</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
|
||||
<ul class="auth-card__scopes">
|
||||
<li v-for="scope in data.scopes" :key="scope.id" class="auth-card__scope">
|
||||
<span class="auth-card__scope-check">✓</span>
|
||||
{{ scope.description }}
|
||||
</li>
|
||||
<li v-if="!data.scopes.length" class="auth-card__scope">
|
||||
{{ t('authorize.noScopes') }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="auth-card__actions">
|
||||
<form ref="approveForm" method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="_token" :value="data.csrfToken" />
|
||||
<input type="hidden" name="auth_token" :value="data.authToken" />
|
||||
<button type="submit" class="btn btn--primary btn--pill btn--full">
|
||||
{{ t('authorize.approve') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form ref="denyForm" method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="_token" :value="data.csrfToken" />
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<input type="hidden" name="auth_token" :value="data.authToken" />
|
||||
<button type="submit" class="btn btn--secondary btn--pill btn--full">
|
||||
{{ t('authorize.cancel') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="auth-card__title">{{ t('authorize.notFoundTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">
|
||||
<i18n-t keypath="authorize.notFoundBody" tag="span">
|
||||
<template #path><code>/oauth/authorize</code></template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p class="auth-card__footer">
|
||||
<router-link to="/" class="link">{{ t('authorize.goDashboard') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
67
resources/js/pages/AuthorizedApps.vue
Normal file
67
resources/js/pages/AuthorizedApps.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { AuthorizedAppToken } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tokens = ref<AuthorizedAppToken[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<AuthorizedAppToken[]>('/web-api/authorized-apps');
|
||||
tokens.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function revoke(token: AuthorizedAppToken): Promise<void> {
|
||||
if (!confirm(t('authorizedApps.revokeConfirm', { name: token.client.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/authorized-apps/${token.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('authorizedApps.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('authorizedApps.subtitle') }}</p>
|
||||
|
||||
<div class="authorized-apps__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!tokens.length" class="page-subtitle">{{ t('authorizedApps.empty') }}</p>
|
||||
|
||||
<div v-for="token in tokens" :key="token.id" class="card authorized-apps__item">
|
||||
<div>
|
||||
<p class="card__title">{{ token.client.name }}</p>
|
||||
<p class="authorized-apps__item-meta">{{ t('authorizedApps.scopesLabel') }}: {{ token.scopes?.join(', ') || t('common.none') }}</p>
|
||||
</div>
|
||||
<button @click="revoke(token)" class="link link--danger">{{ t('authorizedApps.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.authorized-apps__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.authorized-apps__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.authorized-apps__item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
184
resources/js/pages/Clients.vue
Normal file
184
resources/js/pages/Clients.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { OAuthClient } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const clients = ref<OAuthClient[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const newSecret = ref<{ name: string; secret: string; id: string } | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uris: '',
|
||||
confidential: true,
|
||||
});
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<OAuthClient[]>('/web-api/clients');
|
||||
clients.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function createClient(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
newSecret.value = null;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
grant_type: form.grant_type,
|
||||
confidential: form.grant_type === 'client_credentials' ? true : form.confidential,
|
||||
redirect_uris: form.redirect_uris
|
||||
? form.redirect_uris.split(',').map((u) => u.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
|
||||
const { data } = await axios.post<{ client: OAuthClient; plain_secret: string | null }>('/web-api/clients', payload);
|
||||
|
||||
if (data.plain_secret) {
|
||||
newSecret.value = { name: data.client.name, secret: data.plain_secret, id: data.client.id };
|
||||
}
|
||||
|
||||
form.name = '';
|
||||
form.redirect_uris = '';
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(client: OAuthClient): Promise<void> {
|
||||
if (!confirm(t('clients.revokeConfirm', { name: client.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/clients/${client.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
function grantLabel(grantTypes: string[]): string {
|
||||
if (grantTypes.includes('authorization_code')) return t('clients.grantAuthCode');
|
||||
if (grantTypes.includes('client_credentials')) return t('clients.grantClientCredentials');
|
||||
if (grantTypes.includes('password')) return t('clients.grantPassword');
|
||||
return grantTypes.join(', ');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('clients.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('clients.subtitle') }}</p>
|
||||
|
||||
<div v-if="newSecret" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('clients.secretWarning') }}</p>
|
||||
<p class="banner__line"><strong>{{ t('clients.clientIdLabel') }}</strong> <code class="banner__code">{{ newSecret.id }}</code></p>
|
||||
<p class="banner__line"><strong>{{ t('clients.clientSecretLabel') }}</strong> <code class="banner__code">{{ newSecret.secret }}</code></p>
|
||||
<button class="banner__close" @click="newSecret = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<form class="card clients__form" @submit.prevent="createClient">
|
||||
<h2 class="card__title clients__form-title">{{ t('clients.newClient') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('clients.name') }}</label>
|
||||
<input v-model="form.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('clients.grantType') }}</label>
|
||||
<select v-model="form.grant_type" class="field__select">
|
||||
<option value="authorization_code">{{ t('clients.grantAuthCode') }}</option>
|
||||
<option value="client_credentials">{{ t('clients.grantClientCredentials') }}</option>
|
||||
<option value="password">{{ t('clients.grantPassword') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.grant_type === 'authorization_code'" class="field clients__form-wide">
|
||||
<label class="field__label">{{ t('clients.redirectUris') }}</label>
|
||||
<input v-model="form.redirect_uris" placeholder="https://mi-app.com/callback" class="field__input" />
|
||||
<p v-if="errors.redirect_uris" class="field__error">{{ errors.redirect_uris[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.grant_type === 'authorization_code'" class="field__checkbox-row">
|
||||
<input id="confidential" type="checkbox" v-model="form.confidential" class="field__checkbox" />
|
||||
<label for="confidential">{{ t('clients.confidentialLabel') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="clients__form-wide">
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('clients.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="clients__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!clients.length" class="page-subtitle">{{ t('clients.empty') }}</p>
|
||||
|
||||
<div v-for="client in clients" :key="client.id" class="card clients__item">
|
||||
<div>
|
||||
<p class="card__title">{{ client.name }}</p>
|
||||
<p class="card__subtitle">{{ grantLabel(client.grant_types) }}</p>
|
||||
<p class="clients__item-meta">{{ t('clients.clientIdShort') }}: {{ client.id }}</p>
|
||||
<p v-if="client.redirect_uris?.length" class="clients__item-meta">
|
||||
{{ t('clients.redirectLabel') }}: {{ client.redirect_uris.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clients__form {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clients__form-title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.clients__form-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.clients__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clients__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clients__item-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.clients__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
resources/js/pages/Dashboard.vue
Normal file
72
resources/js/pages/Dashboard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('dashboard.greeting', { name: auth.user?.name }) }}</h1>
|
||||
<p class="page-subtitle">{{ t('dashboard.subtitle') }}</p>
|
||||
|
||||
<div class="dashboard__grid">
|
||||
<router-link to="/clients" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.clientsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.clientsDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/tokens" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.tokensTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.tokensDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/authorized-apps" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.appsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.appsDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="auth.isAdmin" to="/admin/users" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.usersTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.usersDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="auth.isAdmin" to="/admin/analytics" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.analyticsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.analyticsDesc') }}</p>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard__api-card">
|
||||
<h2 class="card__title">{{ t('dashboard.apiTitle') }}</h2>
|
||||
<p class="card__subtitle">
|
||||
<i18n-t keypath="dashboard.apiDesc" tag="span">
|
||||
<template #resource><code>/api/users</code></template>
|
||||
<template #read><code>users:read</code></template>
|
||||
<template #write><code>users:write</code></template>
|
||||
<template #link><a href="/api/documentation" class="link">/api/documentation</a></template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard__grid {
|
||||
margin-top: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard__api-card {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dashboard__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
resources/js/pages/Login.vue
Normal file
69
resources/js/pages/Login.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ email: '', password: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const intended = await auth.login(form);
|
||||
|
||||
if (intended) {
|
||||
window.location.href = intended;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push((route.query.redirect as string) || { name: 'dashboard' });
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { email: [t('login.invalidCredentials')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('login.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('login.subtitle') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.email') }}</label>
|
||||
<input v-model="form.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('login.password') }}</label>
|
||||
<input v-model="form.password" type="password" required class="field__input" />
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('login.submitting') : t('login.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
{{ t('login.noAccount') }}
|
||||
<router-link to="/register" class="link">{{ t('login.signUp') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
103
resources/js/pages/Register.vue
Normal file
103
resources/js/pages/Register.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ name: '', email: '', password: '', password_confirmation: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
const registered = ref(false);
|
||||
const isFirstUser = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isFirstUser.value = await auth.setupIsEmpty();
|
||||
} catch {
|
||||
isFirstUser.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const result = await auth.register(form);
|
||||
|
||||
if (result.bootstrap) {
|
||||
router.push({ name: 'dashboard' });
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<template v-if="registered">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('register.pendingTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('register.pendingBody') }}</p>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<router-link to="/login" class="link">{{ t('register.backToLogin') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('register.title') }}</h1>
|
||||
|
||||
<p v-if="isFirstUser" class="auth-card__banner">{{ t('register.firstUserBanner') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('register.name') }}</label>
|
||||
<input v-model="form.name" type="text" 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('register.email') }}</label>
|
||||
<input v-model="form.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('register.password') }}</label>
|
||||
<input v-model="form.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('register.passwordConfirm') }}</label>
|
||||
<input v-model="form.password_confirmation" type="password" required class="field__input" />
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('register.submitting') : (isFirstUser ? t('register.submitFirstUser') : t('register.submit')) }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
{{ t('register.haveAccount') }}
|
||||
<router-link to="/login" class="link">{{ t('register.signIn') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
145
resources/js/pages/Tokens.vue
Normal file
145
resources/js/pages/Tokens.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PersonalAccessToken, Scope } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tokens = ref<PersonalAccessToken[]>([]);
|
||||
const scopes = ref<Scope[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const newToken = ref<string | null>(null);
|
||||
|
||||
const form = reactive<{ name: string; scopes: string[] }>({ name: '', scopes: [] });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const [tokensRes, scopesRes] = await Promise.all([
|
||||
axios.get<PersonalAccessToken[]>('/web-api/personal-access-tokens'),
|
||||
axios.get<Scope[]>('/web-api/scopes'),
|
||||
]);
|
||||
tokens.value = tokensRes.data;
|
||||
scopes.value = scopesRes.data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
newToken.value = null;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ access_token: string }>('/web-api/personal-access-tokens', form);
|
||||
newToken.value = data.access_token;
|
||||
form.name = '';
|
||||
form.scopes = [];
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token: PersonalAccessToken): Promise<void> {
|
||||
if (!confirm(t('tokens.revokeConfirm', { name: token.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/personal-access-tokens/${token.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('tokens.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('tokens.subtitle') }}</p>
|
||||
|
||||
<div v-if="newToken" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('tokens.tokenWarning') }}</p>
|
||||
<p class="banner__line tokens__token"><code class="banner__code">{{ newToken }}</code></p>
|
||||
<button class="banner__close" @click="newToken = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<form class="card tokens__form" @submit.prevent="create">
|
||||
<h2 class="card__title">{{ t('tokens.newToken') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('tokens.name') }}</label>
|
||||
<input v-model="form.name" required :placeholder="t('tokens.namePlaceholder')" class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('tokens.scopes') }}</label>
|
||||
<div class="tokens__scopes">
|
||||
<label v-for="scope in scopes" :key="scope.id" class="field__checkbox-row">
|
||||
<input type="checkbox" :value="scope.id" v-model="form.scopes" class="field__checkbox" />
|
||||
{{ scope.id }} — {{ scope.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('tokens.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="tokens__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!tokens.length" class="page-subtitle">{{ t('tokens.empty') }}</p>
|
||||
|
||||
<div v-for="token in tokens" :key="token.id" class="card tokens__item">
|
||||
<div>
|
||||
<p class="card__title">{{ token.name }}</p>
|
||||
<p class="tokens__item-meta">{{ t('tokens.scopesLabel') }}: {{ token.scopes?.join(', ') || t('common.none') }}</p>
|
||||
</div>
|
||||
<button @click="revoke(token)" class="link link--danger">{{ t('tokens.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tokens__form {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tokens__scopes {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tokens__token {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tokens__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tokens__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tokens__item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
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