diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php
index 9fa91a6..309e684 100644
--- a/app/Http/Controllers/CandidatePortalController.php
+++ b/app/Http/Controllers/CandidatePortalController.php
@@ -16,6 +16,7 @@ use App\Services\CurrentActor;
use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -24,6 +25,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CandidatePortalController extends Controller
{
@@ -69,7 +71,8 @@ class CandidatePortalController extends Controller
public function login(Request $request, int $company, CandidateProfiles $profiles): Response
{
abort_unless($profiles->portal($company) !== null, 404);
- $data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128']);
+ $data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128',
+ 'opportunity_id' => 'nullable|integer|min:1|max:2147483647']);
$account = CandidateAccount::query()->where('tenant_id', $company)->where('email', mb_strtolower(trim($data['email'])))->first();
$dummy = Hash::make('candidate-unavailable-account');
$valid = Hash::check($data['password'], $account->password ?? $dummy);
@@ -82,6 +85,18 @@ class CandidatePortalController extends Controller
$request->session()->regenerate();
Inertia::clearHistory();
+ if (isset($data['opportunity_id'])) {
+ try {
+ // Recheck current publication, company and deadline after authentication.
+ app(CandidateSubmissions::class)->opportunity($company, (int) $data['opportunity_id']);
+
+ return redirect('/candidaturas/'.$company.'/oportunidades/'.$data['opportunity_id']);
+ } catch (NotFoundHttpException|ModelNotFoundException) {
+ // A withdrawn, expired or foreign opportunity cannot become an authenticated destination.
+ return redirect('/candidaturas/'.$company.'/perfil')->with('candidate_access_status', 'opportunity_unavailable');
+ }
+ }
+
return redirect('/candidaturas/'.$company.'/perfil');
}
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index 4cdfcad..372c11a 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -1,5 +1,11 @@
# Desenvolvimento do SGC
+## Oportunidade escolhida preservada na entrada, 30 de Outubro de 2026
+
+O botão de entrada de cada oportunidade guarda a escolha no formulário de login e apresenta o título escolhido. Após autenticação válida, o servidor volta a conferir publicação, empresa e prazo e encaminha para o formulário dessa oportunidade. Não aceita URLs de retorno fornecidos pelo cliente. Um identificador inexistente, de outra empresa, retirado ou expirado encaminha para o perfil com mensagem genérica de indisponibilidade. A entrada não envia a candidatura, não modifica o perfil e não dispensa confirmação de email ou requisitos documentais. Criar uma nova conta mantém o percurso de preparação do perfil anteriormente existente.
+
+Verificação dirigida final SQLite: 26 testes/400 asserções. PostgreSQL descartável: 66 testes/1426 asserções sobre os percursos de candidatos e Pessoas, incluindo os novos guardas de destino; a mensagem final de indisponibilidade foi adicionalmente conferida em SQLite e no frontend. Dez testes frontend, PHPStan sem erros, tipos, Pint e lint dirigidos. Sem novas migrações ou instalação no domínio; conferência visual continua pendente. Provas integrais da release bf84575 reutilizadas, sem repetição de toda a suíte. A compilação e prova final constam em `proofs/candidate-opportunity-login.json`.
+
## Consulta de oportunidades antes do registo, 30 de Outubro de 2026
A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes.
diff --git a/docs/proofs/candidate-opportunity-login-pg.json b/docs/proofs/candidate-opportunity-login-pg.json
new file mode 100644
index 0000000..1578e65
--- /dev/null
+++ b/docs/proofs/candidate-opportunity-login-pg.json
@@ -0,0 +1,16 @@
+{
+ "checked_at": "2026-10-30T19:18:12+00:00",
+ "database": "disposable PostgreSQL",
+ "application_role_reused_without_privilege_change": true,
+ "result": "passed",
+ "test_files": [
+ "CandidateSubmissionsTest",
+ "PeopleHiringTest",
+ "CandidatePortalTest",
+ "CandidateDocumentFormatsTest"
+ ],
+ "original_evaluation_untouched": true,
+ "database_removed": true,
+ "tests": 66,
+ "assertions": 1426
+}
diff --git a/docs/proofs/candidate-opportunity-login.json b/docs/proofs/candidate-opportunity-login.json
new file mode 100644
index 0000000..ae4381f
--- /dev/null
+++ b/docs/proofs/candidate-opportunity-login.json
@@ -0,0 +1,43 @@
+{
+ "checked_at": "2026-10-30T19:18:47.034145+00:00",
+ "base_commit": "c22846b",
+ "scope": [
+ "selected public opportunity carried into candidate login",
+ "fixed server-generated redirect after authentication",
+ "company publication deadline and unknown-id recheck",
+ "profile fallback with generic unavailable notice",
+ "no automatic submission or registration flow changes"
+ ],
+ "module_sqlite": {
+ "tests": 26,
+ "assertions": 400,
+ "passed": true
+ },
+ "postgresql": {
+ "tests": 66,
+ "assertions": 1426,
+ "passed": true,
+ "disposable_database_removed": true,
+ "final_notice_additionally_checked_in_sqlite_and_frontend": true
+ },
+ "frontend": {
+ "tests": 10,
+ "status": "passed"
+ },
+ "phpstan_errors": 0,
+ "typescript": true,
+ "pint": true,
+ "lint": true,
+ "build": {
+ "exit_code": 0,
+ "completed": true
+ },
+ "new_migrations": false,
+ "production_installed": false,
+ "visual_review": "pending; unavailable connector",
+ "source_sha256": {
+ "app/Http/Controllers/CandidatePortalController.php": "7866cb1c29a85761906637e3caa15f334ee67de8c212ccf4c31f07d021c61b1b",
+ "resources/js/pages/candidate/entry.tsx": "3c5cae01a0ebd5c2fd65569be5d2e039cb8c3e92e784497bfe8df468bdf13bab",
+ "resources/js/pages/candidate/profile.tsx": "b13aea00c82e7e35a955d598a65b5b01b3725d50a39a4b38a0b10c4ac6ef6dd2"
+ }
+}
diff --git a/resources/js/pages/candidate/entry.tsx b/resources/js/pages/candidate/entry.tsx
index f4b069e..66212f4 100644
--- a/resources/js/pages/candidate/entry.tsx
+++ b/resources/js/pages/candidate/entry.tsx
@@ -21,7 +21,11 @@ export default function Entry({
next_page_url: string | null;
};
}) {
- const login = useForm({ email: '', password: '' });
+ const login = useForm({
+ email: '',
+ password: '',
+ opportunity_id: null as number | null,
+ });
const register = useForm({
email: '',
password: '',
@@ -110,6 +114,9 @@ export default function Entry({
+ login.setData('opportunity_id', item.id)
+ }
>
Entrar para preparar a candidatura
@@ -138,6 +145,17 @@ export default function Entry({
+ {login.data.opportunity_id !== null && (
+