Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
import type { z } from 'zod'
|
|
import { auth } from '@/lib/auth'
|
|
import { canAccess, getApiAdminContext } from '@/lib/permissions'
|
|
import { type ActionResult, actionError, handleActionError } from '@/lib/safe-action-shared'
|
|
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
|
|
|
|
export type { ActionResult }
|
|
|
|
// ── Admin action wrapper ─────────────────────────────────────────────
|
|
|
|
interface AdminActionOptions<TSchema extends z.ZodType | undefined = undefined> {
|
|
permission?: string
|
|
schema?: TSchema
|
|
}
|
|
|
|
type AdminActionContext<TSchema extends z.ZodType | undefined> = {
|
|
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
|
|
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
|
|
|
|
/**
|
|
* Create a server action with admin auth + optional permission + optional Zod validation.
|
|
*
|
|
* @example
|
|
* export const updateNews = adminAction(
|
|
* { permission: PERMS.NEWS_EDIT, schema: updateNewsSchema },
|
|
* async (ctx) => {
|
|
* await prisma.websiteArticle.update({ ... })
|
|
* return actionOk()
|
|
* }
|
|
* )
|
|
*/
|
|
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
|
options: AdminActionOptions<TSchema>,
|
|
handler: (ctx: AdminActionContext<TSchema>) => Promise<ActionResult>,
|
|
) {
|
|
return async (
|
|
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
|
|
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
|
): Promise<ActionResult> => {
|
|
try {
|
|
const apiCtx = await getApiAdminContext()
|
|
if (!apiCtx) return actionError('Unauthorized')
|
|
|
|
if (options.permission) {
|
|
if (!canAccess(apiCtx.permissions, options.permission, apiCtx.session.user.rank)) {
|
|
await logAuthorizationEvent({
|
|
kind: 'permission.denied', userId: apiCtx.session.user.id,
|
|
username: apiCtx.session.user.name ?? undefined, rank: apiCtx.session.user.rank,
|
|
permission: options.permission, source: 'adminAction', reason: 'Permission check denied',
|
|
})
|
|
return actionError('Unauthorized')
|
|
}
|
|
}
|
|
|
|
let data: unknown
|
|
if (options.schema) {
|
|
const parsed = options.schema.safeParse(input)
|
|
if (!parsed.success) {
|
|
return {
|
|
ok: false,
|
|
error: 'Validation failed',
|
|
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
|
}
|
|
}
|
|
data = parsed.data
|
|
}
|
|
|
|
const ctx = {
|
|
session: apiCtx.session,
|
|
...(options.schema ? { data } : {}),
|
|
} as AdminActionContext<TSchema>
|
|
|
|
return await handler(ctx)
|
|
} catch (error) {
|
|
return handleActionError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Auth action wrapper (no permissions) ─────────────────────────────
|
|
|
|
interface AuthActionOptions<TSchema extends z.ZodType | undefined = undefined> {
|
|
schema?: TSchema
|
|
}
|
|
|
|
type AuthActionContext<TSchema extends z.ZodType | undefined> = {
|
|
session: { user: { id: number; username: string; rank: number; look: string; mail: string } }
|
|
} & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : object)
|
|
|
|
/**
|
|
* Create a server action with auth only (no permission check).
|
|
*/
|
|
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
|
options: AuthActionOptions<TSchema>,
|
|
handler: (ctx: AuthActionContext<TSchema>) => Promise<ActionResult>,
|
|
) {
|
|
return async (
|
|
// biome-ignore lint/suspicious/noConfusingVoidType: void in conditional return lets callers omit the arg when there's no schema
|
|
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
|
): Promise<ActionResult> => {
|
|
try {
|
|
const session = await auth()
|
|
if (!session?.user) return actionError('Unauthorized')
|
|
|
|
let data: unknown
|
|
if (options.schema) {
|
|
const parsed = options.schema.safeParse(input)
|
|
if (!parsed.success) {
|
|
return {
|
|
ok: false,
|
|
error: 'Validation failed',
|
|
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
|
}
|
|
}
|
|
data = parsed.data
|
|
}
|
|
|
|
const ctx = {
|
|
session,
|
|
...(options.schema ? { data } : {}),
|
|
} as AuthActionContext<TSchema>
|
|
|
|
return await handler(ctx)
|
|
} catch (error) {
|
|
return handleActionError(error)
|
|
}
|
|
}
|
|
}
|