mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-19 03:56:33 +02:00
add persistent session and users to access tokens
This commit is contained in:
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { ShareLinkRow } from "@app/components/ShareLinksTable";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from "punycode";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
|
||||
type CreatedShareLink = {
|
||||
accessTokenId: string;
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
resourceNiceId: string;
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (result: ShareLinkRow) => void;
|
||||
onCreated?: (result: CreatedShareLink) => void;
|
||||
};
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
|
||||
values.resourceName ||
|
||||
"Resource" + values.resourceId
|
||||
}),
|
||||
path: values.path
|
||||
path: values.path,
|
||||
persistSession,
|
||||
userId: selectedUser?.id
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
|
||||
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
expiresAt: token.expiresAt,
|
||||
userId: token.userId,
|
||||
userName: selectedUser?.text ?? null,
|
||||
username: null,
|
||||
userEmail: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
|
||||
setOpen(val);
|
||||
setLink(null);
|
||||
setLoading(false);
|
||||
setNeverExpire(false);
|
||||
setPersistSession(false);
|
||||
setSelectedUser(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"shareAssociateUserOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selectedUser &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedUser?.text
|
||||
? selectedUser.text
|
||||
: t("userSelect")}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<UserSelector
|
||||
orgId={org.org.orgId}
|
||||
selectedUser={
|
||||
selectedUser
|
||||
}
|
||||
onSelectUser={
|
||||
setSelectedUser
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"shareAssociateUserDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="persist-session"
|
||||
checked={persistSession}
|
||||
onCheckedChange={(val) =>
|
||||
setPersistSession(
|
||||
val as boolean
|
||||
)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="persist-session"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"sharePersistSession"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sharePersistSessionDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("shareExpireDescription")}
|
||||
</p>
|
||||
|
||||
@@ -35,6 +35,7 @@ import moment from "moment";
|
||||
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: t("user"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("user")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.userId) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{getUserDisplayName({
|
||||
email: r.userEmail,
|
||||
name: r.userName,
|
||||
username: r.username
|
||||
})}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "domain",
|
||||
// header: "Link",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { SelectedUser } from "./users-selector";
|
||||
|
||||
export type { SelectedUser };
|
||||
|
||||
export type UserSelectorProps = {
|
||||
orgId: string;
|
||||
selectedUser?: SelectedUser | null;
|
||||
onSelectUser: (user: SelectedUser | null) => void;
|
||||
allowClear?: boolean;
|
||||
};
|
||||
|
||||
export function UserSelector({
|
||||
orgId,
|
||||
selectedUser,
|
||||
onSelectUser,
|
||||
allowClear = true
|
||||
}: UserSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||
|
||||
const { data: users = [] } = useQuery(
|
||||
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
|
||||
);
|
||||
|
||||
const usersShown = useMemo(() => {
|
||||
const allUsers: Array<SelectedUser> = users.map((u) => ({
|
||||
id: u.id,
|
||||
text: getUserDisplayName(u)
|
||||
}));
|
||||
if (
|
||||
debouncedValue.trim().length === 0 &&
|
||||
selectedUser &&
|
||||
!allUsers.find((user) => user.id === selectedUser.id)
|
||||
) {
|
||||
allUsers.unshift(selectedUser);
|
||||
}
|
||||
return allUsers;
|
||||
}, [users, selectedUser, debouncedValue]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("userSearch")}
|
||||
value={userSearchQuery}
|
||||
onValueChange={setUserSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allowClear && (
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
onSelectUser(null);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedUser ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t("none")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{usersShown.map((user) => (
|
||||
<CommandItem
|
||||
value={`${user.text}:${user.id}`}
|
||||
key={user.id}
|
||||
onSelect={() => {
|
||||
onSelectUser(user);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
user.id === selectedUser?.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{user.text}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user