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"> <a href="https://docs.fossorial.io">
Full Documentation Full Documentation
</a> </a>
<span> | </span>
<a href="mailto:numbat@fossorial.io">
Contact Us
</a>
</h5> </h5>
</div> </div>
@ -68,41 +72,17 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment ### Easy Deployment
- Run on any cloud provider or on-premises. - 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. - 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 ### 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. - Attach as many sites to the central server as you wish.
## Screenshots <img src="public/screenshots/collage.png" alt="Collage"/>
<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>
## Deployment and Usage Example ## Deployment and Usage Example
@ -112,7 +92,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
> [!TIP] > [!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! > 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**: 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. - Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server. - Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles** 4. **Expose Resources**:
- Define organizations and invite users. - Add resources to the central server and configure access control rules.
- Implement user- or role-based permissions to control resource access. - Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**: **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. 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**: **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. 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 ## Similar Projects and Inspirations
**Cloudflare Tunnels**: **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( export function serializeSessionCookie(
token: string, token: string,
isSecure: boolean isSecure: boolean,
expiresAt: Date
): string { ): string {
if (isSecure) { 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 { } 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 { export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) { 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 { } else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`; return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
} }

View file

@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
cookieName: string, cookieName: string,
domain: string, domain: string,
token: string, token: string,
isHttp: boolean = false isHttp: boolean = false,
expiresAt?: Date
): string { ): string {
if (!isHttp) { 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 { } 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 nodemailer from "nodemailer";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() { function createEmailClient() {
const emailConfig = config.getRawConfig().email; const emailConfig = config.getRawConfig().email;
@ -13,7 +14,7 @@ function createEmailClient() {
return; return;
} }
return nodemailer.createTransport({ const settings = {
host: emailConfig.smtp_host, host: emailConfig.smtp_host,
port: emailConfig.smtp_port, port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false, secure: emailConfig.smtp_secure || false,
@ -21,7 +22,15 @@ function createEmailClient() {
user: emailConfig.smtp_user, user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass 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(); export const emailClient = createEmailClient();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,13 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { TopbarNav } from "@app/components/TopbarNav"; 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 { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@ -11,6 +18,14 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react"; import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider"; 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"; export const dynamic = "force-dynamic";
@ -38,7 +53,7 @@ const topNavItems = [
{ {
title: "Shareable Links", title: "Shareable Links",
href: "/{orgId}/settings/share-links", href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" /> icon: <LinkIcon className="h-4 w-4" />
}, },
{ {
title: "General", title: "General",
@ -95,19 +110,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( 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="container mx-auto flex flex-col content-between"> <div className="border-b">
<div className="my-4"> <div className="container mx-auto flex flex-col content-between">
<UserProvider user={user}> <div className="my-4">
<Header orgId={params.orgId} orgs={orgs} /> <UserProvider user={user}>
</UserProvider> <Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </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]">
{children} <div className="container mx-auto sm:px-0 px-3">
{children}
</div>
</div> </div>
</> </>
); );

View file

@ -66,6 +66,8 @@ import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain"; import { ListDomainsResponse } from "@server/routers/domain";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { StrategySelect } from "@app/components/StrategySelect";
const createResourceFormSchema = z const createResourceFormSchema = z
.object({ .object({
@ -140,6 +142,7 @@ export default function CreateResourceForm({
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain" "subdomain"
); );
const [loadingPage, setLoadingPage] = useState(true);
const form = useForm<CreateResourceFormValues>({ const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema), resolver: zodResolver(createResourceFormSchema),
@ -215,8 +218,17 @@ export default function CreateResourceForm({
} }
}; };
fetchSites(); const load = async () => {
fetchDomains(); setLoadingPage(true);
await fetchSites();
await fetchDomains();
await new Promise((r) => setTimeout(r, 200));
setLoadingPage(false);
};
load();
}, [open]); }, [open]);
async function onSubmit(data: CreateResourceFormValues) { async function onSubmit(data: CreateResourceFormValues) {
@ -231,7 +243,7 @@ export default function CreateResourceForm({
protocol: data.protocol, protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort, proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId, siteId: data.siteId,
isBaseDomain: data.http ? undefined : data.isBaseDomain isBaseDomain: data.http ? data.isBaseDomain : undefined
} }
) )
.catch((e) => { .catch((e) => {
@ -253,6 +265,7 @@ export default function CreateResourceForm({
goToResource(id); goToResource(id);
} else { } else {
setShowSnippets(true); setShowSnippets(true);
router.refresh();
} }
} }
} }
@ -262,6 +275,21 @@ export default function CreateResourceForm({
router.push(`/${orgId}/settings/resources/${id || resourceId}`); 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 ( return (
<> <>
<Credenza <Credenza
@ -282,236 +310,458 @@ export default function CreateResourceForm({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
{!showSnippets && ( {loadingPage ? (
<Form {...form} key={formKey}> <LoaderPlaceholder height="300px" />
<form ) : (
onSubmit={form.handleSubmit(onSubmit)} <div>
className="space-y-4" {!showSnippets && (
id="create-resource-form" <Form {...form} key={formKey}>
> <form
{!env.flags.allowRawResources || ( onSubmit={form.handleSubmit(
<FormField onSubmit
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>
)} )}
/> className="space-y-4"
)} id="create-resource-form"
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<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> <FormLabel>
Subdomain Name
</FormLabel> </FormLabel>
)} <FormControl>
<div className="flex"> <Input {...field} />
<div className="w-full mr-1"> </FormControl>
<FormField <FormMessage />
control={ </FormItem>
form.control )}
} />
name="subdomain"
render={({ <FormField
field control={form.control}
}) => ( name="siteId"
<FormControl> render={({ field }) => (
<Input <FormItem className="flex flex-col">
{...field} <FormLabel>
className="text-right" Site
placeholder="Enter subdomain" </FormLabel>
/> <Popover>
</FormControl> <PopoverTrigger
)} asChild
/>
</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> <FormControl>
<SelectTrigger> <Button
<SelectValue /> variant="outline"
</SelectTrigger> role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl> </FormControl>
<SelectContent> </PopoverTrigger>
{baseDomains.map( <PopoverContent className="p-0">
( <Command>
option <CommandInput placeholder="Search site" />
) => ( <CommandList>
<SelectItem <CommandEmpty>
key={ No
option.domainId site
} found.
value={ </CommandEmpty>
option.domainId <CommandGroup>
} {sites.map(
> (
{ site
option.baseDomain ) => (
} <CommandItem
</SelectItem> value={`${site.siteId}:${site.name}:${site.niceId}`}
) key={
)} site.siteId
</SelectContent> }
</Select> onSelect={() => {
<FormMessage /> form.setValue(
</FormItem> "siteId",
)} site.siteId
/> );
)} }}
</> >
)} <CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
<FormDescription>
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>
)}
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in
Docker Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
{!form.watch("http") && (
<Link <Link
className="text-sm text-primary flex items-center gap-1" className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp" href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
@ -524,228 +774,15 @@ export default function CreateResourceForm({
</span> </span>
<SquareArrowOutUpRight size={14} /> <SquareArrowOutUpRight size={14} />
</Link> </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>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site" />
<CommandList>
<CommandEmpty>
No site
found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
<FormDescription>
This site will provide
connectivity to the
resource.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
{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>
<div className="flex-grow"> )}
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</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
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
<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>
Make sure to follow the full guide
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div> </div>
)} )}
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{!showSnippets && ( {!showSnippets && (
<Button <Button
type="submit" type="submit"
@ -765,10 +802,6 @@ export default function CreateResourceForm({
Go to Resource Go to Resource
</Button> </Button>
)} )}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>

View file

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

View file

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

View file

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

View file

@ -38,7 +38,8 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionFooter SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
@ -438,6 +439,7 @@ export default function ResourceAuthenticationPage() {
setActiveRolesTagIndex setActiveRolesTagIndex
} }
placeholder="Select a role" placeholder="Select a role"
size="sm"
tags={ tags={
usersRolesForm.getValues() usersRolesForm.getValues()
.roles .roles
@ -466,14 +468,6 @@ export default function ResourceAuthenticationPage() {
true true
} }
sortTags={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> </FormControl>
<FormMessage /> <FormMessage />
@ -504,6 +498,7 @@ export default function ResourceAuthenticationPage() {
usersRolesForm.getValues() usersRolesForm.getValues()
.users .users
} }
size="sm"
setTags={( setTags={(
newUsers newUsers
) => { ) => {
@ -528,14 +523,6 @@ export default function ResourceAuthenticationPage() {
true true
} }
sortTags={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> </FormControl>
<FormMessage /> <FormMessage />
@ -582,7 +569,7 @@ export default function ResourceAuthenticationPage() {
</span> </span>
</div> </div>
<Button <Button
variant="outline" variant="outlinePrimary"
onClick={ onClick={
authInfo.password authInfo.password
? removeResourcePassword ? removeResourcePassword
@ -608,7 +595,7 @@ export default function ResourceAuthenticationPage() {
</span> </span>
</div> </div>
<Button <Button
variant="outline" variant="outlinePrimary"
onClick={ onClick={
authInfo.pincode authInfo.pincode
? removeResourcePincode ? removeResourcePincode
@ -664,6 +651,7 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={ activeTagIndex={
activeEmailTagIndex activeEmailTagIndex
} }
size={"sm"}
validateTag={( validateTag={(
tag tag
) => { ) => {
@ -708,14 +696,6 @@ export default function ResourceAuthenticationPage() {
false false
} }
sortTags={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> </FormControl>
<FormDescription> <FormDescription>

View file

@ -39,6 +39,7 @@ import {
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
@ -241,10 +242,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data); >(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId; target.targetId = res.data.data.targetId;
} else if (target.updated) { } else if (target.updated) {
await api.post( await api.post(`/target/${target.targetId}`, data);
`/target/${target.targetId}`,
data
);
} }
setTargets([ setTargets([
@ -261,9 +259,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) { for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`); await api.delete(`/target/${targetId}`);
setTargets( setTargets(targets.filter((t) => t.targetId !== targetId));
targets.filter((t) => t.targetId !== targetId)
);
} }
toast({ toast({
@ -459,7 +455,8 @@ export default function ReverseProxyTargets(props: {
SSL Configuration SSL Configuration
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
Setup SSL to secure your connections with Let's Encrypt certificates Setup SSL to secure your connections with Let's
Encrypt certificates
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@ -490,7 +487,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(addTarget)} onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4" 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 && ( {resource.http && (
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
@ -545,18 +542,6 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
<FormMessage /> <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> </FormItem>
)} )}
/> />
@ -575,83 +560,68 @@ export default function ReverseProxyTargets(props: {
/> />
</FormControl> </FormControl>
<FormMessage /> <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> </FormItem>
)} )}
/> />
<Button
type="submit"
variant="outlinePrimary"
className="mt-8"
>
Add Target
</Button>
</div> </div>
<Button type="submit" variant="outline">
Add Target
</Button>
</form> </form>
</Form> </Form>
<TableContainer> <Table>
<Table> <TableHeader>
<TableHeader> {table.getHeaderGroups().map((headerGroup) => (
{table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => (
{headerGroup.headers.map((header) => ( <TableHead key={header.id}>
<TableHead key={header.id}> {header.isPlaceholder
{header.isPlaceholder ? null
? null : flexRender(
: flexRender( header.column.columnDef
header.column .header,
.columnDef.header, header.getContext()
header.getContext() )}
)} </TableHead>
</TableHead> ))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))} ))}
</TableRow> </TableRow>
))} ))
</TableHeader> ) : (
<TableBody> <TableRow>
{table.getRowModel().rows?.length ? ( <TableCell
table.getRowModel().rows.map((row) => ( colSpan={columns.length}
<TableRow key={row.id}> className="h-24 text-center"
{row >
.getVisibleCells() No targets. Add a target using the form.
.map((cell) => ( </TableCell>
<TableCell key={cell.id}> </TableRow>
{flexRender( )}
cell.column </TableBody>
.columnDef.cell, <TableCaption>
cell.getContext() Adding more than one target above will enable load
)} balancing.
</TableCell> </TableCaption>
))} </Table>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the
form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load
balancing.
</p>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button

View file

@ -129,6 +129,7 @@ export default function GeneralForm() {
ListDomainsResponse["domains"] ListDomainsResponse["domains"]
>([]); >([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain" resource.isBaseDomain ? "basedomain" : "subdomain"
); );
@ -184,8 +185,14 @@ export default function GeneralForm() {
} }
}; };
fetchDomains(); const load = async () => {
fetchSites(); await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []); }, []);
async function onSubmit(data: GeneralFormValues) { async function onSubmit(data: GeneralFormValues) {
@ -263,391 +270,406 @@ export default function GeneralForm() {
} }
return ( return (
<SettingsContainer> !loadingPage && (
<SettingsSection> <SettingsContainer>
<SettingsSectionHeader> <SettingsSection>
<SettingsSectionTitle> <SettingsSectionHeader>
General Settings <SettingsSectionTitle>
</SettingsSectionTitle> General Settings
<SettingsSectionDescription> </SettingsSectionTitle>
Configure the general settings for this resource <SettingsSectionDescription>
</SettingsSectionDescription> Configure the general settings for this resource
</SettingsSectionHeader> </SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form} key={formKey}> <Form {...form} key={formKey}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form" id="general-settings-form"
> >
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<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) => {
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>
)}
{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
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="max-w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
value={
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 ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{!resource.http && (
<FormField <FormField
control={form.control} control={form.control}
name="proxyPort" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>Name</FormLabel>
Port Number
</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
type="number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter> {resource.http && (
<Button <>
type="submit" {env.flags
loading={saveLoading} .allowBaseDomainResources && (
disabled={saveLoading} <FormField
form="general-settings-form" control={form.control}
> name="isBaseDomain"
Save Settings render={({ field }) => (
</Button> <FormItem>
</SettingsSectionFooter> <FormLabel>
</SettingsSection> Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<SettingsSection> <div className="col-span-2">
<SettingsSectionHeader> {domainType === "subdomain" ? (
<SettingsSectionTitle> <div className="w-fill space-y-2">
Transfer Resource <FormLabel>
</SettingsSectionTitle> Subdomain
<SettingsSectionDescription> </FormLabel>
Transfer this resource to a different site <div className="flex">
</SettingsSectionDescription> <div className="w-1/2">
</SettingsSectionHeader> <FormField
control={
<SettingsSectionBody> form.control
<SettingsSectionForm> }
<Form {...transferForm}> name="subdomain"
<form render={({
onSubmit={transferForm.handleSubmit(onTransfer)} field
className="space-y-4" }) => (
id="transfer-form" <FormItem>
> <FormControl>
<FormField <Input
control={transferForm.control} {...field}
name="siteId" className="border-r-0 rounded-r-none"
render={({ field }) => ( />
<FormItem> </FormControl>
<FormLabel> <FormMessage />
Destination Site </FormItem>
</FormLabel> )}
<Popover />
open={open} </div>
onOpenChange={setOpen} <div className="w-1/2">
> <FormField
<PopoverTrigger asChild> control={
<FormControl> form.control
<Button }
variant="outline" name="domainId"
role="combobox" render={({
className={cn( field
"w-full justify-between", }) => (
!field.value && <FormItem>
"text-muted-foreground" <Select
)} onValueChange={
> field.onChange
{field.value }
? sites.find( defaultValue={
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites"
className="h-9"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value field.value
? "opacity-100" }
: "opacity-0" value={
)} field.value
/> }
</CommandItem> >
) <FormControl>
)} <SelectTrigger className="rounded-l-none">
</CommandGroup> <SelectValue />
</Command> </SelectTrigger>
</PopoverContent> </FormControl>
</Popover> <SelectContent>
<FormMessage /> {baseDomains.map(
</FormItem> (
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 ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)} )}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter> {!resource.http && (
<Button <FormField
type="submit" control={form.control}
loading={transferLoading} name="proxyPort"
disabled={transferLoading} render={({ field }) => (
form="transfer-form" <FormItem>
> <FormLabel>
Transfer Resource Port Number
</Button> </FormLabel>
</SettingsSectionFooter> <FormControl>
</SettingsSection> <Input
</SettingsContainer> type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)
); );
} }

View file

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

View file

@ -33,6 +33,7 @@ import {
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
@ -94,7 +95,7 @@ enum RuleAction {
enum RuleMatch { enum RuleMatch {
PATH = "Path", PATH = "Path",
IP = "IP", IP = "IP",
CIDR = "IP Range", CIDR = "IP Range"
} }
export default function ResourceRules(props: { export default function ResourceRules(props: {
@ -623,7 +624,7 @@ export default function ResourceRules(props: {
onSubmit={addRuleForm.handleSubmit(addRule)} onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4" 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 <FormField
control={addRuleForm.control} control={addRuleForm.control}
name="action" name="action"
@ -711,68 +712,63 @@ export default function ResourceRules(props: {
</FormItem> </FormItem>
)} )}
/> />
<Button
type="submit"
variant="outlinePrimary"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</div> </div>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</form> </form>
</Form> </Form>
<TableContainer> <Table>
<Table> <TableHeader>
<TableHeader> {table.getHeaderGroups().map((headerGroup) => (
{table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => (
{headerGroup.headers.map((header) => ( <TableHead key={header.id}>
<TableHead key={header.id}> {header.isPlaceholder
{header.isPlaceholder ? null
? null : flexRender(
: flexRender( header.column.columnDef
header.column .header,
.columnDef.header, header.getContext()
header.getContext() )}
)} </TableHead>
</TableHead> ))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))} ))}
</TableRow> </TableRow>
))} ))
</TableHeader> ) : (
<TableBody> <TableRow>
{table.getRowModel().rows?.length ? ( <TableCell
table.getRowModel().rows.map((row) => ( colSpan={columns.length}
<TableRow key={row.id}> className="h-24 text-center"
{row >
.getVisibleCells() No rules. Add a rule using the form.
.map((cell) => ( </TableCell>
<TableCell key={cell.id}> </TableRow>
{flexRender( )}
cell.column </TableBody>
.columnDef.cell, <TableCaption>
cell.getContext() Rules are evaluated by priority in ascending order.
)} </TableCaption>
</TableCell> </Table>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Rules are evaluated by priority in ascending order.
</p>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button

View file

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

View file

@ -273,7 +273,21 @@ export default function ShareLinksTable({
} }
return "Never"; 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 ( return (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,11 +37,11 @@ export default async function RootLayout({
> >
<EnvProvider env={pullEnv()}> <EnvProvider env={pullEnv()}>
{/* Main content */} {/* Main content */}
<div className="flex-grow">{children}</div> <div className="flex-grow pb-3 md:pb-0">{children}</div>
{/* Footer */} {/* Footer */}
<footer className="w-full mt-12 py-3 mb-6 px-4"> <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 select-none"> <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"> <div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span> <span>Pangolin</span>
</div> </div>

View file

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

View file

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

View file

@ -29,10 +29,8 @@ import {
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast"; 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 { 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"; import { CheckCircle2 } from "lucide-react";
const disableSchema = z.object({ const disableSchema = z.object({
@ -152,36 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Authenticator Code Authenticator Code
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<InputOTP <Input {...field} />
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>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -210,6 +179,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
)} )}
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{step === "password" && ( {step === "password" && (
<Button <Button
type="submit" type="submit"
@ -220,9 +192,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Disable 2FA Disable 2FA
</Button> </Button>
)} )}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>

View file

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

View file

@ -11,7 +11,7 @@ export default function SettingsSectionTitle({
}: SettingsSectionTitleProps) { }: SettingsSectionTitleProps) {
return ( return (
<div <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 <h2
className={`text-${ className={`text-${

View file

@ -26,7 +26,7 @@ export function SidebarSettings({
<aside className="lg:w-1/5"> <aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} /> <SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside> </aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""}`}> <div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children} {children}
</div> </div>
</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="w-full">
<div <div
className={cn( 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 styleClasses?.inlineTagsContainer
)} )}
> >
@ -644,7 +644,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
) : ( ) : (
<div <div
className={cn( 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 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" "bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
}, },
size: { size: {
sm: "text-xs h-7", sm: "text-xs h-6",
md: "text-sm h-8", md: "text-sm h-8",
lg: "text-base h-9", lg: "text-base h-9",
xl: "text-lg h-10" xl: "text-lg h-10"
@ -67,7 +67,7 @@ export const tagVariants = cva(
variant: "default", variant: "default",
size: "md", size: "md",
shape: "default", shape: "default",
borderStyle: "default", borderStyle: "none",
interaction: "nonClickable", interaction: "nonClickable",
animation: "fadeIn", animation: "fadeIn",
textStyle: "normal" textStyle: "normal"
@ -144,7 +144,7 @@ export const Tag: React.FC<TagProps> = ({
}} }}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
`py-1 px-3 h-full hover:bg-transparent`, `p-1 h-full hover:bg-transparent`,
tagClasses?.closeButton tagClasses?.closeButton
)} )}
> >

View file

@ -16,6 +16,8 @@ const buttonVariants = cva(
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground", "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: secondary:
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80", "bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",

View file

@ -41,7 +41,7 @@ const InputOTPSlot = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( 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", isActive && "z-10 ring-2 ring-ring ring-offset-background",
className className
)} )}

View file

@ -20,8 +20,8 @@ const SelectTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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", "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} {...props}
> >

View file

@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( 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 className
)} )}
{...props} {...props}
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( 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> </SwitchPrimitives.Root>

View file

@ -77,7 +77,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( 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: { variants: {
variant: { variant: {