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:
2026-07-26 01:02:41 +02:00
commit c2b5291a74
174 changed files with 29318 additions and 0 deletions

View 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>