feat(auth): complete sqlite auth and client invitations
This commit is contained in:
+4
-2
@@ -7,6 +7,7 @@ import { getServerConfig } from "@/server/config";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import * as schema from "@/server/db/schema";
|
||||
import {
|
||||
authorizeSessionCreation,
|
||||
completeFirstFreelancerSetup,
|
||||
recordAuthAuditEvent,
|
||||
reserveFirstFreelancerSetup,
|
||||
@@ -46,12 +47,12 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
useSecureCookies: config.nodeEnv === "production",
|
||||
useSecureCookies: config.secureCookies,
|
||||
cookiePrefix: "neta",
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.nodeEnv === "production",
|
||||
secure: config.secureCookies,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
@@ -69,6 +70,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => authorizeSessionCreation(session.userId),
|
||||
after: async (session) => {
|
||||
await recordAuthAuditEvent({
|
||||
type: "login_succeeded",
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { SessionContext } from "@/server/auth/session";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import {
|
||||
account,
|
||||
appProfiles,
|
||||
authAuditEvents,
|
||||
portalInvitations,
|
||||
session,
|
||||
user,
|
||||
} from "@/server/db/schema";
|
||||
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
|
||||
|
||||
const DEFAULT_INVITATION_TTL_HOURS = 72;
|
||||
|
||||
const createInvitationSchema = z.object({
|
||||
clientId: z.string().trim().min(1).max(128),
|
||||
email: z.email().transform(normalizeAuthEmail),
|
||||
expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS),
|
||||
});
|
||||
|
||||
const acceptInvitationSchema = z.object({
|
||||
token: z.string().trim().min(32).max(256),
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export type PortalInvitationErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "INVALID_INPUT"
|
||||
| "INVITATION_NOT_FOUND"
|
||||
| "INVITATION_NOT_PENDING"
|
||||
| "INVITATION_EXPIRED"
|
||||
| "CLIENT_ALREADY_LINKED"
|
||||
| "EMAIL_ALREADY_REGISTERED";
|
||||
|
||||
export class PortalInvitationError extends Error {
|
||||
constructor(
|
||||
public readonly code: PortalInvitationErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PortalInvitationError";
|
||||
}
|
||||
}
|
||||
|
||||
export type PortalInvitationPreview = {
|
||||
email: string;
|
||||
expiresAt: Date;
|
||||
status: "pending" | "accepted" | "revoked" | "expired";
|
||||
};
|
||||
|
||||
export async function createPortalInvitation(
|
||||
actor: SessionContext,
|
||||
input: z.input<typeof createInvitationSchema>,
|
||||
): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> {
|
||||
assertFreelancerActor(actor);
|
||||
|
||||
const parsed = parseOrThrow(createInvitationSchema, input);
|
||||
const rawToken = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashInvitationToken(rawToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + parsed.expiresInHours * 60 * 60 * 1000);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
const invitationId = db.transaction((tx) => {
|
||||
const [linkedProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.clientId, parsed.clientId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (linkedProfile) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||
);
|
||||
}
|
||||
|
||||
const replacedInvitations = tx
|
||||
.select({ id: portalInvitations.id, email: portalInvitations.email })
|
||||
.from(portalInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.clientId, parsed.clientId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "revoked" })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.clientId, parsed.clientId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
for (const replaced of replacedInvitations) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_revoked",
|
||||
authUserId: actor.user.id,
|
||||
email: replaced.email,
|
||||
metadata: { invitationId: replaced.id, reason: "replaced", clientId: parsed.clientId },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const inserted = tx
|
||||
.insert(portalInvitations)
|
||||
.values({
|
||||
tokenHash,
|
||||
clientId: parsed.clientId,
|
||||
email: parsed.email,
|
||||
status: "pending",
|
||||
expiresAt,
|
||||
createdByUserId: actor.user.id,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning({ id: portalInvitations.id })
|
||||
.get();
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_created",
|
||||
authUserId: actor.user.id,
|
||||
email: parsed.email,
|
||||
metadata: {
|
||||
invitationId: inserted.id,
|
||||
clientId: parsed.clientId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return inserted.id;
|
||||
});
|
||||
|
||||
return {
|
||||
id: invitationId,
|
||||
invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPortalInvitationPreview(rawToken: string): PortalInvitationPreview | null {
|
||||
if (!isPlausibleToken(rawToken)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { db } = getSqliteConnection();
|
||||
const [invitation] = db
|
||||
.select({
|
||||
id: portalInvitations.id,
|
||||
email: portalInvitations.email,
|
||||
status: portalInvitations.status,
|
||||
expiresAt: portalInvitations.expiresAt,
|
||||
})
|
||||
.from(portalInvitations)
|
||||
.where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken)))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
|
||||
db.transaction((tx) => {
|
||||
const result = tx
|
||||
.update(portalInvitations)
|
||||
.set({ status: "expired" })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitation.id),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_expired",
|
||||
email: invitation.email,
|
||||
metadata: { invitationId: invitation.id },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { ...invitation, status: "expired" };
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}
|
||||
|
||||
export async function acceptPortalInvitation(input: {
|
||||
token: string;
|
||||
displayName?: string;
|
||||
password: string;
|
||||
}): Promise<{ authUserId: string; clientId: string; email: string }> {
|
||||
const preview = getPortalInvitationPreview(input.token);
|
||||
|
||||
if (!preview) {
|
||||
await recordInvitationFailure(null, "INVITATION_NOT_FOUND");
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
|
||||
}
|
||||
|
||||
if (preview.status === "expired") {
|
||||
await recordInvitationFailure(preview.email, "INVITATION_EXPIRED");
|
||||
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
||||
}
|
||||
|
||||
if (preview.status !== "pending") {
|
||||
await recordInvitationFailure(preview.email, "INVITATION_NOT_PENDING");
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseOrThrow(acceptInvitationSchema, {
|
||||
...input,
|
||||
displayName: input.displayName || getDefaultDisplayName(preview.email),
|
||||
});
|
||||
const passwordHash = await hashPassword(parsed.password);
|
||||
const tokenHash = hashInvitationToken(parsed.token);
|
||||
const authUserId = randomUUID();
|
||||
const accountId = randomUUID();
|
||||
const now = new Date();
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
try {
|
||||
return db.transaction((tx) => {
|
||||
const [invitation] = tx
|
||||
.select()
|
||||
.from(portalInvitations)
|
||||
.where(eq(portalInvitations.tokenHash, tokenHash))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
|
||||
}
|
||||
|
||||
if (invitation.status !== "pending") {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
|
||||
);
|
||||
}
|
||||
|
||||
if (invitation.expiresAt.getTime() <= now.getTime()) {
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "expired" })
|
||||
.where(eq(portalInvitations.id, invitation.id))
|
||||
.run();
|
||||
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
||||
}
|
||||
|
||||
const [existingUser] = tx
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, invitation.email))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (existingUser) {
|
||||
throw new PortalInvitationError(
|
||||
"EMAIL_ALREADY_REGISTERED",
|
||||
"Bu e-posta adresiyle kayıtlı bir hesap zaten var.",
|
||||
);
|
||||
}
|
||||
|
||||
const [linkedProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.clientId, invitation.clientId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (linkedProfile) {
|
||||
throw new PortalInvitationError(
|
||||
"CLIENT_ALREADY_LINKED",
|
||||
"Bu müşteri için portal hesabı zaten mevcut.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.insert(user)
|
||||
.values({
|
||||
id: authUserId,
|
||||
name: parsed.displayName,
|
||||
email: invitation.email,
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(account)
|
||||
.values({
|
||||
id: accountId,
|
||||
accountId: authUserId,
|
||||
providerId: "credential",
|
||||
userId: authUserId,
|
||||
password: passwordHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
tx.insert(appProfiles)
|
||||
.values({
|
||||
authUserId,
|
||||
email: invitation.email,
|
||||
displayName: parsed.displayName,
|
||||
role: "client",
|
||||
clientId: invitation.clientId,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const accepted = tx
|
||||
.update(portalInvitations)
|
||||
.set({ status: "accepted", acceptedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitation.id),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
if (accepted.changes !== 1) {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Davet başka bir istek tarafından kullanıldı.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_accepted",
|
||||
authUserId,
|
||||
email: invitation.email,
|
||||
metadata: { invitationId: invitation.id, clientId: invitation.clientId },
|
||||
})
|
||||
.run();
|
||||
|
||||
return { authUserId, clientId: invitation.clientId, email: invitation.email };
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error instanceof PortalInvitationError ? error.code : "transaction_failed";
|
||||
await recordInvitationFailure(preview.email, code);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function revokePortalInvitation(actor: SessionContext, invitationId: number): void {
|
||||
assertFreelancerActor(actor);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [invitation] = tx
|
||||
.select({ id: portalInvitations.id, email: portalInvitations.email })
|
||||
.from(portalInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(portalInvitations.id, invitationId),
|
||||
eq(portalInvitations.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!invitation) {
|
||||
throw new PortalInvitationError(
|
||||
"INVITATION_NOT_PENDING",
|
||||
"Aktif davet bulunamadı.",
|
||||
);
|
||||
}
|
||||
|
||||
tx.update(portalInvitations)
|
||||
.set({ status: "revoked" })
|
||||
.where(eq(portalInvitations.id, invitation.id))
|
||||
.run();
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_revoked",
|
||||
authUserId: actor.user.id,
|
||||
email: invitation.email,
|
||||
metadata: { invitationId },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export function setClientPortalAccess(
|
||||
actor: SessionContext,
|
||||
clientId: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
assertFreelancerActor(actor);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [profile] = tx
|
||||
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
|
||||
.from(appProfiles)
|
||||
.where(and(eq(appProfiles.clientId, clientId), eq(appProfiles.role, "client")))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!profile) {
|
||||
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Müşteri portal hesabı bulunamadı.");
|
||||
}
|
||||
|
||||
tx.update(appProfiles)
|
||||
.set({ disabled: !enabled, updatedAt: new Date() })
|
||||
.where(eq(appProfiles.authUserId, profile.authUserId))
|
||||
.run();
|
||||
|
||||
if (!enabled) {
|
||||
tx.delete(session).where(eq(session.userId, profile.authUserId)).run();
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: enabled ? "client_access_enabled" : "client_access_disabled",
|
||||
authUserId: actor.user.id,
|
||||
email: profile.email,
|
||||
metadata: { clientId, targetAuthUserId: profile.authUserId },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export function hashInvitationToken(rawToken: string): string {
|
||||
return createHash("sha256").update(rawToken, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function assertFreelancerActor(actor: SessionContext): void {
|
||||
if (actor.profile.disabled || actor.profile.role !== "freelancer") {
|
||||
throw new PortalInvitationError("FORBIDDEN", "Bu işlem için yetkiniz yok.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlausibleToken(value: string): boolean {
|
||||
return typeof value === "string" && value.length >= 32 && value.length <= 256;
|
||||
}
|
||||
|
||||
function parseOrThrow<TSchema extends z.ZodType>(
|
||||
schema: TSchema,
|
||||
input: unknown,
|
||||
): z.output<TSchema> {
|
||||
const result = schema.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw new PortalInvitationError("INVALID_INPUT", "Girilen bilgiler geçersiz.");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function recordInvitationFailure(email: string | null, reason: string): Promise<void> {
|
||||
const { db } = getSqliteConnection();
|
||||
db.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "invitation_accept_failed",
|
||||
email,
|
||||
metadata: { reason },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
+17
-4
@@ -20,13 +20,20 @@ export type SessionContext = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
clientId: string | null;
|
||||
disabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
|
||||
return getSessionContextFromHeaders(await headers());
|
||||
});
|
||||
|
||||
export async function getSessionContextFromHeaders(
|
||||
requestHeaders: Headers,
|
||||
): Promise<SessionContext | null> {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
headers: requestHeaders,
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
@@ -38,7 +45,12 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
|
||||
|
||||
const profile = getProfileByAuthUserId(session.user.id);
|
||||
|
||||
if (!profile || profile.disabled || profile.authUserId !== session.user.id) {
|
||||
if (
|
||||
!profile ||
|
||||
profile.disabled ||
|
||||
profile.authUserId !== session.user.id ||
|
||||
(profile.role === "client" && !profile.clientId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +59,7 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
|
||||
user: session.user,
|
||||
profile,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function requireSession(): Promise<SessionContext> {
|
||||
const context = await getSessionContext();
|
||||
@@ -72,7 +84,7 @@ export async function requireFreelancer(): Promise<SessionContext> {
|
||||
export async function requireClientUser(): Promise<SessionContext> {
|
||||
const context = await requireSession();
|
||||
|
||||
if (context.profile.role !== "client") {
|
||||
if (context.profile.role !== "client" || !context.profile.clientId) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -88,6 +100,7 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
|
||||
email: appProfiles.email,
|
||||
displayName: appProfiles.displayName,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
|
||||
+132
-7
@@ -122,6 +122,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
.all();
|
||||
|
||||
if (freelancerCount > 0) {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "registration_rejected",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason: "setup_completed" },
|
||||
})
|
||||
.run();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -135,6 +142,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
const now = new Date();
|
||||
|
||||
if (setupState?.status === "completed") {
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "registration_rejected",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason: "setup_completed" },
|
||||
})
|
||||
.run();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -178,21 +192,52 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
|
||||
});
|
||||
}
|
||||
|
||||
export function failFirstFreelancerSetup(email: string, reason: string): void {
|
||||
const normalizedEmail = normalizeAuthEmail(email);
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const [setupState] = tx
|
||||
.select()
|
||||
.from(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (setupState?.status === "pending" && setupState.lockedBy === normalizedEmail) {
|
||||
tx.delete(appSetupState)
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.run();
|
||||
}
|
||||
|
||||
tx.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "setup_failed",
|
||||
email: normalizedEmail,
|
||||
metadata: { reason },
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeFirstFreelancerSetup(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
}): Promise<void> {
|
||||
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||
const now = new Date();
|
||||
const { db } = getSqliteConnection();
|
||||
|
||||
db.transaction((tx) => {
|
||||
completeFirstFreelancerSetupInTransaction(tx, {
|
||||
id: user.id,
|
||||
email: normalizedEmail,
|
||||
name: user.name ?? null,
|
||||
});
|
||||
completeFirstFreelancerSetupInTransaction(
|
||||
tx,
|
||||
{
|
||||
id: user.id,
|
||||
email: normalizedEmail,
|
||||
name: user.name ?? null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,9 +250,29 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
email: string;
|
||||
name?: string | null;
|
||||
},
|
||||
repaired = true,
|
||||
): void {
|
||||
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||
const now = new Date();
|
||||
const [existingProfile] = tx
|
||||
.select({ id: appProfiles.id })
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, user.id))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (existingProfile) {
|
||||
tx.update(appSetupState)
|
||||
.set({
|
||||
status: "completed",
|
||||
lockedBy: normalizedEmail,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
tx.insert(appProfiles)
|
||||
.values({
|
||||
@@ -215,6 +280,7 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
email: normalizedEmail,
|
||||
displayName: user.name || getDefaultDisplayName(normalizedEmail),
|
||||
role: "freelancer",
|
||||
clientId: null,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -247,7 +313,7 @@ function completeFirstFreelancerSetupInTransaction(
|
||||
type: "setup_completed",
|
||||
authUserId: user.id,
|
||||
email: normalizedEmail,
|
||||
metadata: { role: "freelancer", repaired: true },
|
||||
metadata: { role: "freelancer", repaired },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -269,3 +335,62 @@ export async function recordAuthAuditEvent(input: {
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function authorizeSessionCreation(authUserId: string): boolean {
|
||||
const { db } = getSqliteConnection();
|
||||
let [profile] = db
|
||||
.select({
|
||||
email: appProfiles.email,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (!profile) {
|
||||
const [authUser] = db
|
||||
.select({ email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
|
||||
if (authUser && repairFirstFreelancerSetupForEmail(authUser.email)) {
|
||||
[profile] = db
|
||||
.select({
|
||||
email: appProfiles.email,
|
||||
role: appProfiles.role,
|
||||
clientId: appProfiles.clientId,
|
||||
disabled: appProfiles.disabled,
|
||||
})
|
||||
.from(appProfiles)
|
||||
.where(eq(appProfiles.authUserId, authUserId))
|
||||
.limit(1)
|
||||
.all();
|
||||
}
|
||||
}
|
||||
|
||||
if (profile && !profile.disabled && (profile.role !== "client" || profile.clientId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
db.insert(authAuditEvents)
|
||||
.values({
|
||||
type: "login_failed",
|
||||
authUserId: profile ? authUserId : null,
|
||||
email: profile?.email ?? null,
|
||||
metadata: {
|
||||
reason: profile?.disabled
|
||||
? "disabled_profile"
|
||||
: profile?.role === "client" && !profile.clientId
|
||||
? "unlinked_client_profile"
|
||||
: "missing_profile",
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
+14
-2
@@ -4,10 +4,22 @@ export type UserRole = (typeof userRoles)[number];
|
||||
|
||||
export type SetupStatus = "pending" | "completed";
|
||||
|
||||
export const portalInvitationStatuses = ["pending", "accepted", "revoked", "expired"] as const;
|
||||
|
||||
export type PortalInvitationStatus = (typeof portalInvitationStatuses)[number];
|
||||
|
||||
export type AuthAuditEventType =
|
||||
| "setup_started"
|
||||
| "setup_completed"
|
||||
| "setup_failed"
|
||||
| "registration_rejected"
|
||||
| "login_succeeded"
|
||||
| "login_failed"
|
||||
| "logout_succeeded";
|
||||
|
||||
| "logout_succeeded"
|
||||
| "invitation_created"
|
||||
| "invitation_revoked"
|
||||
| "invitation_expired"
|
||||
| "invitation_accepted"
|
||||
| "invitation_accept_failed"
|
||||
| "client_access_disabled"
|
||||
| "client_access_enabled";
|
||||
|
||||
+26
-3
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -24,6 +25,7 @@ export type ServerConfig = {
|
||||
tmpDir: string;
|
||||
appUrl: string;
|
||||
trustedOrigins: string[];
|
||||
secureCookies: boolean;
|
||||
betterAuthSecret?: string;
|
||||
};
|
||||
|
||||
@@ -35,12 +37,16 @@ export function getServerConfig(): ServerConfig {
|
||||
}
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
const isProductionBuild = process.env.NEXT_PHASE === "phase-production-build";
|
||||
const defaultDataDir = isProductionBuild
|
||||
? path.join(os.tmpdir(), `neta-production-build-${process.pid}`)
|
||||
: parsed.NODE_ENV === "production"
|
||||
? "/app/data"
|
||||
: path.join(process.cwd(), ".data");
|
||||
const dataDir = path.resolve(
|
||||
parsed.DATA_DIR && parsed.DATA_DIR.length > 0
|
||||
? parsed.DATA_DIR
|
||||
: parsed.NODE_ENV === "production"
|
||||
? "/app/data"
|
||||
: path.join(process.cwd(), ".data"),
|
||||
: defaultDataDir,
|
||||
);
|
||||
|
||||
const databasePath = path.resolve(
|
||||
@@ -55,6 +61,7 @@ export function getServerConfig(): ServerConfig {
|
||||
parsed.NEXT_PUBLIC_SITE_URL ||
|
||||
"http://localhost:3000",
|
||||
);
|
||||
const secureCookies = validateAppUrlSecurity(appUrl, parsed.NODE_ENV);
|
||||
const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl);
|
||||
const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV);
|
||||
|
||||
@@ -67,6 +74,7 @@ export function getServerConfig(): ServerConfig {
|
||||
tmpDir: path.join(dataDir, "tmp"),
|
||||
appUrl,
|
||||
trustedOrigins,
|
||||
secureCookies,
|
||||
betterAuthSecret,
|
||||
};
|
||||
|
||||
@@ -98,6 +106,21 @@ function normalizeTrustedOrigins(value: string | undefined, appUrl: string): str
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
function validateAppUrlSecurity(
|
||||
appUrl: string,
|
||||
nodeEnv: ServerConfig["nodeEnv"],
|
||||
): boolean {
|
||||
const url = new URL(appUrl);
|
||||
const isHttps = url.protocol === "https:";
|
||||
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
||||
|
||||
if (nodeEnv === "production" && !isHttps && !isLoopback) {
|
||||
throw new Error("Production APP_URL HTTPS kullanmalidir; HTTP yalnizca localhost icin desteklenir.");
|
||||
}
|
||||
|
||||
return isHttps;
|
||||
}
|
||||
|
||||
function normalizeAuthSecret(
|
||||
value: string | undefined,
|
||||
nodeEnv: ServerConfig["nodeEnv"],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `app_profiles` ADD `client_id` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `app_profiles_client_id_unique` ON `app_profiles` (`client_id`);
|
||||
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9dcf0ef9-1e97-48d0-87ac-a5da54e16bc5",
|
||||
"prevId": "cb8d4285-951f-4bbf-b848-953bdf769836",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_user_id_idx": {
|
||||
"name": "account_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"app_profiles": {
|
||||
"name": "app_profiles",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"auth_user_id": {
|
||||
"name": "auth_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"app_profiles_auth_user_id_unique": {
|
||||
"name": "app_profiles_auth_user_id_unique",
|
||||
"columns": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"app_profiles_client_id_unique": {
|
||||
"name": "app_profiles_client_id_unique",
|
||||
"columns": [
|
||||
"client_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"app_profiles_role_idx": {
|
||||
"name": "app_profiles_role_idx",
|
||||
"columns": [
|
||||
"role"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"app_profiles_auth_user_id_user_id_fk": {
|
||||
"name": "app_profiles_auth_user_id_user_id_fk",
|
||||
"tableFrom": "app_profiles",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"app_setup_state": {
|
||||
"name": "app_setup_state",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locked_by": {
|
||||
"name": "locked_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"locked_at": {
|
||||
"name": "locked_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"auth_audit_events": {
|
||||
"name": "auth_audit_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_user_id": {
|
||||
"name": "auth_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"auth_audit_events_type_idx": {
|
||||
"name": "auth_audit_events_type_idx",
|
||||
"columns": [
|
||||
"type"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"auth_audit_events_auth_user_id_idx": {
|
||||
"name": "auth_audit_events_auth_user_id_idx",
|
||||
"columns": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"auth_audit_events_auth_user_id_user_id_fk": {
|
||||
"name": "auth_audit_events_auth_user_id_user_id_fk",
|
||||
"tableFrom": "auth_audit_events",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"auth_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"portal_invitations": {
|
||||
"name": "portal_invitations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accepted_at": {
|
||||
"name": "accepted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by_user_id": {
|
||||
"name": "created_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"portal_invitations_token_hash_unique": {
|
||||
"name": "portal_invitations_token_hash_unique",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"portal_invitations_client_id_idx": {
|
||||
"name": "portal_invitations_client_id_idx",
|
||||
"columns": [
|
||||
"client_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"portal_invitations_email_idx": {
|
||||
"name": "portal_invitations_email_idx",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"portal_invitations_created_by_user_id_user_id_fk": {
|
||||
"name": "portal_invitations_created_by_user_id_user_id_fk",
|
||||
"tableFrom": "portal_invitations",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"created_by_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": [
|
||||
"token"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"session_user_id_idx": {
|
||||
"name": "session_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"verification": {
|
||||
"name": "verification",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": [
|
||||
"identifier"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"runtime_checks": {
|
||||
"name": "runtime_checks",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"runtime_events": {
|
||||
"name": "runtime_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1783709956320,
|
||||
"tag": "0001_silky_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1784205329112,
|
||||
"tag": "0002_mighty_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import type { AuthAuditEventType, SetupStatus, UserRole } from "@/server/auth/types";
|
||||
import type {
|
||||
AuthAuditEventType,
|
||||
PortalInvitationStatus,
|
||||
SetupStatus,
|
||||
UserRole,
|
||||
} from "@/server/auth/types";
|
||||
|
||||
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||
|
||||
@@ -86,6 +91,7 @@ export const appProfiles = sqliteTable(
|
||||
email: text("email").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
role: text("role").$type<UserRole>().notNull(),
|
||||
clientId: text("client_id"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
@@ -95,6 +101,7 @@ export const appProfiles = sqliteTable(
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("app_profiles_auth_user_id_unique").on(table.authUserId),
|
||||
uniqueIndex("app_profiles_client_id_unique").on(table.clientId),
|
||||
index("app_profiles_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
@@ -118,7 +125,7 @@ export const portalInvitations = sqliteTable(
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
clientId: text("client_id").notNull(),
|
||||
email: text("email").notNull(),
|
||||
status: text("status", { enum: ["pending", "accepted", "revoked", "expired"] })
|
||||
status: text("status").$type<PortalInvitationStatus>()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
@@ -148,4 +155,3 @@ export const authAuditEvents = sqliteTable(
|
||||
index("auth_audit_events_auth_user_id_idx").on(table.authUserId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user