Milo Schwartz 6 ماه پیش
والد
کامیت
845d65ad33
31فایلهای تغییر یافته به همراه1281 افزوده شده و 212 حذف شده
  1. 1 1
      server/auth/resource.ts
  2. 1 0
      server/config.ts
  3. 2 1
      server/db/schema.ts
  4. 23 52
      server/logger.ts
  5. 3 3
      server/middlewares/formatError.ts
  6. 16 14
      server/nextServer.ts
  7. 22 17
      server/routers/accessToken/generateAccessToken.ts
  8. 24 7
      server/routers/accessToken/listAccessTokens.ts
  9. 8 6
      server/routers/resource/authWithAccessToken.ts
  10. 0 1
      server/routers/resource/authWithPassword.ts
  11. 0 1
      server/routers/resource/authWithPincode.ts
  12. 0 1
      server/routers/site/pickSiteDefaults.ts
  13. 4 0
      src/api/index.ts
  14. 2 2
      src/app/[orgId]/settings/layout.tsx
  15. 92 87
      src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
  16. 2 2
      src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx
  17. 470 0
      src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx
  18. 150 0
      src/app/[orgId]/settings/share-links/components/ShareLinksDataTable.tsx
  19. 295 0
      src/app/[orgId]/settings/share-links/components/ShareLinksTable.tsx
  20. 65 0
      src/app/[orgId]/settings/share-links/page.tsx
  21. 32 0
      src/app/auth/resource/[resourceId]/components/AccessTokenInvalid.tsx
  22. 2 2
      src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx
  23. 50 8
      src/app/auth/resource/[resourceId]/page.tsx
  24. 2 1
      src/app/layout.tsx
  25. 2 1
      src/app/page.tsx
  26. 1 1
      src/components/ui/command.tsx
  27. 2 2
      src/components/ui/dropdown-menu.tsx
  28. 1 1
      src/components/ui/popover.tsx
  29. 1 1
      src/components/ui/select.tsx
  30. 7 0
      src/lib/shareLinks.ts
  31. 1 0
      src/lib/types/env.ts

+ 1 - 1
server/auth/resource.ts

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

+ 1 - 0
server/config.ts

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

+ 2 - 1
server/db/schema.ts

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

+ 23 - 52
server/logger.ts

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

+ 3 - 3
server/middlewares/formatError.ts

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

+ 16 - 14
server/nextServer.ts

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

+ 22 - 17
server/routers/accessToken/generateAccessToken.ts

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

+ 24 - 7
server/routers/accessToken/listAccessTokens.ts

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

+ 8 - 6
server/routers/resource/authWithAccessToken.ts

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

+ 0 - 1
server/routers/resource/authWithPassword.ts

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

+ 0 - 1
server/routers/resource/authWithPincode.ts

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

+ 0 - 1
server/routers/site/pickSiteDefaults.ts

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

+ 4 - 0
src/api/index.ts

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

+ 2 - 2
src/app/[orgId]/settings/layout.tsx

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

+ 92 - 87
src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx

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

+ 2 - 2
src/app/[orgId]/settings/resources/components/CreateResourceForm.tsx

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

+ 470 - 0
src/app/[orgId]/settings/share-links/components/CreateShareLinkForm.tsx

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

+ 150 - 0
src/app/[orgId]/settings/share-links/components/ShareLinksDataTable.tsx

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

+ 295 - 0
src/app/[orgId]/settings/share-links/components/ShareLinksTable.tsx

@@ -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 - 0
src/app/[orgId]/settings/share-links/page.tsx

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

+ 32 - 0
src/app/auth/resource/[resourceId]/components/AccessTokenInvalid.tsx

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

+ 2 - 2
src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx

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

+ 50 - 8
src/app/auth/resource/[resourceId]/page.tsx

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

+ 2 - 1
src/app/layout.tsx

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

+ 2 - 1
src/app/page.tsx

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

+ 1 - 1
src/components/ui/command.tsx

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

+ 2 - 2
src/components/ui/dropdown-menu.tsx

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

+ 1 - 1
src/components/ui/popover.tsx

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

+ 1 - 1
src/components/ui/select.tsx

@@ -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 - 0
src/lib/shareLinks.ts

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

+ 1 - 0
src/lib/types/env.ts

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