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