Files
prowler/ui/actions/users/tenants.ts

444 lines
12 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { signOut } from "@/auth.config";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
export const getAllTenants = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/tenants`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
if (!response.ok) {
throw new Error(`Failed to fetch tenants data: ${response.statusText}`);
}
return handleApiResponse(response);
} catch (error) {
console.error("Error fetching tenants:", error);
return undefined;
}
};
const editTenantFormSchema = z
.object({
tenantId: z.string(),
name: z.string().trim().min(1, { message: "Name is required" }),
currentName: z.string(),
})
.refine((data) => data.name !== data.currentName, {
message: "Name must be different from the current name",
path: ["name"],
});
export type UpdateTenantNameState =
| { errors: { name?: string } }
| { success: string }
| { error: string };
export async function updateTenantName(
_prevState: UpdateTenantNameState | null,
formData: FormData,
): Promise<UpdateTenantNameState> {
const headers = await getAuthHeaders({ contentType: true });
const formDataObject = Object.fromEntries(formData);
const validatedData = editTenantFormSchema.safeParse(formDataObject);
if (!validatedData.success) {
const formFieldErrors = validatedData.error.flatten().fieldErrors;
return {
errors: {
name: formFieldErrors?.name?.[0],
},
};
}
const { tenantId, name } = validatedData.data;
const payload = {
data: {
type: "tenants",
id: tenantId,
attributes: {
name: name.trim(),
},
},
};
try {
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Failed to update tenant name: ${response.statusText}`);
}
await handleApiResponse(response, "/profile", false);
return { success: "Tenant name updated successfully!" };
} catch (error) {
return handleApiError(error);
}
}
const switchTenantSchema = z.object({
tenantId: z.uuid(),
});
interface SwitchTenantSuccess {
success: true;
accessToken: string;
refreshToken: string;
}
interface SwitchTenantError {
error: string;
}
export type SwitchTenantState = SwitchTenantSuccess | SwitchTenantError;
export async function switchTenant(
_prevState: SwitchTenantState | null,
formData: FormData,
): Promise<SwitchTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = switchTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant ID" };
}
const { tenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: true });
const payload = {
data: {
type: "tokens-switch-tenant",
attributes: {
tenant_id: tenantId,
},
},
};
try {
const url = new URL(`${apiBaseUrl}/tokens/switch`);
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to switch tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
const data = await response.json();
const accessToken = data?.data?.attributes?.access;
const refreshToken = data?.data?.attributes?.refresh;
if (!accessToken || !refreshToken) {
throw new Error("Missing tokens in switch tenant response");
}
return { success: true, accessToken, refreshToken };
} catch (error) {
return handleApiError(error);
}
}
const createTenantSchema = z.object({
name: z
.string()
.trim()
.min(1, { message: "Name is required" })
.max(100, { message: "Name must be 100 characters or less" }),
});
interface CreateTenantSuccess {
success: true;
tenantId: string;
}
interface CreateTenantError {
error: string;
}
export type CreateTenantState = CreateTenantSuccess | CreateTenantError;
export async function createTenant(
_prevState: CreateTenantState | null,
formData: FormData,
): Promise<CreateTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = createTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
const fieldErrors = validatedData.error.flatten().fieldErrors;
return { error: fieldErrors?.name?.[0] || "Invalid input" };
}
const { name } = validatedData.data;
const headers = await getAuthHeaders({ contentType: true });
const payload = {
data: {
type: "tenants",
attributes: { name },
},
};
try {
const url = new URL(`${apiBaseUrl}/tenants`);
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to create tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
const data = await response.json();
const tenantId = data?.data?.id;
if (!tenantId) {
throw new Error("Missing tenant ID in create response");
}
revalidatePath("/profile");
return { success: true, tenantId };
} catch (error) {
return handleApiError(error);
}
}
const deleteTenantSchema = z.object({
tenantId: z.uuid(),
});
const switchThenDeleteTenantSchema = z.object({
tenantId: z.uuid(),
targetTenantId: z.uuid(),
});
interface DeleteTenantSuccess {
success: true;
}
interface DeleteTenantError {
error: string;
}
export type DeleteTenantState = DeleteTenantSuccess | DeleteTenantError;
export async function deleteTenant(
_prevState: DeleteTenantState | null,
formData: FormData,
): Promise<DeleteTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = deleteTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant ID" };
}
const { tenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to delete tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
revalidatePath("/profile");
return { success: true };
} catch (error) {
return handleApiError(error);
}
}
export async function deleteTenantThenSignOut(
_prevState: DeleteTenantState | null,
formData: FormData,
): Promise<DeleteTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = deleteTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant ID" };
}
const { tenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: false });
try {
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to delete tenant: ${response.statusText}`;
throw new Error(errorDetail);
}
} catch (error) {
return handleApiError(error);
}
// Deleting the last tenant also removes the user account server-side, so
// there is no profile left to revalidate; close the session instead.
// signOut redirects by throwing, so it must stay outside the try/catch.
await signOut({ redirectTo: "/sign-in" });
// Unreachable: signOut always redirects. Present to satisfy the return type.
return { success: true };
}
interface SwitchThenDeleteSuccess {
success: true;
accessToken: string;
refreshToken: string;
}
interface SwitchThenDeleteError {
error: string;
accessToken?: string;
refreshToken?: string;
}
export type SwitchThenDeleteTenantState =
| SwitchThenDeleteSuccess
| SwitchThenDeleteError;
export async function switchThenDeleteTenant(
_prevState: SwitchThenDeleteTenantState | null,
formData: FormData,
): Promise<SwitchThenDeleteTenantState> {
const formDataObject = Object.fromEntries(formData);
const validatedData = switchThenDeleteTenantSchema.safeParse(formDataObject);
if (!validatedData.success) {
return { error: "Invalid tenant or target tenant ID" };
}
const { tenantId, targetTenantId } = validatedData.data;
const headers = await getAuthHeaders({ contentType: true });
// Step 1: Switch to the target tenant (current token is still valid)
const switchPayload = {
data: {
type: "tokens-switch-tenant",
attributes: {
tenant_id: targetTenantId,
},
},
};
let newAccessToken: string;
let newRefreshToken: string;
try {
const switchUrl = new URL(`${apiBaseUrl}/tokens/switch`);
const switchResponse = await fetch(switchUrl.toString(), {
method: "POST",
headers,
body: JSON.stringify(switchPayload),
});
if (!switchResponse.ok) {
const errorData = await switchResponse.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to switch tenant: ${switchResponse.statusText}`;
throw new Error(errorDetail);
}
const switchData = await switchResponse.json();
newAccessToken = switchData?.data?.attributes?.access;
newRefreshToken = switchData?.data?.attributes?.refresh;
if (!newAccessToken || !newRefreshToken) {
throw new Error("Missing tokens in switch tenant response");
}
} catch (error) {
return handleApiError(error);
}
// Step 2: Delete the old tenant using the NEW token
const deleteHeaders: Record<string, string> = {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${newAccessToken}`,
};
try {
const deleteUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}`);
const deleteResponse = await fetch(deleteUrl.toString(), {
method: "DELETE",
headers: deleteHeaders,
});
if (!deleteResponse.ok) {
const errorData = await deleteResponse.json().catch(() => null);
const errorDetail =
errorData?.errors?.[0]?.detail ||
`Failed to delete tenant: ${deleteResponse.statusText}`;
// Switch succeeded but delete failed — return tokens so client can still update session
return {
error: errorDetail,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
revalidatePath("/profile");
return {
success: true,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
} catch (error) {
// Switch succeeded but delete threw — return tokens so client can still update session
const errorResult = handleApiError(error);
return {
...errorResult,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
}