Versão 0.5 antes do PWA
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# US15 - Identificação de Utilizador por Username
|
||||||
|
|
||||||
|
**Como** utilizador do RefoodOne
|
||||||
|
**Quero** que a minha conta de acesso seja caracterizada por um username (para login) e um nome completo (para visualização no sistema)
|
||||||
|
**Para** simplificar o login no dia-a-dia e manter o meu nome legível na lista de utilizadores.
|
||||||
|
|
||||||
|
## Descrição do Fluxo
|
||||||
|
1. Ao aceder à página de login, o voluntário insere o seu **Nome de Utilizador** (e.g. `joao`) em vez do e-mail ou nome completo.
|
||||||
|
2. Na lista de utilizadores na área de administração, o Administrador consegue visualizar em colunas separadas o **Nome de Utilizador** e o **Nome Completo**.
|
||||||
|
3. Ao criar ou editar um utilizador, o Administrador pode definir ambos os campos (**Nome Completo** e **Nome de Utilizador**).
|
||||||
|
|
||||||
|
## Critérios de Aceitação
|
||||||
|
|
||||||
|
### 1. Interface Gráfica (UI)
|
||||||
|
- **Ecrã de Login**: Campo de texto rotulado como "Nome de Utilizador" (ex: `refoodpdn`) em vez de "Utilizador / E-mail".
|
||||||
|
- **Ecrã de Listagem**: Coluna para o Nome Completo (Nome) e coluna para o Nome de Utilizador (Username).
|
||||||
|
- **Ecrã de Detalhe/Criação**: Dois campos de texto:
|
||||||
|
- **Nome Completo** (e.g. "João Silva")
|
||||||
|
- **Nome de Utilizador** (e.g. "joao")
|
||||||
|
|
||||||
|
### 2. Comportamento e Regras de Negócio
|
||||||
|
- O `username` deve ser único na base de dados.
|
||||||
|
- Ao atualizar a base de dados, as contas já existentes devem ter o seu `username` atualizado de forma automática para o seu primeiro nome (a primeira palavra do campo nome em minúsculas).
|
||||||
|
- O login é efetuado através do `username`.
|
||||||
|
|
||||||
|
### 3. Integração de Dados
|
||||||
|
- Alteração da tabela `users` para conter ambos os campos `name` e `username`.
|
||||||
Vendored
+1
@@ -6,6 +6,7 @@ declare global {
|
|||||||
interface Locals {
|
interface Locals {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
role: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (session.expiresAt > Date.now()) {
|
if (session.expiresAt > Date.now()) {
|
||||||
event.locals.user = {
|
event.locals.user = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role
|
role: user.role
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,41 @@ if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
|||||||
|
|
||||||
const client = new Database(env.DATABASE_URL);
|
const client = new Database(env.DATABASE_URL);
|
||||||
|
|
||||||
|
// Run schema migration if 'name' column is missing
|
||||||
|
try {
|
||||||
|
const tableInfo = client.prepare("PRAGMA table_info(users)").all() as any[];
|
||||||
|
const hasNameColumn = tableInfo.some((col) => col.name === 'name');
|
||||||
|
if (!hasNameColumn) {
|
||||||
|
console.log("Migrating users table: adding 'name' column...");
|
||||||
|
|
||||||
|
// 1. Add nullable 'name' column
|
||||||
|
client.prepare("ALTER TABLE users ADD COLUMN name TEXT").run();
|
||||||
|
|
||||||
|
// 2. Set 'name' to the current 'username' (which represented the name before)
|
||||||
|
client.prepare("UPDATE users SET name = username").run();
|
||||||
|
|
||||||
|
// 3. Update 'username' to the lowercase first name
|
||||||
|
const allUsers = client.prepare("SELECT id, name FROM users").all() as any[];
|
||||||
|
for (const u of allUsers) {
|
||||||
|
const fullName = u.name || '';
|
||||||
|
const firstName = fullName.trim().split(/\s+/)[0].toLowerCase();
|
||||||
|
|
||||||
|
let uniqueUsername = firstName;
|
||||||
|
let counter = 1;
|
||||||
|
while (true) {
|
||||||
|
const exists = client.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(uniqueUsername, u.id);
|
||||||
|
if (!exists) break;
|
||||||
|
uniqueUsername = `${firstName}${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
client.prepare("UPDATE users SET username = ? WHERE id = ?").run(uniqueUsername, u.id);
|
||||||
|
}
|
||||||
|
console.log("Database migration for users table completed.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Migration failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
// Seed admin user if the users table is empty (US00)
|
// Seed admin user if the users table is empty (US00)
|
||||||
@@ -17,6 +52,7 @@ try {
|
|||||||
console.log('Seeding default admin user (refoodpdn)...');
|
console.log('Seeding default admin user (refoodpdn)...');
|
||||||
const passwordHash = bcrypt.hashSync('rpdn!2512', 10);
|
const passwordHash = bcrypt.hashSync('rpdn!2512', 10);
|
||||||
db.insert(schema.users).values({
|
db.insert(schema.users).values({
|
||||||
|
name: 'Administrador Refood',
|
||||||
username: 'refoodpdn',
|
username: 'refoodpdn',
|
||||||
passwordHash: passwordHash,
|
passwordHash: passwordHash,
|
||||||
role: 'admin'
|
role: 'admin'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
name: text('name').notNull(),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
role: text('role').notNull(), // 'admin' | 'shift_manager' | 'volunteer'
|
role: text('role').notNull(), // 'admin' | 'shift_manager' | 'volunteer'
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export const load: PageServerLoad = async () => {
|
|||||||
const list = db
|
const list = db
|
||||||
.select({
|
.select({
|
||||||
id: schema.users.id,
|
id: schema.users.id,
|
||||||
|
name: schema.users.name,
|
||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
role: schema.users.role,
|
role: schema.users.role,
|
||||||
createdAt: schema.users.createdAt
|
createdAt: schema.users.createdAt
|
||||||
})
|
})
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.orderBy(asc(schema.users.username))
|
.orderBy(asc(schema.users.name))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
<thead class="table-light text-secondary fw-semibold">
|
<thead class="table-light text-secondary fw-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3">Nome</th>
|
<th class="px-4 py-3">Nome</th>
|
||||||
|
<th class="py-3">Username</th>
|
||||||
<th class="py-3">Role</th>
|
<th class="py-3">Role</th>
|
||||||
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
|
<th class="px-4 py-3 text-end" style="width: 120px;">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#if data.users.length === 0}
|
{#if data.users.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-center py-5 text-muted">
|
<td colspan="4" class="text-center py-5 text-muted">
|
||||||
<p class="mb-0">Nenhum utilizador encontrado.</p>
|
<p class="mb-0">Nenhum utilizador encontrado.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -75,7 +76,10 @@
|
|||||||
{#each data.users as u}
|
{#each data.users as u}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 fw-bold text-dark">
|
<td class="px-4 fw-bold text-dark">
|
||||||
{u.username}
|
{u.name}
|
||||||
|
</td>
|
||||||
|
<td class="text-secondary">
|
||||||
|
@{u.username}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-dark-subtle text-dark border px-2.5 py-1.5 rounded-3 fw-semibold">
|
<span class="badge bg-dark-subtle text-dark border px-2.5 py-1.5 rounded-3 fw-semibold">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
const user = db
|
const user = db
|
||||||
.select({
|
.select({
|
||||||
id: schema.users.id,
|
id: schema.users.id,
|
||||||
|
name: schema.users.name,
|
||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
role: schema.users.role,
|
role: schema.users.role,
|
||||||
createdAt: schema.users.createdAt
|
createdAt: schema.users.createdAt
|
||||||
@@ -40,15 +41,16 @@ export const actions: Actions = {
|
|||||||
default: async ({ params, request }) => {
|
default: async ({ params, request }) => {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
const name = data.get('name')?.toString().trim();
|
||||||
const username = data.get('username')?.toString().trim();
|
const username = data.get('username')?.toString().trim();
|
||||||
const role = data.get('role')?.toString().trim();
|
const role = data.get('role')?.toString().trim();
|
||||||
const password = data.get('password')?.toString();
|
const password = data.get('password')?.toString();
|
||||||
const confirmPassword = data.get('confirmPassword')?.toString();
|
const confirmPassword = data.get('confirmPassword')?.toString();
|
||||||
|
|
||||||
if (!username || !role) {
|
if (!name || !username || !role) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Nome de utilizador e Perfil são campos obrigatórios.'
|
error: 'Nome, Nome de utilizador e Perfil são campos obrigatórios.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ export const actions: Actions = {
|
|||||||
const passwordHash = bcrypt.hashSync(password!, 10);
|
const passwordHash = bcrypt.hashSync(password!, 10);
|
||||||
db.update(schema.users)
|
db.update(schema.users)
|
||||||
.set({
|
.set({
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
passwordHash
|
passwordHash
|
||||||
@@ -110,6 +113,7 @@ export const actions: Actions = {
|
|||||||
} else {
|
} else {
|
||||||
db.update(schema.users)
|
db.update(schema.users)
|
||||||
.set({
|
.set({
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role
|
role
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,20 @@
|
|||||||
};
|
};
|
||||||
}}>
|
}}>
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label fw-semibold text-secondary small">Nome Completo <span class="text-danger">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
class="form-control rounded-3 border-2"
|
||||||
|
placeholder="Ex: João Silva"
|
||||||
|
value={form?.name ?? user.name}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
|
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -45,7 +59,7 @@
|
|||||||
name="username"
|
name="username"
|
||||||
id="username"
|
id="username"
|
||||||
class="form-control rounded-3 border-2"
|
class="form-control rounded-3 border-2"
|
||||||
placeholder="Ex: joao.silva"
|
placeholder="Ex: joao"
|
||||||
value={form?.username ?? user.username}
|
value={form?.username ?? user.username}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import bcrypt from 'bcrypt';
|
|||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request }) => {
|
default: async ({ request }) => {
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
const name = data.get('name')?.toString().trim();
|
||||||
const username = data.get('username')?.toString().trim();
|
const username = data.get('username')?.toString().trim();
|
||||||
const role = data.get('role')?.toString().trim();
|
const role = data.get('role')?.toString().trim();
|
||||||
const password = data.get('password')?.toString();
|
const password = data.get('password')?.toString();
|
||||||
const confirmPassword = data.get('confirmPassword')?.toString();
|
const confirmPassword = data.get('confirmPassword')?.toString();
|
||||||
|
|
||||||
if (!username || !role || !password || !confirmPassword) {
|
if (!name || !username || !role || !password || !confirmPassword) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
error: 'Todos os campos obrigatórios devem ser preenchidos.'
|
error: 'Todos os campos obrigatórios devem ser preenchidos.'
|
||||||
@@ -25,6 +27,7 @@ export const actions: Actions = {
|
|||||||
if (role !== 'admin' && role !== 'shift_manager' && role !== 'volunteer') {
|
if (role !== 'admin' && role !== 'shift_manager' && role !== 'volunteer') {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
error: 'Perfil selecionado é inválido.'
|
error: 'Perfil selecionado é inválido.'
|
||||||
@@ -34,6 +37,7 @@ export const actions: Actions = {
|
|||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
error: 'As palavras-passe introduzidas não coincidem.'
|
error: 'As palavras-passe introduzidas não coincidem.'
|
||||||
@@ -43,6 +47,7 @@ export const actions: Actions = {
|
|||||||
if (password.length < 4) {
|
if (password.length < 4) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
error: 'A palavra-passe deve ter pelo menos 4 caracteres.'
|
error: 'A palavra-passe deve ter pelo menos 4 caracteres.'
|
||||||
@@ -60,6 +65,7 @@ export const actions: Actions = {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
success: false,
|
success: false,
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
error: 'O nome de utilizador já está a ser utilizado.'
|
error: 'O nome de utilizador já está a ser utilizado.'
|
||||||
@@ -70,6 +76,7 @@ export const actions: Actions = {
|
|||||||
const passwordHash = bcrypt.hashSync(password, 10);
|
const passwordHash = bcrypt.hashSync(password, 10);
|
||||||
db.insert(schema.users)
|
db.insert(schema.users)
|
||||||
.values({
|
.values({
|
||||||
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
role,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
|||||||
@@ -36,6 +36,20 @@
|
|||||||
};
|
};
|
||||||
}}>
|
}}>
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label fw-semibold text-secondary small">Nome Completo <span class="text-danger">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
class="form-control rounded-3 border-2"
|
||||||
|
placeholder="Ex: João Silva"
|
||||||
|
value={form?.name ?? ''}
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
|
<label for="username" class="form-label fw-semibold text-secondary small">Nome de Utilizador <span class="text-danger">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -43,7 +57,7 @@
|
|||||||
name="username"
|
name="username"
|
||||||
id="username"
|
id="username"
|
||||||
class="form-control rounded-3 border-2"
|
class="form-control rounded-3 border-2"
|
||||||
placeholder="Ex: joao.silva"
|
placeholder="Ex: joao"
|
||||||
value={form?.username ?? ''}
|
value={form?.username ?? ''}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -37,13 +37,13 @@
|
|||||||
};
|
};
|
||||||
}}>
|
}}>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label fw-semibold text-secondary">Utilizador / E-mail</label>
|
<label for="username" class="form-label fw-semibold text-secondary">Nome de Utilizador</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
id="username"
|
id="username"
|
||||||
class="form-control form-control-lg rounded-3 border-2"
|
class="form-control form-control-lg rounded-3 border-2"
|
||||||
placeholder="Ex: refoodpdn"
|
placeholder="Ex: admin"
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user