This commit is contained in:
Owen Schwartz
2024-12-24 12:09:14 -05:00
75 changed files with 1983 additions and 2559 deletions

View File

@@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie,
),
cookie
)
);
const orgUser = await getOrgUser();
} catch {
@@ -40,10 +40,7 @@ export default async function OrgLayout(props: {
try {
const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
cookie,
),
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
);
await getOrg();
} catch {

View File

@@ -126,7 +126,7 @@ export default function CreateRoleForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="create-role-form"
>
<FormField

View File

@@ -173,7 +173,7 @@ export default function DeleteRoleForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="remove-role-form"
>
<FormField

View File

@@ -123,7 +123,7 @@ export default function AccessControlsPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View File

@@ -1,221 +0,0 @@
"use client";
import { createApiClient } from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@app/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
type HeaderProps = {
name?: string;
email: string;
orgId: string;
orgs: ListOrgsResponse["orgs"];
};
export default function Header({ email, orgId, name, orgs }: HeaderProps) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
function getInitials() {
if (name) {
const [firstName, lastName] = name.split(" ");
return `${firstName[0]}${lastName[0]}`;
}
return email.substring(0, 2).toUpperCase();
}
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error("Error logging out", e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out"),
});
})
.then(() => {
router.push("/auth/login");
});
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-9 w-9">
<AvatarFallback>
{getInitials()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="start"
forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{name && (
<p className="text-sm font-medium leading-none truncate">
{name}
</p>
)}
<p className="text-xs leading-none text-muted-foreground truncate">
{email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={logout}>
Logout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<span className="truncate max-w-[150px] md:max-w-none font-medium">
{name || email}
</span>
</div>
<div className="flex items-center">
<div className="hidden md:block">
<div className="flex items-center gap-4 mr-4">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground"
>
Documentation
</Link>
<Link
href="/support"
className="text-muted-foreground hover:text-foreground"
>
Support
</Link>
</div>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
role="combobox"
aria-expanded={open}
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
Organization
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs.find(
(org) =>
org.orgId === orgId,
)?.name
: "Select organization..."}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="[100px] md:w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandEmpty>
No organization found.
</CommandEmpty>
<CommandGroup className="[50px]">
<CommandList>
<CommandItem
className="flex items-center border border-input mb-2 cursor-pointer"
onSelect={(currentValue) => {
router.push("/setup");
}}
>
<Plus className="mr-2 h-4 w-4"/>
New Organization
</CommandItem>
{orgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(currentValue) => {
router.push(
`/${org.orgId}/settings`,
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0",
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</>
);
}

View File

@@ -1,62 +0,0 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string;
title: string;
icon: React.ReactNode;
}[];
disabled?: boolean;
orgId: string;
}
export function TopbarNav({
className,
items,
disabled = false,
orgId,
...props
}: TopbarNavProps) {
const pathname = usePathname();
return (
<nav
className={cn(
"flex overflow-x-auto space-x-4 lg:space-x-6",
disabled && "opacity-50 pointer-events-none",
className
)}
{...props}
>
{items.map((item) => (
<Link
key={item.href}
href={item.href.replace("{orgId}", orgId)}
className={cn(
"relative px-3 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId))
? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium",
"whitespace-nowrap",
disabled && "cursor-not-allowed"
)}
onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
<div className="flex items-center gap-2 relative px-2 py-0.5 rounded-md">
{item.icon && (
<div className="hidden md:block">{item.icon}</div>
)}
<span className="relative z-10">{item.title}</span>
</div>
</Link>
))}
</nav>
);
}

View File

@@ -57,12 +57,11 @@ export default function GeneralPage() {
async function deleteOrg() {
try {
const res = await api
.delete<AxiosResponse<DeleteOrgResponse>>(`/org/${org?.org.orgId}`);
const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
`/org/${org?.org.orgId}`
);
if (res.status === 200) {
console.log("Org deleted");
}
} catch (err) {
console.error(err);
@@ -72,7 +71,7 @@ export default function GeneralPage() {
description: formatAxiosError(
err,
"An error occurred while deleting the org."
),
)
});
}
}
@@ -118,61 +117,63 @@ export default function GeneralPage() {
</p>
</div>
}
buttonText="Confirm delete organization"
buttonText="Confirm Delete Organization"
onConfirm={deleteOrg}
string={org?.org.name || ""}
title="Delete organization"
title="Delete Organization"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-lg"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the org
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
<Card className="max-w-lg border-red-900 mt-5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
Once you delete this org, there is no going back. Please
be certain.
</p>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
<section className="space-y-8 max-w-lg">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</CardFooter>
</Card>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the org
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">
Once you delete this org, there is no going back.
Please be certain.
</p>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Organization Data
</Button>
</CardFooter>
</Card>
</section>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { Metadata } from "next";
import { TopbarNav } from "./components/TopbarNav";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
import Header from "./components/Header";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/api";
@@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
export const dynamic = "force-dynamic";
@@ -99,38 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<Header
email={user.email}
orgId={params.orgId}
orgs={orgs}
/>
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">{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-neutral-300 dark:text-neutral-700 space-x-3 select-none">
<div>Built by Fossorial</div>
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
</div>
</>
);
}

View File

@@ -412,7 +412,7 @@ export default function ResourceAuthenticationPage() {
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={usersRolesForm.control}
@@ -639,7 +639,7 @@ export default function ResourceAuthenticationPage() {
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-8">
<form className="space-y-4">
<FormField
control={whitelistForm.control}
name="emails"

View File

@@ -157,8 +157,8 @@ export default function ReverseProxyTargets(props: {
async function addTarget(data: AddTargetFormValues) {
// Check if target with same IP, port and method already exists
const isDuplicate = targets.some(
target => target.ip === data.ip &&
target.port === data.port &&
target => target.ip === data.ip &&
target.port === data.port &&
target.method === data.method
);
@@ -439,7 +439,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(
addTarget as any,
)}
className="space-y-8"
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField

View File

@@ -135,7 +135,7 @@ export default function GeneralForm() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View File

@@ -63,6 +63,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeSVG } from "qrcode.react";
type FormProps = {
open: boolean;
@@ -226,13 +227,13 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Sharable Link</CredenzaTitle>
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
<CredenzaDescription>
Anyone with this link can access the resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-8">
<div className="space-y-4">
{!link && (
<Form {...form}>
<form
@@ -436,10 +437,10 @@ export default function CreateShareLinkForm({
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.
this time, the link will no
longer work, and users who used
this link will lose access to
the resource.
</p>
</div>
</form>
@@ -448,14 +449,24 @@ export default function CreateShareLinkForm({
{link && (
<div className="max-w-md space-y-4">
<p>
You will be able to see this link once.
You will only be able to see this link once.
Make sure to copy it.
</p>
<p>
Anyone with this link can access the
resource. Share it with care.
</p>
<CopyTextBox text={link} wrapText={false} />
<div className="w-64 h-64 mx-auto flex items-center justify-center">
<QRCodeSVG
value={link}
size={256}
/>
</div>
<div className="mx-auto">
<CopyTextBox text={link} wrapText={false} />
</div>
</div>
)}
</div>

View File

@@ -77,7 +77,7 @@ export default function GeneralPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View File

@@ -203,7 +203,7 @@ PersistentKeepalive = 5`
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="create-site-form"
>
<FormField

View File

@@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<Check className="w-4 h-4" />
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
);
} else {
return (
<span className="text-red-500 flex items-center space-x-2">
<X className="w-4 h-4" />
<span className="text-gray-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);