Preserva base local aceite de identidade e acessos
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,21 @@
|
||||
APP_NAME="SGF Tempo"
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
APP_LOCALE=pt
|
||||
APP_FALLBACK_LOCALE=pt
|
||||
LOG_CHANNEL=single
|
||||
LOG_LEVEL=warning
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=55432
|
||||
DB_URL=
|
||||
SESSION_DRIVER=file
|
||||
CACHE_STORE=file
|
||||
QUEUE_CONNECTION=sync
|
||||
MAIL_MAILER=log
|
||||
BROADCAST_CONNECTION=log
|
||||
APP_KEY=
|
||||
APP_ENV=local
|
||||
DB_DATABASE=sgf_tempo_dev
|
||||
DB_USERNAME=sgf_dev_app
|
||||
DB_PASSWORD=
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
@@ -0,0 +1,32 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.testing
|
||||
.env.browser-testing
|
||||
.env.*.local
|
||||
/storage/app/private/*
|
||||
/storage/framework/browser-proof/
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,18 @@
|
||||
# SGF Tempo — instruções locais
|
||||
|
||||
- Projecto exclusivo em `C:\laragon\www\sgf-tempo`; não alterar InvesteMoz, os seus dados, configurações, ferramentas ou serviços.
|
||||
- Base de requisitos: SGF_TEMPO_Roteiro_v0_9.docx. Distinguir decisões acordadas de propostas técnicas e autorização de execução.
|
||||
- Usar `scripts/sgf.ps1` para ferramentas e arranque. Não usar o PHP/Composer do PATH por omissão nem alterar o PATH global.
|
||||
- Versões directas fixadas nos manifests; dependências completas nos lockfiles. Qualquer substituição de versão exige apresentação prévia ao utilizador; não instalar ferramentas adicionais automaticamente.
|
||||
- Monólito modular em `app/Modules`, contexto comum em `app/Shared/Context`. Separar tenant, empresa fiscal e unidade desde o primeiro modelo empresarial.
|
||||
- Testes apenas na base `sgf_tempo_test`, cluster próprio 127.0.0.1:55432. Manter TestingDatabaseGuard e usar dados sintéticos.
|
||||
- Aplicação usa sgf_dev_app; migrações de desenvolvimento usam wrapper e proprietário separado. Nunca usar o administrador PostgreSQL na aplicação.
|
||||
- Nunca apresentar segredos, ler configurações privadas do portal ou versionar .env/.env.testing. .env.example deve permanecer sem segredos.
|
||||
- Verificar portas antes de iniciar; não parar processos de outros projectos. Não publicar nem implementar módulos financeiros sem nova autorização de âmbito.
|
||||
- PostGIS e Python adiados até haver função concreta. Não instalar Laravel Boost, IA, serviços externos ou módulos adicionais como preparação automática.
|
||||
- Validar mudanças da fundação com `sgf.ps1 test`; mudanças React com `sgf.ps1 npm run build`. Documentar limites e comandos de arranque no README.
|
||||
- Primeiro percurso: login/logout por sessão, contexto de empresa/unidade, nomes e concessões explícitas. Não criar tenants/empresas pela interface nem conceder todas as unidades implicitamente.
|
||||
- Não criar contas reais ou primeiros acessos sem pré-visualização concreta e confirmação do utilizador. Palavras-passe apenas em entrada oculta no terminal local, nunca no chat, argumentos, logs ou documentação.
|
||||
- As alterações usam o token do contexto revisto, validação de permissões no servidor e auditoria na mesma transacção. Não remover os triggers de imutabilidade ou de auditoria append-only.
|
||||
- Provas de navegador usam browser-testing na porta 8001 e apenas sgf_tempo_test, com sessões/cache isolados e CSRF activo. Parar a prova antes de executar testes que recriam a base de testes.
|
||||
- Em 25 de Outubro de 2026, o utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões no âmbito demonstrado e com os limites de docs/IDENTITY_DELIVERY.md. Preservar essa entrega como base. Serviços permanece apenas em especificação; não há autorização para implementação financeira ou publicação.
|
||||
@@ -0,0 +1,63 @@
|
||||
# SGF TEMPO
|
||||
|
||||
Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9 de 25 de Outubro de 2026. Laravel modular com React, independente do InvesteMoz. Versões e lockfiles preservados; sem módulos financeiros, integrações externas ou publicação.
|
||||
|
||||
**Estado:** incremento local de entrada, contexto e permissões aceite e concluído pelo utilizador em 25 de Outubro de 2026, com os limites de [IDENTITY_DELIVERY.md](docs/IDENTITY_DELIVERY.md). Preservar esta base. Serviços mantém-se apenas em especificação; implementação financeira e publicação não estão autorizadas.
|
||||
|
||||
## Abrir e parar
|
||||
|
||||
Em `C:\laragon\www\sgf-tempo`:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 start
|
||||
```
|
||||
|
||||
Abrir http://127.0.0.1:8000. Se a porta estiver ocupada, o arranque recusa sem terminar processos. `status` mostra o estado; `stop` pára apenas os processos próprios registados e o cluster exclusivo. Não existe serviço Windows novo nem alteração global da política de execução.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 status
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 stop
|
||||
```
|
||||
|
||||
## Primeira conta
|
||||
|
||||
**Estado actual:** a conta inicial de Osmane Nala já foi criada e verificada na Tempo Lda. Não repetir o comando de criação abaixo; conserva-se como registo do procedimento. Entrar com o email indicado e a palavra-passe definida localmente.
|
||||
|
||||
|
||||
Sem utilizadores, o ecrã mostra «Falta concluir a primeira conta» e o procedimento local. A existência da aplicação não significa que haja uma conta criada. Não há palavras-passe por omissão nem administrador universal.
|
||||
|
||||
A operação inicial de Osmane Nala / Tempo Lda foi pré-visualizada e confirmada na sessão. Para a concluir, o utilizador deve executar no seu terminal local:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1
|
||||
```
|
||||
|
||||
O procedimento apresenta o âmbito já confirmado nesta sessão e solicita uma nova palavra-passe duas vezes com Read-Host -AsSecureString do PowerShell, mostrando apenas asteriscos. Não é necessário responder yes novamente. Não introduzir palavras-passe no chat, em argumentos ou no motivo. Só depois da confirmação e das duas entradas válidas são criados, atomicamente, o tenant, empresa, utilizador e concessões. Se for interrompido ou falhar, a criação não fica parcialmente aplicada. Não repetir depois de a conta existir.
|
||||
|
||||
Concluído o procedimento, actualizar o estado no navegador e entrar com a credencial definida localmente. A conta inicial tem apenas consulta/edição do nome da empresa, criação de unidades e gestão delimitada de acessos na empresa aprovada. Não se criam unidades iniciais: o utilizador pode criá-las com a permissão específica. Ao criá-las, recebe concessões explícitas de consulta e edição apenas para cada nova unidade, conforme decisão aprovada.
|
||||
|
||||
## Percurso disponível
|
||||
|
||||
1. Entrar por email/palavra-passe; tentativas repetidas são limitadas.
|
||||
2. Seleccionar uma empresa autorizada e uma unidade com consulta explicitamente concedida.
|
||||
3. Consultar a área inicial com empresa e unidade visíveis.
|
||||
4. Consultar/editar nomes segundo as concessões; criar unidades apenas com `unit.create`.
|
||||
5. Gerir acessos de utilizadores existentes apenas na empresa activa e sem delegar direitos superiores aos próprios.
|
||||
6. Sair: a sessão anterior é invalidada.
|
||||
|
||||
Não existe criação de tenants ou empresas pela interface, auto-registo, opção «todas as unidades», recuperação por email ou fornecedor externo. Consultar [utilização e recuperação](docs/IDENTITY_WORKFLOW.md) para os procedimentos locais de utilizadores e recuperação.
|
||||
|
||||
## Testes e ferramentas
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/verify-foundation.php
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/identity-status.php
|
||||
```
|
||||
|
||||
Os testes destrutivos usam exclusivamente `sgf_tempo_test`, verificada antes da execução. Não usar fresh, reset ou wipe em desenvolvimento. Migrações futuras aprovadas: `sgf.ps1 migrate`, com proprietário de migrações separado; a aplicação continua a usar `sgf_dev_app` sem administração ou criação de tabelas.
|
||||
|
||||
Para Vite, `sgf.ps1 frontend` (porta 5173, estrita). A aplicação continua a ser aberta na porta 8000. As ferramentas usam caminhos explícitos em `C:\laragon\tools\sgf-tempo`; o cluster fica em `C:\laragon\data\sgf-tempo\postgres-17`, porta 55432. Não alterar as versões ou o ambiente do InvesteMoz.
|
||||
|
||||
[Relatório do incremento e provas](docs/IDENTITY_DELIVERY.md) · [Registo histórico da preparação inicial](docs/LOCAL_ENVIRONMENT.md)
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ProvisionIdentityUser extends Command
|
||||
{
|
||||
protected $signature = 'identity:provision {--email=} {--name=} {--company=} {--tenant-name=} {--company-name=} {--company-permission=*} {--unit-permission=*} {--operator=} {--reason=} {--existing-user} {--protected-stdin} {--apply}';
|
||||
protected $description = 'Pré-visualizar e, com confirmação local, criar utilizador e primeiros acessos delimitados.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! $this->safeLocalDatabase()) { return self::FAILURE; }
|
||||
$email = mb_strtolower(trim((string) $this->option('email')));
|
||||
$name = trim((string) $this->option('name'));
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '' || mb_strlen($name) > 255 || mb_strlen($email) > 255 || ! $this->option('operator') || ! $this->option('reason')) {
|
||||
$this->error('Indique email, nome, operador local e motivo. Não forneça palavras-passe por argumentos.'); return self::FAILURE;
|
||||
}
|
||||
$existing = User::where('email', $email)->first();
|
||||
if ($existing && ! $this->option('existing-user')) { $this->error('O utilizador já existe. Use recuperação local ou gestão de acessos.'); return self::FAILURE; }
|
||||
if ($this->option('existing-user') && (! $existing || $existing->name !== $name || ! $this->option('company'))) {
|
||||
$this->error('A recuperação de concessões exige utilizador/nome existentes e --company explícito.'); return self::FAILURE;
|
||||
}
|
||||
$company = $this->option('company') ? DB::table('companies')->where('id', $this->option('company'))->first() : null;
|
||||
if ($this->option('company') && ! $company) { $this->error('Empresa inexistente; não será criada automaticamente.'); return self::FAILURE; }
|
||||
$newScope = $this->option('tenant-name') || $this->option('company-name');
|
||||
if ($newScope) {
|
||||
if ($existing) { $this->error('A recuperação não cria um novo âmbito.'); return self::FAILURE; }
|
||||
$tenantName = trim((string) $this->option('tenant-name'));
|
||||
$companyName = trim((string) $this->option('company-name'));
|
||||
if ($company || $tenantName === '' || $companyName === '' || mb_strlen($tenantName) > 255 || mb_strlen($companyName) > 255
|
||||
|| DB::table('tenants')->where('name', $tenantName)->exists()) {
|
||||
$this->error('Para novo âmbito, indique nomes explícitos, distintos de um tenant existente, sem --company. Nada será reutilizado por inferência.'); return self::FAILURE;
|
||||
}
|
||||
$company = (object) ['id' => 0, 'tenant_id' => 0, 'name' => $companyName];
|
||||
}
|
||||
$grants = [];
|
||||
foreach ($this->option('company-permission') as $permission) {
|
||||
if (! $company || ! in_array($permission, Permissions::COMPANY, true)) { $this->error('Concessão de empresa inválida.'); return self::FAILURE; }
|
||||
$grants[] = ['permission' => $permission, 'unit_id' => null];
|
||||
}
|
||||
foreach ($this->option('unit-permission') as $raw) {
|
||||
[$unit, $permission] = array_pad(explode(':', $raw, 2), 2, null);
|
||||
if (! $company || ! ctype_digit((string) $unit) || ! in_array($permission, Permissions::UNIT, true)
|
||||
|| ! DB::table('units')->where('id', $unit)->where('company_id', $company->id)->where('tenant_id', $company->tenant_id)->exists()) {
|
||||
$this->error('Concessão de unidade inválida.'); return self::FAILURE;
|
||||
}
|
||||
$grants[] = ['permission' => $permission, 'unit_id' => (int) $unit];
|
||||
}
|
||||
if ($grants && ! in_array('company.view', array_column($grants, 'permission'), true)) { $this->error('Os primeiros acessos exigem company.view explícito.'); return self::FAILURE; }
|
||||
if ($newScope && ! $grants) { $this->error('O novo âmbito exige concessões iniciais explícitas.'); return self::FAILURE; }
|
||||
foreach ($grants as $grant) {
|
||||
if ($grant['permission'] === 'unit.edit' && ! in_array(['permission' => 'unit.view', 'unit_id' => $grant['unit_id']], $grants)) {
|
||||
$this->error('unit.edit exige unit.view explícito para a mesma unidade.'); return self::FAILURE;
|
||||
}
|
||||
}
|
||||
if ($grants && DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) {
|
||||
$this->error('Esta empresa já tem gestor. Crie apenas o utilizador sem concessões e use a gestão de acessos.'); return self::FAILURE;
|
||||
}
|
||||
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$name <$email>"],
|
||||
['Tenant', $newScope ? 'CRIAR: '.$tenantName : ($company?->tenant_id ?? 'sem acesso')], ['Empresa', $newScope ? 'CRIAR: '.$companyName : ($company ? "$company->id — $company->name" : 'sem acesso')],
|
||||
['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
|
||||
$this->table(['Permissão', 'Unidade'], array_map(fn ($g) => [$g['permission'], $g['unit_id'] ?? 'âmbito da empresa'], $grants));
|
||||
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Obtenha confirmação desta operação antes de repetir com --apply.'); return self::SUCCESS; }
|
||||
if ($existing && ! $grants) { $this->error('Indique as concessões a recuperar.'); return self::FAILURE; }
|
||||
$protected = null;
|
||||
if ($this->option('protected-stdin')) {
|
||||
if ($existing) { $this->error('Esta entrada protegida destina-se apenas à criação de uma nova conta.'); return self::FAILURE; }
|
||||
try { $protected = \App\Modules\Identity\Services\ProtectedProvisioningInput::read(STDIN, $email); }
|
||||
catch (\RuntimeException $exception) { $this->error($exception->getMessage()); return self::FAILURE; }
|
||||
} elseif (! $this->input->isInteractive() || ! $this->confirm($existing ? 'Confirma recuperar apenas as concessões acima, mantendo a palavra-passe?' : 'Confirma a criação exacta acima, sem outros acessos?', false)) { return self::FAILURE; }
|
||||
$password = null;
|
||||
if (! $existing) {
|
||||
$password = $protected ? $protected[0] : $this->secret('Nova palavra-passe (mínimo 12 caracteres; não será apresentada)', false);
|
||||
$repeat = $protected ? $protected[1] : $this->secret('Repita a palavra-passe', false);
|
||||
if (! is_string($password) || $password === '') { $this->error('A entrada oculta não recebeu uma palavra-passe. Nada alterado.'); return self::FAILURE; }
|
||||
if (mb_strlen($password) < 12) { $this->error('A palavra-passe precisa de pelo menos 12 caracteres. Nada alterado.'); return self::FAILURE; }
|
||||
if (strlen($password) > 72) { $this->error('A palavra-passe excede o limite de 72 bytes do algoritmo actual. Nada alterado.'); return self::FAILURE; }
|
||||
if ($password !== $repeat) { $this->error('As duas entradas ocultas não coincidem. Nada alterado.'); return self::FAILURE; }
|
||||
}
|
||||
try {
|
||||
DB::transaction(function () use ($email, $name, $password, $company, $grants, $newScope, $existing): void {
|
||||
if ($newScope) {
|
||||
// Serialize local bootstrap commands; never silently reuse or duplicate the named scope.
|
||||
DB::statement('SELECT pg_advisory_xact_lock(72644101)');
|
||||
if (DB::table('tenants')->where('name', trim($this->option('tenant-name')))->exists()) { throw new \RuntimeException('Scope changed'); }
|
||||
$tenantId = DB::table('tenants')->insertGetId(['name' => trim($this->option('tenant-name')), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$companyId = DB::table('companies')->insertGetId(['tenant_id' => $tenantId, 'name' => trim($this->option('company-name')), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$company = (object) ['id' => $companyId, 'tenant_id' => $tenantId];
|
||||
Audit::record('local.scope_created', null, $tenantId, $companyId,
|
||||
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
|
||||
}
|
||||
if ($company && $grants) {
|
||||
DB::table('companies')->where('id', $company->id)->lockForUpdate()->first();
|
||||
if (DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) { throw new \RuntimeException('Scope changed'); }
|
||||
}
|
||||
$user = $existing ?? User::create(['name' => $name, 'email' => $email, 'password' => $password]);
|
||||
if ($company && $grants) {
|
||||
DB::table('company_memberships')->insertOrIgnore(['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id]);
|
||||
foreach ($grants as $grant) {
|
||||
DB::table('permission_grants')->insertOrIgnore($grant + ['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
Audit::record($existing ? 'local.access_restored' : 'local.user_provisioned', null, $company?->tenant_id, $company?->id, subject: $user->id,
|
||||
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason'), 'grants' => $grants]);
|
||||
});
|
||||
} catch (\Throwable) { $this->error('Operação não concluída. Nenhum detalhe sensível foi registado. Verifique o estado e repita a pré-visualização.'); return self::FAILURE; }
|
||||
$this->info($existing ? 'Concessões recuperadas no âmbito apresentado; credencial preservada.' : 'Utilizador criado com os acessos apresentados. Nenhuma palavra-passe foi registada.'); return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function safeLocalDatabase(): bool
|
||||
{
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) {
|
||||
$this->error('Procedimento permitido apenas no desenvolvimento local próprio, com sgf_dev_app.'); return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class RecoverIdentityUser extends Command
|
||||
{
|
||||
protected $signature = 'identity:recover {--email=} {--operator=} {--reason=} {--apply}';
|
||||
protected $description = 'Recuperação local assistida, com confirmação e invalidação das sessões existentes.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { $this->error('Destino local inválido.'); return self::FAILURE; }
|
||||
$user = User::where('email', mb_strtolower(trim((string) $this->option('email'))))->first();
|
||||
if (! $user || ! $this->option('operator') || ! $this->option('reason')) { $this->error('Indique utilizador existente, operador e motivo.'); return self::FAILURE; }
|
||||
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$user->id — $user->name <$user->email>"],
|
||||
['Efeito', 'Alterar palavra-passe e invalidar todas as sessões; manter concessões existentes'], ['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
|
||||
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Confirme a identidade e autorização da pessoa antes de --apply.'); return self::SUCCESS; }
|
||||
if (! $this->input->isInteractive() || ! $this->confirm('Confirma a recuperação exacta apresentada?', false)) { return self::FAILURE; }
|
||||
$password = $this->secret('Nova palavra-passe (mínimo 12 caracteres)', false);
|
||||
$repeat = $this->secret('Repita a palavra-passe', false);
|
||||
if (! is_string($password) || $password === '') { $this->error('A entrada oculta não recebeu uma palavra-passe. Nada alterado.'); return self::FAILURE; }
|
||||
if (mb_strlen($password) < 12) { $this->error('A palavra-passe precisa de pelo menos 12 caracteres. Nada alterado.'); return self::FAILURE; }
|
||||
if (strlen($password) > 72) { $this->error('A palavra-passe excede o limite de 72 bytes do algoritmo actual. Nada alterado.'); return self::FAILURE; }
|
||||
if ($password !== $repeat) { $this->error('As duas entradas ocultas não coincidem. Nada alterado.'); return self::FAILURE; }
|
||||
try {
|
||||
DB::transaction(function () use ($user, $password): void {
|
||||
$current = User::whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||
$current->password = $password;
|
||||
$current->auth_version++;
|
||||
$current->remember_token = bin2hex(random_bytes(30));
|
||||
$current->save();
|
||||
Audit::record('local.password_recovered', null, subject: $current->id,
|
||||
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
|
||||
});
|
||||
} catch (\Throwable) { $this->error('Recuperação não concluída; nenhum detalhe sensível foi apresentado.'); return self::FAILURE; }
|
||||
$this->info('Recuperação concluída. As sessões anteriores serão recusadas no pedido seguinte.'); return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
protected $attributes = ['auth_version' => 1];
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'auth_version' => 'integer',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\IdentityWorkflow;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class IdentityController extends Controller
|
||||
{
|
||||
public function __construct(private IdentityWorkflow $workflow) {}
|
||||
|
||||
public function session(Request $request)
|
||||
{
|
||||
if ($request->user() && (int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
|
||||
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
|
||||
}
|
||||
return response()->json($this->workflow->snapshot($request))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email|max:255', 'password' => 'required|string|max:1024']);
|
||||
$email = mb_strtolower(trim($data['email']));
|
||||
if (strlen($data['password']) > 72) { throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']); }
|
||||
$key = 'sgf-login:'.hash('sha256', $email.'|'.$request->ip());
|
||||
$ipKey = 'sgf-login-ip:'.hash('sha256', (string) $request->ip());
|
||||
abort_if(RateLimiter::tooManyAttempts($key, config('identity.login_attempts')) || RateLimiter::tooManyAttempts($ipKey, config('identity.login_ip_attempts')), 429, 'Demasiadas tentativas. Aguarde antes de tentar novamente.');
|
||||
if (! Auth::attempt(['email' => $email, 'password' => $data['password']], false)) {
|
||||
RateLimiter::hit($key, config('identity.login_decay_seconds'));
|
||||
RateLimiter::hit($ipKey, config('identity.login_decay_seconds'));
|
||||
Audit::record('auth.failed', null);
|
||||
throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']);
|
||||
}
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('company_context');
|
||||
$request->session()->put('auth_version', (int) $request->user()->auth_version);
|
||||
Audit::record('auth.login', $request->user()->id);
|
||||
return $this->session($request);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Audit::record('auth.logout', $request->user()->id);
|
||||
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
|
||||
return response()->json(['csrf' => csrf_token()])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function select(Request $request) { return response()->json($this->workflow->select($request)); }
|
||||
public function company(Request $request, int $company)
|
||||
{
|
||||
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
|
||||
abort_unless($company === $ids['company_id'], 403);
|
||||
return response()->json(\Illuminate\Support\Facades\DB::table('companies')->where('id', $company)->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']));
|
||||
}
|
||||
public function unit(Request $request, int $unit)
|
||||
{
|
||||
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
|
||||
app(\App\Modules\Identity\Services\Permissions::class)->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'unit.view', $unit);
|
||||
$record = \Illuminate\Support\Facades\DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']);
|
||||
abort_unless($record, 403);
|
||||
return response()->json($record);
|
||||
}
|
||||
public function renameCompany(Request $request, int $company) { $this->workflow->renameCompany($request, $company); return response()->json(['ok' => true]); }
|
||||
public function renameUnit(Request $request, int $unit) { $this->workflow->renameUnit($request, $unit); return response()->json(['ok' => true]); }
|
||||
public function createUnit(Request $request) { return response()->json(['id' => $this->workflow->createUnit($request)], 201); }
|
||||
public function accesses(Request $request) { return response()->json($this->workflow->accessList($request)); }
|
||||
public function grant(Request $request) { $this->workflow->changeAccess($request, true); return response()->json(['ok' => true]); }
|
||||
public function revoke(Request $request) { $this->workflow->changeAccess($request, false); return response()->json(['ok' => true]); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class RequireCompanyContext
|
||||
{
|
||||
public function __construct(private CompanyContext $context) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$this->context->clear();
|
||||
try {
|
||||
$selection = $request->session()->get('company_context', []);
|
||||
$tenant = $selection['tenant_id'] ?? null;
|
||||
$company = $selection['company_id'] ?? null;
|
||||
abort_unless($request->user() && $tenant && $company, 403, 'Contexto de empresa obrigatório.');
|
||||
$this->context->selectFor((int) $request->user()->getKey(), $tenant, $company, $selection['unit_id'] ?? null);
|
||||
if (! $request->isMethodSafe()) {
|
||||
$token = $request->header('X-SGF-Context');
|
||||
abort_unless(is_string($token) && hash_equals($selection['token'] ?? '', $token), 409, 'O contexto mudou. Reabra o formulário antes de guardar.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class RequireValidSession
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
abort_unless($request->user(), 401);
|
||||
if ((int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
abort(401, 'Sessão terminada. Entre novamente.');
|
||||
}
|
||||
$response = $next($request);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
// Identity catalogue; company selection is authorized by CompanyContext.
|
||||
final class Company extends Model
|
||||
{
|
||||
protected $fillable = ['tenant_id', 'name'];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Tenant extends Model
|
||||
{
|
||||
protected $fillable = ['name'];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use App\Shared\Context\BelongsToCompany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Unit extends Model
|
||||
{
|
||||
use BelongsToCompany;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('unit-access', function (\Illuminate\Database\Eloquent\Builder $query): void {
|
||||
$query->whereIn('units.id', app(\App\Shared\Context\CompanyContext::class)->permittedUnitIds());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class Audit
|
||||
{
|
||||
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
|
||||
{
|
||||
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
|
||||
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants']));
|
||||
DB::table('identity_audit')->insert([
|
||||
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
|
||||
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
|
||||
'details' => json_encode($safe, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class IdentityWorkflow
|
||||
{
|
||||
public function __construct(private Permissions $permissions, private CompanyContext $context) {}
|
||||
|
||||
public function companies(int $user): array
|
||||
{
|
||||
return DB::table('companies as c')->join('permission_grants as g', 'g.company_id', '=', 'c.id')
|
||||
->where('g.user_id', $user)->where('g.permission', 'company.view')->whereNull('g.unit_id')
|
||||
->select('c.id', 'c.tenant_id', 'c.name')->orderBy('c.name')->get()->all();
|
||||
}
|
||||
|
||||
public function units(int $user, int $tenant, int $company): array
|
||||
{
|
||||
return DB::table('units')->where('tenant_id', $tenant)->where('company_id', $company)
|
||||
->whereIn('id', $this->permissions->unitIds($user, $tenant, $company))
|
||||
->select('id', 'name')->orderBy('name')->get()->all();
|
||||
}
|
||||
|
||||
public function snapshot(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
$result = ['user' => $user ? ['id' => $user->id, 'name' => $user->name, 'email' => $user->email] : null,
|
||||
'csrf' => csrf_token(), 'companies' => [], 'context' => null,
|
||||
'setup_pending' => app()->environment('local') && ! DB::table('users')->exists()];
|
||||
if (! $user) { return $result; }
|
||||
$result['companies'] = $this->companies($user->id);
|
||||
$selected = $request->session()->get('company_context');
|
||||
if (! $selected) { return $result; }
|
||||
try {
|
||||
$this->context->selectFor($user->id, $selected['tenant_id'], $selected['company_id'], $selected['unit_id']);
|
||||
$company = DB::table('companies')->where('id', $selected['company_id'])->first(['id', 'name', 'tenant_id']);
|
||||
$unit = $selected['unit_id'] ? DB::table('units')->where('id', $selected['unit_id'])->first(['id', 'name']) : null;
|
||||
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id)
|
||||
->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all();
|
||||
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
|
||||
'token' => $selected['token'], 'grants' => $grants];
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
|
||||
if ($exception->getStatusCode() !== 403) { throw $exception; }
|
||||
$request->session()->forget('company_context');
|
||||
$result['notice'] = 'O acesso ao contexto anterior mudou. Seleccione um contexto autorizado.';
|
||||
} finally { $this->context->clear(); }
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function select(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1']);
|
||||
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
|
||||
// Selection is explicit, never silently carries the old unit into another company.
|
||||
$this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
|
||||
$request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
|
||||
'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]);
|
||||
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
|
||||
return $this->snapshot($request);
|
||||
}
|
||||
|
||||
public function renameCompany(Request $request, int $company): void
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $user) use ($company, $data): void {
|
||||
abort_unless($company === $ids['company_id'], 403);
|
||||
$this->permissions->require($user, $ids['tenant_id'], $company, 'company.edit');
|
||||
$before = DB::table('companies')->where('id', $company)->value('name');
|
||||
DB::table('companies')->where('id', $company)->update(['name' => trim($data['name']), 'updated_at' => now()]);
|
||||
Audit::record('company.renamed', $user, $ids['tenant_id'], $company, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
|
||||
});
|
||||
}
|
||||
|
||||
public function renameUnit(Request $request, int $unit): void
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $user) use ($unit, $data): void {
|
||||
abort_unless($this->context->unitId() === $unit, 409, 'Seleccione esta unidade antes de editar.');
|
||||
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.edit', $unit);
|
||||
$before = DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->value('name');
|
||||
abort_if($before === null, 403);
|
||||
DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->update(['name' => trim($data['name']), 'updated_at' => now()]);
|
||||
Audit::record('unit.renamed', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
|
||||
});
|
||||
}
|
||||
|
||||
public function createUnit(Request $request): int
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
return $this->mutate($request, function ($ids, $user) use ($data): int {
|
||||
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.create');
|
||||
$unit = DB::table('units')->insertGetId($ids + ['name' => trim($data['name']), 'created_at' => now(), 'updated_at' => now()]);
|
||||
Audit::record('unit.created', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['after_name' => trim($data['name'])]);
|
||||
foreach (Permissions::UNIT as $permission) {
|
||||
$this->insertGrant($user, $ids, $unit, $permission);
|
||||
Audit::record('access.granted_on_creation', $user, $ids['tenant_id'], $ids['company_id'], $unit, $user, ['permission' => $permission]);
|
||||
}
|
||||
return $unit;
|
||||
});
|
||||
}
|
||||
|
||||
public function accessList(Request $request): array
|
||||
{
|
||||
$ids = $this->context->identifiers();
|
||||
$this->permissions->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'access.manage');
|
||||
$allowedUnits = $this->context->permittedUnitIds();
|
||||
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
|
||||
->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get();
|
||||
$grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id'])
|
||||
->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']);
|
||||
return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];
|
||||
}
|
||||
|
||||
public function changeAccess(Request $request, bool $grant): void
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email|max:255', 'permission' => 'required|string', 'unit_id' => 'nullable|integer|min:1',
|
||||
'reason' => 'required|string|max:500', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'user_id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $actor) use ($data, $grant): void {
|
||||
$company = $ids['company_id']; $tenant = $ids['tenant_id'];
|
||||
$this->permissions->require($actor, $tenant, $company, 'access.manage');
|
||||
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
|
||||
$catalogue = $unit === null ? Permissions::COMPANY : Permissions::UNIT;
|
||||
abort_unless(in_array($data['permission'], $catalogue, true), 422, 'Permissão ou âmbito inválido.');
|
||||
$this->permissions->require($actor, $tenant, $company, $data['permission'], $unit);
|
||||
if ($unit !== null) {
|
||||
$this->permissions->require($actor, $tenant, $company, 'unit.view', $unit);
|
||||
abort_unless(DB::table('units')->where('id', $unit)->where('tenant_id', $tenant)->where('company_id', $company)->exists(), 403);
|
||||
}
|
||||
$target = User::query()->where('email', mb_strtolower($data['email']))->first();
|
||||
if (! $target) { throw ValidationException::withMessages(['email' => 'Não foi possível atribuir este acesso. Confirme o utilizador pelo procedimento local.']); }
|
||||
$membership = DB::table('company_memberships')->where($ids)->where('user_id', $target->id);
|
||||
if (! $membership->exists()) {
|
||||
abort_unless($grant && $data['permission'] === 'company.view' && $unit === null, 403, 'Conceda primeiro consulta da empresa.');
|
||||
DB::table('company_memberships')->insert($ids + ['user_id' => $target->id]);
|
||||
}
|
||||
if ($grant && $data['permission'] !== 'company.view') {
|
||||
$this->permissions->require($target->id, $tenant, $company, 'company.view');
|
||||
if ($unit && $data['permission'] === 'unit.edit') { $this->permissions->require($target->id, $tenant, $company, 'unit.view', $unit); }
|
||||
}
|
||||
$removed = [];
|
||||
if ($grant) { $this->insertGrant($target->id, $ids, $unit, $data['permission']); }
|
||||
else {
|
||||
$query = DB::table('permission_grants')->where($ids)->where('user_id', $target->id);
|
||||
if ($data['permission'] === 'company.view') {
|
||||
// Revocation deactivates all derived grants; reject partial authority rather than silently exceeding it.
|
||||
foreach ($query->get() as $existing) { $this->permissions->require($actor, $tenant, $company, $existing->permission, $existing->unit_id ? (int) $existing->unit_id : null); }
|
||||
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
|
||||
$query->delete();
|
||||
} else {
|
||||
if ($data['permission'] === 'unit.view') {
|
||||
if ($this->permissions->has($target->id, $tenant, $company, 'unit.edit', $unit)) { $this->permissions->require($actor, $tenant, $company, 'unit.edit', $unit); }
|
||||
$query->where('unit_id', $unit)->whereIn('permission', Permissions::UNIT);
|
||||
} else { $query->where('unit_id', $unit)->where('permission', $data['permission']); }
|
||||
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
|
||||
$query->delete();
|
||||
}
|
||||
}
|
||||
Audit::record($grant ? 'access.granted' : 'access.revoked', $actor, $tenant, $company, $unit, $target->id,
|
||||
['permission' => $data['permission'], 'reason' => $data['reason'], 'grants' => $grant ? [['permission' => $data['permission'], 'unit_id' => $unit]] : $removed]);
|
||||
});
|
||||
}
|
||||
|
||||
private function insertGrant(int $user, array $ids, ?int $unit, string $permission): void
|
||||
{
|
||||
DB::table('permission_grants')->insertOrIgnore($ids + ['user_id' => $user, 'unit_id' => $unit, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
private function mutate(Request $request, callable $operation): mixed
|
||||
{
|
||||
$selected = $this->context->identifiers();
|
||||
return DB::transaction(function () use ($request, $operation, $selected) {
|
||||
// All access changes and business mutations serialize on the same company row.
|
||||
DB::table('companies')->where('id', $selected['company_id'])->where('tenant_id', $selected['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($request->user()->id, $selected['tenant_id'], $selected['company_id'], $this->context->unitId());
|
||||
return $operation($selected, $request->user()->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class LocalDatabaseGuard
|
||||
{
|
||||
public static function allows(): bool
|
||||
{
|
||||
if (! app()->environment('local')) { return false; }
|
||||
$connection = config('database.default');
|
||||
$config = config("database.connections.$connection", []);
|
||||
foreach (['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432', 'database' => 'sgf_tempo_dev', 'username' => 'sgf_dev_app'] as $key => $expected) {
|
||||
if ((string) ($config[$key] ?? '') !== $expected) { return false; }
|
||||
}
|
||||
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])) { return false; }
|
||||
try {
|
||||
$identity = DB::selectOne('SELECT current_database() AS db, current_user AS role, rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user');
|
||||
return $identity->db === 'sgf_tempo_dev' && $identity->role === 'sgf_dev_app'
|
||||
&& ! $identity->rolsuper && ! $identity->rolcreatedb && ! $identity->rolcreaterole && ! $identity->rolbypassrls;
|
||||
} catch (\Throwable) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class Permissions
|
||||
{
|
||||
public const COMPANY = ['company.view', 'company.edit', 'unit.create', 'access.manage'];
|
||||
public const UNIT = ['unit.view', 'unit.edit'];
|
||||
|
||||
public function has(int $user, int $tenant, int $company, string $permission, ?int $unit = null): bool
|
||||
{
|
||||
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
|
||||
->where('company_id', $company)->where('permission', $permission)->where('unit_id', $unit)->exists();
|
||||
}
|
||||
|
||||
public function require(int $user, int $tenant, int $company, string $permission, ?int $unit = null): void
|
||||
{
|
||||
abort_unless($this->has($user, $tenant, $company, $permission, $unit), 403, 'Acção não autorizada.');
|
||||
}
|
||||
|
||||
public function unitIds(int $user, int $tenant, int $company): array
|
||||
{
|
||||
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
|
||||
->where('company_id', $company)->where('permission', 'unit.view')->pluck('unit_id')->map(fn ($id) => (int) $id)->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ProtectedProvisioningInput
|
||||
{
|
||||
public static function read($stream, string $email): array
|
||||
{
|
||||
try {
|
||||
$raw = stream_get_contents($stream, 16385);
|
||||
if (! is_string($raw) || strlen($raw) > 16384) { throw new RuntimeException(); }
|
||||
$data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($data) || ($data['confirmed'] ?? null) !== true || ($data['email'] ?? null) !== $email
|
||||
|| ! is_string($data['password'] ?? null) || ! is_string($data['confirmation'] ?? null)) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
return [$data['password'], $data['confirmation']];
|
||||
} catch (\Throwable) {
|
||||
// Never include payload, parser errors, credentials or tokens in diagnostics.
|
||||
throw new RuntimeException('Entrada protegida inválida ou não confirmada. Nada alterado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Illuminate\Console\Events\CommandStarting;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->environment(['testing', 'browser-testing']) || defined('PHPUNIT_COMPOSER_INSTALL')) {
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
}
|
||||
|
||||
Event::listen(CommandStarting::class, function (CommandStarting $event): void {
|
||||
if (in_array($event->command, ['migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'db:wipe'], true)) {
|
||||
$connection = $event->input->hasOption('database') ? $event->input->getOption('database') : null;
|
||||
TestingDatabaseGuard::assertSafe($connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
trait BelongsToCompany
|
||||
{
|
||||
protected static function bootBelongsToCompany(): void
|
||||
{
|
||||
static::addGlobalScope('company', function (Builder $query): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
$query->where($query->getModel()->qualifyColumn($column), $value);
|
||||
}
|
||||
});
|
||||
|
||||
static::saving(function (Model $model): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
if ($model->getAttribute($column) !== null && (int) $model->getAttribute($column) !== $value) {
|
||||
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
|
||||
}
|
||||
$model->setAttribute($column, $value);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleting(function (Model $model): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
if ((int) $model->getAttribute($column) !== $value) {
|
||||
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use App\Modules\Identity\Models\Company;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
|
||||
final class CompanyContext
|
||||
{
|
||||
private ?array $identifiers = null;
|
||||
private ?int $userId = null;
|
||||
private ?int $unitId = null;
|
||||
|
||||
public function selectFor(int $userId, int $tenantId, int $companyId, ?int $unitId = null): void
|
||||
{
|
||||
$this->clear();
|
||||
$allowed = DB::table('company_memberships')
|
||||
->where('user_id', $userId)->where('tenant_id', $tenantId)
|
||||
->where('company_id', $companyId)->exists();
|
||||
|
||||
if (! $allowed || ! Company::query()->where('tenant_id', $tenantId)->whereKey($companyId)->exists()) {
|
||||
throw new AccessDeniedHttpException('Empresa não autorizada.');
|
||||
}
|
||||
|
||||
app(Permissions::class)->require($userId, $tenantId, $companyId, 'company.view');
|
||||
if ($unitId !== null) {
|
||||
app(Permissions::class)->require($userId, $tenantId, $companyId, 'unit.view', $unitId);
|
||||
abort_unless(DB::table('units')->where('tenant_id', $tenantId)->where('company_id', $companyId)->where('id', $unitId)->exists(), 403);
|
||||
}
|
||||
|
||||
$this->identifiers = ['tenant_id' => $tenantId, 'company_id' => $companyId];
|
||||
$this->userId = $userId;
|
||||
$this->unitId = $unitId;
|
||||
}
|
||||
|
||||
public function identifiers(): array
|
||||
{
|
||||
return $this->identifiers ?? throw new AccessDeniedHttpException('Contexto de empresa obrigatório.');
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->identifiers = null;
|
||||
$this->userId = null;
|
||||
$this->unitId = null;
|
||||
}
|
||||
|
||||
public function userId(): int { $this->identifiers(); return $this->userId; }
|
||||
public function unitId(): ?int { $this->identifiers(); return $this->unitId; }
|
||||
public function permittedUnitIds(): array
|
||||
{
|
||||
$ids = $this->identifiers();
|
||||
return app(Permissions::class)->unitIds($this->userId(), $ids['tenant_id'], $ids['company_id']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Testing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
final class TestingDatabaseGuard
|
||||
{
|
||||
public static function assertSafe(?string $connection = null): void
|
||||
{
|
||||
$name = $connection ?: config('database.default');
|
||||
$config = config("database.connections.{$name}", []);
|
||||
$expected = [
|
||||
'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432',
|
||||
'database' => 'sgf_tempo_test', 'username' => 'sgf_test_runner',
|
||||
];
|
||||
foreach ($expected as $key => $value) {
|
||||
if ((string) ($config[$key] ?? '') !== $value) {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: apenas sgf_tempo_test no cluster próprio é permitido.');
|
||||
}
|
||||
}
|
||||
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write']) || ($config['search_path'] ?? 'public') !== 'public') {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: redireccionamento de ligação não permitido.');
|
||||
}
|
||||
$identity = DB::connection($name)->selectOne('SELECT current_database() AS db, current_user AS role');
|
||||
if ($identity->db !== 'sgf_tempo_test' || $identity->role !== 'sgf_test_runner') {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: destino efectivo inválido.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "tempo/sgf-tempo",
|
||||
"type": "project",
|
||||
"description": "SGF Tempo local modular foundation",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "~8.4.0",
|
||||
"ext-pdo_pgsql": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-sodium": "*",
|
||||
"laravel/framework": "13.38.0",
|
||||
"brick/math": "0.14.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "1.6.12",
|
||||
"phpunit/phpunit": "12.5.40"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"test": "@php vendor/phpunit/phpunit/phpunit",
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
|
||||
},
|
||||
"platform": {
|
||||
"php": "8.4.27"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => env('CACHE_FILE_PATH', storage_path('framework/cache/data')),
|
||||
'lock_path' => env('CACHE_LOCK_PATH', storage_path('framework/cache/locks')),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'login_attempts' => 5,
|
||||
'login_decay_seconds' => 60,
|
||||
'login_ip_attempts' => 30,
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'max_files' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'monthly' => [
|
||||
'driver' => 'monthly',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'max_files' => 3,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Resend, Postmark, AWS, and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => env('SESSION_FILES', storage_path('framework/sessions')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenants', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
});
|
||||
Schema::create('companies', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'id']);
|
||||
});
|
||||
Schema::create('company_memberships', function (Blueprint $table): void {
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('company_id');
|
||||
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
|
||||
$table->primary(['user_id', 'tenant_id', 'company_id']);
|
||||
});
|
||||
Schema::create('units', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('company_id');
|
||||
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('units');
|
||||
Schema::dropIfExists('company_memberships');
|
||||
Schema::dropIfExists('companies');
|
||||
Schema::dropIfExists('tenants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', fn (Blueprint $table) => $table->unsignedInteger('auth_version')->default(1));
|
||||
Schema::table('units', fn (Blueprint $table) => $table->unique(['tenant_id', 'company_id', 'id']));
|
||||
Schema::create('permission_grants', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('company_id');
|
||||
$table->unsignedBigInteger('unit_id')->nullable();
|
||||
$table->string('permission', 40);
|
||||
$table->timestamps();
|
||||
$table->foreign(['user_id', 'tenant_id', 'company_id'])->references(['user_id', 'tenant_id', 'company_id'])->on('company_memberships')->restrictOnDelete();
|
||||
$table->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
|
||||
});
|
||||
DB::statement("CREATE UNIQUE INDEX permission_grants_company_unique ON permission_grants(user_id, company_id, permission) WHERE unit_id IS NULL");
|
||||
DB::statement("CREATE UNIQUE INDEX permission_grants_unit_unique ON permission_grants(user_id, company_id, unit_id, permission) WHERE unit_id IS NOT NULL");
|
||||
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit') AND unit_id IS NOT NULL))");
|
||||
Schema::create('identity_audit', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('actor_id')->nullable();
|
||||
$table->unsignedBigInteger('tenant_id')->nullable();
|
||||
$table->unsignedBigInteger('company_id')->nullable();
|
||||
$table->unsignedBigInteger('unit_id')->nullable();
|
||||
$table->unsignedBigInteger('subject_id')->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->jsonb('details');
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
});
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sgf_immutable_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN
|
||||
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
|
||||
END IF;
|
||||
IF TG_TABLE_NAME IN ('units', 'permission_grants') THEN
|
||||
IF NEW.company_id IS DISTINCT FROM OLD.company_id THEN
|
||||
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
|
||||
END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'permission_grants' THEN
|
||||
IF NEW.user_id IS DISTINCT FROM OLD.user_id OR NEW.unit_id IS DISTINCT FROM OLD.unit_id OR NEW.permission IS DISTINCT FROM OLD.permission THEN
|
||||
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER companies_immutable BEFORE UPDATE ON companies FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
|
||||
CREATE TRIGGER units_immutable BEFORE UPDATE ON units FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
|
||||
CREATE TRIGGER grants_immutable BEFORE UPDATE ON permission_grants FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
|
||||
CREATE OR REPLACE FUNCTION sgf_append_only_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN RAISE EXCEPTION 'SGF_APPEND_ONLY_AUDIT'; END; $$;
|
||||
CREATE TRIGGER audit_append_only BEFORE UPDATE OR DELETE ON identity_audit FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('identity_audit');
|
||||
Schema::dropIfExists('permission_grants');
|
||||
DB::statement('DROP TRIGGER IF EXISTS units_immutable ON units');
|
||||
DB::statement('DROP TRIGGER IF EXISTS companies_immutable ON companies');
|
||||
DB::statement('DROP FUNCTION IF EXISTS sgf_immutable_ownership()');
|
||||
DB::statement('DROP FUNCTION IF EXISTS sgf_append_only_audit()');
|
||||
Schema::table('units', fn (Blueprint $table) => $table->dropUnique(['tenant_id', 'company_id', 'id']));
|
||||
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('auth_version'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sgf_immutable_membership() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.user_id IS DISTINCT FROM OLD.user_id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.company_id IS DISTINCT FROM OLD.company_id THEN
|
||||
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER membership_immutable BEFORE UPDATE ON company_memberships FOR EACH ROW EXECUTE FUNCTION sgf_immutable_membership();
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TRIGGER IF EXISTS membership_immutable ON company_memberships');
|
||||
DB::statement('DROP FUNCTION IF EXISTS sgf_immutable_membership()');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// No default accounts, passwords or scopes. Use confirmed local provisioning.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Entrega do primeiro percurso funcional
|
||||
|
||||
Implementação local sobre o SGF_TEMPO_Roteiro_v0_9.docx e a avaliação anterior. Destino: `C:\laragon\www\sgf-tempo`. Sem reinstalação, mudança de versões, publicação, acesso ao InvesteMoz ou módulos financeiros.
|
||||
|
||||
## Funcional
|
||||
|
||||
- Autenticação real por email/palavra-passe com sessão, limitação de tentativas por identidade/IP, regeneração na entrada e invalidação na saída.
|
||||
- Se não existir utilizador no ambiente local, o ecrã explica a preparação pendente; não apresenta um login que ainda não pode funcionar.
|
||||
- Selecção explícita de empresa e unidade, contexto visível, pertenças e permissões revalidadas no servidor a cada pedido.
|
||||
- Consulta e edição dos nomes existentes; criação de unidades exige concessão própria e atribui ao criador consulta e edição explícitas apenas da unidade criada (consequência aprovada pelo utilizador).
|
||||
- Concessões persistidas como dados, separadas de tenant/empresa/unidade, sem privilégio por nome/email e sem «todas as unidades».
|
||||
- Gestão de acessos limitada à empresa activa e aos direitos detidos pelo gestor, incluindo os direitos dependentes abrangidos por revogação.
|
||||
- Tokens de contexto recusam gravações de formulários anteriores à mudança de empresa/unidade. Identificadores manipulados são rejeitados.
|
||||
- Triggers PostgreSQL impedem transferir identificadores de empresas, unidades, pertenças e concessões em operações em massa. Auditoria append-only conserva alterações de nomes e acessos, com actor/âmbito/motivo e sem credenciais/tokens.
|
||||
- Procedimentos locais de pré-visualização, criação de utilizador/primeiro âmbito e recuperação de credencial/concessões. Exigem confirmação concreta, operador e motivo; não usam administração PostgreSQL.
|
||||
|
||||
## Delta de base de dados aplicado
|
||||
|
||||
Base de destino: **sgf_tempo_dev**, cluster próprio **127.0.0.1:55432**, migrações executadas por **sgf_dev_owner**. A aplicação continua a usar **sgf_dev_app**.
|
||||
|
||||
1. `2026_10_25_010000_add_identity_workflow.php`: `users.auth_version`, chave composta de unidades, tabelas `permission_grants` e `identity_audit`, índices/constraints de âmbito e triggers de propriedade/auditoria.
|
||||
2. `2026_10_25_020000_lock_membership_identifiers.php`: trigger adicional de imutabilidade das chaves de `company_memberships`.
|
||||
|
||||
Alterações aditivas, ensaiadas primeiro em sgf_tempo_test. Não houve reset/fresh/wipe ou eliminação de dados em desenvolvimento. A base estava sem utilizadores, tenants, empresas ou unidades antes da operação inicial; nenhum dado sintético foi colocado nela. Eventos de utilização local permanecem na auditoria.
|
||||
|
||||
## Ficheiros principais
|
||||
|
||||
| Grupo | Ficheiros |
|
||||
| --- | --- |
|
||||
| Autenticação e HTTP | `routes/web.php`, `IdentityController.php`, `RequireValidSession.php`, `RequireCompanyContext.php` |
|
||||
| Regras e auditoria | `IdentityWorkflow.php`, `Permissions.php`, `Audit.php`, `LocalDatabaseGuard.php` |
|
||||
| Fundação reutilizada | `CompanyContext.php`, `Unit.php`, `User.php`, `AppServiceProvider.php`; sem recriação de modelos |
|
||||
| Procedimentos locais | `ProvisionIdentityUser.php`, `RecoverIdentityUser.php`, `scripts/identity-status.php` |
|
||||
| Interface | `resources/js/app.jsx`, `resources/css/app.css` |
|
||||
| Configuração local | `config/identity.php`, caminhos opcionais de sessões/cache, ignores dos artefactos de prova |
|
||||
| Provas | `tests/Support/IdentityFixture.php`, `IdentityWorkflowTest.php`, adaptação de `CompanyIsolationTest.php`, `browser-proof.ps1`, `http-proof.mjs`, `prepare-browser-fixture.php` |
|
||||
| Documentação | README, AGENTS, `IDENTITY_WORKFLOW.md` e este relatório |
|
||||
|
||||
O seeder de exemplo deixou de criar uma conta com credencial previsível. Os lockfiles e as versões directas mantiveram-se.
|
||||
|
||||
## Resultados
|
||||
|
||||
**PHPUnit: 27 testes, 112 asserções aprovadas.** Compilação Vite concluída. `composer check-platform-reqs` aprovado. O incremento não instalou dependências nem alterou versões: PHP 8.4.27 x64 NTS, Laravel 13.38.0, React/React DOM 19.2.9, Node 24.22.1 ARM64, npm 11.19.1, Vite 8.3.5, PHPUnit 12.5.40, PostgreSQL 17.11.
|
||||
|
||||
| Critério | Evidência |
|
||||
| --- | --- |
|
||||
| Entrada inválida/válida e saída | Testes com credenciais; login real no Edge com conta sintética e saída verificada |
|
||||
| Sessão antiga não reutilizável | Prova HTTP real, replay do cookie anterior à saída: 401 |
|
||||
| Empresa/unidade não autorizadas | Testes e HTTP: 403; navegador abriu directamente unidade não autorizada e mostrou recusa |
|
||||
| Consulta sem edição | Testes/API: 403; navegador mostrou apenas leitura, sem botão de gravação |
|
||||
| Revogação no pedido seguinte | Dois clientes HTTP independentes: concessão permitiu edição (200), revogação recusou o pedido seguinte (403) |
|
||||
| Formulário de contexto anterior | Testes e prova HTTP: 409 |
|
||||
| Identificadores e operações em massa | Testes de payload manipulado e triggers de empresa, unidade, pertença e concessão |
|
||||
| Delegação superior à própria | Testes e prova HTTP com gestor limitado: 403 |
|
||||
| Protecção dos pedidos | Prova HTTP sem CSRF/origem válida: 419; ambiente de navegador mantém protecção activa |
|
||||
| Destino dos testes | Guard existente preservado; comando destrutivo contra desenvolvimento recusado; procedimentos locais recusam base de testes |
|
||||
|
||||
As provas visuais usam **apenas sgf_tempo_test**, na porta 8001, com sessões/cache/cookie separados. Não se confundem com a conta real nem com os testes `actingAs`. O servidor temporário foi parado e os dados sintéticos da prova foram eliminados pela recriação posterior exclusivamente da base de testes. A credencial temporária foi removida do ficheiro local; não foi incluída nos relatórios.
|
||||
|
||||
Capturas:
|
||||
|
||||
- [Entrada inválida](proofs/01-invalid-login.png)
|
||||
- [Empresa e unidade autorizadas](proofs/02-authorized-context.png)
|
||||
- [Gestão de acessos](proofs/03-access-management.png)
|
||||
- [Consulta sem edição](proofs/04-read-only-access.png)
|
||||
- [URL directa recusada](proofs/05-direct-url-refused.png)
|
||||
- [Saída](proofs/06-logged-out.png)
|
||||
- [Resultados HTTP sem segredos](proofs/http-results.json)
|
||||
|
||||
## Primeiro administrador
|
||||
|
||||
Foi confirmada a operação concreta: tenant Tempo Lda, empresa Tempo Lda e Osmane Nala (`nala@tempo.co.mz`), com `company.view`, `company.edit`, `unit.create` e `access.manage` exclusivamente nessa empresa. Sem unidades iniciais e sem acessos universais.
|
||||
|
||||
A palavra-passe só pode ser definida pelo utilizador no terminal local, com entrada oculta. O assistente não a recebe no chat nem a define por omissão. O procedimento aprovado está em `C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1`. A confirmação da operação não equivale à conta já criada: verificar a conclusão com `scripts/identity-status.php` e a mensagem de sucesso do terminal.
|
||||
|
||||
Uma primeira tentativa de abertura automática do terminal foi rejeitada pela revisão automática; uma invocação simples do script já preparado abriu depois uma janela PowerShell normal. A instrução ao utilizador é confirmar com `yes`, definir a palavra-passe e repeti-la (a entrada não ecoa caracteres), e actualizar o estado no navegador.
|
||||
|
||||
## Limites
|
||||
|
||||
Este incremento não decide o isolamento de produção entre clientes nem implementa RLS, MFA, auto-registo, email, anexos, exportações, filas de negócio, fiscalidade, contabilidade ou planos comerciais. O escopo Eloquent é apenas uma camada: endpoints/serviços verificam autorização, constraints/trigger protegem relações e identificadores e os testes demonstram os caminhos incluídos. SQL administrativo e o operador do sistema continuam sujeitos a responsabilidades próprias; isto não é certificação de isolamento para produção.
|
||||
|
||||
O catálogo de acções suportadas está no código e as concessões estão nos dados. Não foi construído um motor geral de parâmetros ou toda a experiência Camaleão. Reutilizam-se cadastros e contexto sem copiar empresas/unidades por utilizador. A recuperação local preserva concessões ao alterar uma credencial; a recuperação excepcional de gestão requer outro plano explícito e confirmação, sem administrador universal.
|
||||
## Correcção da recolha inicial de credencial
|
||||
|
||||
O procedimento aprovado usa agora Read-Host -AsSecureString do PowerShell. A confirmação da operação já dada na sessão é preservada; a credencial e a repetição seguem apenas pela entrada padrão do processo, com identidade/consentimento explícitos, sem argumentos ou ficheiros de segredos. O transporte foi verificado com entrada sintética aleatória contendo Unicode e espaços; a sintaxe PowerShell também foi validada. A suite passou com 30 testes e 117 asserções. A criação real continua dependente da conclusão dos dois pedidos protegidos pelo utilizador local.
|
||||
|
||||
## Conta inicial concluída
|
||||
|
||||
A criação foi concluída pelo utilizador no terminal local. Verificação posterior: Osmane Nala, nala@tempo.co.mz; tenant e empresa Tempo Lda; uma pertença e exactamente quatro concessões de empresa (company.view, company.edit, unit.create, access.manage); nenhuma unidade inicial. O guard local e a verificação de privilégios da aplicação passaram. A palavra-passe não foi consultada pelo assistente. Não repetir o procedimento de criação; usar o login e, se necessário, recuperação local assistida.
|
||||
|
||||
## Verificação final para aceitação
|
||||
|
||||
Em 25 de Outubro de 2026, a sessão real de Samira foi verificada no navegador: consulta de Tempo Lda e SEGI com campos só de leitura, sem botões de gravação/criação/gestão. A abertura directa de /api/accesses foi recusada. Foi removida a selecção de unidade, confirmada a limpeza do contexto e novamente seleccionada SEGI. A saída regressou ao ecrã de entrada e uma recarga permaneceu sem autenticação. Capturas: proofs/09-samira-read-only.png e proofs/10-real-session-logout.png.
|
||||
|
||||
Os direitos de Osmane foram conferidos por leitura e confrontados com as provas anteriores no navegador: gestão de acessos e consulta/edição da empresa, criação de unidades e consulta/edição explícitas da SEGI. Não foi iniciada outra sessão de Osmane nesta verificação nem consultada qualquer palavra-passe.
|
||||
|
||||
Estado na verificação: entrega tecnicamente pronta para aceitação do utilizador, no âmbito local e com os limites documentados. A verificação técnica pelo assistente não substitui essa aceitação nem autoriza implementação financeira ou publicação. Prestação de serviços permanece apenas em especificação.
|
||||
|
||||
## Aceitação e encerramento pelo utilizador
|
||||
|
||||
Em 25 de Outubro de 2026, o utilizador declarou:
|
||||
|
||||
> Aceito e dou por concluído o incremento local de entrada, contexto e permissões, no âmbito demonstrado e com os limites documentados em IDENTITY_DELIVERY.md. Preserva esta entrega como base. Serviços mantém-se apenas em especificação; não autorizo ainda implementação financeira nem publicação.
|
||||
|
||||
Estado actual: **aceite e concluído**, exclusivamente no âmbito local demonstrado. Preservar esta entrega e as respectivas provas como base dos incrementos seguintes. Esta aceitação não amplia as funcionalidades, não elimina os limites documentados e não autoriza implementação financeira ou publicação.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Utilização e recuperação local
|
||||
|
||||
## Contexto e permissões
|
||||
|
||||
Tenant é o cliente contratante, empresa é a entidade empresarial e unidade é uma subdivisão dessa empresa. Pertença ao tenant não autoriza as suas empresas. Pertença à empresa, por si só, também não concede operações: as permissões são linhas em `permission_grants`.
|
||||
|
||||
| Concessão | Âmbito | Efeito |
|
||||
| --- | --- | --- |
|
||||
| company.view | Empresa | Consultar e seleccionar empresa |
|
||||
| company.edit | Empresa | Editar apenas o nome da empresa |
|
||||
| unit.create | Empresa | Criar unidade; receber consulta/edição explícitas apenas da unidade criada |
|
||||
| access.manage | Empresa | Gerir concessões nesta empresa, limitado pelos próprios direitos |
|
||||
| unit.view | Unidade concreta | Consultar e seleccionar essa unidade |
|
||||
| unit.edit | Unidade concreta | Editar o nome dessa unidade, exigindo consulta e selecção dela |
|
||||
|
||||
Não existem papéis universais, privilégios por email ou acesso implícito a todas as unidades. Para conceder a edição de unidade é necessário conceder primeiro a consulta dessa unidade; para outros direitos é necessária consulta da empresa. Um gestor só pode conceder ou revogar direitos que possui no mesmo âmbito. A revogação de consulta de empresa remove os seus direitos dependentes apenas se o gestor tiver autoridade sobre todos eles; caso contrário é recusada. A revogação de consulta de uma unidade remove também a sua edição nas mesmas condições. Estas dependências são registadas na auditoria.
|
||||
|
||||
A mudança de empresa limpa a unidade anterior. Cada selecção gera um token de contexto; um pedido de gravação com o token anterior é recusado com 409. Os formulários visíveis são recarregados quando o contexto muda. Identificadores recebidos são sempre revalidados no servidor. Mudanças de direitos aplicam-se no pedido seguinte; cada alteração revalida direitos dentro da transacção e partilha um bloqueio da empresa com as alterações de acessos.
|
||||
|
||||
## Preparar outro utilizador, sem acessos automáticos
|
||||
|
||||
Substituir os campos pelos dados expressamente autorizados. A execução sem `--apply` é apenas pré-visualização:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:provision --email="EMAIL" --name="NOME" --operator="RESPONSAVEL" --reason="MOTIVO"
|
||||
```
|
||||
|
||||
Depois da confirmação da operação concreta, repetir com `--apply` num terminal interactivo. A palavra-passe é introduzida de forma oculta, duas vezes. Requer pelo menos 12 caracteres e até 72 bytes, respeitando o algoritmo bcrypt existente. A conta fica sem empresa e sem permissões até um gestor autorizado conceder os direitos pela interface, começando por `company.view`.
|
||||
|
||||
Para os primeiros acessos de uma empresa sem gestor, o mesmo comando aceita `--company=ID`, `--company-permission=PERMISSAO` repetido e `--unit-permission=ID:PERMISSAO` repetido. Não existe concessão por omissão. Para novo âmbito inicial, `--tenant-name` e `--company-name` são explícitos, exclusivos de `--company`, e não há reutilização por inferência. Se já houver gestor na empresa, a concessão deve ser feita pela interface dentro dos direitos desse gestor.
|
||||
|
||||
Todos os procedimentos locais recusam ambientes ou bases diferentes de `local` / `sgf_tempo_dev`, e exigem o papel de aplicação `sgf_dev_app`, não o administrador PostgreSQL.
|
||||
|
||||
## Recuperar palavra-passe
|
||||
|
||||
Verificar primeiro a identidade da pessoa e a autorização do responsável local. Não solicitar a credencial no chat. Pré-visualizar:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:recover --email="EMAIL" --operator="RESPONSAVEL" --reason="MOTIVO"
|
||||
```
|
||||
|
||||
Só depois da confirmação da operação, repetir com `--apply` e definir a nova credencial nos dois pedidos ocultos do terminal. A alteração é transaccional, aumenta a versão de autenticação e invalida as sessões anteriores no pedido seguinte. As concessões são preservadas. O operador e o motivo ficam na auditoria; palavras-passe, hashes, cookies e tokens não são incluídos nela. Não colocar segredos no motivo.
|
||||
|
||||
## Recuperar gestão quando não resta gestor
|
||||
|
||||
Não há excepção automática nem administrador universal. O operador local deve pré-visualizar e obter confirmação de concessões exactas para uma empresa existente e utilizador existente:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:provision --existing-user --email="EMAIL" --name="NOME_EXISTENTE" --company=ID --company-permission=company.view --company-permission=access.manage --operator="RESPONSAVEL" --reason="MOTIVO"
|
||||
```
|
||||
|
||||
A recuperação só é permitida se não existir concessão `access.manage` nessa empresa. Não altera a credencial, não cria outro tenant/empresa e não acrescenta direitos além dos explicitamente apresentados. `--apply` exige confirmação no terminal. Não usar SQL directo como procedimento habitual de recuperação.
|
||||
|
||||
## Prova de navegador com dados sintéticos
|
||||
|
||||
Nunca preparar contas de demonstração em desenvolvimento. Depois de parar outras provas e com as tabelas de teste vazias, iniciar:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\browser-proof.ps1 start
|
||||
```
|
||||
|
||||
A prova usa `sgf_tempo_test`, endereço 127.0.0.1:8001, cookie, sessões e cache próprios. O ambiente `browser-testing` mantém CSRF activo e aplica o mesmo guard do destino de testes. O script recusa uma base não vazia e não apaga dados. A credencial aleatória sintética fica apenas num ficheiro local com ACL restrita, nunca no relatório. Não executar a suite enquanto esta prova está activa.
|
||||
|
||||
```powershell
|
||||
C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe .\scripts\http-proof.mjs
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\browser-proof.ps1 stop
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
|
||||
```
|
||||
|
||||
Parar elimina o ficheiro temporário da credencial; o último teste recria apenas a base de testes e elimina os dados sintéticos da prova. Não apresentar esse ficheiro, palavras-passe, cookies ou tokens. As capturas guardadas em `docs/proofs` mostram apenas entidades sintéticas.
|
||||
@@ -0,0 +1,56 @@
|
||||
> Registo histórico da preparação inicial. O estado actual e as instruções de utilização estão no README e em IDENTITY_WORKFLOW.md.
|
||||
|
||||
# Registo da preparação local
|
||||
|
||||
Data: 25 de Outubro de 2026. Destino confirmado: `C:\laragon\www\sgf-tempo`.
|
||||
|
||||
## Versões efectivamente instaladas ou utilizadas
|
||||
|
||||
| Componente | Versão | Localização / origem |
|
||||
| --- | --- | --- |
|
||||
| Windows | 11 Pro ARM64, 10.0.26300 | Sistema existente |
|
||||
| PHP | 8.4.27 NTS VS17 x64 | C:\laragon\tools\sgf-tempo\php-8.4.27 |
|
||||
| Composer | 2.10.3 | C:\laragon\tools\sgf-tempo\composer-2.10.3\composer.phar |
|
||||
| Node.js | 24.22.1 ARM64 | C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64 |
|
||||
| npm | 11.19.1 | Incluído nesse Node.js |
|
||||
| Esqueleto Laravel | 13.11.0 | laravel/laravel, sem scripts de criação automáticos |
|
||||
| Laravel Framework | 13.38.0 | composer.lock |
|
||||
| React / React DOM | 19.2.9 / 19.2.9 | package-lock.json |
|
||||
| Vite | 8.3.5 | package-lock.json |
|
||||
| Plugin React | 6.1.2 | package-lock.json |
|
||||
| Laravel Vite Plugin | 3.2.0 | package-lock.json |
|
||||
| brick/math | 0.14.8 | composer.lock |
|
||||
| PHPUnit | 12.5.40 | composer.lock |
|
||||
| Mockery | 1.6.12 | Dependência auxiliar dos testes, composer.lock |
|
||||
| PostgreSQL | 17.11 x64 | Binários existentes; cluster e configuração exclusivos |
|
||||
| Git | 2.55.0.windows.5 | Instalação existente; repositório local inicializado |
|
||||
|
||||
Sem substituições das versões propostas. PostGIS não foi activado e Python não foi instalado nem incorporado. A detecção anterior encontrou PostGIS 3.6.2 em ficheiros de extensão e Python 3.10.6 fora do PATH; nenhum é dependência desta entrega.
|
||||
|
||||
## Integridade e compatibilidade
|
||||
|
||||
Downloads de PHP, Node.js e Composer conferidos por SHA-256 contra metadados oficiais; arquivos e comprovativos estão em `C:\laragon\tools\sgf-tempo\downloads`. PHP x64 executou em Windows ARM64, carregou as extensões necessárias e executou Laravel/PHPUnit. Node ARM64 executou npm, a compilação e Vite. Resolução conjunta concluída sem ignorar requisitos de plataforma. Dependências transitivas exactas preservadas nos lockfiles.
|
||||
|
||||
Fontes: https://www.php.net/downloads.php, https://windows.php.net/downloads/releases/releases.json, https://nodejs.org/dist/v24.22.1/SHASUMS256.txt, https://getcomposer.org/download/2.10.3/composer.phar.sha256sum, https://repo.packagist.org e https://registry.npmjs.org.
|
||||
|
||||
## Verificações concluídas
|
||||
|
||||
- Composer check-platform-reqs: aprovado.
|
||||
- Composer validate --strict --no-check-all: aprovado. --no-check-all desactiva os avisos contra versões exactas, que são deliberados neste âmbito; as verificações de esquema e lock permanecem.
|
||||
- Composer audit e npm audit: sem vulnerabilidades conhecidas reportadas no momento da preparação.
|
||||
- Vite build: concluído; manifest e recursos gerados.
|
||||
- PHPUnit: 11 testes, 27 asserções aprovadas. Inclui acesso por pertença explícita, recusa entre empresas do mesmo tenant e de tenants diferentes, contexto ausente/limpo, escopo de leitura/escrita, modelo de contexto anterior, relação inválida na base e protecção do destino dos testes.
|
||||
- migrate:fresh --env=local: recusado pelo guard antes da execução destrutiva.
|
||||
- Aplicação de desenvolvimento: sgf_dev_app sem administração, sem CREATE no esquema e sem CONNECT à base de testes. Executor de testes sem CONNECT à base de desenvolvimento.
|
||||
- Base de desenvolvimento sem fixtures dos testes.
|
||||
- HTTP: /, /up e manifest com 200; /api/context sem autenticação com 401.
|
||||
- Vite serviu JSX transformado em ARM64, HTTP 200.
|
||||
- Colisão na porta 8000: arranque recusado, PIDs existentes preservados.
|
||||
- Paragem e novo arranque do cluster e servidor próprios: concluídos.
|
||||
- .env, .env.testing, vendor e node_modules ignorados pelo Git. Sem commit, remoto ou publicação.
|
||||
|
||||
## Limites e decisões pendentes
|
||||
|
||||
A demonstração é local e limitada à identidade/contexto. Não constitui isolamento de produção ou aceitação dos módulos. Permanecem pendentes estratégia de isolamento entre clientes, RLS, autenticação de produto, permissões detalhadas, propagação de contexto a filas/anexos/exportações, regras financeiras, fiscalidade, planos, preços, elegibilidade, quotas, fornecedores e operação dos futuros ambientes. As tabelas cache/jobs do esqueleto são apenas infraestrutura Laravel; esta entrega usa cache/sessões locais e fila síncrona, sem trabalhadores financeiros.
|
||||
|
||||
Não foram alterados o InvesteMoz, o serviço PostgreSQL existente, o PATH global ou a política global de execução. Servidor HTTP e cluster SGF ligam apenas a 127.0.0.1. Instruções de arranque e paragem no README.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Primeiro percurso operacional de serviços
|
||||
|
||||
Data: 25 de Outubro de 2026.
|
||||
|
||||
Referência: SGF_TEMPO_Roteiro_v0_9.docx, particularmente secções 5, 14 a 19 e 20 a 24. Este documento especifica um percurso para revisão; não autoriza emissão fiscal, operações reais, movimentação de fundos ou implementação integral.
|
||||
|
||||
## Decisões já tomadas
|
||||
|
||||
- Começar pelo sector de prestação de serviços, com um serviço pontual como cenário de desenvolvimento.
|
||||
- Reutilizar a fundação existente: tenant, empresa, unidade, utilizador, sessão, contexto, concessões e auditoria.
|
||||
- Preservar independência do InvesteMoz, versões instaladas e separação entre desenvolvimento e testes.
|
||||
- Usar dados sintéticos nas provas, sem os inserir como operações da Tempo Lda/SEGI.
|
||||
|
||||
As entidades, estados, limites e entregas abaixo são propostas técnicas. Valores de teste não são preços comerciais, taxas fiscais ou regras contabilísticas aprovadas.
|
||||
|
||||
## Condição antes da implementação seguinte
|
||||
|
||||
O incremento de entrada, contexto e permissões tem implementação e provas técnicas documentadas em IDENTITY_DELIVERY.md. A verificação por leitura confirmou uma única SEGI, os direitos de gestão de Osmane na Tempo Lda e apenas company.view/unit.view para Samira na Tempo Lda/SEGI. A última suite executada passou com 30 testes e 117 asserções; não foi repetida nesta revisão documental.
|
||||
|
||||
O utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões em 25 de Outubro de 2026, no âmbito demonstrado e com os limites de IDENTITY_DELIVERY.md. Preservar essa entrega como base. Limites conhecidos: âmbito local, sem MFA/RLS de produção, sem criação geral de contas pela interface e com procedimentos locais assistidos de recuperação. A aceitação está registada separadamente do cenário de serviços.
|
||||
|
||||
Nesta etapa está autorizada apenas a especificação de serviços. As entregas A/B/C abaixo são um plano para decisões posteriores, não uma ordem para as implementar agora.
|
||||
|
||||
## Resultado pretendido
|
||||
|
||||
Um utilizador autorizado escolhe um cliente e um serviço, regista a prestação, prepara a facturação, acompanha recebimentos parciais e uma correcção e consegue explicar o saldo pelas origens. Os acontecimentos alimentam captura contabilística permanente, incluindo pendências. Reconciliação e fecho só são apresentados como concluídos quando cumprirem os controlos aprovados.
|
||||
|
||||
Percurso de referência:
|
||||
|
||||
Cliente → serviço → prestação → documento de facturação → recebimento parcial → correcção → segundo recebimento → reconciliação → fecho.
|
||||
|
||||
Os acontecimentos têm identidades e estados próprios. A prestação não é uma factura e uma factura não comprova recebimento. Registar um recebimento não inicia pagamento, transferência ou débito bancário.
|
||||
|
||||
| Acontecimento | Significado | O que não demonstra por si só |
|
||||
| --- | --- | --- |
|
||||
| Serviço prestado | Registo do trabalho realizado, cliente, conteúdo, unidade e data da prestação | Factura emitida, pagamento recebido ou reconhecimento contabilístico aprovado |
|
||||
| Factura | Documento de cobrança com identidade, data, linhas e regras próprias | Serviço concluído ou dinheiro recebido |
|
||||
| Recebimento | Observação/registo de um valor recebido, com origem/evidência e afectação | Emissão de factura, liquidação confirmada pela fonte ou reconciliação concluída |
|
||||
|
||||
Conservar separadamente datas, estados, identificadores e relações desses três acontecimentos. Não criar os três como efeito implícito de um único botão.
|
||||
|
||||
## Cenário sintético de aceitação
|
||||
|
||||
Entidades exclusivamente de teste: Empresa Exemplo de Serviços, Unidade de Teste, Cliente Exemplo e Serviço Pontual de Teste. Não utilizar os nomes, contas ou operações reais da Tempo Lda como fixtures.
|
||||
|
||||
Para demonstrar as relações e a aritmética do sub-razão, usar o seguinte exemplo em MZN. O valor 1 000,00 é um montante de referência artificial do teste; não significa preço aprovado, tributação nula ou montante fiscal já apurado.
|
||||
|
||||
### Caso principal liquidação por dois recebimentos
|
||||
|
||||
| Acontecimento | Valor do teste | Saldo aritmético por receber |
|
||||
| --- | ---: | ---: |
|
||||
| Documento de referência do serviço | 1 000,00 | 1 000,00 |
|
||||
| Recebimento parcial afectado ao documento | 400,00 | 600,00 |
|
||||
| Segundo recebimento afectado ao documento | 600,00 | 0,00 |
|
||||
|
||||
Resultado: 1 000,00 − 400,00 − 600,00 = 0,00. Cada recebimento tem identidade e afectação próprias. Repetir qualquer comando/importação ou processá-lo em concorrência não pode acrescentar outra afectação nem reduzir novamente o saldo. Mudar o conteúdo mantendo a mesma chave de repetição deve produzir conflito.
|
||||
|
||||
### Variante de correcção redutora
|
||||
|
||||
Reiniciar o cenário apenas com fixtures de teste próprias, sem alterar o caso principal nem a factura original. Ensaiar uma redução de 100,00 numa linha do documento, identificando documento e linha de origem, identidade da correcção, montante, data, autor e motivo sintético.
|
||||
|
||||
| Acontecimento | Valor do teste | Saldo aritmético por receber |
|
||||
| --- | ---: | ---: |
|
||||
| Documento original preservado | 1 000,00 | 1 000,00 |
|
||||
| Primeiro recebimento | 400,00 | 600,00 |
|
||||
| Correcção redutora independente | 100,00 | 500,00 |
|
||||
| Segundo recebimento | 500,00 | 0,00 |
|
||||
|
||||
Resultado: 1 000,00 − 100,00 − 400,00 − 500,00 = 0,00. A correcção não apaga nem altera silenciosamente o original. A forma documental legal da correcção permanece por validar.
|
||||
|
||||
Este cenário distingue três provas: saldo operacional explicado, contabilização válida e fecho válido. A primeira não demonstra automaticamente as outras duas. Um perfil artificial de cálculo/contabilização, se aprovado para ensaio, será exclusivo de testes, identificado como sintético e impossibilitado de activar regras reais em desenvolvimento.
|
||||
|
||||
### Reconciliação exclusivamente simulada
|
||||
|
||||
Comparar os dois recebimentos/afectações com duas observações de fonte sintética, identificadas como simulação e contendo identificador, documento de referência, moeda, montante, data e estado de teste. Uma observação repetida não cria outro recebimento. Demonstrar correspondência individual e total, além de uma variante com divergência que permanece pendente.
|
||||
|
||||
Não consultar bancos, iniciar pagamentos, transferir fundos, executar débitos ou apresentar observações artificiais como extractos reais. A prova demonstra o mecanismo e os controlos incluídos, não integração ou liquidação bancária real.
|
||||
|
||||
## Significado de fecho neste cenário
|
||||
|
||||
Fecho significa o encerramento controlado de um período contabilístico de teste, com evidência preservada. Não significa apenas terminar o formulário, marcar a prestação como concluída ou obter saldo por receber igual a zero. Não é submissão fiscal ou bancária.
|
||||
|
||||
O fecho proposto deve:
|
||||
|
||||
1. Identificar empresa, período, corte e versões de regras/dados consideradas.
|
||||
2. Verificar que os acontecimentos elegíveis foram capturados, processados e ligados aos documentos/afectações, sem efeitos perdidos ou repetidos.
|
||||
3. Conferir o sub-razão, a reconciliação simulada e os lançamentos necessários, com débitos/créditos e saldos explicáveis sob um perfil de teste explicitamente aprovado.
|
||||
4. Recusar encerramento integral se houver pendência material, divergência, classificação em falta ou configuração insuficiente. Mostrar o motivo sem inventar uma conta/regra.
|
||||
5. Preservar evidências e actor responsável, passar de aberto para em fecho/encerrado conforme validações e impedir gravações concorrentes no período encerrado.
|
||||
|
||||
Eventos tardios ou correcções posteriores não reabrem nem reescrevem o período automaticamente. O tratamento deve ser uma operação autorizada segundo política ainda por validar. Sem perfil contabilístico suficientemente definido, a prova esperada é a recusa fundamentada do fecho; não uma conclusão artificial de conformidade.
|
||||
|
||||
## Dados mínimos propostos
|
||||
|
||||
| Entidade | Dados e vínculos iniciais |
|
||||
| --- | --- |
|
||||
| Contraparte com papel de cliente | Identificador estável, empresa, nome e papel de cliente; não duplicar o futuro cadastro de fornecedor |
|
||||
| Serviço | Identificador, empresa, nome e descrição; sem preço comercial ou regra fiscal por omissão |
|
||||
| Prestação | Empresa, unidade, cliente, linhas de serviço, descrição, quantidade, data da prestação e estado |
|
||||
| Rascunho de facturação | Origem da prestação, cliente, empresa/unidade, moeda, linhas, preços propostos, versões e pendências |
|
||||
| Documento confirmado futuro | Identidade própria e fotografia dos dados/regras efectivamente aplicados; numeração e validade dependem de decisão |
|
||||
| Recebimento futuro | Identidade, empresa, cliente, data, moeda, montante, origem/evidência, estado e afectações aos documentos |
|
||||
| Correcção futura | Referência ao original, linhas/valores abrangidos, motivo, autor e efeitos aprovados |
|
||||
| Evento durável | Identidade, origem/versionamento, empresa/unidade, instante, tipo, correlação e processamento |
|
||||
| Pendência contabilística | Evento, informação/regra em falta, estado, motivo e historial de resolução |
|
||||
|
||||
Não acrescentar já contratos recorrentes, horas, equipas de projecto, custos, campos fiscais presumidos ou stock. A moeda funcional, precisão e regras de cálculo da empresa não são inferidas do exemplo de teste.
|
||||
|
||||
Identificadores de tenant, empresa e unidade vêm do contexto autorizado. Cliente e serviço devem pertencer à empresa da prestação. Uma escolha de cliente reutiliza o cadastro; documentos confirmados futuros preservam a fotografia histórica necessária em vez de dependerem do cadastro mutável.
|
||||
|
||||
## Estados propostos
|
||||
|
||||
- Prestação: rascunho, confirmada ou cancelada antes de confirmação. Uma correcção posterior referencia a prestação confirmada; não elimina a origem.
|
||||
- Facturação: rascunho, com pendências, pronta para validação e confirmada apenas com regras/configuração suficientes. Guardar um rascunho não equivale a emitir factura.
|
||||
- Cobrança: sem afectação, parcialmente recebida ou saldo liquidado, derivados dos montantes e estados admissíveis, não de um botão livre.
|
||||
- Recebimento: registado, com evidência por verificar, conciliado ou corrigido por operação rastreável. Os critérios concretos de evidência e liquidação serão aprovados.
|
||||
- Contabilidade: capturado, pendente, pronto, contabilizado ou sem efeito justificado. Falha técnica fica distinguida de informação em falta.
|
||||
- Período: aberto, em fecho ou encerrado, sujeito à política a validar.
|
||||
|
||||
O primeiro incremento não deve apresentar os estados futuros como capacidades já entregues.
|
||||
|
||||
## Regras técnicas propostas
|
||||
|
||||
1. Guardar preços, quantidades e montantes com precisão controlada; usar brick/math existente no servidor e cadeias decimais nas APIs. Não usar o JavaScript como autoridade financeira.
|
||||
2. Validar escala e arredondamento explicitamente. A política financeira/fiscal precisa de aprovação; não depender de coerção silenciosa da base.
|
||||
3. Negar por omissão cada acção e verificar empresa, unidade, registo e permissão no servidor. As concessões actuais de identidade não concedem acesso automático às novas áreas.
|
||||
4. Recusar um formulário com contexto ou versão desactualizados. Mudança de empresa/unidade não relocaliza registos nem reaproveita silenciosamente autorizações anteriores.
|
||||
5. Usar chaves de idempotência por empresa/operação/conteúdo para comandos repetíveis. Repetição idêntica recupera o resultado; conteúdo diferente com a mesma chave origina conflito.
|
||||
6. Confirmar alteração, evento durável e trabalho pendente na mesma transacção. A outbox é uma proposta técnica a implementar no núcleo, sem exigir Redis ou novo fornecedor.
|
||||
7. Não duplicar efeitos contabilísticos quando um evento é repetido ou uma interface comercial é activada posteriormente.
|
||||
8. Sem plano de contas, classificação, imposto, moeda ou reconhecimento aprovados, criar pendências explícitas. Não escolher contas transitórias para ocultar lacunas.
|
||||
9. Manter auditoria das alterações relevantes, sem palavras-passe, tokens ou raciocínio interno da IA.
|
||||
10. Fecho verifica eventos, pendências, reconciliação e integridade, incluindo concorrência. Um saldo operacional nulo não basta para fechar.
|
||||
|
||||
## Ecrãs propostos
|
||||
|
||||
Reutilizar a navegação e o cabeçalho de contexto existentes:
|
||||
|
||||
- Clientes: pesquisar, consultar e preparar o cadastro mínimo de contraparte com papel de cliente.
|
||||
- Serviços: pesquisar e manter o catálogo mínimo.
|
||||
- Prestações: lista, detalhe e formulário com cliente, serviço e origem reutilizados.
|
||||
- Facturação: rascunho ligado à prestação, valores e pendências visíveis; sem aparência de documento fiscal emitido.
|
||||
- Detalhe do percurso: linha do tempo de prestação, documentos, recebimentos, correcções e pendências, conforme cada entrega disponibilizada.
|
||||
- Posteriormente, recebimentos/reconciliação e fecho, depois de aprovadas as regras respectivas.
|
||||
|
||||
As interfaces mostram o que está guardado, o que falta e a próxima acção permitida. Não disponibilizar operações de servidor ainda inexistentes através de botões simulados.
|
||||
|
||||
## Permissões propostas
|
||||
|
||||
Separar consulta e alteração de cadastros, criação/edição/confirmação de prestações, preparação/confirmação de documentos, registo/afectação/correcção de recebimentos, reconciliação e fecho.
|
||||
|
||||
As concessões serão dados parametrizados, mas o catálogo cobre apenas acções suportadas. Gestão de acessos não implica capacidade financeira. Não ampliar automaticamente os direitos de Osmane ou Samira. A matriz concreta e a autoridade inicial de concessão das novas acções precisam de aprovação antes da disponibilização.
|
||||
|
||||
## Entregas verificáveis
|
||||
|
||||
### A Cadastros e preparação operacional
|
||||
|
||||
Proposta para a primeira implementação:
|
||||
|
||||
- Contraparte/cliente e serviço mínimos.
|
||||
- Prestação em rascunho, reutilizando esses cadastros e o contexto existente.
|
||||
- Rascunho de facturação ligado à prestação, sem emissão fiscal ou numeração definitiva.
|
||||
- Captura durável dos acontecimentos abrangidos e pendências explícitas de classificação, sem inventar lançamentos.
|
||||
- Permissões, auditoria, controlo de versão/contexto e repetição segura desde estes primeiros comandos.
|
||||
- Dados sintéticos e provas apenas na base de testes; nenhuma factura ou recebimento real em desenvolvimento.
|
||||
|
||||
Critério: percurso cliente → serviço → prestação → rascunho reproduzível, com origem preservada, acessos indevidos recusados e informação insuficiente assinalada. Isto não é ainda o circuito financeiro completo.
|
||||
|
||||
### B Documentos e contabilidade permanente
|
||||
|
||||
Após validar campos/regras aplicáveis: confirmação documental, fotografia histórica, eventos/outbox, consumidor repetível, regras de contabilização e pendências. Demonstrar contabilidade permanente mesmo sem interface comercial de Contabilidade contratada.
|
||||
|
||||
### C Recebimentos correcção reconciliação e fecho
|
||||
|
||||
Completar o cenário sintético, incluindo os dois recebimentos, correcção ligada ao original, reconciliação explicável e tentativas de fecho com e sem pendências. Demonstrar concorrência, interrupções e recuperação. Utilização real fica dependente de regras e responsáveis aprovados.
|
||||
|
||||
Demonstrar separadamente o caso principal (400,00 + 600,00 liquidam 1 000,00) e a variante de correcção (400,00 + 500,00 liquidam o saldo líquido de 900,00). A reconciliação é sempre identificada como simulada.
|
||||
|
||||
Estas entregas partilham o mesmo núcleo. Não criam três produtos ou versões divergentes.
|
||||
|
||||
## Critérios de aceitação
|
||||
|
||||
- Cliente/serviço de outra empresa ou unidade não autorizada recusados por URL/API, identificador manipulado ou tarefa.
|
||||
- Utilizador de consulta não consegue alterar, confirmar, receber, corrigir ou fechar.
|
||||
- Direitos revogados e contexto alterado produzem recusa no pedido seguinte.
|
||||
- Um cliente e uma prestação são reutilizados na facturação sem recaptura ou duplicação de cadastros.
|
||||
- Guardar rascunho, repetir comando e retomar depois de falha não duplica documentos/eventos.
|
||||
- Documento confirmado futuro conserva dados e regras mesmo que o cadastro seja alterado.
|
||||
- Os valores do cenário e todas as afectações são explicáveis pelas origens e calculados com decimais controlados.
|
||||
- Falta de regra fiscal/contabilística gera pendência, não taxa zero, conta inventada ou estado contabilizado.
|
||||
- Correcção conserva o original, motivo e vínculos, sem edição directa de lançamentos confirmados.
|
||||
- Falhas antes/depois do commit e dois consumidores concorrentes não deixam efeitos parciais ou duplicados.
|
||||
- Fecho com lacuna material é recusado; lançamento concorrente não ultrapassa período encerrado.
|
||||
- Activação posterior de interface de Contabilidade não repete efeitos já produzidos.
|
||||
- Testes destrutivos continuam restritos a sgf_tempo_test e fixtures não entram como operações da Tempo Lda.
|
||||
|
||||
## Decisões pendentes antes das entregas correspondentes
|
||||
|
||||
Para A: aprovar esta delimitação e a matriz de permissões/autoridade inicial; confirmar quais dados mínimos do cliente são indispensáveis para um rascunho (a proposta inicial exige apenas nome).
|
||||
|
||||
Para B/C: responsáveis de validação contabilística/fiscal, documentos e numeração, regime/impostos aplicáveis, moeda funcional, plano de contas, reconhecimento, arredondamento, evidências de recebimento/reconciliação, corte e regras de fecho/correcção. O exemplo de teste não decide estas matérias.
|
||||
|
||||
Não seleccionar fornecedor, API bancária ou integração de pagamento. Não construir agora plano comercial, motor geral de parâmetros, IA, sincronização offline ou toda a orquestração Camaleão. As lacunas não justificam codificar valores de negócio fixos como substituto de validação.
|
||||
|
||||
## Estado desta etapa
|
||||
|
||||
Especificação proposta para revisão. Nenhuma migração, cadastro, documento, evento financeiro ou lançamento foi criado nesta etapa. O incremento de identidade está aceite e concluído; qualquer implementação posterior exige autorização sobre uma entrega concreta. O utilizador manteve expressamente serviços apenas em especificação e não autorizou implementação financeira ou publicação. Não implementar a entrega A nesta revisão documental.
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"environment": "sgf_tempo_test, browser-testing, localhost:8001",
|
||||
"results": [
|
||||
"CSRF: pedido de alteração sem token/origem válida recusado (419).",
|
||||
"URLs/API: empresa do mesmo tenant e unidade sem acesso recusadas; consulta não permite edição (403).",
|
||||
"Concessão e revogação: efeito no pedido seguinte em sessão independente (200 → 403).",
|
||||
"Formulário anterior à mudança de contexto recusado (409).",
|
||||
"Gestor limitado não consegue conceder edição que não possui (403).",
|
||||
"Replay do cookie da sessão terminada recusado (401)."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
{
|
||||
"name": "sgf-tempo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sgf-tempo",
|
||||
"dependencies": {
|
||||
"react": "19.2.9",
|
||||
"react-dom": "19.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "6.1.2",
|
||||
"laravel-vite-plugin": "3.2.0",
|
||||
"vite": "8.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
|
||||
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.152.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"oxc-transform-react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
|
||||
"integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"tinyglobby": "^0.2.12",
|
||||
"vite-plugin-full-reload": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"clean-orphaned-assets": "bin/clean.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fontaine": "^0.8.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"fontaine": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.9",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.9.tgz",
|
||||
"integrity": "sha512-RuLdAokXv3sRmZa2/N8QH0KmTBIgDKC+buRUikGqow31+mGSGidLCX2o4XFq8OV+xBFWfhM9DVgTq1PPHADH4w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.9",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.9.tgz",
|
||||
"integrity": "sha512-YceU74W21fXEHszKBLj16MY0CmvpFKOs60iVrIyW/cCgOPsZJNaB6GVJEtIUqt6AG6EjzKLui5lPRrxAeS328Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-full-reload": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
|
||||
"integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"picomatch": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-full-reload/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "sgf-tempo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.9",
|
||||
"react-dom": "19.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"laravel-vite-plugin": "3.2.0",
|
||||
"vite": "8.3.5",
|
||||
"@vitejs/plugin-react": "6.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_URL" value="" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '../css/app.css';
|
||||
|
||||
const labels = { 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [page, setPage] = useState('home');
|
||||
const context = state?.context;
|
||||
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
|
||||
|
||||
async function api(path, method = 'GET', body, token = context?.token) {
|
||||
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { await refresh(); }
|
||||
const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.';
|
||||
throw new Error(message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function refresh() {
|
||||
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Não foi possível carregar a sessão.');
|
||||
const next = await response.json(); setState(next);
|
||||
if (next.notice) setNotice(next.notice);
|
||||
}
|
||||
useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
|
||||
async function perform(operation, message) {
|
||||
setBusy(true); setError(''); setNotice('');
|
||||
try { await operation(); await refresh(); if (message) setNotice(message); }
|
||||
catch (e) { setError(e.message); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function select(companyId, unitId = null) {
|
||||
const company = state.companies.find(c => c.id === Number(companyId));
|
||||
if (!company) return;
|
||||
await perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Os formulários foram recarregados.');
|
||||
}
|
||||
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
|
||||
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
|
||||
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
|
||||
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : [])].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
|
||||
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
|
||||
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
|
||||
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
|
||||
{page === 'home' && <><div className="page-title"><p className="eyebrow">ÁREA INICIAL</p><h1>Bem-vindo, {state.user.name.split(' ')[0]}.</h1><p className="muted">O contexto permanece visível e cada operação é validada pelo servidor.</p></div><div className="summary-grid"><section className="panel"><span className="eyebrow">EMPRESA ACTIVA</span><h2>{context.company.name}</h2><p>Cliente contratante #{context.company.tenant_id} · Empresa #{context.company.id}</p><button onClick={() => setPage('company')}>Consultar empresa</button></section><section className="panel"><span className="eyebrow">UNIDADE ACTIVA</span><h2>{context.unit?.name || 'Ainda não seleccionada'}</h2><p>{context.units.length} unidade(s) com consulta autorizada</p><button onClick={() => setPage('units')}>Ver unidades</button></section></div></>}
|
||||
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
|
||||
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
|
||||
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
||||
</div>}
|
||||
</main><footer>SGF TEMPO · Primeiro percurso funcional · Sem módulos financeiros nesta etapa</footer></div></div>;
|
||||
}
|
||||
function Login({ busy, onSubmit }) {
|
||||
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
|
||||
return <form onSubmit={async e => { e.preventDefault(); await onSubmit(email, password); setPassword(''); }}><label>Email<input type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required /></label><label>Palavra-passe<input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required /></label><button className="primary" disabled={busy} type="submit">{busy ? 'A entrar…' : 'Entrar'}</button></form>;
|
||||
}
|
||||
function NameForm({ label, initial, editable, busy, onSave, button = 'Guardar nome' }) {
|
||||
const [name, setName] = useState(initial);
|
||||
return <form className="name-form" onSubmit={e => { e.preventDefault(); onSave(name); }}><label>{label}<input value={name} onChange={e => setName(e.target.value)} readOnly={!editable} maxLength={255} required /></label>{editable && <button className="primary" disabled={busy || !name.trim()}>{button}</button>}</form>;
|
||||
}
|
||||
function AccessPanel({ context, can, api, perform, busy }) {
|
||||
const [list, setList] = useState(null); const [loadError, setLoadError] = useState('');
|
||||
const [email, setEmail] = useState(''); const [scope, setScope] = useState('company'); const [permission, setPermission] = useState('company.view'); const [reason, setReason] = useState('');
|
||||
const unit = scope === 'company' ? null : Number(scope);
|
||||
const options = (unit === null ? ['company.view', 'company.edit', 'unit.create', 'access.manage'] : ['unit.view', 'unit.edit']).filter(p => can(p, unit));
|
||||
useEffect(() => { api('/api/accesses').then(setList).catch(e => setLoadError(e.message)); }, [context.token]);
|
||||
async function change(grant) { await perform(async () => { await api(grant ? '/api/accesses' : '/api/accesses/revoke', 'POST', { email, permission, unit_id: unit, reason }, context.token); setList(await api('/api/accesses')); }, grant ? 'Concessão registada.' : 'Revogação registada.'); }
|
||||
return <><section className="panel"><p className="eyebrow">AUTORIZAÇÕES EXPLÍCITAS</p><h1>Gestão de acessos</h1><p>Actua apenas nesta empresa e pode delegar apenas direitos que possui no mesmo âmbito. Os utilizadores são preparados pelo procedimento local.</p>{loadError && <p role="alert">{loadError}</p>}<form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email exacto do utilizador<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><div className="form-grid"><label>Âmbito<select value={scope} onChange={e => { setScope(e.target.value); setPermission(e.target.value === 'company' ? 'company.view' : 'unit.view'); }}><option value="company">Empresa activa</option>{context.units.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}</select></label><label>Permissão<select value={permission} onChange={e => setPermission(e.target.value)}>{options.map(p => <option key={p} value={p}>{labels[p]}</option>)}</select></label></div><label>Motivo da alteração<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><p className="help">Não escreva palavras-passe ou outros segredos no motivo. A revogação de consulta da empresa remove também os direitos dependentes, apenas se tiver autoridade sobre todos eles. Revogar a sua própria gestão pode exigir recuperação assistida de acessos.</p><div className="actions"><button className="primary" disabled={busy || !options.includes(permission)}>Conceder acesso</button><button type="button" disabled={busy || !email || !reason || !options.includes(permission)} onClick={() => change(false)}>Revogar acesso</button></div></form></section><section className="panel"><h2>Acessos nesta empresa</h2>{list ? <div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Permissão</th><th>Âmbito</th></tr></thead><tbody>{list.grants.map(g => { const member = list.members.find(m => m.id === g.user_id); return <tr key={g.id}><td>{member?.name}<small>{member?.email}</small></td><td>{labels[g.permission]}</td><td>{g.unit_id ? context.units.find(u => u.id === g.unit_id)?.name || 'Unidade autorizada' : 'Empresa'}</td></tr>; })}</tbody></table></div> : <p>A carregar acessos…</p>}</section></>;
|
||||
}
|
||||
createRoot(document.getElementById('app')).render(<App />);
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-MZ">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SGF Tempo</title>
|
||||
@viteReactRefresh
|
||||
@vite('resources/js/app.jsx')
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Identity\Http\RequireCompanyContext;
|
||||
use App\Modules\Identity\Http\IdentityController;
|
||||
use App\Modules\Identity\Http\RequireValidSession;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('app');
|
||||
});
|
||||
|
||||
Route::get('/api/session', [IdentityController::class, 'session']);
|
||||
Route::post('/api/login', [IdentityController::class, 'login']);
|
||||
Route::middleware(['auth', RequireValidSession::class])->group(function (): void {
|
||||
Route::post('/api/logout', [IdentityController::class, 'logout']);
|
||||
Route::post('/api/context', [IdentityController::class, 'select']);
|
||||
Route::middleware(RequireCompanyContext::class)->group(function (): void {
|
||||
Route::get('/api/context', fn (\App\Shared\Context\CompanyContext $context) => response()->json($context->identifiers() + ['unit_id' => $context->unitId()]));
|
||||
Route::patch('/api/companies/{company}', [IdentityController::class, 'renameCompany']);
|
||||
Route::get('/api/companies/{company}', [IdentityController::class, 'company']);
|
||||
Route::get('/api/units/{unit}', [IdentityController::class, 'unit']);
|
||||
Route::patch('/api/units/{unit}', [IdentityController::class, 'renameUnit']);
|
||||
Route::post('/api/units', [IdentityController::class, 'createUnit']);
|
||||
Route::get('/api/accesses', [IdentityController::class, 'accesses']);
|
||||
Route::post('/api/accesses', [IdentityController::class, 'grant']);
|
||||
Route::post('/api/accesses/revoke', [IdentityController::class, 'revoke']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
param([ValidateSet('start','stop')][string]$Action = 'start')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
|
||||
$sgfPhp = 'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
|
||||
$sgfRun = 'C:\laragon\data\sgf-tempo\run'
|
||||
$recordFile = Join-Path $sgfRun 'browser-proof.json'
|
||||
Set-Location -LiteralPath $sgfProject
|
||||
if ($Action -eq 'stop') {
|
||||
if (Test-Path -LiteralPath $recordFile) {
|
||||
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
|
||||
$process = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
|
||||
if ($process) {
|
||||
if ($process.Path -ne $sgfPhp -or $process.StartTime.ToUniversalTime().ToString('o') -ne $record.started) { throw 'Process identity mismatch; no process stopped.' }
|
||||
Stop-Process -Id $process.Id
|
||||
}
|
||||
Remove-Item -LiteralPath $recordFile
|
||||
}
|
||||
$fixtureFile = 'C:\laragon\data\sgf-tempo\secrets\browser-fixture.json'
|
||||
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile }
|
||||
Write-Output 'Browser proof server stopped. Synthetic data remains only in sgf_tempo_test until the next test run.'
|
||||
exit 0
|
||||
}
|
||||
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 occupied; no process was stopped.' }
|
||||
if (Test-Path -LiteralPath $recordFile) { throw 'Browser proof record exists; stop that proof first.' }
|
||||
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
|
||||
if ($LASTEXITCODE) { throw 'Could not prepare synthetic fixtures.' }
|
||||
$utf8 = New-Object Text.UTF8Encoding $false
|
||||
$lines = Get-Content -LiteralPath '.env.testing' | Where-Object { $_ -notmatch '^(APP_ENV|APP_URL|SESSION_DRIVER|SESSION_COOKIE|SESSION_FILES|CACHE_STORE|CACHE_FILE_PATH|CACHE_LOCK_PATH)=' }
|
||||
$lines += @('APP_ENV=browser-testing','APP_URL=http://127.0.0.1:8001','SESSION_DRIVER=file','SESSION_COOKIE=sgf_browser_proof',
|
||||
'SESSION_FILES=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/sessions', 'CACHE_STORE=file',
|
||||
'CACHE_FILE_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/cache', 'CACHE_LOCK_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/locks')
|
||||
[IO.File]::WriteAllLines((Join-Path $sgfProject '.env.browser-testing'), $lines, $utf8)
|
||||
$account = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
& icacls.exe '.env.browser-testing' /inheritance:r /grant:r "${account}:F" 'SYSTEM:F' | Out-Null
|
||||
New-Item -ItemType Directory -Path 'storage\framework\browser-proof\sessions','storage\framework\browser-proof\cache','storage\framework\browser-proof\locks' -Force | Out-Null
|
||||
$env:APP_ENV = 'browser-testing'
|
||||
try {
|
||||
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 became occupied; no process stopped.' }
|
||||
$process = Start-Process -FilePath $sgfPhp -ArgumentList @('-S','127.0.0.1:8001','-t','public','scripts/router.php') -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\browser-proof.stdout.log" -RedirectStandardError "$sgfRun\browser-proof.stderr.log"
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $recordFile -Encoding UTF8
|
||||
} finally { Remove-Item Env:APP_ENV -ErrorAction SilentlyContinue }
|
||||
Write-Output 'Browser proof: http://127.0.0.1:8001 — synthetic data only, isolated session/cache, CSRF enabled.'
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { fwrite(STDERR, "Invalid local destination.\n"); exit(1); }
|
||||
$tables = \Illuminate\Support\Facades\DB::select("SELECT relname, has_table_privilege(current_user, c.oid, 'SELECT') AS can_read, has_table_privilege(current_user, c.oid, 'INSERT') AS can_insert, has_table_privilege(current_user, c.oid, 'UPDATE') AS can_update FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND relname IN ('users','tenants','companies','company_memberships','permission_grants','identity_audit') ORDER BY relname");
|
||||
$sequences = \Illuminate\Support\Facades\DB::select("SELECT relname, has_sequence_privilege(current_user, c.oid, 'USAGE') AS can_use FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND relkind = 'S' ORDER BY relname");
|
||||
echo json_encode(['guard' => 'passed', 'tables' => $tables, 'sequences' => $sequences], JSON_PRETTY_PRINT).PHP_EOL;
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const base = 'http://127.0.0.1:8001';
|
||||
const fixture = JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json', 'utf8'));
|
||||
const results = [];
|
||||
function client() {
|
||||
const cookies = new Map();
|
||||
let csrf = ''; let context = '';
|
||||
return {
|
||||
cookies,
|
||||
async request(path, method = 'GET', body, {token = context, csrfHeader = true} = {}) {
|
||||
const headers = {Accept:'application/json', 'Content-Type':'application/json', Cookie:[...cookies].map(([k,v]) => `${k}=${v}`).join('; '), 'X-SGF-Context':token};
|
||||
if (csrfHeader) headers['X-CSRF-TOKEN'] = csrf;
|
||||
const response = await fetch(base+path, {method, headers, ...(body === undefined ? {} : {body:JSON.stringify(body)})});
|
||||
for (const cookie of response.headers.getSetCookie()) { const pair=cookie.split(';',1)[0]; const split=pair.indexOf('='); cookies.set(pair.slice(0,split),pair.slice(split+1)); }
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data.csrf) csrf=data.csrf;
|
||||
if (data.context?.token) context=data.context.token;
|
||||
return {status:response.status,data};
|
||||
},
|
||||
async login(email) {
|
||||
assert.equal((await this.request('/api/session')).status,200);
|
||||
assert.equal((await this.request('/api/login','POST',{email,password:fixture.password})).status,200);
|
||||
},
|
||||
async select(company=fixture.company,unit=fixture.unit) {
|
||||
const result=await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:company,unit_id:unit});
|
||||
assert.equal(result.status,200); return result.data.context.token;
|
||||
}
|
||||
};
|
||||
}
|
||||
const manager=client(); await manager.login('gestor@example.test'); const token=await manager.select();
|
||||
assert.equal((await manager.request(`/api/companies/${fixture.company}`,'PATCH',{name:'CSRF must refuse'},{csrfHeader:false})).status,419);
|
||||
results.push('CSRF: pedido de alteração sem token/origem válida recusado (419).');
|
||||
const reader=client(); await reader.login('consulta@example.test'); const readerToken=await reader.select();
|
||||
assert.equal((await reader.request(`/api/companies/${fixture.sibling}`)).status,403);
|
||||
assert.equal((await reader.request(`/api/units/${fixture.hidden}`)).status,403);
|
||||
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Not allowed'})).status,403);
|
||||
results.push('URLs/API: empresa do mesmo tenant e unidade sem acesso recusadas; consulta não permite edição (403).');
|
||||
const grant={email:'consulta@example.test',permission:'company.edit',reason:'Prova HTTP sintética'};
|
||||
assert.equal((await manager.request('/api/accesses','POST',grant)).status,200);
|
||||
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Empresa Horizonte — Prova HTTP'})).status,200);
|
||||
assert.equal((await manager.request('/api/accesses/revoke','POST',grant)).status,200);
|
||||
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Revoked'})).status,403);
|
||||
results.push('Concessão e revogação: efeito no pedido seguinte em sessão independente (200 → 403).');
|
||||
await manager.select(fixture.sibling,null);
|
||||
assert.equal((await manager.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Stale'},{token})).status,409);
|
||||
results.push('Formulário anterior à mudança de contexto recusado (409).');
|
||||
const limited=client(); await limited.login('limitado@example.test'); await limited.select();
|
||||
assert.equal((await limited.request('/api/accesses','POST',grant)).status,403);
|
||||
results.push('Gestor limitado não consegue conceder edição que não possui (403).');
|
||||
const oldCookies=new Map(reader.cookies);
|
||||
assert.equal((await reader.request('/api/logout','POST')).status,200);
|
||||
reader.cookies.clear(); for(const [key,value] of oldCookies) reader.cookies.set(key,value);
|
||||
assert.equal((await reader.request('/api/context')).status,401);
|
||||
results.push('Replay do cookie da sessão terminada recusado (401).');
|
||||
await manager.request('/api/logout','POST'); await limited.request('/api/logout','POST');
|
||||
await fs.writeFile(new URL('../docs/proofs/http-results.json',import.meta.url),JSON.stringify({environment:'sgf_tempo_test, browser-testing, localhost:8001',results},null,2));
|
||||
console.log(results.join('\n'));
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$db = \Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS db, current_user AS role');
|
||||
$counts = [];
|
||||
foreach (['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
|
||||
$counts[$table] = \Illuminate\Support\Facades\Schema::hasTable($table) ? \Illuminate\Support\Facades\DB::table($table)->count() : 'pending migration';
|
||||
}
|
||||
echo json_encode(['database' => $db->db, 'role' => $db->role, 'counts' => $counts], JSON_PRETTY_PRINT).PHP_EOL;
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
|
||||
foreach (['users', 'tenants', 'companies', 'units'] as $table) {
|
||||
if (\Illuminate\Support\Facades\DB::table($table)->exists()) {
|
||||
fwrite(STDERR, "Test database is not empty. No data was deleted. Stop other test workflows and prepare the test database explicitly.\n"); exit(1);
|
||||
}
|
||||
}
|
||||
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\IdentityFixture::create());
|
||||
$safe = array_intersect_key($fixture, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
|
||||
file_put_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json', json_encode($safe, JSON_THROW_ON_ERROR));
|
||||
echo "Synthetic browser fixtures prepared only in sgf_tempo_test. Credentials stored locally with restricted ACL; not displayed.\n";
|
||||
@@ -0,0 +1,44 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Email,
|
||||
[Parameter(Mandatory=$true)][string]$Name,
|
||||
[Parameter(Mandatory=$true)][string]$Operator,
|
||||
[Parameter(Mandatory=$true)][string]$Reason
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$project = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if ($project -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
|
||||
Set-Location -LiteralPath $project
|
||||
$OutputEncoding = New-Object Text.UTF8Encoding $false
|
||||
[Console]::OutputEncoding = $OutputEncoding
|
||||
$sgfPhp = 'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
|
||||
$arguments = @('artisan','identity:provision',"--email=$Email","--name=$Name","--operator=$Operator","--reason=$Reason")
|
||||
& $sgfPhp @arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Preview refused. No account created.' }
|
||||
$answer = Read-Host 'Confirma criar apenas esta conta, sem permissoes automaticas? [sim/nao]'
|
||||
if ($answer -notin @('sim','s','yes','y')) { Write-Host 'Operacao cancelada. Nada criado.'; exit 1 }
|
||||
Write-Host 'Defina a palavra-passe apenas neste terminal. Minimo 12 caracteres; maximo 72 bytes UTF-8.'
|
||||
while ($true) {
|
||||
$first = Read-Host 'Nova palavra-passe' -AsSecureString
|
||||
$repeat = Read-Host 'Repita a palavra-passe' -AsSecureString
|
||||
$firstPtr = [IntPtr]::Zero; $repeatPtr = [IntPtr]::Zero
|
||||
$plainFirst = $null; $plainRepeat = $null; $payload = $null
|
||||
try {
|
||||
$firstPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($first)
|
||||
$repeatPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($repeat)
|
||||
$plainFirst = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($firstPtr)
|
||||
$plainRepeat = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($repeatPtr)
|
||||
if ($plainFirst.Length -lt 12) { Write-Host 'Use pelo menos 12 caracteres. Tente novamente.' -ForegroundColor Yellow; continue }
|
||||
if ([Text.Encoding]::UTF8.GetByteCount($plainFirst) -gt 72) { Write-Host 'Excede 72 bytes. Tente novamente.' -ForegroundColor Yellow; continue }
|
||||
if ($plainFirst -cne $plainRepeat) { Write-Host 'As entradas nao coincidem. Tente novamente.' -ForegroundColor Yellow; continue }
|
||||
$payload = @{confirmed=$true;email=$Email.Trim().ToLowerInvariant();password=$plainFirst;confirmation=$plainRepeat} | ConvertTo-Json -Compress
|
||||
$payload | & $sgfPhp @arguments --apply --protected-stdin
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Account creation refused. Copy only the error message, never the password.' }
|
||||
Write-Host 'Conta criada sem acessos automaticos. Um gestor autorizado pode agora conceder os direitos na aplicacao.' -ForegroundColor Green
|
||||
break
|
||||
} finally {
|
||||
if ($firstPtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($firstPtr) }
|
||||
if ($repeatPtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($repeatPtr) }
|
||||
$plainFirst=$null; $plainRepeat=$null; $payload=$null
|
||||
$first.Dispose(); $repeat.Dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
$public = realpath(__DIR__.'/../public');
|
||||
$uri = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/');
|
||||
$file = realpath($public.$uri);
|
||||
if ($uri !== '/' && $file && str_starts_with($file, $public.DIRECTORY_SEPARATOR) && is_file($file)) {
|
||||
return false;
|
||||
}
|
||||
require $public.'/index.php';
|
||||
@@ -0,0 +1,130 @@
|
||||
param(
|
||||
[ValidateSet('start', 'stop', 'status', 'frontend', 'php', 'composer', 'npm', 'test', 'migrate')]
|
||||
[string]$Action = 'status',
|
||||
[Parameter(ValueFromRemainingArguments = $true)] [string[]]$ToolArguments
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
|
||||
$sgfTools = 'C:\laragon\tools\sgf-tempo'
|
||||
$sgfData = 'C:\laragon\data\sgf-tempo'
|
||||
$sgfCluster = Join-Path $sgfData 'postgres-17'
|
||||
$sgfPg = 'C:\Program Files\PostgreSQL\17\bin'
|
||||
$sgfPhp = "$sgfTools\php-8.4.27\php.exe"
|
||||
$sgfNode = "$sgfTools\node-v24.22.1-win-arm64\node.exe"
|
||||
$sgfComposer = "$sgfTools\composer-2.10.3\composer.phar"
|
||||
$sgfNpmCli = "$sgfTools\node-v24.22.1-win-arm64\node_modules\npm\bin\npm-cli.js"
|
||||
$sgfRun = Join-Path $sgfData 'run'
|
||||
$env:PATH = "$sgfTools\php-8.4.27;$sgfTools\node-v24.22.1-win-arm64;" + $env:PATH
|
||||
$env:COMPOSER_HOME = "$sgfTools\composer-home"
|
||||
$env:COMPOSER_CACHE_DIR = "$sgfTools\composer-cache"
|
||||
$env:npm_config_cache = "$sgfTools\npm-cache"
|
||||
$env:npm_config_userconfig = "$sgfTools\npmrc"
|
||||
Remove-Item Env:COMPOSER_AUTH -ErrorAction SilentlyContinue
|
||||
Set-Location -LiteralPath $sgfProject
|
||||
New-Item -ItemType Directory -Path $sgfRun -Force | Out-Null
|
||||
|
||||
function Assert-FreePort([int]$Port) {
|
||||
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
|
||||
throw "Port $Port is occupied. No process was stopped."
|
||||
}
|
||||
}
|
||||
function Start-SgfDatabase {
|
||||
$listeners = @(Get-NetTCPConnection -LocalPort 55432 -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($listeners.Count) {
|
||||
$pidFile = Join-Path $sgfCluster 'postmaster.pid'
|
||||
if (!(Test-Path -LiteralPath $pidFile)) { throw 'Port 55432 belongs to another process.' }
|
||||
$clusterPid = [int](Get-Content -LiteralPath $pidFile -TotalCount 1)
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $clusterPid"
|
||||
$normalizedCommand = if ($process) { $process.CommandLine.Replace('/', '\') } else { '' }
|
||||
if (($listeners.OwningProcess -notcontains $clusterPid) -or !$process -or $process.ExecutablePath -ne "$sgfPg\postgres.exe" -or $normalizedCommand -notlike "*$sgfCluster*") {
|
||||
throw 'Port 55432 is not owned by the SGF cluster. No process was stopped.'
|
||||
}
|
||||
return
|
||||
}
|
||||
Assert-FreePort 55432
|
||||
& "$sgfPg\pg_ctl.exe" -D $sgfCluster -l (Join-Path $sgfData 'postgres.log') -w start
|
||||
if ($LASTEXITCODE) { throw 'SGF database start failed.' }
|
||||
}
|
||||
function Start-SgfProcess([string]$Name, [string]$Executable, [string[]]$Arguments, [int]$Port) {
|
||||
Assert-FreePort $Port
|
||||
$process = Start-Process -FilePath $Executable -ArgumentList $Arguments -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\$Name.stdout.log" -RedirectStandardError "$sgfRun\$Name.stderr.log"
|
||||
$record = @{pid=$process.Id; executable=$Executable; project=$sgfProject; port=$Port; started=$process.StartTime.ToUniversalTime().ToString('o')}
|
||||
$record | ConvertTo-Json | Set-Content -LiteralPath "$sgfRun\$Name.json" -Encoding UTF8
|
||||
$ready = $false
|
||||
for ($attempt = 0; $attempt -lt 40; $attempt++) {
|
||||
$process.Refresh()
|
||||
if ($process.HasExited) { throw "$Name exited. See SGF run logs." }
|
||||
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
|
||||
if ($listeners.OwningProcess -contains $process.Id) { $ready = $true; break }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
if (!$ready) { throw "$Name did not become ready. No other process was stopped." }
|
||||
Write-Output "$Name ready at http://127.0.0.1:$Port"
|
||||
}
|
||||
|
||||
switch ($Action) {
|
||||
'start' {
|
||||
Assert-FreePort 8000
|
||||
Start-SgfDatabase
|
||||
Start-SgfProcess 'web' $sgfPhp @('-S', '127.0.0.1:8000', '-t', 'public', 'scripts/router.php') 8000
|
||||
}
|
||||
'frontend' {
|
||||
Start-SgfProcess 'vite' $sgfNode @('node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '5173', '--strictPort') 5173
|
||||
}
|
||||
'status' {
|
||||
& "$sgfPg\pg_ctl.exe" -D $sgfCluster status
|
||||
Get-NetTCPConnection -LocalPort 55432,8000,5173 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress,LocalPort,OwningProcess
|
||||
}
|
||||
'stop' {
|
||||
foreach ($name in @('vite', 'web')) {
|
||||
$recordFile = "$sgfRun\$name.json"
|
||||
if (Test-Path -LiteralPath $recordFile) {
|
||||
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
|
||||
$process = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
|
||||
if ($process) {
|
||||
if ($process.Path -ne $record.executable -or $record.project -ne $sgfProject -or $process.StartTime.ToUniversalTime().ToString('o') -ne $record.started) {
|
||||
throw "Process identity mismatch for $name; refusing to stop it."
|
||||
}
|
||||
Stop-Process -Id $process.Id
|
||||
}
|
||||
Remove-Item -LiteralPath $recordFile
|
||||
if ($name -eq 'vite') {
|
||||
$hotFile = Join-Path $sgfProject 'public\hot'
|
||||
if ((Test-Path -LiteralPath $hotFile) -and (Get-Content -LiteralPath $hotFile -Raw).Trim() -eq 'http://127.0.0.1:5173') {
|
||||
Remove-Item -LiteralPath $hotFile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
& "$sgfPg\pg_ctl.exe" -D $sgfCluster -m fast -w stop
|
||||
if ($LASTEXITCODE -ne 0 -and (Test-Path -LiteralPath "$sgfCluster\postmaster.pid")) { throw 'SGF cluster stop failed.' }
|
||||
}
|
||||
'php' { & $sgfPhp @ToolArguments; if ($LASTEXITCODE) { throw 'PHP command failed.' } }
|
||||
'composer' { & $sgfPhp $sgfComposer @ToolArguments; if ($LASTEXITCODE) { throw 'Composer command failed.' } }
|
||||
'npm' { & $sgfNode $sgfNpmCli @ToolArguments; if ($LASTEXITCODE) { throw 'npm command failed.' } }
|
||||
'test' {
|
||||
if (Test-Path -LiteralPath "$sgfRun\browser-proof.json") { throw 'Stop the browser proof before tests reset sgf_tempo_test.' }
|
||||
Start-SgfDatabase
|
||||
& $sgfPhp 'vendor/phpunit/phpunit/phpunit' @ToolArguments
|
||||
if ($LASTEXITCODE) { throw 'Tests failed.' }
|
||||
}
|
||||
'migrate' {
|
||||
Start-SgfDatabase
|
||||
$credentials = Get-Content -LiteralPath "$sgfData\secrets\database.json" -Raw | ConvertFrom-Json
|
||||
$env:APP_ENV = 'local'
|
||||
$env:DB_CONNECTION = 'pgsql'
|
||||
$env:DB_HOST = '127.0.0.1'
|
||||
$env:DB_PORT = '55432'
|
||||
$env:DB_DATABASE = 'sgf_tempo_dev'
|
||||
$env:DB_USERNAME = 'sgf_dev_owner'
|
||||
$env:DB_PASSWORD = $credentials.dev_owner
|
||||
$env:DB_URL = ''
|
||||
try {
|
||||
& $sgfPhp artisan migrate --no-interaction
|
||||
if ($LASTEXITCODE) { throw 'Development migration failed.' }
|
||||
} finally {
|
||||
foreach ($name in @('APP_ENV','DB_CONNECTION','DB_HOST','DB_PORT','DB_DATABASE','DB_USERNAME','DB_PASSWORD','DB_URL')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$identity = DB::selectOne('SELECT current_database() AS db, current_user AS role');
|
||||
$privileges = DB::selectOne("SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user");
|
||||
$ddl = DB::selectOne("SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS allowed");
|
||||
$crossDatabase = DB::selectOne("SELECT has_database_privilege(current_user, 'sgf_tempo_test', 'CONNECT') AS allowed");
|
||||
if ($identity->db !== 'sgf_tempo_dev' || $identity->role !== 'sgf_dev_app'
|
||||
|| $privileges->rolsuper || $privileges->rolcreatedb || $privileges->rolcreaterole
|
||||
|| $privileges->rolbypassrls || $ddl->allowed || $crossDatabase->allowed) {
|
||||
fwrite(STDERR, "Development database identity/privilege check failed.\n");
|
||||
exit(1);
|
||||
}
|
||||
if (DB::table('users')->where('email', 'like', '%@example.test')->exists()) {
|
||||
fwrite(STDERR, "Synthetic test accounts must not be present in development.\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Development connection: sgf_dev_app; no administration, schema creation or test database access. No test fixtures in development.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
try {
|
||||
[$password, $confirmation] = \App\Modules\Identity\Services\ProtectedProvisioningInput::read(STDIN, 'synthetic@example.test');
|
||||
$expected = getenv('SGF_SYNTHETIC_HASH');
|
||||
if (! is_string($expected) || $expected === '' || ! hash_equals($expected, hash('sha256', $password)) || $password !== $confirmation) { exit(1); }
|
||||
echo "Protected PowerShell-to-PHP transport: OK (synthetic input, no database writes).\n";
|
||||
} catch (\Throwable) { fwrite(STDERR, "Protected transport verification failed; payload not displayed.\n"); exit(1); }
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,10 @@
|
||||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
lsp-*.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!data/
|
||||
!locks/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||