Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen 2025-03-02 21:46:22 -05:00
commit 81c142e8ae
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
57 changed files with 1436 additions and 1333 deletions

View file

@ -25,6 +25,10 @@ _Your own self-hosted zero trust tunnel._
<a href="https://docs.fossorial.io">
Full Documentation
</a>
<span> | </span>
<a href="mailto:numbat@fossorial.io">
Contact Us
</a>
</h5>
</div>
@ -68,41 +72,17 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment
- Run on any cloud provider or on-premises.
- Docker Compose based setup for simplified deployment.
- **Docker Compose based setup** for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
### Modular Design
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
- Attach as many sites to the central server as you wish.
## Screenshots
<div align="center">
<table>
<tr>
<td align="center"><img src="public/screenshots/sites.png" alt="Sites Example" width="200"/></td>
<td align="center"><img src="public/screenshots/users.png" alt="Users Example" width="200"/></td>
<td align="center"><img src="public/screenshots/share-link.png" alt="Share Link Example" width="200"/></td>
</tr>
<tr>
<td align="center"><b>Sites</b></td>
<td align="center"><b>Users</b></td>
<td align="center"><b>Share Link</b></td>
</tr>
<tr>
<td align="center"><img src="public/screenshots/auth.png" alt="Authentication Example" width="200"/></td>
<td align="center"><img src="public/screenshots/connectivity.png" alt="Connectivity Example" width="200"/></td>
<td align="center"></td>
</tr>
<tr>
<td align="center"><b>Authentication</b></td>
<td align="center"><b>Connectivity</b></td>
<td align="center"><b></b></td>
</tr>
</table>
</div>
<img src="public/screenshots/collage.png" alt="Collage"/>
## Deployment and Usage Example
@ -112,7 +92,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
> [!TIP]
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
2. **Domain Configuration**:
@ -123,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles**
4. **Expose Resources**:
- Define organizations and invite users.
- Implement user- or role-based permissions to control resource access.
- Add resources to the central server and configure access control rules.
- Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
@ -134,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**Use Case Example - IoT Networks**:
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
<img src="public/screenshots/resources.png" alt="Resources"/>
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
## Similar Projects and Inspirations
**Cloudflare Tunnels**:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

View file

@ -129,18 +129,19 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
export function serializeSessionCookie(
token: string,
isSecure: boolean
isSecure: boolean,
expiresAt: Date
): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
}
}
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}

View file

@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
cookieName: string,
domain: string,
token: string,
isHttp: boolean = false
isHttp: boolean = false,
expiresAt?: Date
): string {
if (!isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
if (expiresAt === undefined) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
}
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
if (expiresAt === undefined) {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
}
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
}
}

View file

@ -3,6 +3,7 @@ export * from "@server/emails/sendEmail";
import nodemailer from "nodemailer";
import config from "@server/lib/config";
import logger from "@server/logger";
import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
@ -13,7 +14,7 @@ function createEmailClient() {
return;
}
return nodemailer.createTransport({
const settings = {
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false,
@ -21,7 +22,15 @@ function createEmailClient() {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass
}
});
} as SMTPTransport.Options;
if (emailConfig.smtp_tls_reject_unathorized !== undefined) {
settings.tls = {
rejectUnauthorized: emailConfig.smtp_tls_reject_unathorized
};
}
return nodemailer.createTransport(settings);
}
export const emailClient = createEmailClient();

View file

@ -160,6 +160,7 @@ const configSchema = z.object({
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
smtp_tls_reject_unathorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
@ -184,7 +185,8 @@ const configSchema = z.object({
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional()
allow_base_domain_resources: z.boolean().optional(),
allow_local_sites: z.boolean().optional()
})
.optional()
});

View file

@ -137,9 +137,13 @@ export async function login(
}
const token = generateSessionToken();
await createSession(token, existingUser.userId);
const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);

View file

@ -170,9 +170,13 @@ export async function signup(
// });
const token = generateSessionToken();
await createSession(token, userId);
const sess = await createSession(token, userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) {

View file

@ -102,6 +102,8 @@ export async function exchangeSession(
const token = generateSessionToken();
let expiresAt: number | null = null;
if (requestSession.userSessionId) {
const [res] = await db
.select()
@ -118,6 +120,7 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
expiresAt = res.expiresAt;
}
} else if (requestSession.accessTokenId) {
const [res] = await db
@ -140,8 +143,12 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
expiresAt = res.expiresAt;
}
} else {
const expires = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await createResourceSession({
token,
resourceId: resource.resourceId,
@ -152,11 +159,10 @@ export async function exchangeSession(
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
expiresAt: expires,
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
expiresAt = expires;
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
@ -164,7 +170,8 @@ export async function exchangeSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
expiresAt ? new Date(expiresAt) : undefined
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));

View file

@ -384,7 +384,7 @@ async function createAccessTokenSession(
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
@ -397,7 +397,8 @@ async function createAccessTokenSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");

View file

@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,11 +24,11 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -40,13 +40,13 @@ type CreateRoleFormProps = {
const formSchema = z.object({
name: z.string({ message: "Name is required" }).max(32),
description: z.string().max(255).optional(),
description: z.string().max(255).optional()
});
export default function CreateRoleForm({
open,
setOpen,
afterCreate,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@ -58,8 +58,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
description: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@ -70,7 +70,7 @@ export default function CreateRoleForm({
`/org/${org?.org.orgId}/role`,
{
name: values.name,
description: values.description,
description: values.description
} as CreateRoleBody
)
.catch((e) => {
@ -80,7 +80,7 @@ export default function CreateRoleForm({
description: formatAxiosError(
e,
"An error occurred while creating the role."
),
)
});
});
@ -88,7 +88,7 @@ export default function CreateRoleForm({
toast({
variant: "default",
title: "Role created",
description: "The role has been successfully created.",
description: "The role has been successfully created."
});
if (open) {
@ -135,9 +135,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -150,9 +148,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -162,6 +158,9 @@ export default function CreateRoleForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
@ -170,9 +169,6 @@ export default function CreateRoleForm({
>
Create Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@ -23,7 +23,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
@ -32,10 +32,10 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -47,14 +47,14 @@ type CreateRoleFormProps = {
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" }),
newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete,
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@ -66,9 +66,9 @@ export default function DeleteRoleForm({
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
@ -77,7 +77,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@ -96,8 +96,8 @@ export default function DeleteRoleForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: "",
},
newRoleId: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@ -106,8 +106,8 @@ export default function DeleteRoleForm({
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId,
},
roleId: values.newRoleId
}
})
.catch((e) => {
toast({
@ -116,7 +116,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
)
});
});
@ -124,7 +124,7 @@ export default function DeleteRoleForm({
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed.",
description: "The role has been successfully removed."
});
if (open) {
@ -214,6 +214,9 @@ export default function DeleteRoleForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="remove-role-form"
@ -222,9 +225,6 @@ export default function DeleteRoleForm({
>
Remove Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -37,7 +37,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
@ -194,9 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -340,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
@ -348,9 +349,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
>
Create Invitation
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
</Breadcrumb>
</div>
<div className="space-y-0.5 select-none mb-6">
<div className="space-y-0.5 mb-6">
<h2 className="text-2xl font-bold tracking-tight">
User {user?.email}
</h2>
@ -73,7 +73,6 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={true}
>
{children}
</SidebarSettings>

View file

@ -1,6 +1,13 @@
import { Metadata } from "next";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
import {
Cog,
Combine,
LinkIcon,
Settings,
Users,
Waypoints
} from "lucide-react";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
@ -11,6 +18,14 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
export const dynamic = "force-dynamic";
@ -38,7 +53,7 @@ const topNavItems = [
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "General",
@ -95,7 +110,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
<div className="border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
@ -105,10 +121,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3">
{children}
</div>
</div>
</>
);
}

View file

@ -66,6 +66,8 @@ import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { StrategySelect } from "@app/components/StrategySelect";
const createResourceFormSchema = z
.object({
@ -140,6 +142,7 @@ export default function CreateResourceForm({
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain"
);
const [loadingPage, setLoadingPage] = useState(true);
const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema),
@ -215,8 +218,17 @@ export default function CreateResourceForm({
}
};
fetchSites();
fetchDomains();
const load = async () => {
setLoadingPage(true);
await fetchSites();
await fetchDomains();
await new Promise((r) => setTimeout(r, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateResourceFormValues) {
@ -231,7 +243,7 @@ export default function CreateResourceForm({
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId,
isBaseDomain: data.http ? undefined : data.isBaseDomain
isBaseDomain: data.http ? data.isBaseDomain : undefined
}
)
.catch((e) => {
@ -253,6 +265,7 @@ export default function CreateResourceForm({
goToResource(id);
} else {
setShowSnippets(true);
router.refresh();
}
}
}
@ -262,6 +275,21 @@ export default function CreateResourceForm({
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
}
const launchOptions = [
{
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number."
}
];
return (
<>
<Credenza
@ -282,338 +310,47 @@ export default function CreateResourceForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div>
{!showSnippets && (
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4"
id="create-resource-form"
>
{!env.flags.allowRawResources || (
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a
raw TCP/UDP
resource.
</FormDescription>
</div>
<FormControl>
<Switch
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>
Name
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is display name for the
resource.
</FormDescription>
</FormItem>
)}
/>
{form.watch("http") &&
env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
{form.watch("http") && (
<>
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
<div className="flex">
<div className="w-full mr-1">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
)}
/>
</div>
<div className="max-w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP
resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription>
The protocol to use
for the resource.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
The port number to
proxy requests to
(required for
non-HTTP resources).
</FormDescription>
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<FormLabel>
Site
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<PopoverTrigger
asChild
>
<FormControl>
<Button
variant="outline"
@ -631,7 +368,8 @@ export default function CreateResourceForm({
) =>
site.siteId ===
field.value
)?.name
)
?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@ -642,7 +380,8 @@ export default function CreateResourceForm({
<CommandInput placeholder="Search site" />
<CommandList>
<CommandEmpty>
No site
No
site
found.
</CommandEmpty>
<CommandGroup>
@ -684,13 +423,311 @@ export default function CreateResourceForm({
</Popover>
<FormMessage />
<FormDescription>
This site will provide
connectivity to the
resource.
This site will
provide connectivity
to the resource.
</FormDescription>
</FormItem>
)}
/>
{!env.flags.allowRawResources || (
<div className="space-y-2">
<FormLabel>
Resource Type
</FormLabel>
<StrategySelect
options={launchOptions}
defaultValue="http"
onChange={(value) =>
form.setValue(
"http",
value === "http"
)
}
/>
<FormDescription>
You cannot change the
type of resource after
creation.
</FormDescription>
</div>
)}
{form.watch("http") &&
env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("http") && (
<>
{domainType ===
"subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<FormLabel>
Base
Domain
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={
field.value
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
The external
port number
to proxy
requests.
</FormDescription>
</FormItem>
)}
/>
</>
)}
</form>
</Form>
)}
@ -698,9 +735,6 @@ export default function CreateResourceForm({
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
1
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
@ -715,13 +749,10 @@ export default function CreateResourceForm({
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
2
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in Docker
Compose
Gerbil: Expose Ports in
Docker Compose
</h3>
<CopyTextBox
text={`ports:
@ -738,14 +769,20 @@ export default function CreateResourceForm({
rel="noopener noreferrer"
>
<span>
Make sure to follow the full guide
Learn how to configure TCP/UDP
resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div>
)}
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{!showSnippets && (
<Button
type="submit"
@ -765,10 +802,6 @@ export default function CreateResourceForm({
Go to Resource
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,22 +24,22 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100),
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: "",
password: ""
};
type SetPasswordFormProps = {
@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password,
password: data.password
})
.catch((e) => {
toast({
@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({
description: formatAxiosError(
e,
"An error occurred while setting the resource password"
),
)
});
})
.then(() => {
toast({
title: "Resource password set",
description:
"The resource password has been set successfully",
"The resource password has been set successfully"
});
if (onSetPassword) {
@ -153,6 +153,9 @@ export default function SetResourcePasswordForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
@ -161,9 +164,6 @@ export default function SetResourcePasswordForm({
>
Enable Password Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@ -24,27 +24,27 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6),
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: "",
pincode: ""
};
type SetPincodeFormProps = {
@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode,
pincode: data.pincode
})
.catch((e) => {
toast({
@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({
title: "Error setting resource PIN code",
description: formatAxiosError(
e,
"An error occurred while setting the resource PIN code",
),
"An error occurred while setting the resource PIN code"
)
});
})
.then(() => {
toast({
title: "Resource PIN code set",
description:
"The resource pincode has been set successfully",
"The resource pincode has been set successfully"
});
if (onSetPincode) {
@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
@ -189,9 +192,6 @@ export default function SetResourcePincodeForm({
>
Enable PIN Code Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -38,7 +38,8 @@ import {
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
@ -438,6 +439,7 @@ export default function ResourceAuthenticationPage() {
setActiveRolesTagIndex
}
placeholder="Select a role"
size="sm"
tags={
usersRolesForm.getValues()
.roles
@ -466,14 +468,6 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormMessage />
@ -504,6 +498,7 @@ export default function ResourceAuthenticationPage() {
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
@ -528,14 +523,6 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormMessage />
@ -582,7 +569,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.password
? removeResourcePassword
@ -608,7 +595,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.pincode
? removeResourcePincode
@ -664,6 +651,7 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
@ -708,14 +696,6 @@ export default function ResourceAuthenticationPage() {
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>

View file

@ -39,6 +39,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@ -241,10 +242,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
} else if (target.updated) {
await api.post(
`/target/${target.targetId}`,
data
);
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
@ -261,9 +259,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((t) => t.targetId !== targetId)
);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
toast({
@ -459,7 +455,8 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with Let's Encrypt certificates
Setup SSL to secure your connections with Let's
Encrypt certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@ -490,7 +487,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
{resource.http && (
<FormField
control={addTargetForm.control}
@ -545,18 +542,6 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} />
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the IP or hostname
of the target service on
your network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the IP of the
WireGuard peer.
</FormDescription>
) : null}
</FormItem>
)}
/>
@ -575,30 +560,20 @@ export default function ReverseProxyTargets(props: {
/>
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the port of the
target service on your
network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the port exposed on
an address on the WireGuard
network.
</FormDescription>
) : null}
</FormItem>
)}
/>
</div>
<Button type="submit" variant="outline">
<Button
type="submit"
variant="outlinePrimary"
className="mt-8"
>
Add Target
</Button>
</div>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -608,8 +583,8 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
@ -621,13 +596,10 @@ export default function ReverseProxyTargets(props: {
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
@ -640,18 +612,16 @@ export default function ReverseProxyTargets(props: {
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the
form.
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
<TableCaption>
Adding more than one target above will enable load
balancing.
</p>
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View file

@ -129,6 +129,7 @@ export default function GeneralForm() {
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
@ -184,8 +185,14 @@ export default function GeneralForm() {
}
};
fetchDomains();
fetchSites();
const load = async () => {
await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []);
async function onSubmit(data: GeneralFormValues) {
@ -263,6 +270,7 @@ export default function GeneralForm() {
}
return (
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
@ -279,7 +287,7 @@ export default function GeneralForm() {
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
@ -292,63 +300,73 @@ export default function GeneralForm() {
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
resource.
</FormDescription>
</FormItem>
)}
/>
{resource.http && (
<>
{env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val as any
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val === "basedomain"
val ===
"basedomain"
? true
: false
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
<div className="flex">
<div className="w-full mr-1">
<div className="w-1/2">
<FormField
control={
form.control
@ -361,8 +379,7 @@ export default function GeneralForm() {
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
@ -370,7 +387,7 @@ export default function GeneralForm() {
)}
/>
</div>
<div className="max-w-1/2">
<div className="w-1/2">
<FormField
control={
form.control
@ -392,7 +409,7 @@ export default function GeneralForm() {
}
>
<FormControl>
<SelectTrigger>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
@ -431,6 +448,9 @@ export default function GeneralForm() {
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
Base Domain
</FormLabel>
<Select
onValueChange={
field.onChange
@ -472,6 +492,7 @@ export default function GeneralForm() {
)}
/>
)}
</div>
</>
)}
@ -488,11 +509,13 @@ export default function GeneralForm() {
<Input
type="number"
value={
field.value ?? ""
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target.value
e.target
.value
? parseInt(
e
.target
@ -504,11 +527,6 @@ export default function GeneralForm() {
/>
</FormControl>
<FormMessage />
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
</FormItem>
)}
/>
@ -544,7 +562,9 @@ export default function GeneralForm() {
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(onTransfer)}
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
@ -573,7 +593,9 @@ export default function GeneralForm() {
>
{field.value
? sites.find(
(site) =>
(
site
) =>
site.siteId ===
field.value
)?.name
@ -586,7 +608,6 @@ export default function GeneralForm() {
<Command>
<CommandInput
placeholder="Search sites"
className="h-9"
/>
<CommandEmpty>
No sites found.
@ -649,5 +670,6 @@ export default function GeneralForm() {
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)
);
}

View file

@ -130,9 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
<OrgProvider org={org}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<ResourceInfoBox />
</div>
{children}
</SidebarSettings>
</ResourceProvider>

View file

@ -33,6 +33,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@ -94,7 +95,7 @@ enum RuleAction {
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range",
CIDR = "IP Range"
}
export default function ResourceRules(props: {
@ -623,7 +624,7 @@ export default function ResourceRules(props: {
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
@ -711,17 +712,16 @@ export default function ResourceRules(props: {
</FormItem>
)}
/>
</div>
<Button
type="submit"
variant="outline"
variant="outlinePrimary"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</div>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -731,8 +731,8 @@ export default function ResourceRules(props: {
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
@ -744,13 +744,10 @@ export default function ResourceRules(props: {
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column
.columnDef.cell,
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
@ -768,11 +765,10 @@ export default function ResourceRules(props: {
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
<TableCaption>
Rules are evaluated by priority in ascending order.
</p>
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View file

@ -152,9 +152,11 @@ export default function CreateShareLinkForm({
if (res?.status === 200) {
setResources(
res.data.data.resources.filter((r) => {
res.data.data.resources
.filter((r) => {
return r.http;
}).map((r) => ({
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
@ -274,7 +276,7 @@ export default function CreateShareLinkForm({
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">
<FormLabel>
Resource
</FormLabel>
<Popover>
@ -318,9 +320,7 @@ export default function CreateShareLinkForm({
r
) => (
<CommandItem
value={
`${r.name}:${r.resourceId}`
}
value={`${r.name}:${r.resourceId}`}
key={
r.resourceId
}
@ -369,13 +369,11 @@ export default function CreateShareLinkForm({
name="title"
render={({ field }) => (
<FormItem>
<Label>
<FormLabel>
Title (optional)
</Label>
</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -383,8 +381,9 @@ export default function CreateShareLinkForm({
/>
<div className="space-y-4">
<Label>Expire In</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="timeUnit"
@ -444,6 +443,7 @@ export default function CreateShareLinkForm({
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
@ -552,6 +552,9 @@ export default function CreateShareLinkForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
@ -560,9 +563,6 @@ export default function CreateShareLinkForm({
>
Create Link
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -273,7 +273,21 @@ export default function ShareLinksTable({
}
return "Never";
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() => deleteSharelink(row.original.accessTokenId)}
>
Delete
</Button>
</div>
)
}
];
return (

View file

@ -41,6 +41,7 @@ import Link from "next/link";
import {
ArrowUpRight,
ChevronsUpDown,
Loader2,
SquareArrowOutUpRight
} from "lucide-react";
import {
@ -48,6 +49,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
const createSiteFormSchema = z.object({
name: z
@ -97,6 +99,8 @@ export default function CreateSiteForm({
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [loadingPage, setLoadingPage] = useState(true);
const handleCheckboxChange = (checked: boolean) => {
// setChecked?.(checked);
setIsChecked(checked);
@ -121,6 +125,8 @@ export default function CreateSiteForm({
useEffect(() => {
if (!open) return;
const load = async () => {
setLoadingPage(true);
// reset all values
setLoading?.(false);
setIsLoading(false);
@ -132,7 +138,8 @@ export default function CreateSiteForm({
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
api.get(`/org/${orgId}/pick-site-defaults`)
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
@ -142,6 +149,12 @@ export default function CreateSiteForm({
setSiteDefaults(res.data.data);
}
});
await new Promise((resolve) => setTimeout(resolve, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateSiteFormValues) {
@ -257,7 +270,9 @@ PersistentKeepalive = 5`
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return (
return loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div className="space-y-4">
<Form {...form}>
<form
@ -276,8 +291,7 @@ PersistentKeepalive = 5`
</FormControl>
<FormMessage />
<FormDescription>
This is the the display name for the
site.
This is the the display name for the site.
</FormDescription>
</FormItem>
)}
@ -331,7 +345,6 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
@ -358,12 +371,16 @@ PersistentKeepalive = 5`
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<div className="mx-auto mb-2">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
@ -405,10 +422,6 @@ PersistentKeepalive = 5`
</CollapsibleContent>
</Collapsible>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>

View file

@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-site-form"
@ -69,9 +72,6 @@ export default function CreateSiteFormModal({
>
Create Site
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -68,9 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<SiteProvider site={site}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<SiteInfoCard />
</div>
{children}
</SidebarSettings>
</SiteProvider>

View file

@ -198,8 +198,7 @@ export default function VerifyEmailForm({
<FormMessage />
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
email address.
</FormDescription>
</FormItem>
)}

View file

@ -21,8 +21,8 @@
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--border: 20 5.9% 80%;
--input: 20 5.9% 75%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@ -49,8 +49,8 @@
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 25.0%;
--input: 12 6.5% 25.0%;
--border: 12 6.5% 30.0%;
--input: 12 6.5% 35.0%;
--ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;

View file

@ -37,11 +37,11 @@ export default async function RootLayout({
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow">{children}</div>
<div className="flex-grow pb-3 md:pb-0">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600 select-none">
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>

View file

@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
@ -15,14 +15,14 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import {
InviteUserBody,
InviteUserResponse,
ListUsersResponse,
ListUsersResponse
} from "@server/routers/user";
import { AxiosResponse } from "axios";
import React, { useState } from "react";
@ -37,7 +37,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
@ -61,7 +61,7 @@ export default function InviteUserForm({
title,
onConfirm,
buttonText,
dialog,
dialog
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
@ -69,15 +69,15 @@ export default function InviteUserForm({
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation",
}),
message: "Invalid confirmation"
})
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
string: "",
},
string: ""
}
});
function reset() {
@ -128,6 +128,9 @@ export default function InviteUserForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="confirm-delete-form"
@ -136,9 +139,6 @@ export default function InviteUserForm({
>
{buttonText}
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -78,7 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
<CredenzaClose className={cn("mb-3 md:mb-0", className)} {...props}>
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
{children}
</CredenzaClose>
);
@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4", className)} {...props}>
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
{children}
</div>
);
@ -168,7 +168,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
<CredenzaFooter className={className} {...props}>
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
{children}
</CredenzaFooter>
);

View file

@ -29,10 +29,8 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { useUserContext } from "@app/hooks/useUserContext";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { CheckCircle2 } from "lucide-react";
const disableSchema = z.object({
@ -152,36 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Authenticator Code
</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -210,6 +179,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{step === "password" && (
<Button
type="submit"
@ -220,9 +192,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Disable 2FA
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -36,7 +36,7 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
@ -222,7 +222,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
@ -279,6 +282,9 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
@ -295,9 +301,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
Submit
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -0,0 +1,21 @@
"use client";
import React from "react";
import { Loader2 } from "lucide-react"; // Ensure you have lucide-react installed
interface LoaderProps {
height?: string;
}
const LoaderPlaceholder: React.FC<LoaderProps> = ({ height = "100px" }) => {
return (
<div
className="flex items-center justify-center w-full"
style={{ height }}
>
<Loader2 className="animate-spin" />
</div>
);
};
export default LoaderPlaceholder;

View file

@ -1,5 +1,5 @@
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-4">{children}</div>
return <div className="space-y-6">{children}</div>
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
@ -7,7 +7,7 @@ export function SettingsSection({ children }: { children: React.ReactNode }) {
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-0.5 pb-8">{children}</div>
return <div className="space-y-0.5 pb-6">{children}</div>
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
@ -19,7 +19,7 @@ export function SettingsSectionTitle({ children }: { children: React.ReactNode }
}
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
return <p className="text-muted-foreground">{children}</p>
return <p className="text-muted-foreground text-sm">{children}</p>
}
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {

View file

@ -11,7 +11,7 @@ export default function SettingsSectionTitle({
}: SettingsSectionTitleProps) {
return (
<div
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
className={`space-y-0.5 ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
>
<h2
className={`text-${

View file

@ -26,7 +26,7 @@ export function SidebarSettings({
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""}`}>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children}
</div>
</div>

View file

@ -0,0 +1,53 @@
"use client";
import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
interface StrategyOption {
id: string;
title: string;
description: string;
}
interface StrategySelectProps {
options: StrategyOption[];
defaultValue?: string;
onChange?: (value: string) => void;
}
export function StrategySelect({
options,
defaultValue,
onChange
}: StrategySelectProps) {
return (
<RadioGroup
defaultValue={defaultValue}
onValueChange={onChange}
className="grid gap-4"
>
{options.map((option) => (
<label
key={option.id}
htmlFor={option.id}
className={cn(
"relative flex cursor-pointer rounded-lg border-2 p-4",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="pl-7">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{option.description}
</div>
</div>
</label>
))}
</RadioGroup>
);
}

View file

@ -490,7 +490,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-2 p-2 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-transparent`,
styleClasses?.inlineTagsContainer
)}
>
@ -644,7 +644,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
) : (
<div
className={cn(
`flex flex-row flex-wrap items-center p-2 gap-2 h-fit w-full bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center p-1.5 gap-1.5 h-fit w-full bg-transparent text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>

View file

@ -22,7 +22,7 @@ export const tagVariants = cva(
"bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
},
size: {
sm: "text-xs h-7",
sm: "text-xs h-6",
md: "text-sm h-8",
lg: "text-base h-9",
xl: "text-lg h-10"
@ -67,7 +67,7 @@ export const tagVariants = cva(
variant: "default",
size: "md",
shape: "default",
borderStyle: "default",
borderStyle: "none",
interaction: "nonClickable",
animation: "fadeIn",
textStyle: "normal"
@ -144,7 +144,7 @@ export const Tag: React.FC<TagProps> = ({
}}
disabled={disabled}
className={cn(
`py-1 px-3 h-full hover:bg-transparent`,
`p-1 h-full hover:bg-transparent`,
tagClasses?.closeButton
)}
>

View file

@ -16,6 +16,8 @@ const buttonVariants = cva(
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground",
outlinePrimary:
"border-2 border-primary bg-card hover:bg-primary/10 text-primary",
secondary:
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",

View file

@ -41,7 +41,7 @@ const InputOTPSlot = React.forwardRef<
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
"relative flex h-10 w-10 items-center justify-center border-y-2 border-r-2 border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l-2 last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}

View file

@ -20,8 +20,8 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
"rounded-md"
"rounded-md",
className
)}
{...props}
>

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-4 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-3 w-3 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>

View file

@ -77,7 +77,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View file

@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{
variants: {
variant: {