Compare commits

...

4 Commits

Author SHA1 Message Date
Daniel Volz 259f00e7a0 fix: unify number stepper layout and detail modal padding (#279)
Reorder stepper DOM elements (input first) and apply refill-number-stepper
class to both steppers for consistent CSS order-based layout.
Fix missing bottom padding on .med-detail-body.
2026-02-22 17:57:36 +01:00
github-actions[bot] e9f2760815 chore: update test count badges [skip ci] 2026-02-22 16:55:21 +00:00
Daniel Volz d0e2ee0783 fix: trim whitespace from username on login and registration (#277)
Add .trim() to both loginSchema and registerSchema Zod validators so
leading/trailing spaces are stripped before validation and DB lookup.
Includes 5 new test cases covering trim behavior for both endpoints.
2026-02-22 17:51:41 +01:00
Daniel Volz c620146c4b chore: release v1.15.0 (#275) 2026-02-22 16:54:49 +01:00
8 changed files with 107 additions and 26 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" /> <img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" /> <img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p> </p>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.14.4", "version": "1.15.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -1
View File
@@ -53,6 +53,7 @@ const sensitiveRateLimitConfig = {
const registerSchema = z.object({ const registerSchema = z.object({
username: z username: z
.string() .string()
.trim()
.min(3, "Username must be at least 3 characters") .min(3, "Username must be at least 3 characters")
.max(50, "Username must be at most 50 characters") .max(50, "Username must be at most 50 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"), .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
@@ -63,7 +64,7 @@ const registerSchema = z.object({
}); });
const loginSchema = z.object({ const loginSchema = z.object({
username: z.string().min(1, "Username is required"), username: z.string().trim().min(1, "Username is required"),
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
rememberMe: z.boolean().optional().default(false), rememberMe: z.boolean().optional().default(false),
}); });
+80
View File
@@ -245,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.json().code).toBe("VALIDATION_ERROR"); expect(response.json().code).toBe("VALIDATION_ERROR");
}); });
it("should register with trimmed username when input has whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " trimuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(201);
expect(response.json().user.username).toBe("trimuser");
});
it("should reject whitespace-only username on registration", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should reject duplicate username even with surrounding whitespace", async () => {
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "spacedupe",
password: "TestPassword123",
},
});
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " spacedupe ",
password: "AnotherPassword123",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().code).toBe("USERNAME_EXISTS");
});
it("should reject invalid username characters", async () => { it("should reject invalid username characters", async () => {
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -341,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.json().code).toBe("INVALID_CREDENTIALS"); expect(response.json().code).toBe("INVALID_CREDENTIALS");
}); });
it("should login successfully when username has leading/trailing whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " loginuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
expect(response.json().user.username).toBe("loginuser");
});
it("should reject whitespace-only username on login", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should support rememberMe option", async () => { it("should support rememberMe option", async () => {
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.14.4", "version": "1.15.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+18 -18
View File
@@ -275,6 +275,14 @@ export function MedDetailModal({
return ( return (
<div className="number-stepper refill-number-stepper"> <div className="number-stepper refill-number-stepper">
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
<button <button
type="button" type="button"
className="stepper-btn decrement" className="stepper-btn decrement"
@@ -284,14 +292,6 @@ export function MedDetailModal({
> >
<Minus size={16} aria-hidden="true" /> <Minus size={16} aria-hidden="true" />
</button> </button>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
<button <button
type="button" type="button"
className="stepper-btn increment" className="stepper-btn increment"
@@ -321,16 +321,7 @@ export function MedDetailModal({
const canIncrement = clamped < max; const canIncrement = clamped < max;
return ( return (
<div className="number-stepper"> <div className="number-stepper refill-number-stepper">
<button
type="button"
className="stepper-btn decrement"
onClick={() => onChange(Math.max(min, clamped - 1))}
disabled={!canDecrement}
aria-label={decrementLabel}
>
<Minus size={16} aria-hidden="true" />
</button>
<input <input
type="number" type="number"
min={min} min={min}
@@ -341,6 +332,15 @@ export function MedDetailModal({
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed))); onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
}} }}
/> />
<button
type="button"
className="stepper-btn decrement"
onClick={() => onChange(Math.max(min, clamped - 1))}
disabled={!canDecrement}
aria-label={decrementLabel}
>
<Minus size={16} aria-hidden="true" />
</button>
<button <button
type="button" type="button"
className="stepper-btn increment" className="stepper-btn increment"
+1 -1
View File
@@ -4549,7 +4549,7 @@ button.has-validation-error {
} }
.med-detail-body { .med-detail-body {
padding: 1.5rem 2rem 0; padding: 1.5rem 2rem 2rem;
background: var(--bg-primary); background: var(--bg-primary);
} }
+3 -3
View File
@@ -446,7 +446,7 @@
} }
.refill-number-stepper input { .refill-number-stepper input {
order: initial; order: 0;
text-align: center; text-align: center;
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
} }
@@ -460,13 +460,13 @@
} }
.refill-number-stepper .stepper-btn.decrement { .refill-number-stepper .stepper-btn.decrement {
order: initial; order: -1;
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary)); background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
color: var(--danger); color: var(--danger);
} }
.refill-number-stepper .stepper-btn.increment { .refill-number-stepper .stepper-btn.increment {
order: initial; order: 1;
border-right: none; border-right: none;
border-left: 1px solid var(--border-primary); border-left: 1px solid var(--border-primary);
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary)); background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));