Compare commits

...

2 Commits

Author SHA1 Message Date
lukidev 162c751cd7
feat: Cria users/tenants|roles
feat: Cria users/tenants|roles
2025-12-19 15:21:23 -03:00
lukibeg 6ca995b0b4 feat: Cria users/tenants|roles 2025-12-19 15:20:27 -03:00
13 changed files with 692 additions and 301 deletions

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Inertia\Inertia;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TenantController extends Controller
{
public function create()
{
// Renderiza a view Vue (vamos criar no passo 4)
return Inertia::render('Admin/Tenants/Create');
}
/**
* Processa o salvamento
*/
public function store(Request $request)
{
if (Auth::user()->role !== 'admin') {
abort(401, 'Não autorizado a realizar tal ação.');
}
$request->validate([
'name' => 'required|string|max:255|unique:tenants,name',
]);
do {
$apiKey = 'sk_' . Str::random(60);
} while (Tenant::where('api_key', $apiKey)->exists());
Tenant::create([
'name' => $request->name,
'api_key' => $apiKey,
]);
// MUDANÇA AQUI: back() em vez de route('dashboard')
// O preserveScroll no Vue garante que a tela não pule.
return back()->with('message', 'Tenant criado com sucesso! Chave gerada.');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class UserController extends Controller
{
public function store(Request $request)
{
if (Auth::user()->role !== 'admin') {
abort(401, 'Não autorizado a realizar tal ação.');
}
// 1. Validação
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'role' => 'required|in:admin,supervisor', // Limitamos as roles
// O tenant é obrigatório para supervisors, mas opcional para admins (depende da sua regra)
// Aqui vou assumir que todo usuário pertence a um tenant por segurança
'tenant_id' => 'required|exists:tenants,id',
]);
// 2. Criação
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role' => $request->role,
'tenant_id' => $request->tenant_id,
]);
// 3. Retorno (Mantém na mesma página)
return back()->with('message', 'Usuário criado com sucesso!');
}
}

View File

@ -10,6 +10,10 @@ class QueueController extends Controller
{
public function setQueueName(Request $request)
{
if (!Auth::user()->role !== "supervisor") {
abort(403, 'Apenas administradores podem gerenciar setores.');
}
$validated = $request->validate([
'queue_number' => 'required|numeric',
'friendly_name' => 'required|string|max:255'
@ -30,6 +34,10 @@ public function setQueueName(Request $request)
public function updateSectors(Request $request)
{
if (!Auth::user()->role !== "admin") {
abort(403, 'Apenas administradores podem gerenciar setores.');
}
$data = $request->validate([
'queues' => 'required|array',
'queues.*.id' => 'required|exists:queues,id', // Garante que a fila existe

View File

@ -4,6 +4,7 @@
use Illuminate\Http\Request;
use Inertia\Middleware;
use App\Models\Tenant;
class HandleInertiaRequests extends Middleware
{
@ -34,6 +35,13 @@ public function share(Request $request): array
'auth' => [
'user' => $request->user(),
],
'tenants' => function () use ($request) {
if ($request->user() && $request->user()->role === 'admin') {
return Tenant::select('id', 'name')->orderBy('name')->get();
}
return [];
},
];
}
}

View File

@ -8,7 +8,7 @@
class Tenant extends Model
{
use HasFactory, BelongsToTenant;
use HasFactory;
protected $fillable = ['name', 'api_key'];

34
composer.lock generated
View File

@ -1231,16 +1231,16 @@
},
{
"name": "inertiajs/inertia-laravel",
"version": "v2.0.14",
"version": "v2.0.16",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f"
"reference": "c060177e1b5120e70d1c26fe7c1007a8c8238f0d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f",
"reference": "c4a7bbefbfb9995ce2189f1665ba73276cb3cb6f",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c060177e1b5120e70d1c26fe7c1007a8c8238f0d",
"reference": "c060177e1b5120e70d1c26fe7c1007a8c8238f0d",
"shasum": ""
},
"require": {
@ -1295,22 +1295,22 @@
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.14"
"source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.16"
},
"time": "2025-12-10T13:29:20+00:00"
"time": "2025-12-17T21:57:59+00:00"
},
{
"name": "laravel/framework",
"version": "v12.42.0",
"version": "v12.43.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "509b33095564c5165366d81bbaa0afaac28abe75"
"reference": "195b893593a9298edee177c0844132ebaa02102f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75",
"reference": "509b33095564c5165366d81bbaa0afaac28abe75",
"url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f",
"reference": "195b893593a9298edee177c0844132ebaa02102f",
"shasum": ""
},
"require": {
@ -1519,7 +1519,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-12-09T15:51:23+00:00"
"time": "2025-12-16T18:53:08+00:00"
},
{
"name": "laravel/prompts",
@ -3503,16 +3503,16 @@
},
{
"name": "psy/psysh",
"version": "v0.12.16",
"version": "v0.12.18",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67"
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
"reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196",
"shasum": ""
},
"require": {
@ -3576,9 +3576,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.16"
"source": "https://github.com/bobthecow/psysh/tree/v0.12.18"
},
"time": "2025-12-07T03:39:01+00:00"
"time": "2025-12-17T14:35:46+00:00"
},
{
"name": "pusher/pusher-php-server",

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Adicionamos a coluna 'role' com valor padrão 'supervisor'
// Assim, qualquer novo usuário criado será supervisor, a menos que digamos o contrário.
$table->string('role')->default('supervisor')->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View File

@ -22,6 +22,7 @@ public function run(): void
'name' => 'Admin OmniBoard',
'email' => 'admin@omniboard.com',
'password' => bcrypt('password'), // A senha será 'password'
'role' => 'admin',
]);
}
}

564
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
const showModal = ref(false);
const form = useForm({
name: '',
});
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
form.reset();
form.clearErrors();
};
const submit = () => {
form.post(route('tenants.store'), {
preserveScroll: true,
onSuccess: () => {
closeModal();
// Opcional: Se quiser resetar apenas se der sucesso
form.reset();
},
onError: () => {
// Se der erro (ex: nome duplicado), o modal continua aberto
// e o InputError mostra a mensagem.
document.getElementById('tenant_name').focus();
}
});
};
defineExpose({ openModal });
</script>
<template>
<div>
<Modal :show="showModal" @close="closeModal">
<div class="p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Cadastrar Novo Tenant
</h2>
<p class="text-sm text-gray-600 mb-6">
Informe o nome da empresa. A <strong>API Key</strong> será gerada automaticamente.
</p>
<form @submit.prevent="submit">
<div class="mb-6">
<InputLabel for="tenant_name" value="Nome da Empresa" />
<TextInput id="tenant_name" ref="nameInput" v-model="form.name" type="text"
class="mt-1 block w-full" placeholder="Ex: Call Center Filial Sul" required
@keyup.enter="submit" />
<InputError :message="form.errors.name" class="mt-2" />
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="closeModal">
Cancelar
</SecondaryButton>
<PrimaryButton class="bg-indigo-600 hover:bg-indigo-700"
:class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Gerar Tenant
</PrimaryButton>
</div>
</form>
</div>
</Modal>
</div>
</template>

View File

@ -0,0 +1,118 @@
<script setup>
import { ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
import InputError from '@/Components/InputError.vue';
const showModal = ref(false);
const page = usePage();
// Assumindo que você passará a lista de tenants globalmente ou na página
// Se não tiver tenants na props, retorna array vazio para não quebrar
const tenants = page.props.tenants || [];
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
role: 'supervisor', // Padrão
tenant_id: '',
});
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
form.reset();
form.clearErrors();
};
const submit = () => {
form.post(route('admin.users.store'), {
preserveScroll: true,
onSuccess: () => closeModal(),
});
};
// Expõe a função para o Layout Pai chamar
defineExpose({ openModal });
</script>
<template>
<Modal :show="showModal" @close="closeModal">
<div class="p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
Cadastrar Novo Usuário
</h2>
<form @submit.prevent="submit">
<div class="mb-4">
<InputLabel for="name" value="Nome Completo" />
<TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" required />
<InputError :message="form.errors.name" class="mt-2" />
</div>
<div class="mb-4">
<InputLabel for="email" value="E-mail" />
<TextInput id="email" v-model="form.email" type="email" class="mt-1 block w-full" required />
<InputError :message="form.errors.email" class="mt-2" />
</div>
<div class="mb-4">
<InputLabel for="tenant_id" value="Vincular ao Cliente (Tenant)" />
<select id="tenant_id" v-model="form.tenant_id"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
required>
<option value="" disabled>Selecione uma empresa...</option>
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
{{ tenant.name }}
</option>
</select>
<InputError :message="form.errors.tenant_id" class="mt-2" />
<p v-if="tenants.length === 0" class="text-xs text-red-500 mt-1">
Nenhum tenant encontrado. Crie um tenant primeiro.
</p>
</div>
<div class="mb-4">
<InputLabel for="role" value="Função / Permissão" />
<select id="role" v-model="form.role"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="supervisor">Supervisor</option>
<option value="admin">Administrador</option>
</select>
<InputError :message="form.errors.role" class="mt-2" />
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<InputLabel for="password" value="Senha" />
<TextInput id="password" v-model="form.password" type="password" class="mt-1 block w-full"
required />
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div>
<InputLabel for="password_confirmation" value="Confirmar Senha" />
<TextInput id="password_confirmation" v-model="form.password_confirmation" type="password"
class="mt-1 block w-full" required />
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="closeModal"> Cancelar </SecondaryButton>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Criar Usuário
</PrimaryButton>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -6,9 +6,22 @@ import DropdownLink from '@/Components/DropdownLink.vue';
import { Link } from '@inertiajs/vue3';
import SetQueueNameForm from '@/Components/SetQueueNameForm.vue';
import SetQueueSectorForm from '@/Components/SetQueueSectorForm.vue';
import CreateTenantModal from '@/Components/CreateTenantModal.vue';
import CreateUserModal from '@/Components/CreateUserModal.vue';
const showingSidebar = ref(false);
const tenantModalRef = ref(null);
const userModalRef = ref(null);
const openTenant = () => {
tenantModalRef.value?.openModal();
};
const openUser = () => {
userModalRef.value?.openModal();
};
const menuItems = [
{ name: 'Dashboard', route: 'dashboard', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' },
{ name: 'Monitoramento', route: '#', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
@ -37,6 +50,7 @@ const menuItems = [
</div>
<nav class="mt-6 px-3 space-y-1">
<Link v-for="item in menuItems" :key="item.name" :href="item.route === '#' ? '#' : route(item.route)"
class="group flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200"
:class="item.active || route().current(item.route)
@ -50,8 +64,40 @@ const menuItems = [
</svg>
{{ item.name }}
</Link>
<div v-if="$page.props.auth.user.role === 'admin'" class="pt-4 mt-4 border-t border-gray-100">
<p class="px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
Administração
</p>
<button @click="openTenant"
class="w-full group flex items-center px-4 py-3 text-sm font-medium rounded-lg text-gray-600 hover:bg-gray-50 hover:text-ingline-600 transition-all duration-200">
<svg class="mr-3 flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-ingline-500" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
Novo Cliente
</button>
<button @click="openUser"
class="w-full group flex items-center px-4 py-3 text-sm font-medium rounded-lg text-gray-600 hover:bg-gray-50 hover:text-ingline-600 transition-all duration-200">
<svg class="mr-3 flex-shrink-0 h-5 w-5 text-gray-400 group-hover:text-ingline-500" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Novo Usuário
</button>
</div>
</nav>
<CreateTenantModal ref="tenantModalRef" />
<CreateUserModal ref="userModalRef" />
<div class="absolute bottom-0 w-full p-4 bg-white border-t border-gray-100">
<div class="flex items-center space-x-3">
<div
@ -113,10 +159,11 @@ const menuItems = [
</button>
</template>
<template #content>
<DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
<SetQueueNameForm />
<SetQueueSectorForm />
<div class="border-t border-gray-100 my-1"></div>
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>

View File

@ -6,6 +6,9 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Http\Controllers\QueueController;
use App\Http\Controllers\Admin\TenantController;
use App\Http\Controllers\Admin\UserController;
Route::get('/', function () {
return Inertia::render('Welcome', [
@ -20,6 +23,8 @@
Route::put('/queues/sectors', [QueueController::class, 'updateSectors'])
->name('queues.update-sectors')
->middleware('auth');
Route::post('Admin/tenants', [TenantController::class, 'store'])->name('tenants.store')->middleware('auth');
Route::post('Admin/users', [UserController::class, 'store'])->name('users.store')->middleware('auth');
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth'])->name('dashboard');