fotografias dos beneficiarios

This commit is contained in:
Duarte
2026-06-06 10:57:26 +01:00
parent 1133775c85
commit a8a05a10ef
11 changed files with 451 additions and 22 deletions
+4
View File
@@ -23,3 +23,7 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# SQLite # SQLite
*.db *.db
# Persistent local data / uploads
/data/
+3 -3
View File
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm';
import { redirect, type Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session'); const sessionId = event.cookies.get('__Host-session');
event.locals.user = null; event.locals.user = null;
if (sessionId) { if (sessionId) {
@@ -33,11 +33,11 @@ export const handle: Handle = async ({ event, resolve }) => {
} else { } else {
// Session expired, clean up // Session expired, clean up
db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run();
event.cookies.delete('session', { path: '/' }); event.cookies.delete('__Host-session', { path: '/' });
} }
} else { } else {
// Invalid session cookie // Invalid session cookie
event.cookies.delete('session', { path: '/' }); event.cookies.delete('__Host-session', { path: '/' });
} }
} catch (err) { } catch (err) {
console.error('Error in session auth hook:', err); console.error('Error in session auth hook:', err);
+12
View File
@@ -66,4 +66,16 @@ export const settings = sqliteTable('settings', {
updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now()) updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now())
}); });
export const beneficiaryDocuments = sqliteTable('beneficiary_documents', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
beneficiaryId: text('beneficiary_id')
.notNull()
.references(() => beneficiaries.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
originalName: text('original_name').notNull(),
mimeType: text('mime_type').notNull(),
createdAt: integer('created_at').notNull().$defaultFn(() => Date.now())
});
@@ -1,8 +1,10 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema'; import * as schema from '$lib/server/db/schema';
import { eq, and, ne } from 'drizzle-orm'; import { eq, and, ne, desc } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import fs from 'fs';
import path from 'path';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const id = params.id; const id = params.id;
@@ -18,8 +20,17 @@ export const load: PageServerLoad = async ({ params }) => {
throw error(404, 'Beneficiário não encontrado'); throw error(404, 'Beneficiário não encontrado');
} }
// Carregar os documentos associados ordenados por data de criação descrescente
const documents = db
.select()
.from(schema.beneficiaryDocuments)
.where(eq(schema.beneficiaryDocuments.beneficiaryId, id))
.orderBy(desc(schema.beneficiaryDocuments.createdAt))
.all();
return { return {
beneficiary beneficiary,
documents
}; };
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) { if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
@@ -31,7 +42,7 @@ export const load: PageServerLoad = async ({ params }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ params, request }) => { update: async ({ params, request }) => {
const id = params.id; const id = params.id;
const data = await request.formData(); const data = await request.formData();
const numberStr = data.get('number')?.toString().trim(); const numberStr = data.get('number')?.toString().trim();
@@ -117,5 +128,126 @@ export const actions: Actions = {
// Redirect on success // Redirect on success
throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Alterações guardadas com sucesso.')}`); throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Alterações guardadas com sucesso.')}`);
},
uploadDocument: async ({ params, request, locals }) => {
// Validar permissões adicionais
const user = locals.user;
if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) {
return fail(403, { error: 'Não autorizado' });
}
const id = params.id;
const data = await request.formData();
const file = data.get('document') as File;
if (!file || file.size === 0) {
return fail(400, { error: 'Nenhum ficheiro foi carregado.' });
}
// 1. Limite de tamanho (5MB)
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
return fail(400, { error: 'O ficheiro excede o limite máximo de 5MB.' });
}
// 2. Validar tipo MIME
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
if (!ALLOWED_TYPES.includes(file.type)) {
return fail(400, { error: 'Apenas são permitidas imagens (JPEG, PNG, WebP).' });
}
try {
// Criar diretório se não existir
const dirPath = 'data/documents';
fs.mkdirSync(dirPath, { recursive: true });
// Gerar nome único seguro
const fileId = crypto.randomUUID();
let ext = 'jpg';
if (file.type === 'image/png') ext = 'png';
else if (file.type === 'image/webp') ext = 'webp';
const fileName = `${fileId}.${ext}`;
const filePath = path.join(dirPath, fileName);
// Gravar ficheiro no disco
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
fs.writeFileSync(filePath, buffer);
// Guardar metadados na BD
db.insert(schema.beneficiaryDocuments)
.values({
id: fileId,
beneficiaryId: id,
filePath,
originalName: file.name || 'documento.jpg',
mimeType: file.type,
createdAt: Date.now()
})
.run();
return { success: true };
} catch (err) {
console.error('Error uploading document:', err);
return fail(500, { error: 'Erro ao guardar o documento no servidor.' });
}
},
deleteDocument: async ({ params, request, locals }) => {
// Validar permissões adicionais
const user = locals.user;
if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) {
return fail(403, { error: 'Não autorizado' });
}
const id = params.id;
const data = await request.formData();
const docId = data.get('docId')?.toString().trim();
if (!docId) {
return fail(400, { error: 'Identificador do documento em falta.' });
}
try {
// Buscar o registo para obter o caminho do ficheiro
const doc = db
.select()
.from(schema.beneficiaryDocuments)
.where(eq(schema.beneficiaryDocuments.id, docId))
.get();
if (!doc) {
return fail(404, { error: 'Documento não encontrado.' });
}
// Garantir que o documento pertence a este beneficiário
if (doc.beneficiaryId !== id) {
return fail(403, { error: 'Acesso negado.' });
}
// Eliminar do disco
const allowedDir = path.resolve('data/documents');
const absolutePath = path.resolve(doc.filePath);
if (absolutePath.startsWith(allowedDir + path.sep)) {
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
} else {
return fail(400, { error: 'Caminho de ficheiro inválido.' });
}
// Eliminar da BD
db.delete(schema.beneficiaryDocuments)
.where(eq(schema.beneficiaryDocuments.id, docId))
.run();
return { success: true };
} catch (err) {
console.error('Error deleting document:', err);
return fail(500, { error: 'Erro ao eliminar o documento.' });
}
} }
}; };
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
let { data, form }: { data: any; form: any } = $props(); let { data, form }: { data: any; form: any } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let isUploading = $state(false);
let uploadError = $state<string | null>(null);
let selectedDocForLightbox = $state<any>(null);
// Fallback to loaded data if form action result is empty // Fallback to loaded data if form action result is empty
const beneficiary = $derived(data.beneficiary); const beneficiary = $derived(data.beneficiary);
const successMessage = $derived(page.url.searchParams.get('success'));
</script> </script>
<svelte:head> <svelte:head>
@@ -24,6 +29,17 @@
<p class="text-muted mb-0">Atualizar informações do beneficiário #{beneficiary.number}</p> <p class="text-muted mb-0">Atualizar informações do beneficiário #{beneficiary.number}</p>
</div> </div>
{#if successMessage}
<div class="alert alert-success border-0 shadow-sm rounded-3 d-flex align-items-center gap-2 mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-check-circle-fill text-success" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
{successMessage}
</div>
</div>
{/if}
{#if form?.error} {#if form?.error}
<div class="alert alert-danger border-0 rounded-3 mb-4" role="alert"> <div class="alert alert-danger border-0 rounded-3 mb-4" role="alert">
{form.error} {form.error}
@@ -31,7 +47,7 @@
{/if} {/if}
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;"> <div class="card border-0 shadow-sm rounded-4 p-4 bg-white" style="max-width: 800px;">
<form method="POST" use:enhance={() => { <form method="POST" action="?/update" use:enhance={() => {
isLoading = true; isLoading = true;
return async ({ update }) => { return async ({ update }) => {
isLoading = false; isLoading = false;
@@ -141,4 +157,189 @@
</div> </div>
</form> </form>
</div> </div>
<!-- Documentos Card -->
<div class="card border-0 shadow-sm rounded-4 p-4 bg-white mt-4" style="max-width: 800px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-bold text-dark mb-1">Documentos do Beneficiário</h4>
<p class="text-muted mb-0 small">Fotografias e cópias digitais dos documentos do agregado</p>
</div>
<!-- Botão para carregar foto -->
<div>
<form
method="POST"
action="?/uploadDocument"
enctype="multipart/form-data"
use:enhance={() => {
isUploading = true;
uploadError = null;
return async ({ result, update }) => {
isUploading = false;
if (result.type === 'failure') {
uploadError = result.data?.error?.toString() || 'Erro ao carregar ficheiro.';
} else {
uploadError = null;
}
await update({ reset: false });
};
}}
>
<label class="btn btn-outline-success rounded-3 px-3 py-2 fw-semibold d-inline-flex align-items-center gap-2 cursor-pointer" style="cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-camera" viewBox="0 0 16 16">
<path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z"/>
<path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/>
</svg>
{#if isUploading}
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
A carregar...
{:else}
Tirar Foto / Adicionar
{/if}
<input
type="file"
name="document"
accept="image/*"
capture="environment"
class="d-none"
disabled={isUploading}
onchange={(e) => {
const files = e.currentTarget.files;
if (files && files.length > 0) {
e.currentTarget.form?.requestSubmit();
}
}}
/>
</label>
</form>
</div>
</div>
{#if uploadError}
<div class="alert alert-danger border-0 rounded-3 mb-4 py-2 small" role="alert">
{uploadError}
</div>
{/if}
<!-- Galeria de Fotos -->
{#if !data.documents || data.documents.length === 0}
<div class="text-center py-5 bg-light rounded-4 border-2 border-dashed text-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-file-earmark-image mb-2 text-black-50" viewBox="0 0 16 16">
<path d="M6.502 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/>
<path d="M14 14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zM4 1v13a1 1 0 0 0 1 1h6v-6h5V4.5h-5.5A1.5 1.5 0 0 1 9 3V1z"/>
</svg>
<p class="mb-0 small">Nenhum documento registado para este beneficiário.</p>
</div>
{:else}
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 g-3">
{#each data.documents as doc}
{@const docUrl = `/admin/beneficiarios/${beneficiary.id}/documentos/${doc.id}`}
<div class="col">
<div class="card h-100 border rounded-3 overflow-hidden shadow-sm position-relative doc-card">
<button
type="button"
class="btn p-0 border-0 w-100 text-start"
style="height: 120px;"
onclick={() => selectedDocForLightbox = doc}
>
<img
src={docUrl}
alt={doc.originalName}
class="w-100 h-100"
style="object-fit: cover;"
loading="lazy"
/>
</button>
<div class="card-footer bg-white py-1 px-2 border-top-0 d-flex justify-content-between align-items-center">
<span class="text-truncate text-muted small pe-2" title={doc.originalName}>
{doc.originalName}
</span>
<form
method="POST"
action="?/deleteDocument"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
await update({ reset: false });
};
}}
class="m-0"
>
<input type="hidden" name="docId" value={doc.id} />
<button
type="submit"
class="btn btn-sm btn-link text-danger p-0 border-0"
title="Apagar Documento"
disabled={isLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash3" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
</svg>
</button>
</form>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div> </div>
<!-- Lightbox Modal -->
{#if selectedDocForLightbox}
<div class="modal-backdrop fade show" style="z-index: 1050;"></div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal d-block fade show"
tabindex="-1"
role="dialog"
style="z-index: 1055;"
onclick={() => selectedDocForLightbox = null}
onkeydown={(e) => e.key === 'Escape' && (selectedDocForLightbox = null)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-dialog modal-dialog-centered modal-lg"
role="document"
onclick={(e) => e.stopPropagation()}
>
<div class="modal-content border-0 shadow-lg rounded-4 overflow-hidden bg-dark text-white">
<div class="modal-header border-0 bg-dark py-2 px-3 d-flex justify-content-between align-items-center">
<span class="text-truncate text-white-50 small pe-3">{selectedDocForLightbox.originalName}</span>
<button
type="button"
class="btn-close btn-close-white"
onclick={() => selectedDocForLightbox = null}
aria-label="Fechar"
></button>
</div>
<div class="modal-body p-0 text-center bg-black d-flex align-items-center justify-content-center" style="min-height: 300px; max-height: 80vh;">
<img
src={`/admin/beneficiarios/${beneficiary.id}/documentos/${selectedDocForLightbox.id}`}
alt={selectedDocForLightbox.originalName}
class="img-fluid"
style="max-height: 80vh; object-fit: contain; width: auto;"
/>
</div>
</div>
</div>
</div>
{/if}
<style>
.doc-card {
transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.doc-card:hover {
transform: translateY(-2px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -0,0 +1,66 @@
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { error, type RequestEvent } from '@sveltejs/kit';
import fs from 'fs';
import path from 'path';
export const GET = async (event: RequestEvent) => {
const { params, locals } = event;
const { id: beneficiaryId, docId } = params as any;
if (!beneficiaryId || !docId) {
throw error(400, 'Parâmetros em falta');
}
// 1. Verificar autenticação e permissões (apenas admin e shift_manager)
const user = locals.user;
if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) {
throw error(403, 'Acesso não autorizado');
}
// 2. Proteção contra Path Traversal
const safeDocId = path.basename(docId);
// 3. Consultar metadados do documento na base de dados
// Garante que o documento existe e que pertence ao beneficiário especificado no URL
const doc = db
.select()
.from(schema.beneficiaryDocuments)
.where(
and(
eq(schema.beneficiaryDocuments.id, safeDocId),
eq(schema.beneficiaryDocuments.beneficiaryId, beneficiaryId)
)
)
.get();
if (!doc) {
throw error(404, 'Documento não encontrado');
}
// 4. Resolver o caminho do ficheiro de forma segura e validar limites de diretório
const allowedDir = path.resolve('data/documents');
const absolutePath = path.resolve(doc.filePath);
if (!absolutePath.startsWith(allowedDir + path.sep)) {
throw error(400, 'Acesso a diretório inválido');
}
if (!fs.existsSync(absolutePath)) {
throw error(404, 'Ficheiro não encontrado no servidor');
}
// 5. Ler o ficheiro e retornar com cabeçalhos de segurança
const fileBuffer = fs.readFileSync(absolutePath);
return new Response(fileBuffer, {
headers: {
'Content-Type': doc.mimeType,
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'private, max-age=3600'
}
});
};
@@ -63,26 +63,35 @@ export const actions: Actions = {
}); });
} }
// Insert beneficiary // Insert beneficiary and return the created ID
db.insert(schema.beneficiaries) const newBeneficiary = db.insert(schema.beneficiaries)
.values({ .values({
number, number,
name, name,
contact, contact,
householdSize, householdSize,
observations, observations,
status: 'ativo' status: 'ativo',
isParent: true
}) })
.run(); .returning({ id: schema.beneficiaries.id })
.get();
if (!newBeneficiary || !newBeneficiary.id) {
throw new Error('Falha ao obter o ID do beneficiário registado.');
}
// Redirect on success to the edit page of the newly created beneficiary
throw redirect(303, `/admin/beneficiarios/${newBeneficiary.id}?success=${encodeURIComponent('Beneficiário criado com sucesso.')}`);
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && 'status' in err) {
throw err; // Let SvelteKit redirect bubble up
}
console.error('Error creating beneficiary:', err); console.error('Error creating beneficiary:', err);
return fail(500, { return fail(500, {
success: false, success: false,
error: 'Ocorreu um erro ao guardar o beneficiário. Tente novamente.' error: 'Ocorreu um erro ao guardar o beneficiário. Tente novamente.'
}); });
} }
// Redirect on success
throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Beneficiário guardado com sucesso.')}`);
} }
}; };
@@ -70,10 +70,10 @@ export const actions: Actions = {
error: 'As palavras-passe introduzidas não coincidem.' error: 'As palavras-passe introduzidas não coincidem.'
}); });
} }
if (password!.length < 4) { if (password!.length < 8) {
return fail(400, { return fail(400, {
success: false, success: false,
error: 'A palavra-passe deve ter pelo menos 4 caracteres.' error: 'A palavra-passe deve ter pelo menos 8 caracteres.'
}); });
} }
} }
@@ -110,6 +110,11 @@ export const actions: Actions = {
}) })
.where(eq(schema.users.id, id)) .where(eq(schema.users.id, id))
.run(); .run();
// Invalidate all active sessions for this user
db.delete(schema.sessions)
.where(eq(schema.sessions.userId, id))
.run();
} else { } else {
db.update(schema.users) db.update(schema.users)
.set({ .set({
@@ -44,13 +44,13 @@ export const actions: Actions = {
}); });
} }
if (password.length < 4) { if (password.length < 8) {
return fail(400, { return fail(400, {
success: false, success: false,
name, name,
username, username,
role, role,
error: 'A palavra-passe deve ter pelo menos 4 caracteres.' error: 'A palavra-passe deve ter pelo menos 8 caracteres.'
}); });
} }
+2 -2
View File
@@ -64,11 +64,11 @@ export const actions: Actions = {
.run(); .run();
// Set session cookie // Set session cookie
cookies.set('session', sessionId, { cookies.set('__Host-session', sessionId, {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: !dev, secure: true,
maxAge: 60 * 60 * 24 * 7 // 7 days in seconds maxAge: 60 * 60 * 24 * 7 // 7 days in seconds
}); });
+2 -2
View File
@@ -6,7 +6,7 @@ import type { Actions } from './$types';
export const actions: Actions = { export const actions: Actions = {
default: async ({ cookies }) => { default: async ({ cookies }) => {
const sessionId = cookies.get('session'); const sessionId = cookies.get('__Host-session');
if (sessionId) { if (sessionId) {
try { try {
@@ -17,7 +17,7 @@ export const actions: Actions = {
} }
// Clear cookie // Clear cookie
cookies.delete('session', { path: '/' }); cookies.delete('__Host-session', { path: '/' });
} }
throw redirect(303, '/login'); throw redirect(303, '/login');