mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-23 13:48:51 +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;
|
||||
doNotExtend?: boolean;
|
||||
expiresAt?: number | null;
|
||||
sessionLength: number;
|
||||
sessionLength?: number | null;
|
||||
}): Promise<ResourceSession> {
|
||||
if (
|
||||
!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.RESOURCE_SESSION_COOKIE_NAME =
|
||||
parsedConfig.data.server.resource_session_cookie_name;
|
||||
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
|
||||
|
||||
export default parsedConfig.data;
|
||||
|
|
|
@ -288,7 +288,7 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
|||
tokenHash: text("tokenHash").notNull(),
|
||||
sessionLength: integer("sessionLength").notNull(),
|
||||
expiresAt: integer("expiresAt"),
|
||||
title: text("title").notNull(),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
});
|
||||
|
@ -378,3 +378,4 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
|||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
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";
|
||||
|
||||
const hformat = winston.format.printf(
|
||||
({ level, label, message, timestamp, ...metadata }) => {
|
||||
({ level, label, message, timestamp, stack, ...metadata }) => {
|
||||
let msg = `${timestamp} [${level}]${label ? `[${label}]` : ""}: ${message}`;
|
||||
if (stack) {
|
||||
msg += `\nStack: ${stack}`;
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
msg += JSON.stringify(metadata);
|
||||
msg += ` ${JSON.stringify(metadata)}`;
|
||||
}
|
||||
return msg;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const transports: any = [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.colorize(),
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp(),
|
||||
hformat,
|
||||
),
|
||||
}),
|
||||
];
|
||||
const transports: any = [new winston.transports.Console({})];
|
||||
|
||||
if (config.app.save_logs) {
|
||||
transports.push(
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: path.join(
|
||||
APP_PATH,
|
||||
"logs",
|
||||
"pangolin-%DATE%.log",
|
||||
),
|
||||
filename: path.join(APP_PATH, "logs", "pangolin-%DATE%.log"),
|
||||
datePattern: "YYYY-MM-DD",
|
||||
zippedArchive: true,
|
||||
maxSize: "20m",
|
||||
maxFiles: "7d",
|
||||
createSymlink: true,
|
||||
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(),
|
||||
),
|
||||
}),
|
||||
symlinkName: "pangolin.log"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: config.app.log_level.toLowerCase(),
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.colorize(),
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp(),
|
||||
hformat,
|
||||
hformat
|
||||
),
|
||||
transports,
|
||||
transports
|
||||
});
|
||||
|
||||
// process.on("uncaughtException", (error) => {
|
||||
// logger.error("Uncaught Exception:", { error, stack: error.stack });
|
||||
// process.exit(1);
|
||||
// });
|
||||
//
|
||||
// process.on("unhandledRejection", (reason, _) => {
|
||||
// logger.error("Unhandled Rejection:", { reason });
|
||||
// });
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", { error, stack: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, _) => {
|
||||
logger.error("Unhandled Rejection:", { reason });
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
|
|
@ -11,9 +11,9 @@ export const errorHandlerMiddleware: ErrorRequestHandler = (
|
|||
next: NextFunction
|
||||
) => {
|
||||
const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR;
|
||||
if (process.env.ENVIRONMENT !== "prod") {
|
||||
logger.error(error);
|
||||
}
|
||||
// if (process.env.ENVIRONMENT !== "prod") {
|
||||
// logger.error(error);
|
||||
// }
|
||||
res?.status(statusCode).send({
|
||||
data: null,
|
||||
success: false,
|
||||
|
|
|
@ -22,7 +22,9 @@ export async function createNextServer() {
|
|||
|
||||
nextServer.listen(nextPort, (err?: any) => {
|
||||
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;
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
SESSION_COOKIE_EXPIRES
|
||||
} from "@server/auth";
|
||||
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 response from "@server/utils/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
@ -26,9 +26,7 @@ export const generateAccssTokenParamsSchema = z.object({
|
|||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
export type GenerateAccessTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
export type GenerateAccessTokenResponse = ResourceAccessToken;
|
||||
|
||||
export async function generateAccessToken(
|
||||
req: Request,
|
||||
|
@ -79,30 +77,37 @@ export async function generateAccessToken(
|
|||
|
||||
const token = generateIdFromEntropySize(25);
|
||||
|
||||
const tokenHash = await hash(token, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1
|
||||
});
|
||||
// const tokenHash = await hash(token, {
|
||||
// memoryCost: 19456,
|
||||
// timeCost: 2,
|
||||
// outputLen: 32,
|
||||
// parallelism: 1
|
||||
// });
|
||||
|
||||
const id = generateId(15);
|
||||
await db.insert(resourceAccessToken).values({
|
||||
const [result] = await db.insert(resourceAccessToken).values({
|
||||
accessTokenId: id,
|
||||
orgId: resource.orgId,
|
||||
resourceId,
|
||||
tokenHash,
|
||||
tokenHash: token,
|
||||
expiresAt: expiresAt || null,
|
||||
sessionLength: sessionLength,
|
||||
title: title || `${resource.name} Token ${new Date().getTime()}`,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
createdAt: new Date().getTime()
|
||||
});
|
||||
}).returning();
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate access token"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GenerateAccessTokenResponse>(res, {
|
||||
data: {
|
||||
token: `${id}.${token}`
|
||||
},
|
||||
data: result,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource access token generated successfully",
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
import response from "@server/utils/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
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 stoi from "@server/utils/stoi";
|
||||
|
||||
|
@ -54,29 +54,47 @@ function queryAccessTokens(
|
|||
resourceId: resourceAccessToken.resourceId,
|
||||
sessionLength: resourceAccessToken.sessionLength,
|
||||
expiresAt: resourceAccessToken.expiresAt,
|
||||
tokenHash: resourceAccessToken.tokenHash,
|
||||
title: resourceAccessToken.title,
|
||||
description: resourceAccessToken.description,
|
||||
createdAt: resourceAccessToken.createdAt
|
||||
createdAt: resourceAccessToken.createdAt,
|
||||
resourceName: resources.name
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
return db
|
||||
.select(cols)
|
||||
.from(resourceAccessToken)
|
||||
.leftJoin(resources, eq(resourceAccessToken.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resourceAccessToken.resourceId, accessibleResourceIds),
|
||||
eq(resourceAccessToken.orgId, orgId)
|
||||
inArray(
|
||||
resourceAccessToken.resourceId,
|
||||
accessibleResourceIds
|
||||
),
|
||||
eq(resourceAccessToken.orgId, orgId),
|
||||
or(
|
||||
isNull(resourceAccessToken.expiresAt),
|
||||
gt(resourceAccessToken.expiresAt, new Date().getTime())
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (resourceId) {
|
||||
return db
|
||||
.select(cols)
|
||||
.from(resourceAccessToken)
|
||||
.leftJoin(resources, eq(resourceAccessToken.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.resourceId, resourceId)
|
||||
inArray(
|
||||
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
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
|
|
|
@ -98,12 +98,14 @@ export async function authWithAccessToken(
|
|||
);
|
||||
}
|
||||
|
||||
const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1
|
||||
});
|
||||
// const validCode = await verify(tokenItem.tokenHash, accessToken, {
|
||||
// memoryCost: 19456,
|
||||
// timeCost: 2,
|
||||
// outputLen: 32,
|
||||
// parallelism: 1
|
||||
// });
|
||||
logger.debug(`${accessToken} ${tokenItem.tokenHash}`)
|
||||
const validCode = accessToken === tokenItem.tokenHash;
|
||||
|
||||
if (!validCode) {
|
||||
return next(
|
||||
|
|
|
@ -123,7 +123,6 @@ export async function authWithPassword(
|
|||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
token,
|
||||
resource.fullDomain
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
|
|
|
@ -131,7 +131,6 @@ export async function authWithPincode(
|
|||
const cookie = serializeResourceSessionCookie(
|
||||
cookieName,
|
||||
token,
|
||||
resource.fullDomain
|
||||
);
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
|
|
|
@ -84,7 +84,6 @@ export async function pickSiteDefaults(
|
|||
status: HttpCode.OK,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
|
|
|
@ -8,6 +8,10 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
|
|||
return apiInstance;
|
||||
}
|
||||
|
||||
if (apiInstance) {
|
||||
return apiInstance
|
||||
}
|
||||
|
||||
let baseURL;
|
||||
const suffix = "api/v1";
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ const topNavItems = [
|
|||
},
|
||||
{
|
||||
title: "Sharable Links",
|
||||
href: "/{orgId}/settings/links",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
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>
|
||||
|
||||
<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>
|
||||
<a
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
|
|
|
@ -69,7 +69,9 @@ export default function ResourceAuthenticationPage() {
|
|||
const { resource, updateResource, authInfo, updateAuthInfo } =
|
||||
useResourceContext();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
|
@ -610,13 +612,16 @@ export default function ResourceAuthenticationPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{env.EMAIL_ENABLED === "true" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Switch
|
||||
id="whitelist-toggle"
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
defaultChecked={
|
||||
resource.emailWhitelistEnabled
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
setWhitelistEnabled(val)
|
||||
}
|
||||
|
@ -626,13 +631,12 @@ export default function ResourceAuthenticationPage() {
|
|||
</Label>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Enable resource whitelist to require email-based
|
||||
authentication (one-time passwords) for resource
|
||||
access.
|
||||
Enable resource whitelist to require
|
||||
email-based authentication (one-time
|
||||
passwords) for resource access.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{whitelistEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form className="space-y-8">
|
||||
<FormField
|
||||
|
@ -690,7 +694,6 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<Button
|
||||
loading={loadingSaveWhitelist}
|
||||
|
@ -699,6 +702,8 @@ export default function ResourceAuthenticationPage() {
|
|||
>
|
||||
Save Whitelist
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -228,7 +228,7 @@ export default function CreateResourceForm({
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-[350px] justify-between",
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
|
@ -244,7 +244,7 @@ export default function CreateResourceForm({
|
|||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0">
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site..." />
|
||||
<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 { AxiosResponse } from "axios";
|
||||
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 ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import { createApiClient } from "@app/api";
|
||||
|
@ -166,7 +166,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||
|
||||
const onWhitelistSubmit = (values: any) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithAccessTokenResponse>>(
|
||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||
`/auth/resource/${props.resource.id}/whitelist`,
|
||||
{ email: values.email, otp: values.otp }
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AuthWithAccessTokenResponse,
|
||||
GetResourceAuthInfoResponse,
|
||||
GetResourceResponse,
|
||||
GetResourceResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAuthPortal from "./components/ResourceAuthPortal";
|
||||
import { internal, priv } from "@app/api";
|
||||
|
@ -13,10 +14,14 @@ import ResourceNotFound from "./components/ResourceNotFound";
|
|||
import ResourceAccessDenied from "./components/ResourceAccessDenied";
|
||||
import { cookies } from "next/headers";
|
||||
import { CheckResourceSessionResponse } from "@server/routers/auth";
|
||||
import AccessTokenInvalid from "./components/AccessTokenInvalid";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
searchParams: Promise<{ redirect: string | undefined }>;
|
||||
searchParams: Promise<{
|
||||
redirect: string | undefined;
|
||||
token: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
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;
|
||||
|
||||
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 (
|
||||
user &&
|
||||
!user.emailVerified &&
|
||||
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
|
||||
) {
|
||||
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 {
|
||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||
`/resource/${params.resourceId}`,
|
||||
await authCookieHeader(),
|
||||
await authCookieHeader()
|
||||
);
|
||||
|
||||
doRedirect = true;
|
||||
|
@ -121,7 +163,7 @@ export default async function ResourceAuthPage(props: {
|
|||
}}
|
||||
resource={{
|
||||
name: authInfo.resourceName,
|
||||
id: authInfo.resourceId,
|
||||
id: authInfo.resourceId
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
|
|
|
@ -33,7 +33,8 @@ export default async function RootLayout({
|
|||
NEXT_PORT: process.env.NEXT_PORT as string,
|
||||
SERVER_EXTERNAL_PORT: process.env
|
||||
.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}
|
||||
|
|
|
@ -12,9 +12,10 @@ import { cache } from "react";
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ const Command = React.forwardRef<
|
|||
<CommandPrimitive
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
@ -65,7 +65,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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" &&
|
||||
"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
|
||||
|
|
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;
|
||||
NEXT_PORT: string;
|
||||
ENVIRONMENT: string;
|
||||
EMAIL_ENABLED: string;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue