mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-21 13:06:59 +02:00
Compare commits
8 Commits
dd2c9f2a02
...
e248571268
| Author | SHA1 | Date | |
|---|---|---|---|
| e248571268 | |||
| fcf03854ff | |||
| dd1fba4e45 | |||
| a1ab8d8f35 | |||
| c789e967db | |||
| d870b9ff49 | |||
| 9c09019ddb | |||
| 9d88683fc5 |
+2
-2
@@ -3542,14 +3542,14 @@
|
||||
"sshConnecting": "Connecting…",
|
||||
"sshInitializing": "Initializing…",
|
||||
"sshSignInTitle": "Sign in to SSH",
|
||||
"sshSignInDescription": "Enter your SSH credentials",
|
||||
"sshSignInDescription": "Enter your SSH credentials to connect",
|
||||
"sshPasswordTab": "Password",
|
||||
"sshPrivateKeyTab": "Private Key",
|
||||
"sshPrivateKeyField": "Private Key",
|
||||
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
|
||||
"sshLearnMore": "Learn more",
|
||||
"sshPrivateKeyFile": "Private Key File",
|
||||
"sshAuthenticate": "Authenticate",
|
||||
"sshAuthenticate": "Connect",
|
||||
"sshTerminate": "Terminate",
|
||||
"sshPoweredBy": "Powered by",
|
||||
"sshErrorNoTarget": "No target specified",
|
||||
|
||||
@@ -147,12 +147,10 @@ export const resources = pgTable("resources", {
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
sso: boolean("sso"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
|
||||
@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
|
||||
@@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", {
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
@@ -219,9 +219,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
|
||||
@@ -145,9 +145,9 @@ export async function verifyResourceSession(
|
||||
| ResourcePolicyHeaderAuth
|
||||
| null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
}
|
||||
| undefined = localCache.get(resourceCacheKey);
|
||||
|
||||
@@ -203,9 +203,9 @@ export async function getUserResources(
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
sso: boolean;
|
||||
sso: boolean | null;
|
||||
mode: string;
|
||||
emailWhitelistEnabled: boolean;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
policyEmailWhitelistEnabled: boolean | null;
|
||||
}> = [];
|
||||
if (uniqueResourceIds.length > 0) {
|
||||
|
||||
@@ -123,6 +123,16 @@ const listResourcesSchema = z.object({
|
||||
description:
|
||||
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
|
||||
}),
|
||||
protocol: z
|
||||
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"],
|
||||
description:
|
||||
"Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively."
|
||||
}),
|
||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
@@ -437,6 +447,7 @@ export async function listResources(
|
||||
enabled,
|
||||
query,
|
||||
healthStatus,
|
||||
protocol,
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
@@ -632,6 +643,28 @@ export async function listResources(
|
||||
if (typeof healthStatus !== "undefined") {
|
||||
conditions.push(eq(resources.health, healthStatus));
|
||||
}
|
||||
|
||||
if (typeof protocol !== "undefined") {
|
||||
switch (protocol) {
|
||||
case "http":
|
||||
conditions.push(
|
||||
and(
|
||||
eq(resources.mode, "http"),
|
||||
eq(resources.ssl, false)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "https":
|
||||
conditions.push(
|
||||
and(eq(resources.mode, "http"), eq(resources.ssl, true))
|
||||
);
|
||||
break;
|
||||
default:
|
||||
conditions.push(eq(resources.mode, protocol));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (siteId != null) {
|
||||
const resourcesWithSite = db
|
||||
.select({ resourceId: targets.resourceId })
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
resourceWhitelist
|
||||
resourceWhitelist,
|
||||
roleResources,
|
||||
roles,
|
||||
Transaction,
|
||||
userResources
|
||||
} from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
@@ -310,6 +314,61 @@ export async function updateResource(
|
||||
}
|
||||
}
|
||||
|
||||
async function clearResourceSpecificSettings(
|
||||
resourceId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db
|
||||
) {
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
throw new Error(`Admin role not found for org ${orgId}`);
|
||||
}
|
||||
// remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(eq(resourcePassword.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(eq(resourcePincode.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(eq(resourceHeaderAuth.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(eq(resourceWhitelist.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
// delete the roles and the users as well
|
||||
trx
|
||||
.delete(userResources)
|
||||
.where(eq(userResources.resourceId, resourceId)),
|
||||
// except the admin role
|
||||
trx
|
||||
.delete(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
ne(roleResources.roleId, adminRole[0].roleId)
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
async function updateHttpResource(
|
||||
route: {
|
||||
req: Request;
|
||||
@@ -372,6 +431,15 @@ async function updateHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
// catch when the resource policy changes or gets cleared
|
||||
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
@@ -560,9 +628,17 @@ async function updateHttpResource(
|
||||
emailWhitelistEnabled,
|
||||
applyRules,
|
||||
skipToIdpId,
|
||||
...resourceOnlyData
|
||||
...resourceOnlyDataRest
|
||||
} = updateData;
|
||||
|
||||
const resourceOnlyData = {
|
||||
...resourceOnlyDataRest,
|
||||
sso: null, // reset these because they are controlled by the inline policy
|
||||
emailWhitelistEnabled: null,
|
||||
applyRules: null,
|
||||
skipToIdpId: null
|
||||
};
|
||||
|
||||
const policyUpdate: Record<string, unknown> = {};
|
||||
if (sso !== undefined) policyUpdate.sso = sso;
|
||||
if (emailWhitelistEnabled !== undefined)
|
||||
@@ -659,81 +735,6 @@ async function updateRawResource(
|
||||
.limit(1);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
const [existingPolicy] = await trx
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// we are in an inline policy and we need to clear out the old tables
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(
|
||||
eq(
|
||||
resourceRules.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResourceConflict] = await trx
|
||||
.select()
|
||||
@@ -758,9 +759,24 @@ async function updateRawResource(
|
||||
}
|
||||
}
|
||||
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
trx
|
||||
); // none of these are supported on raw resources
|
||||
|
||||
// we should make sure sso, emailWhitelistEnabled, and applyRules are null because this is a raw resource
|
||||
const realUpdateData = {
|
||||
...updateData,
|
||||
sso: null,
|
||||
emailWhitelistEnabled: null,
|
||||
applyRules: null,
|
||||
skipToIdpId: null
|
||||
};
|
||||
|
||||
[updatedResource] = await trx
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
.set(realUpdateData)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
});
|
||||
|
||||
@@ -304,6 +304,25 @@ export default async function migration() {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP DEFAULT;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP DEFAULT;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP DEFAULT;`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
@@ -600,6 +619,14 @@ export default async function migration() {
|
||||
`);
|
||||
}
|
||||
|
||||
// clear the sso, applyRules, and emailWhitelistEnabled columns on all resources since that information is now in the resource policies
|
||||
await db.execute(sql`
|
||||
UPDATE "resources"
|
||||
SET "sso" = null,
|
||||
"applyRules" = null,
|
||||
"emailWhitelistEnabled" = null
|
||||
`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log(
|
||||
`Migrated inline resource policies for ${existingResources.length} resource(s)`
|
||||
|
||||
@@ -680,6 +680,25 @@ export default async function migration() {
|
||||
deleteResourceRules.run(resource.resourceId);
|
||||
deleteResourceWhitelist.run(resource.resourceId);
|
||||
}
|
||||
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
|
||||
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'sso' integer;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;`
|
||||
).run();
|
||||
});
|
||||
|
||||
migrateInlinePolicies();
|
||||
|
||||
@@ -373,11 +373,7 @@ export default function RdpClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("rdpSignInTitle")} - ${resourceName}`
|
||||
: t("rdpSignInTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("rdpSignInTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("rdpSignInDescription")} (${resourceName})`
|
||||
|
||||
@@ -510,11 +510,7 @@ export default function SshClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("sshSignInTitle")} - ${resourceName}`
|
||||
: t("sshSignInTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("sshSignInTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("sshSignInDescription")} (${resourceName})`
|
||||
|
||||
@@ -252,11 +252,7 @@ export default function VncClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("vncTitle")} - ${resourceName}`
|
||||
: t("vncTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("vncTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("vncSignInDescription")} (${resourceName})`
|
||||
|
||||
@@ -278,7 +278,50 @@ export default function PublicResourcesTable({
|
||||
accessorKey: "protocol",
|
||||
friendlyName: t("protocol"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("protocol")}</span>,
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "https",
|
||||
label: t("editInternalResourceDialogModeHttps")
|
||||
},
|
||||
{
|
||||
value: "tcp",
|
||||
label: t("editInternalResourceDialogTcp")
|
||||
},
|
||||
{
|
||||
value: "udp",
|
||||
label: t("editInternalResourceDialogUdp")
|
||||
},
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
},
|
||||
{
|
||||
value: "rdp",
|
||||
label: t("rdpTitle")
|
||||
},
|
||||
{
|
||||
value: "vnc",
|
||||
label: t("vncTitle")
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("protocol") ?? undefined
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("protocol", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("protocol")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
|
||||
@@ -42,16 +42,16 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonVariants({
|
||||
variant: "outline"
|
||||
}),
|
||||
"justify-between w-full inline-flex",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-9 py-0",
|
||||
"justify-between w-full flex items-center",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-auto min-h-9 py-1.5",
|
||||
"whitespace-normal",
|
||||
"hover:bg-transparent hover:text-muted-foreground",
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 min-w-0 flex-1",
|
||||
"overflow-x-auto flex-nowrap h-full"
|
||||
"flex items-center gap-1 min-w-0 flex-1 flex-wrap"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => {
|
||||
@@ -60,15 +60,13 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm flex-none",
|
||||
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm shrink-0",
|
||||
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5 whitespace-nowrap",
|
||||
isLocked && "opacity-60"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||
{option.text}
|
||||
</span>
|
||||
<span>{option.text}</span>
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
|
||||
Reference in New Issue
Block a user