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:
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user