mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-24 21:15:27 +02:00
Compare commits
1 Commits
12aea2901d
..
1.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 40f2262f3e |
@@ -482,32 +482,14 @@ jobs:
|
|||||||
echo "==> cosign sign (key) --recursive ${REF}"
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
|
|
||||||
# Retry wrapper for verification to handle registry propagation delays
|
|
||||||
retry_verify() {
|
|
||||||
local cmd="$1"
|
|
||||||
local attempts=6
|
|
||||||
local delay=5
|
|
||||||
local i=1
|
|
||||||
until eval "$cmd"; do
|
|
||||||
if [ $i -ge $attempts ]; then
|
|
||||||
echo "Verification failed after $attempts attempts"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
|
||||||
sleep $delay
|
|
||||||
i=$((i+1))
|
|
||||||
delay=$((delay*2))
|
|
||||||
# Cap the delay to avoid very long waits
|
|
||||||
if [ $delay -gt 60 ]; then delay=60; fi
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"
|
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
||||||
|
|
||||||
echo "==> cosign verify (keyless policy) ${REF}"
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"
|
cosign verify \
|
||||||
|
--certificate-oidc-issuer "${issuer}" \
|
||||||
|
--certificate-identity-regexp "${id_regex}" \
|
||||||
|
"${REF}" -o text
|
||||||
|
|
||||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -37,55 +37,27 @@ const paramsSchema = z.strictObject({
|
|||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
.union([
|
||||||
z.literal(""),
|
z.string().length(0),
|
||||||
z
|
z.url().refine(
|
||||||
.url("Must be a valid URL")
|
async (url) => {
|
||||||
.superRefine(async (url, ctx) => {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url);
|
||||||
method: "HEAD"
|
return (
|
||||||
}).catch(() => {
|
response.status === 200 &&
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
(
|
||||||
return fetch(url, { method: "GET" });
|
response.headers.get("content-type") ?? ""
|
||||||
});
|
).startsWith("image/")
|
||||||
|
);
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let errorMessage =
|
return false;
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
error: "Invalid logo URL, must be a valid image URL"
|
||||||
|
}
|
||||||
|
)
|
||||||
])
|
])
|
||||||
.transform((val) => (val === "" ? null : val))
|
.optional(),
|
||||||
.nullish(),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
@@ -106,7 +78,7 @@ export async function upsertLoginPageBranding(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = await bodySchema.safeParseAsync(req.body);
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -145,8 +117,9 @@ export async function upsertLoginPageBranding(
|
|||||||
typeof loginPageBranding
|
typeof loginPageBranding
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
|
if ((updateData.logoUrl ?? "").trim().length === 0) {
|
||||||
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
|
updateData.logoUrl = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
build !== "saas" &&
|
build !== "saas" &&
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { sendTerminateClient } from "./terminate";
|
||||||
|
import { OlmErrorCodes } from "../olm/error";
|
||||||
|
|
||||||
const archiveClientSchema = z.strictObject({
|
const archiveClientSchema = z.strictObject({
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -74,6 +77,9 @@ export async function archiveClient(
|
|||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
|
// Rebuild associations to clean up related data
|
||||||
|
await rebuildClientAssociationsFromClient(client, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { olms } from "@server/db";
|
import { olms, clients } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -8,6 +8,9 @@ import response from "@server/lib/response";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { sendTerminateClient } from "../client/terminate";
|
||||||
|
import { OlmErrorCodes } from "./error";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -34,7 +37,26 @@ export async function archiveUserOlm(
|
|||||||
|
|
||||||
const { olmId } = parsedParams.data;
|
const { olmId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Archive the OLM and disconnect associated clients in a transaction
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
// Find all clients associated with this OLM
|
||||||
|
const associatedClients = await trx
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.olmId, olmId));
|
||||||
|
|
||||||
|
// Disconnect clients from the OLM (set olmId to null)
|
||||||
|
for (const client of associatedClients) {
|
||||||
|
await trx
|
||||||
|
.update(clients)
|
||||||
|
.set({ olmId: null })
|
||||||
|
.where(eq(clients.clientId, client.clientId));
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client, trx);
|
||||||
|
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the OLM (set archived to true)
|
||||||
await trx
|
await trx
|
||||||
.update(olms)
|
.update(olms)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
|
|||||||
@@ -43,52 +43,25 @@ export type AuthPageCustomizationProps = {
|
|||||||
|
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z.union([
|
logoUrl: z.union([
|
||||||
z.literal(""),
|
z.string().length(0),
|
||||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
z.url().refine(
|
||||||
try {
|
async (url) => {
|
||||||
const response = await fetch(url, {
|
try {
|
||||||
method: "HEAD"
|
const response = await fetch(url);
|
||||||
}).catch(() => {
|
return (
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
response.status === 200 &&
|
||||||
return fetch(url, { method: "GET" });
|
(response.headers.get("content-type") ?? "").startsWith(
|
||||||
});
|
"image/"
|
||||||
|
)
|
||||||
if (response.status !== 200) {
|
);
|
||||||
ctx.addIssue({
|
} catch (error) {
|
||||||
code: "custom",
|
return false;
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
{
|
||||||
if (!contentType.startsWith("image/")) {
|
error: "Invalid logo URL, must be a valid image URL"
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (
|
|
||||||
error instanceof TypeError &&
|
|
||||||
error.message.includes("fetch")
|
|
||||||
) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
]),
|
]),
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
@@ -432,7 +405,9 @@ export default function AuthPageBrandingForm({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={isDeletingBranding}
|
loading={
|
||||||
|
isUpdatingBranding || isDeletingBranding
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdatingBranding ||
|
isUpdatingBranding ||
|
||||||
isDeletingBranding ||
|
isDeletingBranding ||
|
||||||
@@ -447,7 +422,7 @@ export default function AuthPageBrandingForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="auth-page-branding-form"
|
form="auth-page-branding-form"
|
||||||
loading={isUpdatingBranding}
|
loading={isUpdatingBranding || isDeletingBranding}
|
||||||
disabled={
|
disabled={
|
||||||
isUpdatingBranding ||
|
isUpdatingBranding ||
|
||||||
isDeletingBranding ||
|
isDeletingBranding ||
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
TableRow
|
TableRow
|
||||||
} from "@app/components/ui/table";
|
} from "@app/components/ui/table";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -58,6 +59,8 @@ export default function ViewDevicesDialog({
|
|||||||
|
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
|
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
|
||||||
|
|
||||||
const fetchDevices = async () => {
|
const fetchDevices = async () => {
|
||||||
@@ -105,6 +108,8 @@ export default function ViewDevicesDialog({
|
|||||||
d.olmId === olmId ? { ...d, archived: true } : d
|
d.olmId === olmId ? { ...d, archived: true } : d
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
setIsArchiveModalOpen(false);
|
||||||
|
setSelectedDevice(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error archiving device:", error);
|
console.error("Error archiving device:", error);
|
||||||
toast({
|
toast({
|
||||||
@@ -148,6 +153,8 @@ export default function ViewDevicesDialog({
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setDevices([]);
|
setDevices([]);
|
||||||
|
setSelectedDevice(null);
|
||||||
|
setIsArchiveModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -256,7 +263,12 @@ export default function ViewDevicesDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
archiveDevice(device.olmId);
|
setSelectedDevice(
|
||||||
|
device
|
||||||
|
);
|
||||||
|
setIsArchiveModalOpen(
|
||||||
|
true
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -349,6 +361,34 @@ export default function ViewDevicesDialog({
|
|||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
||||||
|
{selectedDevice && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isArchiveModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsArchiveModalOpen(val);
|
||||||
|
if (!val) {
|
||||||
|
setSelectedDevice(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
{t("deviceQuestionArchive") ||
|
||||||
|
"Are you sure you want to archive this device?"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t("deviceMessageArchive") ||
|
||||||
|
"The device will be archived and removed from your active devices list."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("deviceArchiveConfirm") || "Archive Device"}
|
||||||
|
onConfirm={async () => archiveDevice(selectedDevice.olmId)}
|
||||||
|
string={selectedDevice.name || selectedDevice.olmId}
|
||||||
|
title={t("archiveDevice") || "Archive Device"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user