share links

This commit is contained in:
Milo Schwartz 2024-12-20 22:24:44 -05:00
parent 72dc02ff2e
commit 845d65ad33
No known key found for this signature in database
31 changed files with 1281 additions and 212 deletions

View file

@ -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 &&

View file

@ -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;

View file

@ -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>;

View file

@ -4,82 +4,53 @@ import * as winston from "winston";
import path from "path";
const hformat = winston.format.printf(
({ level, label, message, timestamp, ...metadata }) => {
let msg = `${timestamp} [${level}]${label ? `[${label}]` : ""}: ${message} `;
({ 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;

View file

@ -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,

View file

@ -7,23 +7,25 @@ import config from "@server/config";
const nextPort = config.server.next_port;
export async function createNextServer() {
// const app = next({ dev });
const app = next({ dev: process.env.ENVIRONMENT !== "prod" });
const handle = app.getRequestHandler();
// const app = next({ dev });
const app = next({ dev: process.env.ENVIRONMENT !== "prod" });
const handle = app.getRequestHandler();
await app.prepare();
await app.prepare();
const nextServer = express();
const nextServer = express();
nextServer.all("*", (req, res) => {
const parsedUrl = parse(req.url!, true);
return handle(req, res, parsedUrl);
});
nextServer.all("*", (req, res) => {
const parsedUrl = parse(req.url!, true);
return handle(req, res, parsedUrl);
});
nextServer.listen(nextPort, (err?: any) => {
if (err) throw err;
logger.info(`Next.js server is running on http://localhost:${nextPort}`);
});
nextServer.listen(nextPort, (err?: any) => {
if (err) throw err;
logger.info(
`Next.js server is running on http://localhost:${nextPort}`
);
});
return nextServer;
return nextServer;
}

View file

@ -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",

View file

@ -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")

View file

@ -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(

View file

@ -123,7 +123,6 @@ export async function authWithPassword(
const cookie = serializeResourceSessionCookie(
cookieName,
token,
resource.fullDomain
);
res.appendHeader("Set-Cookie", cookie);

View file

@ -131,7 +131,6 @@ export async function authWithPincode(
const cookie = serializeResourceSessionCookie(
cookieName,
token,
resource.fullDomain
);
res.appendHeader("Set-Cookie", cookie);

View file

@ -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")

View file

@ -8,6 +8,10 @@ export function createApiClient({ env }: { env: env }): AxiosInstance {
return apiInstance;
}
if (apiInstance) {
return apiInstance
}
let baseURL;
const suffix = "api/v1";

View file

@ -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"

View file

@ -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,95 +612,98 @@ export default function ResourceAuthenticationPage() {
</div>
</div>
<Separator />
{env.EMAIL_ENABLED === "true" && (
<>
<Separator />
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="whitelist-toggle"
defaultChecked={
resource.emailWhitelistEnabled
}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require
email-based authentication (one-time
passwords) for resource access.
</span>
</div>
<div>
<div className="flex items-center space-x-2 mb-2">
<Switch
id="whitelist-toggle"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={(val) =>
setWhitelistEnabled(val)
}
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require email-based
authentication (one-time passwords) for resource
access.
</span>
</div>
<Form {...whitelistForm}>
<form className="space-y-8">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(tag) => {
return z
.string()
.email()
.safeParse(tag)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(newRoles) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={false}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-8">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(tag) => {
return z
.string()
.email()
.safeParse(tag)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(newRoles) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={false}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
>
Save Whitelist
</Button>
</>
)}
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
>
Save Whitelist
</Button>
</section>
</div>
</>

View file

@ -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>

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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);
}}
/>
</>
);
}

View 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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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 }
)

View file

@ -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}
/>

View file

@ -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}

View file

@ -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 });

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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
View file

@ -0,0 +1,7 @@
export function constructShareLink(
resourceId: number,
id: string,
token: string
) {
return `${window.location.origin}/auth/resource/${resourceId}?token=${id}.${token}`;
}

View file

@ -2,4 +2,5 @@ export type env = {
SERVER_EXTERNAL_PORT: string;
NEXT_PORT: string;
ENVIRONMENT: string;
EMAIL_ENABLED: string;
};