feat: add new UI components and improve existing ones

- Introduced `Field` and `Label` components for better form handling.
- Refactored `Input` and `Textarea` components to use forward refs and improved styling.
- Updated `PendingSubmitButton` to use the new `Button` component.
- Enhanced `Skeleton` component for better layout handling.
- Revamped `Toast` component to support live regions and improved accessibility.
- Updated sidebar configuration to use typed icons.
- Added phase 3 UI boundary checks to prevent usage of deprecated imports.
- Implemented new authentication action handler for better cookie management.
- Improved setup logic for freelancer accounts with repair functionality.
This commit is contained in:
Poyraz
2026-07-10 22:47:02 +03:00
parent ae8fa1425c
commit 24fcdf9a77
28 changed files with 1183 additions and 943 deletions
+79
View File
@@ -0,0 +1,79 @@
import "server-only";
import { parseSetCookieHeader, splitSetCookieHeader, toCookieOptions } from "better-auth/cookies";
import { cookies, headers } from "next/headers";
import { auth } from "@/server/auth/auth";
import { getServerConfig } from "@/server/config";
type SetCookieHeaders = Headers & {
getSetCookie?: () => string[];
};
export async function callAuthAction<TResponse>(
pathname: `/${string}`,
body?: Record<string, unknown>,
): Promise<TResponse> {
const requestHeaders = new Headers(await headers());
requestHeaders.set("content-type", "application/json");
const response = await auth.handler(
new Request(`${getServerConfig().appUrl}/api/auth${pathname}`, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(body ?? {}),
}),
);
await applyResponseCookies(response.headers);
const payload = await readJsonPayload(response);
if (!response.ok) {
throw new Error(getAuthErrorMessage(payload));
}
return payload as TResponse;
}
async function applyResponseCookies(responseHeaders: Headers): Promise<void> {
const cookieStore = await cookies();
for (const header of getSetCookieValues(responseHeaders)) {
for (const [name, attributes] of parseSetCookieHeader(header)) {
cookieStore.set(name, attributes.value, toCookieOptions(attributes));
}
}
}
function getSetCookieValues(headersList: Headers): string[] {
const getSetCookie = (headersList as SetCookieHeaders).getSetCookie;
if (typeof getSetCookie === "function") {
return getSetCookie.call(headersList);
}
const header = headersList.get("set-cookie");
return header ? splitSetCookieHeader(header) : [];
}
async function readJsonPayload(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type");
if (!contentType?.includes("application/json")) {
return null;
}
return response.json();
}
function getAuthErrorMessage(payload: unknown): string {
if (payload && typeof payload === "object" && "message" in payload) {
const message = (payload as { message?: unknown }).message;
if (typeof message === "string" && message.trim().length > 0) {
return message;
}
}
return "Kimlik do\u011frulama iste\u011fi tamamlanamad\u0131.";
}
+1 -2
View File
@@ -22,7 +22,7 @@ export const auth = betterAuth({
database: drizzleAdapter(getSqliteConnection().db, {
provider: "sqlite",
schema,
transaction: true,
transaction: false,
}),
emailAndPassword: {
enabled: true,
@@ -92,4 +92,3 @@ export const auth = betterAuth({
});
export type Auth = typeof auth;
+102 -38
View File
@@ -2,7 +2,7 @@ import "server-only";
import { count, eq } from "drizzle-orm";
import { getSqliteConnection } from "@/server/db/client";
import { appProfiles, appSetupState, authAuditEvents } from "@/server/db/schema";
import { appProfiles, appSetupState, authAuditEvents, user as authUsers } from "@/server/db/schema";
import type { AuthAuditEventType } from "@/server/auth/types";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
@@ -57,6 +57,14 @@ export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
return { available: false, locked: false };
}
if (setupState.status === "pending" && setupState.lockedBy) {
const repaired = repairFirstFreelancerSetupForEmail(setupState.lockedBy);
if (repaired) {
return { available: false, locked: false };
}
}
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS;
@@ -67,6 +75,41 @@ export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
};
}
export function repairFirstFreelancerSetupForEmail(email: string): boolean {
const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection();
return db.transaction((tx) => {
const [{ value: freelancerCount }] = tx
.select({ value: count() })
.from(appProfiles)
.where(eq(appProfiles.role, "freelancer"))
.all();
if (freelancerCount > 0) {
return false;
}
const [authUser] = tx
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
})
.from(authUsers)
.where(eq(authUsers.email, normalizedEmail))
.limit(1)
.all();
if (!authUser) {
return false;
}
completeFirstFreelancerSetupInTransaction(tx, authUser);
return true;
});
}
export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> {
const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection();
@@ -97,8 +140,9 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
if (setupState?.status === "pending") {
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
const lockBelongsToSameEmail = setupState.lockedBy === normalizedEmail;
if (Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
if (!lockBelongsToSameEmail && Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
return false;
}
}
@@ -144,48 +188,68 @@ export async function completeFirstFreelancerSetup(user: {
const { db } = getSqliteConnection();
db.transaction((tx) => {
tx.insert(appProfiles)
.values({
authUserId: user.id,
email: normalizedEmail,
displayName: user.name || getDefaultDisplayName(normalizedEmail),
role: "freelancer",
disabled: false,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
.run();
completeFirstFreelancerSetupInTransaction(tx, {
id: user.id,
email: normalizedEmail,
name: user.name ?? null,
});
});
}
tx.insert(appSetupState)
.values({
key: FIRST_FREELANCER_SETUP_KEY,
type SetupTransaction = Parameters<Parameters<ReturnType<typeof getSqliteConnection>["db"]["transaction"]>[0]>[0];
function completeFirstFreelancerSetupInTransaction(
tx: SetupTransaction,
user: {
id: string;
email: string;
name?: string | null;
},
): void {
const normalizedEmail = normalizeAuthEmail(user.email);
const now = new Date();
tx.insert(appProfiles)
.values({
authUserId: user.id,
email: normalizedEmail,
displayName: user.name || getDefaultDisplayName(normalizedEmail),
role: "freelancer",
disabled: false,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing()
.run();
tx.insert(appSetupState)
.values({
key: FIRST_FREELANCER_SETUP_KEY,
status: "completed",
lockedBy: normalizedEmail,
lockedAt: now,
completedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appSetupState.key,
set: {
status: "completed",
lockedBy: normalizedEmail,
lockedAt: now,
completedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appSetupState.key,
set: {
status: "completed",
lockedBy: normalizedEmail,
completedAt: now,
updatedAt: now,
},
})
.run();
},
})
.run();
tx.insert(authAuditEvents)
.values({
type: "setup_completed",
authUserId: user.id,
email: normalizedEmail,
metadata: { role: "freelancer" },
})
.run();
});
tx.insert(authAuditEvents)
.values({
type: "setup_completed",
authUserId: user.id,
email: normalizedEmail,
metadata: { role: "freelancer", repaired: true },
})
.run();
}
export async function recordAuthAuditEvent(input: {