Merge remote-tracking branch 'origin/main' into feat/ongoing-absences

This commit is contained in:
Karl Ludwig Weise
2026-07-20 19:57:44 +02:00
14 changed files with 165 additions and 142 deletions
@@ -66,6 +66,12 @@
let previewUrl = $state<string | null>(null);
let dialogOpen = $state(false);
const clearFileInput = () => {
if (fileInput) {
fileInput.value = "";
}
};
const handleFileSelect = async (
event: Event & { currentTarget: EventTarget & HTMLInputElement },
) => {
@@ -250,7 +256,10 @@
<Button
size="xs"
class="absolute top-1 right-1 cursor-pointer rounded-sm !px-1 py-1"
onclick={() => (value = "")}
onclick={() => {
value = "";
clearFileInput();
}}
>
<Trash />
<span class="sr-only">{m["components.inputCroppedImageBlob.remove"]()}</span>
@@ -271,7 +280,12 @@
<Input type="text" bind:value hidden {...restProps} />
</div>
<Dialog bind:open={dialogOpen}>
<Dialog
bind:open={dialogOpen}
onOpenChangeComplete={() => {
if (!dialogOpen) clearFileInput();
}}
>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
@@ -291,58 +305,80 @@
</div>
<Text style="xs">{m["components.inputCroppedImageBlob.crop.description"]()}</Text>
<div class="flex flex-col gap-0.5">
<div class="flex flex-wrap gap-0.5">
<Button
type="button"
variant="outline"
size="xs"
onclick={() => handleButtonAction("zoom-in")}
>
<ZoomIn class="size-3" />
{m["components.inputCroppedImageBlob.crop.zoomIn"]()}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onclick={() => handleButtonAction("zoom-out")}
>
<ZoomOut class="size-3" />
{m["components.inputCroppedImageBlob.crop.zoomOut"]()}
</Button>
</div>
<div class="flex flex-wrap gap-0.5">
<Button
type="button"
variant="outline"
size="xs"
onclick={() => handleButtonAction("left")}
>
<MoveLeft class="size-3" />
{m["components.inputCroppedImageBlob.crop.moveLeft"]()}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onclick={() => handleButtonAction("right")}
>
<MoveRight class="size-3" />
{m["components.inputCroppedImageBlob.crop.moveRight"]()}
</Button>
<Button type="button" variant="outline" size="xs" onclick={() => handleButtonAction("up")}>
<MoveUp class="size-3" />
{m["components.inputCroppedImageBlob.crop.moveUp"]()}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onclick={() => handleButtonAction("down")}
>
<MoveDown class="size-3" />
{m["components.inputCroppedImageBlob.crop.moveDown"]()}
</Button>
<div class="flex items-center justify-center">
<div class="flex flex-1 flex-col gap-3 px-4 pt-0 pb-6">
<!-- 2. Scale Controls (Horizontal Zoom) -->
<div class="grid h-11 w-full max-w-52 grid-cols-2 gap-2 self-center">
<Button
type="button"
variant="ghost"
size="xs"
class="rounded-lg"
onclick={() => handleButtonAction("zoom-in")}
>
<ZoomIn class="size-4" />
{m["components.inputCroppedImageBlob.crop.zoomIn"]()}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
class="rounded-lg"
onclick={() => handleButtonAction("zoom-out")}
>
<ZoomOut class="size-4" />
{m["components.inputCroppedImageBlob.crop.zoomOut"]()}
</Button>
</div>
<div class="grid h-24 w-full max-w-47 grid-cols-3 grid-rows-2 gap-2 self-center">
<!-- Row 1: Up-Arrow -->
<Button
type="button"
variant="outline"
class="col-start-2 rounded-lg border border-gray-400 active:bg-gray-200"
size="xs"
onclick={() => handleButtonAction("up")}
>
<MoveUp class="size-4 text-gray-600" />
<span class="sr-only">{m["components.inputCroppedImageBlob.crop.moveUp"]()}</span>
</Button>
<!-- Row 2: Left-Arrow, Down-Arrow, Right-Arrow -->
<Button
type="button"
variant="outline"
class="col-start-1 rounded-lg border border-gray-400 active:bg-gray-200"
size="xs"
onclick={() => handleButtonAction("left")}
>
<MoveLeft class="size-4 text-gray-600" />
<span class="sr-only">{m["components.inputCroppedImageBlob.crop.moveLeft"]()}</span>
</Button>
<Button
type="button"
variant="outline"
class="rounded-lg border border-gray-400 active:bg-gray-200"
size="xs"
onclick={() => handleButtonAction("down")}
>
<MoveDown class="size-4 text-gray-600" />
<span class="sr-only">{m["components.inputCroppedImageBlob.crop.moveDown"]()}</span>
</Button>
<Button
type="button"
variant="outline"
class="rounded-lg border border-gray-400 active:bg-gray-200"
size="xs"
onclick={() => handleButtonAction("right")}
>
<MoveRight class="size-4 text-gray-600" />
<span class="sr-only">{m["components.inputCroppedImageBlob.crop.moveRight"]()}</span>
</Button>
</div>
</div>
</div>
</div>
-2
View File
@@ -234,8 +234,6 @@ export const userInvite = pgTable(
export const challengeThrottle = pgTable("challenge_throttle", {
/** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */
id: text("id").primaryKey(),
/** Tenant ID for scoping throttles to single tenants to restrict global lock-out. Might be null for global throttles on administrative accounts */
tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }),
/** Number of failed attempts */
failedAttempts: integer("failed_attempts").default(0).notNull(),
/** When the throttle was last updated */
@@ -852,11 +852,7 @@ describe("AppointmentService", () => {
);
expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123");
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith(
"email-hash-123",
"pin",
"tenant-123",
);
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin");
});
it("should throw NotFoundError when challenge is not found", async () => {
@@ -925,7 +921,6 @@ describe("AppointmentService", () => {
expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith(
"email-hash-123",
"pin",
"tenant-123",
);
});
@@ -6,12 +6,36 @@ import { NotFoundError, ValidationError } from "../../utils/errors";
vi.mock("../../db", () => ({
centralDb: {
insert: vi.fn(),
select: vi.fn(),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(),
})),
})),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
limit: vi.fn(),
},
db: {
insert: vi.fn(),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(),
})),
})),
})),
update: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
set: vi.fn(),
})),
})),
})),
delete: vi.fn(),
transaction: vi.fn(),
limit: vi.fn(),
},
}));
// Mock the email service
@@ -41,17 +65,19 @@ import { UserService } from "../user-service";
describe("UserService", () => {
let mockCentralDb: any;
let mockDb: any;
let mockUuidv7: any;
let mockAddMinutes: any;
let mockSendConfirmationEmail: any;
let mockTenantAdminService: any;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetAllMocks();
// Get mocked modules
const dbModule = await vi.importMock("../../db");
mockCentralDb = dbModule.centralDb;
mockDb = dbModule.db;
const uuidModule = await vi.importMock("uuidv7");
mockUuidv7 = uuidModule.uuidv7;
@@ -226,6 +252,14 @@ describe("UserService", () => {
execute: vi.fn().mockResolvedValue({ count: 1 }),
};
const mockInviteUpdateBuilder: any = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ id: "invite-123", used: true }]),
};
mockInviteUpdateBuilder.then = (resolve: any) => resolve([{ id: "invite-123", used: true }]);
mockDb.update.mockReturnValue(mockInviteUpdateBuilder);
// First call for user lookup, second call for tenant admin count, third for total count
mockCentralDb.select
.mockReturnValueOnce(mockUserInviteBuilder)
@@ -1097,13 +1097,13 @@ export class AppointmentService {
});
// Record failed attempt for throttling
await challengeThrottleService.recordFailedAttempt(emailHash, "pin", this.tenantId);
await challengeThrottleService.recordFailedAttempt(emailHash, "pin");
throw new ValidationError("Invalid challenge response");
}
// Clear throttle on successful verification
await challengeThrottleService.clearThrottle(emailHash, "pin", this.tenantId);
await challengeThrottleService.clearThrottle(emailHash, "pin");
const db = await this.getDb();
+6 -34
View File
@@ -53,23 +53,14 @@ class ChallengeThrottleService {
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle check
*/
async checkThrottle(
identifier: string,
type: ThrottleType,
tenantId?: string,
): Promise<ThrottleResult> {
async checkThrottle(identifier: string, type: ThrottleType): Promise<ThrottleResult> {
const now = new Date();
// Get throttle record from central DB
const records = await centralDb
.select()
.from(challengeThrottle)
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
)
.where(and(eq(challengeThrottle.id, identifier)))
.limit(1);
if (records.length === 0) {
@@ -82,14 +73,7 @@ class ChallengeThrottleService {
// Check if throttle has expired
if (now > record.resetAt) {
// Throttle expired, clean up and allow
await centralDb
.delete(challengeThrottle)
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
);
await centralDb.delete(challengeThrottle).where(and(eq(challengeThrottle.id, identifier)));
return { allowed: true, retryAfterMs: 0, failedAttempts: 0 };
}
@@ -123,11 +107,7 @@ class ChallengeThrottleService {
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle record
*/
async recordFailedAttempt(
identifier: string,
type: ThrottleType,
tenantId?: string,
): Promise<void> {
async recordFailedAttempt(identifier: string, type: ThrottleType): Promise<void> {
const now = new Date();
const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS);
@@ -138,7 +118,6 @@ class ChallengeThrottleService {
failedAttempts: 1,
lastAttemptAt: now,
resetAt,
tenantId: tenantId,
})
.onConflictDoUpdate({
target: challengeThrottle.id,
@@ -160,15 +139,8 @@ class ChallengeThrottleService {
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle clearance
*/
async clearThrottle(identifier: string, type: ThrottleType, tenantId?: string): Promise<void> {
await centralDb
.delete(challengeThrottle)
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
);
async clearThrottle(identifier: string, type: ThrottleType): Promise<void> {
await centralDb.delete(challengeThrottle).where(and(eq(challengeThrottle.id, identifier)));
logger.debug(`Cleared ${type} challenge throttle`, {
identifier: identifier.slice(0, 8),
+8 -6
View File
@@ -329,6 +329,7 @@ export class UserService {
.where(
and(
eq(centralSchema.userInvite.inviteCode, linkToken),
eq(centralSchema.userInvite.used, false),
gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
),
)
@@ -374,18 +375,19 @@ export class UserService {
const retVal = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
resultData.id = retVal[0].id;
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
adminService.validateSetupState();
} else {
resultData = userData;
}
// Mark the invite as used no matter what the user confirmation state is, to prevent re-use of the token
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
// Check if this is the first tenant admin for the tenant
const numberOfUsers = await centralDb
.select({ count: count() })