mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-18 11:36:30 +02:00
add persistent session and users to access tokens
This commit is contained in:
+9
-3
@@ -178,7 +178,7 @@
|
|||||||
"shareDeleteConfirm": "Confirm Delete Shareable Link",
|
"shareDeleteConfirm": "Confirm Delete Shareable Link",
|
||||||
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
||||||
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
||||||
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
|
"shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.",
|
||||||
"accessToken": "Access Token",
|
"accessToken": "Access Token",
|
||||||
"usageExamples": "Usage Examples",
|
"usageExamples": "Usage Examples",
|
||||||
"tokenId": "Token ID",
|
"tokenId": "Token ID",
|
||||||
@@ -196,8 +196,14 @@
|
|||||||
"shareTitleOptional": "Title (optional)",
|
"shareTitleOptional": "Title (optional)",
|
||||||
"sharePathOptional": "Path (optional)",
|
"sharePathOptional": "Path (optional)",
|
||||||
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
||||||
|
"shareAssociateUserOptional": "Associate User (optional)",
|
||||||
|
"shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.",
|
||||||
|
"userSelect": "Select user",
|
||||||
|
"usersNotFound": "No users found",
|
||||||
"expireIn": "Expire In",
|
"expireIn": "Expire In",
|
||||||
"neverExpire": "Never expire",
|
"neverExpire": "Never expire",
|
||||||
|
"sharePersistSession": "Persist session after first use",
|
||||||
|
"sharePersistSessionDescription": "When enabled, the first request with this token sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.",
|
||||||
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
||||||
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
|
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
|
||||||
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
|
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
|
||||||
@@ -3089,8 +3095,8 @@
|
|||||||
"sourceAddress": "Source Address",
|
"sourceAddress": "Source Address",
|
||||||
"destinationAddress": "Destination Address",
|
"destinationAddress": "Destination Address",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
|
|||||||
@@ -905,12 +905,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
userId: varchar("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
path: varchar("path"),
|
path: varchar("path"),
|
||||||
tokenHash: varchar("tokenHash").notNull(),
|
tokenHash: varchar("tokenHash").notNull(),
|
||||||
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||||
title: varchar("title"),
|
title: varchar("title"),
|
||||||
description: varchar("description"),
|
description: varchar("description"),
|
||||||
|
persistSession: boolean("persistSession").notNull().default(false),
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1125,12 +1125,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
path: text("path"),
|
path: text("path"),
|
||||||
tokenHash: text("tokenHash").notNull(),
|
tokenHash: text("tokenHash").notNull(),
|
||||||
sessionLength: integer("sessionLength").notNull(),
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
persistSession: integer("persistSession", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
db,
|
db,
|
||||||
Org,
|
Org,
|
||||||
orgs,
|
orgs,
|
||||||
|
resourceAccessToken,
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
@@ -83,6 +84,15 @@ export async function removeUserFromOrg(
|
|||||||
.delete(userOrgs)
|
.delete(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceAccessToken.userId, userId),
|
||||||
|
eq(resourceAccessToken.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
await trx.delete(userResources).where(
|
await trx.delete(userResources).where(
|
||||||
and(
|
and(
|
||||||
eq(userResources.userId, userId),
|
eq(userResources.userId, userId),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { hash } from "@node-rs/argon2";
|
|
||||||
import {
|
import {
|
||||||
generateId,
|
generateId,
|
||||||
generateIdFromEntropySize,
|
generateIdFromEntropySize,
|
||||||
@@ -8,18 +7,18 @@ import { db } from "@server/db";
|
|||||||
import {
|
import {
|
||||||
ResourceAccessToken,
|
ResourceAccessToken,
|
||||||
resourceAccessToken,
|
resourceAccessToken,
|
||||||
resources
|
resources,
|
||||||
|
userOrgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
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 { createDate, TimeSpan } from "oslo";
|
import { createDate, TimeSpan } from "oslo";
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({
|
|||||||
validForSeconds: z.int().positive().optional(), // seconds
|
validForSeconds: z.int().positive().optional(), // seconds
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
description: z.string().optional()
|
description: z.string().optional(),
|
||||||
|
persistSession: z.boolean().optional().default(false),
|
||||||
|
userId: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateAccssTokenParamsSchema = z.strictObject({
|
export const generateAccssTokenParamsSchema = z.strictObject({
|
||||||
@@ -101,7 +102,14 @@ export async function generateAccessToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { validForSeconds, title, path, description } = parsedBody.data;
|
const {
|
||||||
|
validForSeconds,
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
description,
|
||||||
|
persistSession,
|
||||||
|
userId
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [resource] = await db
|
const [resource] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -112,6 +120,28 @@ export async function generateAccessToken(
|
|||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.orgId, resource.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User is not a member of this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionLength = validForSeconds
|
const sessionLength = validForSeconds
|
||||||
? validForSeconds * 1000
|
? validForSeconds * 1000
|
||||||
@@ -133,23 +163,27 @@ export async function generateAccessToken(
|
|||||||
accessTokenId: id,
|
accessTokenId: id,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
resourceId,
|
resourceId,
|
||||||
|
userId: userId || null,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
expiresAt: expiresAt || null,
|
expiresAt: expiresAt || null,
|
||||||
sessionLength: sessionLength,
|
sessionLength: sessionLength,
|
||||||
title: title || null,
|
title: title || null,
|
||||||
path: path || null,
|
path: path || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
persistSession,
|
||||||
createdAt: new Date().getTime()
|
createdAt: new Date().getTime()
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
accessTokenId: resourceAccessToken.accessTokenId,
|
accessTokenId: resourceAccessToken.accessTokenId,
|
||||||
orgId: resourceAccessToken.orgId,
|
orgId: resourceAccessToken.orgId,
|
||||||
resourceId: resourceAccessToken.resourceId,
|
resourceId: resourceAccessToken.resourceId,
|
||||||
|
userId: resourceAccessToken.userId,
|
||||||
expiresAt: resourceAccessToken.expiresAt,
|
expiresAt: resourceAccessToken.expiresAt,
|
||||||
sessionLength: resourceAccessToken.sessionLength,
|
sessionLength: resourceAccessToken.sessionLength,
|
||||||
title: resourceAccessToken.title,
|
title: resourceAccessToken.title,
|
||||||
path: resourceAccessToken.path,
|
path: resourceAccessToken.path,
|
||||||
description: resourceAccessToken.description,
|
description: resourceAccessToken.description,
|
||||||
|
persistSession: resourceAccessToken.persistSession,
|
||||||
createdAt: resourceAccessToken.createdAt
|
createdAt: resourceAccessToken.createdAt
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import {
|
|||||||
userResources,
|
userResources,
|
||||||
roleResources,
|
roleResources,
|
||||||
resourceAccessToken,
|
resourceAccessToken,
|
||||||
sites
|
sites,
|
||||||
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm";
|
import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
@@ -55,11 +56,16 @@ function queryAccessTokens(
|
|||||||
accessTokenId: resourceAccessToken.accessTokenId,
|
accessTokenId: resourceAccessToken.accessTokenId,
|
||||||
orgId: resourceAccessToken.orgId,
|
orgId: resourceAccessToken.orgId,
|
||||||
resourceId: resourceAccessToken.resourceId,
|
resourceId: resourceAccessToken.resourceId,
|
||||||
|
userId: resourceAccessToken.userId,
|
||||||
|
userName: users.name,
|
||||||
|
username: users.username,
|
||||||
|
userEmail: users.email,
|
||||||
sessionLength: resourceAccessToken.sessionLength,
|
sessionLength: resourceAccessToken.sessionLength,
|
||||||
expiresAt: resourceAccessToken.expiresAt,
|
expiresAt: resourceAccessToken.expiresAt,
|
||||||
tokenHash: resourceAccessToken.tokenHash,
|
tokenHash: resourceAccessToken.tokenHash,
|
||||||
title: resourceAccessToken.title,
|
title: resourceAccessToken.title,
|
||||||
description: resourceAccessToken.description,
|
description: resourceAccessToken.description,
|
||||||
|
persistSession: resourceAccessToken.persistSession,
|
||||||
createdAt: resourceAccessToken.createdAt,
|
createdAt: resourceAccessToken.createdAt,
|
||||||
resourceName: resources.name,
|
resourceName: resources.name,
|
||||||
resourceNiceId: resources.niceId,
|
resourceNiceId: resources.niceId,
|
||||||
@@ -75,6 +81,7 @@ function queryAccessTokens(
|
|||||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
|
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
|
||||||
|
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
@@ -97,6 +104,7 @@ function queryAccessTokens(
|
|||||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
|
.leftJoin(sites, eq(resources.resourceId, sites.siteId))
|
||||||
|
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(
|
inArray(
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import {
|
||||||
|
createResourceSession,
|
||||||
|
serializeResourceSessionCookie,
|
||||||
|
validateResourceSessionToken
|
||||||
|
} from "@server/auth/sessions/resource";
|
||||||
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import {
|
import {
|
||||||
getResourceByDomain,
|
getResourceByDomain,
|
||||||
@@ -13,6 +18,7 @@ import {
|
|||||||
LoginPage,
|
LoginPage,
|
||||||
Org,
|
Org,
|
||||||
Resource,
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
ResourceHeaderAuth,
|
ResourceHeaderAuth,
|
||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
@@ -21,7 +27,10 @@ import {
|
|||||||
ResourcePolicyPassword,
|
ResourcePolicyPassword,
|
||||||
ResourcePolicyHeaderAuth,
|
ResourcePolicyHeaderAuth,
|
||||||
ResourceRule,
|
ResourceRule,
|
||||||
ResourceSession
|
ResourceSession,
|
||||||
|
db,
|
||||||
|
resourceAccessToken,
|
||||||
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
@@ -41,11 +50,13 @@ import {
|
|||||||
enforceResourceSessionLength
|
enforceResourceSessionLength
|
||||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { logRequestAudit } from "./logRequestAudit";
|
import { logRequestAudit } from "./logRequestAudit";
|
||||||
|
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
||||||
import { REGIONS } from "@server/db/regions";
|
import { REGIONS } from "@server/db/regions";
|
||||||
import { localCache } from "#dynamic/lib/cache";
|
import { localCache } from "#dynamic/lib/cache";
|
||||||
import { APP_VERSION } from "@server/lib/consts";
|
import { APP_VERSION } from "@server/lib/consts";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string(), z.string()).optional(),
|
sessions: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -350,22 +361,15 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (valid && tokenItem) {
|
if (valid && tokenItem) {
|
||||||
logRequestAudit(
|
return await allowAccessToken(
|
||||||
{
|
res,
|
||||||
action: true,
|
resource,
|
||||||
reason: 102, // valid access token
|
tokenItem,
|
||||||
resourceId: resource.resourceId,
|
sessions,
|
||||||
orgId: resource.orgId,
|
dontStripSession,
|
||||||
location: ipCC,
|
parsedBody.data,
|
||||||
apiKey: {
|
ipCC
|
||||||
name: tokenItem.title,
|
|
||||||
apiKeyId: tokenItem.accessTokenId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
parsedBody.data
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, undefined, dontStripSession);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,22 +405,15 @@ export async function verifyResourceSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (valid && tokenItem) {
|
if (valid && tokenItem) {
|
||||||
logRequestAudit(
|
return await allowAccessToken(
|
||||||
{
|
res,
|
||||||
action: true,
|
resource,
|
||||||
reason: 102, // valid access token
|
tokenItem,
|
||||||
resourceId: resource.resourceId,
|
sessions,
|
||||||
orgId: resource.orgId,
|
dontStripSession,
|
||||||
location: ipCC,
|
parsedBody.data,
|
||||||
apiKey: {
|
ipCC
|
||||||
name: tokenItem.title,
|
|
||||||
apiKeyId: tokenItem.accessTokenId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
parsedBody.data
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, undefined, dontStripSession);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,22 +663,37 @@ export async function verifyResourceSession(
|
|||||||
"Resource allowed because access token session is valid"
|
"Resource allowed because access token session is valid"
|
||||||
);
|
);
|
||||||
|
|
||||||
logRequestAudit(
|
const [tokenItem] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceAccessToken.accessTokenId,
|
||||||
|
resourceSession.accessTokenId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const userData = tokenItem
|
||||||
|
? await getAccessTokenUserData(
|
||||||
|
tokenItem,
|
||||||
|
resource.orgId
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
logAccessTokenRequestAudit(
|
||||||
{
|
{
|
||||||
action: true,
|
|
||||||
reason: 102, // valid access token
|
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
location: ipCC,
|
location: ipCC,
|
||||||
apiKey: {
|
accessTokenId: resourceSession.accessTokenId,
|
||||||
name: null,
|
tokenTitle: tokenItem?.title ?? null,
|
||||||
apiKeyId: resourceSession.accessTokenId
|
userData
|
||||||
}
|
|
||||||
},
|
},
|
||||||
parsedBody.data
|
parsedBody.data
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, undefined, dontStripSession);
|
return allowed(res, userData, dontStripSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession.userSessionId && sso) {
|
if (resourceSession.userSessionId && sso) {
|
||||||
@@ -892,6 +904,227 @@ function allowed(
|
|||||||
return response<VerifyUserResponse>(res, data);
|
return response<VerifyUserResponse>(res, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function allowAccessToken(
|
||||||
|
res: Response,
|
||||||
|
resource: Resource,
|
||||||
|
tokenItem: ResourceAccessToken,
|
||||||
|
sessions: Record<string, string> | undefined,
|
||||||
|
dontStripSession: boolean | undefined,
|
||||||
|
auditBody: VerifyResourceSessionSchema,
|
||||||
|
location?: string
|
||||||
|
) {
|
||||||
|
const userData = await getAccessTokenUserData(tokenItem, resource.orgId);
|
||||||
|
|
||||||
|
logAccessTokenRequestAudit(
|
||||||
|
{
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
orgId: resource.orgId,
|
||||||
|
location,
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
tokenTitle: tokenItem.title,
|
||||||
|
userData
|
||||||
|
},
|
||||||
|
auditBody
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tokenItem.persistSession) {
|
||||||
|
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
|
||||||
|
return allowed(res, userData, dontStripSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceSessionToken = extractResourceSessionToken(
|
||||||
|
sessions ?? {},
|
||||||
|
resource.ssl
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceSessionToken) {
|
||||||
|
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||||
|
let resourceSession: ResourceSession | null | undefined =
|
||||||
|
localCache.get(sessionCacheKey);
|
||||||
|
|
||||||
|
if (!resourceSession) {
|
||||||
|
const result = await validateResourceSessionToken(
|
||||||
|
resourceSessionToken,
|
||||||
|
resource.resourceId
|
||||||
|
);
|
||||||
|
resourceSession = result?.resourceSession;
|
||||||
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
resourceSession &&
|
||||||
|
!resourceSession.isRequestToken &&
|
||||||
|
resourceSession.accessTokenId === tokenItem.accessTokenId
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
"Resource allowed because existing access token session is valid"
|
||||||
|
);
|
||||||
|
return allowed(res, userData, dontStripSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
|
||||||
|
return await createAccessTokenSession(res, resource, tokenItem, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAccessTokenSession(
|
||||||
|
res: Response,
|
||||||
|
resource: Resource,
|
||||||
|
tokenItem: ResourceAccessToken,
|
||||||
|
userData?: BasicUserData
|
||||||
|
) {
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const sess = await createResourceSession({
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
token,
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
sessionLength: tokenItem.sessionLength,
|
||||||
|
expiresAt: tokenItem.expiresAt,
|
||||||
|
doNotExtend: tokenItem.expiresAt ? true : false
|
||||||
|
});
|
||||||
|
const cookieName = config.getRawConfig().server.session_cookie_name;
|
||||||
|
const cookie = serializeResourceSessionCookie(
|
||||||
|
cookieName,
|
||||||
|
resource.fullDomain!,
|
||||||
|
token,
|
||||||
|
!resource.ssl,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
|
);
|
||||||
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
logger.debug("Access token is valid, creating new session");
|
||||||
|
return allowed(res, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessTokenUserData(
|
||||||
|
tokenItem: ResourceAccessToken,
|
||||||
|
orgId: string
|
||||||
|
): Promise<BasicUserData | undefined> {
|
||||||
|
if (!tokenItem.userId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`;
|
||||||
|
const cached = localCache.get(cacheKey) as BasicUserData | null | undefined;
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, tokenItem.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
localCache.set(cacheKey, null, 5);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||||
|
const userData: BasicUserData = {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
};
|
||||||
|
|
||||||
|
localCache.set(cacheKey, userData, 12);
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAccessTokenRequestAudit(
|
||||||
|
data: {
|
||||||
|
resourceId: number;
|
||||||
|
orgId: string;
|
||||||
|
location?: string;
|
||||||
|
accessTokenId: string;
|
||||||
|
tokenTitle: string | null;
|
||||||
|
userData?: BasicUserData;
|
||||||
|
},
|
||||||
|
body: VerifyResourceSessionSchema
|
||||||
|
) {
|
||||||
|
if (data.userData) {
|
||||||
|
logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 102, // valid access token
|
||||||
|
resourceId: data.resourceId,
|
||||||
|
orgId: data.orgId,
|
||||||
|
location: data.location,
|
||||||
|
user: {
|
||||||
|
username: data.userData.username,
|
||||||
|
userId: data.userData.userId
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
accessTokenId: data.accessTokenId,
|
||||||
|
accessTokenTitle: data.tokenTitle
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 102, // valid access token
|
||||||
|
resourceId: data.resourceId,
|
||||||
|
orgId: data.orgId,
|
||||||
|
location: data.location,
|
||||||
|
apiKey: {
|
||||||
|
name: data.tokenTitle,
|
||||||
|
apiKeyId: data.accessTokenId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAccessTokenAccessAudit(
|
||||||
|
tokenItem: ResourceAccessToken,
|
||||||
|
resource: Resource,
|
||||||
|
userData: BasicUserData | undefined,
|
||||||
|
body: VerifyResourceSessionSchema
|
||||||
|
) {
|
||||||
|
const userAgent =
|
||||||
|
body.headers?.["user-agent"] || body.headers?.["User-Agent"];
|
||||||
|
|
||||||
|
if (userData) {
|
||||||
|
logAccessAudit({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
action: true,
|
||||||
|
type: "accessToken",
|
||||||
|
user: {
|
||||||
|
username: userData.username,
|
||||||
|
userId: userData.userId
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
accessTokenTitle: tokenItem.title
|
||||||
|
},
|
||||||
|
userAgent,
|
||||||
|
requestIp: body.requestIp
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logAccessAudit({
|
||||||
|
orgId: resource.orgId,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
action: true,
|
||||||
|
type: "accessToken",
|
||||||
|
apiKey: {
|
||||||
|
name: tokenItem.title,
|
||||||
|
apiKeyId: tokenItem.accessTokenId
|
||||||
|
},
|
||||||
|
userAgent,
|
||||||
|
requestIp: body.requestIp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function headerAuthChallenged(
|
async function headerAuthChallenged(
|
||||||
res: Response,
|
res: Response,
|
||||||
redirectPath?: string,
|
redirectPath?: string,
|
||||||
|
|||||||
@@ -809,16 +809,6 @@ authenticated.post(
|
|||||||
accessToken.generateAccessToken
|
accessToken.generateAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
`/resource/:resourceId/session-token`,
|
|
||||||
verifyApiKeyResourceAccess,
|
|
||||||
verifyApiKeyUserAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken),
|
|
||||||
logActionAudit(ActionsEnum.createResourceSessionToken),
|
|
||||||
resource.createResourceSessionToken
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
`/access-token/:accessTokenId`,
|
`/access-token/:accessTokenId`,
|
||||||
verifyApiKeyAccessTokenAccess,
|
verifyApiKeyAccessTokenAccess,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { Resource, resources } from "@server/db";
|
import { Resource, resources, users } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -156,11 +156,42 @@ export async function authWithAccessToken(
|
|||||||
doNotExtend: true
|
doNotExtend: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let accessAuditUser: { username: string; userId: string } | undefined;
|
||||||
|
if (tokenItem.userId) {
|
||||||
|
const [associatedUser] = await db
|
||||||
|
.select({
|
||||||
|
userId: users.userId,
|
||||||
|
username: users.username
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, tokenItem.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (associatedUser) {
|
||||||
|
accessAuditUser = {
|
||||||
|
userId: associatedUser.userId,
|
||||||
|
username: associatedUser.username
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logAccessAudit({
|
logAccessAudit({
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource.resourceId,
|
||||||
action: true,
|
action: true,
|
||||||
type: "accessToken",
|
type: "accessToken",
|
||||||
|
apiKey: accessAuditUser
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
name: tokenItem.title,
|
||||||
|
apiKeyId: tokenItem.accessTokenId
|
||||||
|
},
|
||||||
|
user: accessAuditUser,
|
||||||
|
metadata: accessAuditUser
|
||||||
|
? {
|
||||||
|
accessTokenId: tokenItem.accessTokenId,
|
||||||
|
accessTokenTitle: tokenItem.title
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
userAgent: req.headers["user-agent"],
|
userAgent: req.headers["user-agent"],
|
||||||
requestIp: req.ip
|
requestIp: req.ip
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { resources, users, userOrgs } from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
|
|
||||||
const createResourceSessionTokenParams = z.strictObject({
|
|
||||||
resourceId: z.coerce.number().int().positive()
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResourceSessionTokenBody = z.strictObject({
|
|
||||||
userId: z.string().nonempty(),
|
|
||||||
idpId: z.coerce.number().int().positive().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateResourceSessionTokenResponse = {
|
|
||||||
requestToken: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createResourceSessionToken(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = createResourceSessionTokenParams.safeParse(
|
|
||||||
req.params
|
|
||||||
);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = createResourceSessionTokenBody.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
|
||||||
const { userId, idpId } = parsedBody.data;
|
|
||||||
|
|
||||||
const [resource] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = await db
|
|
||||||
.select({ userId: users.userId })
|
|
||||||
.from(userOrgs)
|
|
||||||
.innerJoin(users, eq(userOrgs.userId, users.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(users.userId, userId),
|
|
||||||
eq(userOrgs.orgId, resource.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`User not found in the organization that owns this resource`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUserId = candidates[0].userId;
|
|
||||||
|
|
||||||
const appSessionToken = generateSessionToken();
|
|
||||||
const appSession = await createSession(appSessionToken, targetUserId);
|
|
||||||
|
|
||||||
const requestToken = generateSessionToken();
|
|
||||||
await createResourceSession({
|
|
||||||
resourceId,
|
|
||||||
token: requestToken,
|
|
||||||
userSessionId: appSession.sessionId,
|
|
||||||
isRequestToken: true,
|
|
||||||
expiresAt: Date.now() + 1000 * 30, // 30 seconds
|
|
||||||
sessionLength: 1000 * 30,
|
|
||||||
doNotExtend: true
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug("Resource session token created successfully");
|
|
||||||
|
|
||||||
return response<CreateResourceSessionTokenResponse>(res, {
|
|
||||||
data: { requestToken },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Resource session token created successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ export * from "./getResourceWhitelist";
|
|||||||
export * from "./authWithWhitelist";
|
export * from "./authWithWhitelist";
|
||||||
export * from "./authWithAccessToken";
|
export * from "./authWithAccessToken";
|
||||||
export * from "./getExchangeToken";
|
export * from "./getExchangeToken";
|
||||||
export * from "./createResourceSessionToken";
|
|
||||||
export * from "./createResourceRule";
|
export * from "./createResourceRule";
|
||||||
export * from "./deleteResourceRule";
|
export * from "./deleteResourceRule";
|
||||||
export * from "./listResourceRules";
|
export * from "./listResourceRules";
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
|
|||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||||
import { constructShareLink } from "@app/lib/shareLinks";
|
import { constructShareLink } from "@app/lib/shareLinks";
|
||||||
import { ShareLinkRow } from "@app/components/ShareLinksTable";
|
|
||||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toUnicode } from "punycode";
|
import { toUnicode } from "punycode";
|
||||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||||
|
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||||
|
|
||||||
|
type CreatedShareLink = {
|
||||||
|
accessTokenId: string;
|
||||||
|
resourceId: number;
|
||||||
|
resourceName: string;
|
||||||
|
resourceNiceId: string;
|
||||||
|
title: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number | null;
|
||||||
|
userId?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onCreated?: (result: ShareLinkRow) => void;
|
onCreated?: (result: CreatedShareLink) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateShareLinkForm({
|
export default function CreateShareLinkForm({
|
||||||
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
|
|||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [neverExpire, setNeverExpire] = useState(false);
|
const [neverExpire, setNeverExpire] = useState(false);
|
||||||
|
const [persistSession, setPersistSession] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
|
|||||||
values.resourceName ||
|
values.resourceName ||
|
||||||
"Resource" + values.resourceId
|
"Resource" + values.resourceId
|
||||||
}),
|
}),
|
||||||
path: values.path
|
path: values.path,
|
||||||
|
persistSession,
|
||||||
|
userId: selectedUser?.id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
|
|||||||
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
||||||
title: token.title,
|
title: token.title,
|
||||||
createdAt: token.createdAt,
|
createdAt: token.createdAt,
|
||||||
expiresAt: token.expiresAt
|
expiresAt: token.expiresAt,
|
||||||
|
userId: token.userId,
|
||||||
|
userName: selectedUser?.text ?? null,
|
||||||
|
username: null,
|
||||||
|
userEmail: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
|
|||||||
setOpen(val);
|
setOpen(val);
|
||||||
setLink(null);
|
setLink(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setNeverExpire(false);
|
||||||
|
setPersistSession(false);
|
||||||
|
setSelectedUser(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"shareAssociateUserOptional"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
!selectedUser &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedUser?.text
|
||||||
|
? selectedUser.text
|
||||||
|
: t("userSelect")}
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||||
|
<UserSelector
|
||||||
|
orgId={org.org.orgId}
|
||||||
|
selectedUser={
|
||||||
|
selectedUser
|
||||||
|
}
|
||||||
|
onSelectUser={
|
||||||
|
setSelectedUser
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"shareAssociateUserDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="persist-session"
|
||||||
|
checked={persistSession}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
setPersistSession(
|
||||||
|
val as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="persist-session"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"sharePersistSession"
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"sharePersistSessionDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("shareExpireDescription")}
|
{t("shareExpireDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import moment from "moment";
|
|||||||
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
||||||
import { constructShareLink } from "@app/lib/shareLinks";
|
import { constructShareLink } from "@app/lib/shareLinks";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
|
||||||
export type ShareLinkRow = {
|
export type ShareLinkRow = {
|
||||||
accessTokenId: string;
|
accessTokenId: string;
|
||||||
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
|
userId?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
userEmail?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ShareLinksTableProps = {
|
type ShareLinksTableProps = {
|
||||||
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "userId",
|
||||||
|
friendlyName: t("user"),
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("user")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (!r.userId) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{getUserDisplayName({
|
||||||
|
email: r.userEmail,
|
||||||
|
name: r.userName,
|
||||||
|
username: r.username
|
||||||
|
})}
|
||||||
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// accessorKey: "domain",
|
// accessorKey: "domain",
|
||||||
// header: "Link",
|
// header: "Link",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "./ui/command";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import type { SelectedUser } from "./users-selector";
|
||||||
|
|
||||||
|
export type { SelectedUser };
|
||||||
|
|
||||||
|
export type UserSelectorProps = {
|
||||||
|
orgId: string;
|
||||||
|
selectedUser?: SelectedUser | null;
|
||||||
|
onSelectUser: (user: SelectedUser | null) => void;
|
||||||
|
allowClear?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserSelector({
|
||||||
|
orgId,
|
||||||
|
selectedUser,
|
||||||
|
onSelectUser,
|
||||||
|
allowClear = true
|
||||||
|
}: UserSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||||
|
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||||
|
|
||||||
|
const { data: users = [] } = useQuery(
|
||||||
|
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersShown = useMemo(() => {
|
||||||
|
const allUsers: Array<SelectedUser> = users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
text: getUserDisplayName(u)
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
debouncedValue.trim().length === 0 &&
|
||||||
|
selectedUser &&
|
||||||
|
!allUsers.find((user) => user.id === selectedUser.id)
|
||||||
|
) {
|
||||||
|
allUsers.unshift(selectedUser);
|
||||||
|
}
|
||||||
|
return allUsers;
|
||||||
|
}, [users, selectedUser, debouncedValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("userSearch")}
|
||||||
|
value={userSearchQuery}
|
||||||
|
onValueChange={setUserSearchQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allowClear && (
|
||||||
|
<CommandItem
|
||||||
|
value="__none__"
|
||||||
|
onSelect={() => {
|
||||||
|
onSelectUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedUser ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t("none")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{usersShown.map((user) => (
|
||||||
|
<CommandItem
|
||||||
|
value={`${user.text}:${user.id}`}
|
||||||
|
key={user.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onSelectUser(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
user.id === selectedUser?.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{user.text}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user