mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-23 21:58:50 +02:00
share links
This commit is contained in:
parent
72dc02ff2e
commit
845d65ad33
31 changed files with 1281 additions and 212 deletions
|
@ -25,7 +25,7 @@ export async function createResourceSession(opts: {
|
||||||
usedOtp?: boolean;
|
usedOtp?: boolean;
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength: number;
|
sessionLength?: number | null;
|
||||||
}): Promise<ResourceSession> {
|
}): Promise<ResourceSession> {
|
||||||
if (
|
if (
|
||||||
!opts.passwordId &&
|
!opts.passwordId &&
|
||||||
|
|
|
@ -136,5 +136,6 @@ process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags
|
||||||
process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
|
process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
|
||||||
process.env.RESOURCE_SESSION_COOKIE_NAME =
|
process.env.RESOURCE_SESSION_COOKIE_NAME =
|
||||||
parsedConfig.data.server.resource_session_cookie_name;
|
parsedConfig.data.server.resource_session_cookie_name;
|
||||||
|
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||||
|
|
||||||
export default parsedConfig.data;
|
export default parsedConfig.data;
|
||||||
|
|
|
@ -288,7 +288,7 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
||||||
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").notNull(),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
});
|
});
|
||||||
|
@ -378,3 +378,4 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||||
|
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||||
|
|
|
@ -4,82 +4,53 @@ import * as winston from "winston";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const hformat = winston.format.printf(
|
const hformat = winston.format.printf(
|
||||||
({ level, label, message, timestamp, ...metadata }) => {
|
({ level, label, message, timestamp, stack, ...metadata }) => {
|
||||||
let msg = `${timestamp} [${level}]${label ? `[${label}]` : ""}: ${message}`;
|
let msg = `${timestamp} [${level}]${label ? `[${label}]` : ""}: ${message}`;
|
||||||
|
if (stack) {
|
||||||
|
msg += `\nStack: ${stack}`;
|
||||||
|
}
|
||||||
if (Object.keys(metadata).length > 0) {
|
if (Object.keys(metadata).length > 0) {
|
||||||
msg += JSON.stringify(metadata);
|
msg += ` ${JSON.stringify(metadata)}`;
|
||||||
}
|
}
|
||||||
return msg;
|
return msg;
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const transports: any = [
|
const transports: any = [new winston.transports.Console({})];
|
||||||
new winston.transports.Console({
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.errors({ stack: true }),
|
|
||||||
winston.format.colorize(),
|
|
||||||
winston.format.splat(),
|
|
||||||
winston.format.timestamp(),
|
|
||||||
hformat,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (config.app.save_logs) {
|
if (config.app.save_logs) {
|
||||||
transports.push(
|
transports.push(
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: path.join(
|
filename: path.join(APP_PATH, "logs", "pangolin-%DATE%.log"),
|
||||||
APP_PATH,
|
|
||||||
"logs",
|
|
||||||
"pangolin-%DATE%.log",
|
|
||||||
),
|
|
||||||
datePattern: "YYYY-MM-DD",
|
datePattern: "YYYY-MM-DD",
|
||||||
zippedArchive: true,
|
zippedArchive: true,
|
||||||
maxSize: "20m",
|
maxSize: "20m",
|
||||||
maxFiles: "7d",
|
maxFiles: "7d",
|
||||||
createSymlink: true,
|
createSymlink: true,
|
||||||
symlinkName: "pangolin.log",
|
symlinkName: "pangolin.log"
|
||||||
}),
|
})
|
||||||
);
|
|
||||||
transports.push(
|
|
||||||
new winston.transports.DailyRotateFile({
|
|
||||||
filename: path.join(
|
|
||||||
APP_PATH,
|
|
||||||
"logs",
|
|
||||||
".machinelogs-%DATE%.json",
|
|
||||||
),
|
|
||||||
datePattern: "YYYY-MM-DD",
|
|
||||||
zippedArchive: true,
|
|
||||||
maxSize: "20m",
|
|
||||||
maxFiles: "1d",
|
|
||||||
createSymlink: true,
|
|
||||||
symlinkName: ".machinelogs.json",
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp(),
|
|
||||||
winston.format.splat(),
|
|
||||||
winston.format.json(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: config.app.log_level.toLowerCase(),
|
level: config.app.log_level.toLowerCase(),
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.colorize(),
|
||||||
winston.format.splat(),
|
winston.format.splat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
hformat,
|
hformat
|
||||||
),
|
),
|
||||||
transports,
|
transports
|
||||||
});
|
});
|
||||||
|
|
||||||
// process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
// logger.error("Uncaught Exception:", { error, stack: error.stack });
|
logger.error("Uncaught Exception:", { error, stack: error.stack });
|
||||||
// process.exit(1);
|
process.exit(1);
|
||||||
// });
|
});
|
||||||
//
|
|
||||||
// process.on("unhandledRejection", (reason, _) => {
|
process.on("unhandledRejection", (reason, _) => {
|
||||||
// logger.error("Unhandled Rejection:", { reason });
|
logger.error("Unhandled Rejection:", { reason });
|
||||||
// });
|
});
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|
|
@ -11,9 +11,9 @@ export const errorHandlerMiddleware: ErrorRequestHandler = (
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
) => {
|
) => {
|
||||||
const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR;
|
const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR;
|
||||||
if (process.env.ENVIRONMENT !== "prod") {
|
// if (process.env.ENVIRONMENT !== "prod") {
|
||||||
logger.error(error);
|
// logger.error(error);
|
||||||
}
|
// }
|
||||||
res?.status(statusCode).send({
|
res?.status(statusCode).send({
|
||||||
data: null,
|
data: null,
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
@ -22,7 +22,9 @@ export async function createNextServer() {
|
||||||
|
|
||||||
nextServer.listen(nextPort, (err?: any) => {
|
nextServer.listen(nextPort, (err?: any) => {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
logger.info(`Next.js server is running on http://localhost:${nextPort}`);
|
logger.info(
|
||||||
|
`Next.js server is running on http://localhost:${nextPort}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return nextServer;
|
return nextServer;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
SESSION_COOKIE_EXPIRES
|
SESSION_COOKIE_EXPIRES
|
||||||
} from "@server/auth";
|
} from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { resourceAccessToken, resources } from "@server/db/schema";
|
import { ResourceAccessToken, resourceAccessToken, resources } from "@server/db/schema";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
@ -26,9 +26,7 @@ export const generateAccssTokenParamsSchema = z.object({
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GenerateAccessTokenResponse = {
|
export type GenerateAccessTokenResponse = ResourceAccessToken;
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function generateAccessToken(
|
export async function generateAccessToken(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
@ -79,30 +77,37 @@ export async function generateAccessToken(
|
||||||
|
|
||||||
const token = generateIdFromEntropySize(25);
|
const token = generateIdFromEntropySize(25);
|
||||||
|
|
||||||
const tokenHash = await hash(token, {
|
// const tokenHash = await hash(token, {
|
||||||
memoryCost: 19456,
|
// memoryCost: 19456,
|
||||||
timeCost: 2,
|
// timeCost: 2,
|
||||||
outputLen: 32,
|
// outputLen: 32,
|
||||||
parallelism: 1
|
// parallelism: 1
|
||||||
});
|
// });
|
||||||
|
|
||||||
const id = generateId(15);
|
const id = generateId(15);
|
||||||
await db.insert(resourceAccessToken).values({
|
const [result] = await db.insert(resourceAccessToken).values({
|
||||||
accessTokenId: id,
|
accessTokenId: id,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
resourceId,
|
resourceId,
|
||||||
tokenHash,
|
tokenHash: token,
|
||||||
expiresAt: expiresAt || null,
|
expiresAt: expiresAt || null,
|
||||||
sessionLength: sessionLength,
|
sessionLength: sessionLength,
|
||||||
title: title || `${resource.name} Token ${new Date().getTime()}`,
|
title: title || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
createdAt: new Date().getTime()
|
createdAt: new Date().getTime()
|
||||||
});
|
}).returning();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate access token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<GenerateAccessTokenResponse>(res, {
|
return response<GenerateAccessTokenResponse>(res, {
|
||||||
data: {
|
data: result,
|
||||||
token: `${id}.${token}`
|
|
||||||
},
|
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Resource access token generated successfully",
|
message: "Resource access token generated successfully",
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/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 } from "drizzle-orm";
|
import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stoi from "@server/utils/stoi";
|
import stoi from "@server/utils/stoi";
|
||||||
|
|
||||||
|
@ -54,29 +54,47 @@ function queryAccessTokens(
|
||||||
resourceId: resourceAccessToken.resourceId,
|
resourceId: resourceAccessToken.resourceId,
|
||||||
sessionLength: resourceAccessToken.sessionLength,
|
sessionLength: resourceAccessToken.sessionLength,
|
||||||
expiresAt: resourceAccessToken.expiresAt,
|
expiresAt: resourceAccessToken.expiresAt,
|
||||||
|
tokenHash: resourceAccessToken.tokenHash,
|
||||||
title: resourceAccessToken.title,
|
title: resourceAccessToken.title,
|
||||||
description: resourceAccessToken.description,
|
description: resourceAccessToken.description,
|
||||||
createdAt: resourceAccessToken.createdAt
|
createdAt: resourceAccessToken.createdAt,
|
||||||
|
resourceName: resources.name
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
return db
|
return db
|
||||||
.select(cols)
|
.select(cols)
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
.leftJoin(resources, eq(resourceAccessToken.resourceId, resources.resourceId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(resourceAccessToken.resourceId, accessibleResourceIds),
|
inArray(
|
||||||
eq(resourceAccessToken.orgId, orgId)
|
resourceAccessToken.resourceId,
|
||||||
|
accessibleResourceIds
|
||||||
|
),
|
||||||
|
eq(resourceAccessToken.orgId, orgId),
|
||||||
|
or(
|
||||||
|
isNull(resourceAccessToken.expiresAt),
|
||||||
|
gt(resourceAccessToken.expiresAt, new Date().getTime())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else if (resourceId) {
|
} else if (resourceId) {
|
||||||
return db
|
return db
|
||||||
.select(cols)
|
.select(cols)
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
.leftJoin(resources, eq(resourceAccessToken.resourceId, resources.resourceId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
inArray(
|
||||||
eq(resources.resourceId, resourceId)
|
resourceAccessToken.resourceId,
|
||||||
|
accessibleResourceIds
|
||||||
|
),
|
||||||
|
eq(resourceAccessToken.resourceId, resourceId),
|
||||||
|
or(
|
||||||
|
isNull(resourceAccessToken.expiresAt),
|
||||||
|
gt(resourceAccessToken.expiresAt, new Date().getTime())
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -174,7 +192,6 @@ export async function listAccessTokens(
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
|
|
@ -98,12 +98,14 @@ export async function authWithAccessToken(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
// const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
||||||
memoryCost: 19456,
|
// memoryCost: 19456,
|
||||||
timeCost: 2,
|
// timeCost: 2,
|
||||||
outputLen: 32,
|
// outputLen: 32,
|
||||||
parallelism: 1
|
// parallelism: 1
|
||||||
});
|
// });
|
||||||
|
logger.debug(`${accessToken} ${tokenItem.tokenHash}`)
|
||||||
|
const validCode = accessToken === tokenItem.tokenHash;
|
||||||
|
|
||||||
if (!validCode) {
|
if (!validCode) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -123,7 +123,6 @@ export async function authWithPassword(
|
||||||
const cookie = serializeResourceSessionCookie(
|
const cookie = serializeResourceSessionCookie(
|
||||||
cookieName,
|
cookieName,
|
||||||
token,
|
token,
|
||||||
resource.fullDomain
|
|
||||||
);
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,6 @@ export async function authWithPincode(
|
||||||
const cookie = serializeResourceSessionCookie(
|
const cookie = serializeResourceSessionCookie(
|
||||||
cookieName,
|
cookieName,
|
||||||
token,
|
token,
|
||||||
resource.fullDomain
|
|
||||||
);
|
);
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
|
|
@ -84,7 +84,6 @@ export async function pickSiteDefaults(
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
|
|
@ -8,6 +8,10 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
|
||||||
return apiInstance;
|
return apiInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apiInstance) {
|
||||||
|
return apiInstance
|
||||||
|
}
|
||||||
|
|
||||||
let baseURL;
|
let baseURL;
|
||||||
const suffix = "api/v1";
|
const suffix = "api/v1";
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ const topNavItems = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Sharable Links",
|
title: "Sharable Links",
|
||||||
href: "/{orgId}/settings/links",
|
href: "/{orgId}/settings/share-links",
|
||||||
icon: <Link className="h-4 w-4" />
|
icon: <Link className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -110,7 +110,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
<div className="container mx-auto sm:px-0 px-3">{children}</div>
|
<div className="container mx-auto sm:px-0 px-3">{children}</div>
|
||||||
|
|
||||||
<footer className="w-full mt-6 py-3">
|
<footer className="w-full mt-6 py-3">
|
||||||
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-muted space-x-3 select-none">
|
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none">
|
||||||
<div>Built by Fossorial</div>
|
<div>Built by Fossorial</div>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://github.com/fosrl/pangolin"
|
||||||
|
|
|
@ -69,7 +69,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
const { resource, updateResource, authInfo, updateAuthInfo } =
|
const { resource, updateResource, authInfo, updateAuthInfo } =
|
||||||
useResourceContext();
|
useResourceContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
|
||||||
|
@ -610,13 +612,16 @@ export default function ResourceAuthenticationPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{env.EMAIL_ENABLED === "true" && (
|
||||||
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="whitelist-toggle"
|
id="whitelist-toggle"
|
||||||
defaultChecked={resource.emailWhitelistEnabled}
|
defaultChecked={
|
||||||
|
resource.emailWhitelistEnabled
|
||||||
|
}
|
||||||
onCheckedChange={(val) =>
|
onCheckedChange={(val) =>
|
||||||
setWhitelistEnabled(val)
|
setWhitelistEnabled(val)
|
||||||
}
|
}
|
||||||
|
@ -626,13 +631,12 @@ export default function ResourceAuthenticationPage() {
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
Enable resource whitelist to require email-based
|
Enable resource whitelist to require
|
||||||
authentication (one-time passwords) for resource
|
email-based authentication (one-time
|
||||||
access.
|
passwords) for resource access.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{whitelistEnabled && (
|
|
||||||
<Form {...whitelistForm}>
|
<Form {...whitelistForm}>
|
||||||
<form className="space-y-8">
|
<form className="space-y-8">
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -690,7 +694,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
loading={loadingSaveWhitelist}
|
loading={loadingSaveWhitelist}
|
||||||
|
@ -699,6 +702,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
>
|
>
|
||||||
Save Whitelist
|
Save Whitelist
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -228,7 +228,7 @@ export default function CreateResourceForm({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[350px] justify-between",
|
"justify-between",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
|
@ -244,7 +244,7 @@ export default function CreateResourceForm({
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[350px] p-0">
|
<PopoverContent className="p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search site..." />
|
<CommandInput placeholder="Search site..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|
|
@ -0,0 +1,470 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
|
import { cn, formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { register } from "module";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||||
|
import { constructShareLink } from "@app/lib/shareLinks";
|
||||||
|
import { ShareLinkRow } from "./ShareLinksTable";
|
||||||
|
|
||||||
|
type FormProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onCreated?: (result: ShareLinkRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
resourceId: z.number({ message: "Please select a resource" }),
|
||||||
|
resourceName: z.string(),
|
||||||
|
timeUnit: z.string(),
|
||||||
|
timeValue: z.coerce.number().int().positive().min(1),
|
||||||
|
title: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateShareLinkForm({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
onCreated
|
||||||
|
}: FormProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const [link, setLink] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [neverExpire, setNeverExpire] = useState(false);
|
||||||
|
|
||||||
|
const [resources, setResources] = useState<
|
||||||
|
{ resourceId: number; name: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const timeUnits = [
|
||||||
|
{ unit: "minutes", name: "Minutes" },
|
||||||
|
{ unit: "hours", name: "Hours" },
|
||||||
|
{ unit: "days", name: "Days" },
|
||||||
|
{ unit: "weeks", name: "Weeks" },
|
||||||
|
{ unit: "months", name: "Months" },
|
||||||
|
{ unit: "years", name: "Years" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
timeUnit: "days",
|
||||||
|
timeValue: 30,
|
||||||
|
title: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchResources() {
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListResourcesResponse>
|
||||||
|
>(`/org/${org?.org.orgId}/resources`)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch resources",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while fetching the resources"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.status === 200) {
|
||||||
|
setResources(res.data.data.resources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResources();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// convert time to seconds
|
||||||
|
let timeInSeconds = values.timeValue;
|
||||||
|
switch (values.timeUnit) {
|
||||||
|
case "minutes":
|
||||||
|
timeInSeconds *= 60;
|
||||||
|
break;
|
||||||
|
case "hours":
|
||||||
|
timeInSeconds *= 60 * 60;
|
||||||
|
break;
|
||||||
|
case "days":
|
||||||
|
timeInSeconds *= 60 * 60 * 24;
|
||||||
|
break;
|
||||||
|
case "weeks":
|
||||||
|
timeInSeconds *= 60 * 60 * 24 * 7;
|
||||||
|
break;
|
||||||
|
case "months":
|
||||||
|
timeInSeconds *= 60 * 60 * 24 * 30;
|
||||||
|
break;
|
||||||
|
case "years":
|
||||||
|
timeInSeconds *= 60 * 60 * 24 * 365;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.post<AxiosResponse<GenerateAccessTokenResponse>>(
|
||||||
|
`/resource/${values.resourceId}/access-token`,
|
||||||
|
{
|
||||||
|
validForSeconds: neverExpire ? undefined : timeInSeconds,
|
||||||
|
title:
|
||||||
|
values.title ||
|
||||||
|
`${values.resourceName || "Resource" + values.resourceId} Share Link`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to create share link",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while creating the share link"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.data.data.accessTokenId) {
|
||||||
|
const token = res.data.data;
|
||||||
|
const link = constructShareLink(
|
||||||
|
values.resourceId,
|
||||||
|
token.accessTokenId,
|
||||||
|
token.tokenHash
|
||||||
|
);
|
||||||
|
setLink(link);
|
||||||
|
onCreated?.({
|
||||||
|
...token,
|
||||||
|
resourceName: values.resourceName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
setLink(null);
|
||||||
|
setLoading(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>Create Sharable Link</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Anyone with this link can access the resource
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{!link && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="share-link-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="resourceId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="mb-2">
|
||||||
|
Resource
|
||||||
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between",
|
||||||
|
!field.value &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? resources.find(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
) =>
|
||||||
|
r.resourceId ===
|
||||||
|
field.value
|
||||||
|
)
|
||||||
|
?.name
|
||||||
|
: "Select resource"}
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search resources..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No
|
||||||
|
resources
|
||||||
|
found
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{resources.map(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
value={r.resourceId.toString()}
|
||||||
|
key={
|
||||||
|
r.resourceId
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"resourceId",
|
||||||
|
r.resourceId
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"resourceName",
|
||||||
|
r.name
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
r.resourceId ===
|
||||||
|
field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
r.name
|
||||||
|
}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Label>
|
||||||
|
Title (optional)
|
||||||
|
</Label>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter title"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Expire In</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="timeUnit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<Select
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
defaultValue={field.value.toString()}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{timeUnits.map(
|
||||||
|
(
|
||||||
|
option
|
||||||
|
) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
option.unit
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
option.unit
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
option.name
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="timeValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Enter duration"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
checked={neverExpire}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
setNeverExpire(
|
||||||
|
val as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Never expire
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Expiration time is how long the
|
||||||
|
link will be usable and provide
|
||||||
|
access to the resource. After
|
||||||
|
this time, the link will expire
|
||||||
|
and no longer work, and users
|
||||||
|
who used this link will lose
|
||||||
|
access to the resource.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
{link && (
|
||||||
|
<div className="max-w-md space-y-4">
|
||||||
|
<p>
|
||||||
|
Anyone with this link can access the
|
||||||
|
resource. Share it with care.
|
||||||
|
</p>
|
||||||
|
<CopyTextBox text={link} wrapText={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="share-link-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={link !== null || loading}
|
||||||
|
>
|
||||||
|
Create Link
|
||||||
|
</Button>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
getPaginationRowModel,
|
||||||
|
SortingState,
|
||||||
|
getSortedRowModel,
|
||||||
|
ColumnFiltersState,
|
||||||
|
getFilteredRowModel
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||||
|
import { Plus, Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface ShareLinksDataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
addShareLink?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareLinksDataTable<TData, TValue>({
|
||||||
|
addShareLink,
|
||||||
|
columns,
|
||||||
|
data
|
||||||
|
}: ShareLinksDataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
pagination: {
|
||||||
|
pageSize: 100,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between pb-4">
|
||||||
|
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||||
|
<Input
|
||||||
|
placeholder="Search links"
|
||||||
|
value={
|
||||||
|
(table
|
||||||
|
.getColumn("title")
|
||||||
|
?.getFilterValue() as string) ?? ""
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
table
|
||||||
|
.getColumn("title")
|
||||||
|
?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className="w-full pl-8"
|
||||||
|
/>
|
||||||
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (addShareLink) {
|
||||||
|
addShareLink();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Create Share Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef
|
||||||
|
.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={
|
||||||
|
row.getIsSelected() && "selected"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No links. Create one to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<DataTablePagination table={table} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { ShareLinksDataTable } from "./ShareLinksDataTable";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Check,
|
||||||
|
ArrowUpRight,
|
||||||
|
ShieldOff,
|
||||||
|
ShieldCheck
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
// import CreateResourceForm from "./CreateResourceForm";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
|
import { useToast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient } from "@app/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||||
|
import moment from "moment";
|
||||||
|
import CreateShareLinkForm from "./CreateShareLinkForm";
|
||||||
|
import { constructShareLink } from "@app/lib/shareLinks";
|
||||||
|
|
||||||
|
export type ShareLinkRow = ArrayElement<
|
||||||
|
ListAccessTokensResponse["accessTokens"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ShareLinksTableProps = {
|
||||||
|
shareLinks: ShareLinkRow[];
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShareLinksTable({
|
||||||
|
shareLinks,
|
||||||
|
orgId
|
||||||
|
}: ShareLinksTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks);
|
||||||
|
|
||||||
|
function formatLink(link: string) {
|
||||||
|
return link.substring(0, 20) + "..." + link.substring(link.length - 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSharelink(id: string) {
|
||||||
|
await api.delete(`/access-token/${id}`).catch((e) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete link",
|
||||||
|
description: formatAxiosError(e, "An error occurred deleting link"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRows = rows.filter((r) => r.accessTokenId !== id);
|
||||||
|
setRows(newRows);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Link deleted",
|
||||||
|
description: "The link has been deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<ShareLinkRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "resourceName",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Resource
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<Button variant="outline">
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/resources/${r.resourceId}`}
|
||||||
|
>
|
||||||
|
{r.resourceName}
|
||||||
|
</Link>
|
||||||
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "domain",
|
||||||
|
header: "Link",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
|
||||||
|
const link = constructShareLink(
|
||||||
|
r.resourceId,
|
||||||
|
r.accessTokenId,
|
||||||
|
r.tokenHash
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline mr-2"
|
||||||
|
>
|
||||||
|
{formatLink(link)}
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
const originalIcon = document.querySelector(
|
||||||
|
`#icon-${r.accessTokenId}`
|
||||||
|
);
|
||||||
|
if (originalIcon) {
|
||||||
|
originalIcon.classList.add("hidden");
|
||||||
|
}
|
||||||
|
const checkIcon = document.querySelector(
|
||||||
|
`#check-icon-${r.accessTokenId}`
|
||||||
|
);
|
||||||
|
if (checkIcon) {
|
||||||
|
checkIcon.classList.remove("hidden");
|
||||||
|
setTimeout(() => {
|
||||||
|
checkIcon.classList.add("hidden");
|
||||||
|
if (originalIcon) {
|
||||||
|
originalIcon.classList.remove(
|
||||||
|
"hidden"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
id={`icon-${r.accessTokenId}`}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
id={`check-icon-${r.accessTokenId}`}
|
||||||
|
className="hidden text-green-500 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Copy link</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return moment(r.createdAt).format("lll");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "expiresAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Expires
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
if (r.expiresAt) {
|
||||||
|
return moment(r.expiresAt).format("lll");
|
||||||
|
}
|
||||||
|
return "Never";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const resourceRow = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Open menu
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deleteSharelink(
|
||||||
|
resourceRow.accessTokenId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateShareLinkForm
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
setOpen={setIsCreateModalOpen}
|
||||||
|
onCreated={(val) => {
|
||||||
|
setRows([val, ...rows]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareLinksDataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
addShareLink={() => {
|
||||||
|
setIsCreateModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
65
src/app/[orgId]/settings/share-links/page.tsx
Normal file
65
src/app/[orgId]/settings/share-links/page.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { internal } from "@app/api";
|
||||||
|
import { authCookieHeader } from "@app/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||||
|
import ShareLinksTable, { ShareLinkRow } from "./components/ShareLinksTable";
|
||||||
|
|
||||||
|
type ShareLinksPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
let tokens: ListAccessTokensResponse["accessTokens"] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListAccessTokensResponse>>(
|
||||||
|
`/org/${params.orgId}/access-tokens`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
tokens = res.data.data.accessTokens;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching tokens", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const getOrg = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
|
`/org/${params.orgId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getOrg();
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: ShareLinkRow[] = tokens.map((token) => {
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Manage Share Links"
|
||||||
|
description="Create shareable links to grant temporary or permanent access to your resources"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<ShareLinksTable shareLinks={rows} orgId={params.orgId} />
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@app/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function AccessTokenInvalid() {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center text-2xl font-bold">
|
||||||
|
Acess URL Invalid
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
This shared access URL is invalid. Please contact the resource
|
||||||
|
owner for a new URL.
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<Button>
|
||||||
|
<Link href="/">Go Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { formatAxiosError } from "@app/lib/utils";
|
import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import LoginForm from "@app/components/LoginForm";
|
import LoginForm from "@app/components/LoginForm";
|
||||||
import { AuthWithPasswordResponse, AuthWithAccessTokenResponse } from "@server/routers/resource";
|
import { AuthWithPasswordResponse, AuthWithAccessTokenResponse, AuthWithWhitelistResponse } from "@server/routers/resource";
|
||||||
import { redirect } from "next/dist/server/api-utils";
|
import { redirect } from "next/dist/server/api-utils";
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import { createApiClient } from "@app/api";
|
import { createApiClient } from "@app/api";
|
||||||
|
@ -166,7 +166,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
|
||||||
const onWhitelistSubmit = (values: any) => {
|
const onWhitelistSubmit = (values: any) => {
|
||||||
setLoadingLogin(true);
|
setLoadingLogin(true);
|
||||||
api.post<AxiosResponse<AuthWithAccessTokenResponse>>(
|
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||||
`/auth/resource/${props.resource.id}/whitelist`,
|
`/auth/resource/${props.resource.id}/whitelist`,
|
||||||
{ email: values.email, otp: values.otp }
|
{ email: values.email, otp: values.otp }
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
|
AuthWithAccessTokenResponse,
|
||||||
GetResourceAuthInfoResponse,
|
GetResourceAuthInfoResponse,
|
||||||
GetResourceResponse,
|
GetResourceResponse
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import ResourceAuthPortal from "./components/ResourceAuthPortal";
|
import ResourceAuthPortal from "./components/ResourceAuthPortal";
|
||||||
import { internal, priv } from "@app/api";
|
import { internal, priv } from "@app/api";
|
||||||
|
@ -13,10 +14,14 @@ import ResourceNotFound from "./components/ResourceNotFound";
|
||||||
import ResourceAccessDenied from "./components/ResourceAccessDenied";
|
import ResourceAccessDenied from "./components/ResourceAccessDenied";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
||||||
|
import AccessTokenInvalid from "./components/AccessTokenInvalid";
|
||||||
|
|
||||||
export default async function ResourceAuthPage(props: {
|
export default async function ResourceAuthPage(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
searchParams: Promise<{ redirect: string | undefined }>;
|
searchParams: Promise<{
|
||||||
|
redirect: string | undefined;
|
||||||
|
token: string | undefined;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
@ -43,18 +48,55 @@ export default async function ResourceAuthPage(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso || authInfo.whitelist;
|
|
||||||
const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode && !authInfo.whitelist;
|
|
||||||
|
|
||||||
const redirectUrl = searchParams.redirect || authInfo.url;
|
const redirectUrl = searchParams.redirect || authInfo.url;
|
||||||
|
|
||||||
|
if (searchParams.token) {
|
||||||
|
let doRedirect = false;
|
||||||
|
try {
|
||||||
|
const res = await internal.post<
|
||||||
|
AxiosResponse<AuthWithAccessTokenResponse>
|
||||||
|
>(
|
||||||
|
`/auth/resource/${params.resourceId}/access-token`,
|
||||||
|
{
|
||||||
|
accessToken: searchParams.token
|
||||||
|
},
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.data.session) {
|
||||||
|
doRedirect = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<AccessTokenInvalid />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doRedirect) {
|
||||||
|
redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAuth =
|
||||||
|
authInfo.password ||
|
||||||
|
authInfo.pincode ||
|
||||||
|
authInfo.sso ||
|
||||||
|
authInfo.whitelist;
|
||||||
|
const isSSOOnly =
|
||||||
|
authInfo.sso &&
|
||||||
|
!authInfo.password &&
|
||||||
|
!authInfo.pincode &&
|
||||||
|
!authInfo.whitelist;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user &&
|
user &&
|
||||||
!user.emailVerified &&
|
!user.emailVerified &&
|
||||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||||
) {
|
) {
|
||||||
redirect(
|
redirect(
|
||||||
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`,
|
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +133,7 @@ export default async function ResourceAuthPage(props: {
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||||
`/resource/${params.resourceId}`,
|
`/resource/${params.resourceId}`,
|
||||||
await authCookieHeader(),
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
doRedirect = true;
|
doRedirect = true;
|
||||||
|
@ -121,7 +163,7 @@ export default async function ResourceAuthPage(props: {
|
||||||
}}
|
}}
|
||||||
resource={{
|
resource={{
|
||||||
name: authInfo.resourceName,
|
name: authInfo.resourceName,
|
||||||
id: authInfo.resourceId,
|
id: authInfo.resourceId
|
||||||
}}
|
}}
|
||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -33,7 +33,8 @@ export default async function RootLayout({
|
||||||
NEXT_PORT: process.env.NEXT_PORT as string,
|
NEXT_PORT: process.env.NEXT_PORT as string,
|
||||||
SERVER_EXTERNAL_PORT: process.env
|
SERVER_EXTERNAL_PORT: process.env
|
||||||
.SERVER_EXTERNAL_PORT as string,
|
.SERVER_EXTERNAL_PORT as string,
|
||||||
ENVIRONMENT: process.env.ENVIRONMENT as string
|
ENVIRONMENT: process.env.ENVIRONMENT as string,
|
||||||
|
EMAIL_ENABLED: process.env.EMAIL_ENABLED as string
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -12,9 +12,10 @@ import { cache } from "react";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Page(props: {
|
export default async function Page(props: {
|
||||||
searchParams: Promise<{ redirect: string | undefined }>;
|
searchParams: Promise<{ redirect: string | undefined, t: string | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ const Command = React.forwardRef<
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 w-72 rounded-md border bg-popover p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
|
|
7
src/lib/shareLinks.ts
Normal file
7
src/lib/shareLinks.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export function constructShareLink(
|
||||||
|
resourceId: number,
|
||||||
|
id: string,
|
||||||
|
token: string
|
||||||
|
) {
|
||||||
|
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
|
||||||
|
}
|
|
@ -2,4 +2,5 @@ export type env = {
|
||||||
SERVER_EXTERNAL_PORT: string;
|
SERVER_EXTERNAL_PORT: string;
|
||||||
NEXT_PORT: string;
|
NEXT_PORT: string;
|
||||||
ENVIRONMENT: string;
|
ENVIRONMENT: string;
|
||||||
|
EMAIL_ENABLED: string;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue