minor visual improvements

This commit is contained in:
Milo Schwartz 2025-01-03 22:32:24 -05:00
parent c857a9bd76
commit 0e99e2b62b
No known key found for this signature in database
17 changed files with 285 additions and 270 deletions

View file

@ -4,7 +4,7 @@ Pangolin is a self-hosted tunneled reverse proxy management server with identity
### Installation and Documentation ### Installation and Documentation
- [Installation Instructions](https://docs.fossorial.io) - [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
- [Full Documentation](https://docs.fossorial.io) - [Full Documentation](https://docs.fossorial.io)
## Preview ## Preview
@ -112,7 +112,7 @@ Pangolin was inspired by several existing projects and concepts:
- **Cloudflare Tunnels**: - **Cloudflare Tunnels**:
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
- **Authentic and Authelia**: - **Authentik and Authelia**:
These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management. These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Licensing ## Licensing

View file

@ -1,6 +1,6 @@
app: app:
base_url: https://fossorial.io base_url: https://proxy.example.com
log_level: debug log_level: info
save_logs: false save_logs: false
server: server:
@ -20,7 +20,7 @@ traefik:
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: fossorial.io base_endpoint: proxy.example.com
use_subdomain: false use_subdomain: false
block_size: 16 block_size: 16
subnet_group: 10.0.0.0/8 subnet_group: 10.0.0.0/8
@ -33,9 +33,9 @@ rate_limits:
email: email:
smtp_host: host.hoster.net smtp_host: host.hoster.net
smtp_port: 587 smtp_port: 587
smtp_user: no-reply@example.io smtp_user: no-reply@example.com
smtp_pass: aaaaaaaaaaaaaaaaaa smtp_pass: aaaaaaaaaaaaaaaaaa
no_reply: no-reply@example.io no_reply: no-reply@example.com
users: users:
server_admin: server_admin:
@ -46,3 +46,4 @@ flags:
require_email_verification: true require_email_verification: true
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true

View file

@ -5,6 +5,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion"; import { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
@ -53,17 +54,17 @@ const environmentSchema = z.object({
}), }),
email: z email: z
.object({ .object({
smtp_host: z.string().optional(), smtp_host: z.string(),
smtp_port: portSchema.optional(), smtp_port: portSchema,
smtp_user: z.string().optional(), smtp_user: z.string(),
smtp_pass: z.string().optional(), smtp_pass: z.string(),
no_reply: z.string().email().optional() no_reply: z.string().email(),
}) })
.optional(), .optional(),
users: z.object({ users: z.object({
server_admin: z.object({ server_admin: z.object({
email: z.string().email(), email: z.string().email(),
password: z.string() password: passwordSchema
}) })
}), }),
flags: z flags: z

View file

@ -158,7 +158,7 @@ export default function DeleteRoleForm({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-8"> <div className="space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<p> <p>
You're about to delete the{" "} You're about to delete the{" "}

View file

@ -180,7 +180,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-8"> <div className="space-y-4">
{!inviteLink && ( {!inviteLink && (
<Form {...form}> <Form {...form}>
<form <form

View file

@ -6,7 +6,7 @@ import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";; import { formatAxiosError } from "@app/lib/api";
import { import {
GetResourceAuthInfoResponse, GetResourceAuthInfoResponse,
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
@ -383,7 +383,7 @@ export default function ResourceAuthenticationPage() {
)} )}
<div className="space-y-12"> <div className="space-y-12">
<section className="space-y-8 lg:max-w-2xl"> <section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Users & Roles" title="Users & Roles"
description="Configure which users and roles can visit this resource" description="Configure which users and roles can visit this resource"
@ -397,9 +397,7 @@ export default function ResourceAuthenticationPage() {
defaultChecked={resource.sso} defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)} onCheckedChange={(val) => setSsoEnabled(val)}
/> />
<Label htmlFor="sso-toggle"> <Label htmlFor="sso-toggle">Use Platform SSO</Label>
Use Platform SSO
</Label>
</div> </div>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
Existing users will only have to login once for all Existing users will only have to login once for all
@ -414,6 +412,8 @@ export default function ResourceAuthenticationPage() {
)} )}
className="space-y-4" className="space-y-4"
> >
{ssoEnabled && (
<>
<FormField <FormField
control={usersRolesForm.control} control={usersRolesForm.control}
name="roles" name="roles"
@ -444,8 +444,12 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
enableAutocomplete={true} enableAutocomplete={
autocompleteOptions={allRoles} true
}
autocompleteOptions={
allRoles
}
allowDuplicates={false} allowDuplicates={false}
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
@ -462,9 +466,10 @@ export default function ResourceAuthenticationPage() {
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
These roles will be able to access These roles will be able to
this resource. Admins can always access this resource. Admins
access this resource. can always access this
resource.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -500,8 +505,12 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
enableAutocomplete={true} enableAutocomplete={
autocompleteOptions={allUsers} true
}
autocompleteOptions={
allUsers
}
allowDuplicates={false} allowDuplicates={false}
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
@ -518,16 +527,19 @@ export default function ResourceAuthenticationPage() {
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Users added here will be able to Users added here will be
access this resource. A user will able to access this
always have access to a resource if resource. A user will always
they have a role that has access to have access to a resource if
it. they have a role that has
access to it.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</>
)}
<Button <Button
type="submit" type="submit"
loading={loadingSaveUsersRoles} loading={loadingSaveUsersRoles}
@ -541,7 +553,7 @@ export default function ResourceAuthenticationPage() {
<Separator /> <Separator />
<section className="space-y-8 lg:max-w-2xl"> <section className="space-y-4 lg:max-w-2xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Authentication Methods" title="Authentication Methods"
description="Allow access to the resource via additional auth methods" description="Allow access to the resource via additional auth methods"
@ -617,7 +629,7 @@ export default function ResourceAuthenticationPage() {
<Separator /> <Separator />
<section className="space-y-8 lg:max-w-2xl"> <section className="space-y-4 lg:max-w-2xl">
{env.EMAIL_ENABLED === "true" && ( {env.EMAIL_ENABLED === "true" && (
<> <>
<div> <div>
@ -636,9 +648,9 @@ export default function ResourceAuthenticationPage() {
</Label> </Label>
</div> </div>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
Enable resource whitelist to require email-based Enable resource whitelist to require
authentication (one-time passwords) for resource email-based authentication (one-time
access. passwords) for resource access.
</span> </span>
</div> </div>
@ -660,12 +672,15 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={ activeTagIndex={
activeEmailTagIndex activeEmailTagIndex
} }
validateTag={(tag) => { validateTag={(
tag
) => {
return z return z
.string() .string()
.email() .email()
.safeParse(tag) .safeParse(
.success; tag
).success;
}} }}
setActiveTagIndex={ setActiveTagIndex={
setActiveEmailTagIndex setActiveEmailTagIndex
@ -675,7 +690,9 @@ export default function ResourceAuthenticationPage() {
whitelistForm.getValues() whitelistForm.getValues()
.emails .emails
} }
setTags={(newRoles) => { setTags={(
newRoles
) => {
whitelistForm.setValue( whitelistForm.setValue(
"emails", "emails",
newRoles as [ newRoles as [
@ -684,7 +701,9 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
allowDuplicates={false} allowDuplicates={
false
}
sortTags={true} sortTags={true}
styleClasses={{ styleClasses={{
tag: { tag: {

View file

@ -304,8 +304,8 @@ export default function ReverseProxyTargets(props: {
{row.original.method} {row.original.method}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">HTTP</SelectItem> <SelectItem value="http">http</SelectItem>
<SelectItem value="https">HTTPS</SelectItem> <SelectItem value="https">https</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
@ -412,7 +412,7 @@ export default function ReverseProxyTargets(props: {
return ( return (
<> <>
<div className="space-y-12"> <div className="space-y-12">
<section className="space-y-8"> <section className="space-y-4">
<SettingsSectionTitle <SettingsSectionTitle
title="SSL" title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates" description="Setup SSL to secure your connections with LetsEncrypt certificates"
@ -431,14 +431,14 @@ export default function ReverseProxyTargets(props: {
<hr /> <hr />
<section className="space-y-8"> <section className="space-y-4">
<SettingsSectionTitle <SettingsSectionTitle
title="Targets" title="Targets"
description="Setup targets to route traffic to your services" description="Setup targets to route traffic to your services"
size="1xl" size="1xl"
/> />
<div className="space-y-8"> <div className="space-y-4">
<Form {...addTargetForm}> <Form {...addTargetForm}>
<form <form
onSubmit={addTargetForm.handleSubmit( onSubmit={addTargetForm.handleSubmit(
@ -470,18 +470,18 @@ export default function ReverseProxyTargets(props: {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http"> <SelectItem value="http">
HTTP http
</SelectItem> </SelectItem>
<SelectItem value="https"> <SelectItem value="https">
HTTPS https
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> {/* <FormDescription> */}
Choose the method for how {/* Choose the method for how */}
the target is accessed. {/* the target is accessed. */}
</FormDescription> {/* </FormDescription> */}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -497,10 +497,9 @@ export default function ReverseProxyTargets(props: {
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
<FormDescription> {/* <FormDescription> */}
Enter the IP address of the {/* Use the IP of the resource on your private network if using Newt, or the peer IP if using raw WireGuard. */}
target. {/* </FormDescription> */}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -519,10 +518,10 @@ export default function ReverseProxyTargets(props: {
required required
/> />
</FormControl> </FormControl>
<FormDescription> {/* <FormDescription> */}
Specify the port number for {/* Specify the port number for */}
the target. {/* the target. */}
</FormDescription> {/* </FormDescription> */}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -125,7 +125,7 @@ export default function GeneralForm() {
return ( return (
<> <>
<div className="space-y-12 lg:max-w-2xl"> <div className="space-y-12 lg:max-w-2xl">
<section className="space-y-8"> <section className="space-y-4">
<SettingsSectionTitle <SettingsSectionTitle
title="General Settings" title="General Settings"
description="Configure the general settings for this resource" description="Configure the general settings for this resource"

View file

@ -199,7 +199,7 @@ PersistentKeepalive = 5`
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`; const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
return ( return (
<div className="space-y-8"> <div className="space-y-4">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}

View file

@ -67,7 +67,7 @@ export default function GeneralPage() {
return ( return (
<> <>
<div className="space-y-8 max-w-xl"> <div className="space-y-4 max-w-xl">
<SettingsSectionTitle <SettingsSectionTitle
title="General Settings" title="General Settings"
description="Configure the general settings for this site" description="Configure the general settings for this site"

View file

@ -37,7 +37,7 @@ export default function DashboardLoginForm({
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>Login</CardTitle> <CardTitle>Welcome to Pangolin</CardTitle>
<CardDescription> <CardDescription>
Enter your credentials to access your dashboard Enter your credentials to access your dashboard
</CardDescription> </CardDescription>

View file

@ -456,7 +456,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin} disabled={loadingLogin}
> >
<LockIcon className="w-4 h-4 mr-2" /> <LockIcon className="w-4 h-4 mr-2" />
Login with Password Log In with Password
</Button> </Button>
</form> </form>
</Form> </Form>

View file

@ -91,7 +91,7 @@ export default function InviteStatusCard({
); );
} else if (type === "wrong_user") { } else if (type === "wrong_user") {
return ( return (
<Button onClick={goToLogin}>Login in as different user</Button> <Button onClick={goToLogin}>Log in as different user</Button>
); );
} else if (type === "user_does_not_exist") { } else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>Create an account</Button>; return <Button onClick={goToSignup}>Create an account</Button>;

View file

@ -5,10 +5,6 @@ import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider"; import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider"; import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { cache } from "react";
import { verifySession } from "@app/lib/auth/verifySession";
import Header from "@app/components/Header";
import UserProvider from "@app/providers/UserProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
@ -26,7 +22,7 @@ export default async function RootLayout({
return ( return (
<html suppressHydrationWarning> <html suppressHydrationWarning>
<body className={`${font.className}`}> <body className={`${font.className} min-h-screen flex flex-col`}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"
@ -34,23 +30,22 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<EnvProvider <EnvProvider
// it's import not to pass all of process.env here in case of secrets
// select only the necessary ones
env={{ env={{
NEXT_PORT: process.env.NEXT_PORT as string, NEXT_PORT: process.env.NEXT_PORT as string,
SERVER_EXTERNAL_PORT: process.env SERVER_EXTERNAL_PORT: process.env
.SERVER_EXTERNAL_PORT as string, .SERVER_EXTERNAL_PORT as string,
ENVIRONMENT: process.env.ENVIRONMENT as string, ENVIRONMENT: process.env.ENVIRONMENT as string,
EMAIL_ENABLED: process.env.EMAIL_ENABLED as string, EMAIL_ENABLED: process.env.EMAIL_ENABLED as string,
// optional
DISABLE_USER_CREATE_ORG: DISABLE_USER_CREATE_ORG:
process.env.DISABLE_USER_CREATE_ORG, process.env.DISABLE_USER_CREATE_ORG,
DISABLE_SIGNUP_WITHOUT_INVITE: DISABLE_SIGNUP_WITHOUT_INVITE:
process.env.DISABLE_SIGNUP_WITHOUT_INVITE process.env.DISABLE_SIGNUP_WITHOUT_INVITE
}} }}
> >
{children} {/* Main content */}
<div className="flex-grow">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-4"> <footer className="w-full mt-12 py-3 mb-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none"> <div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">

View file

@ -130,7 +130,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-4">
{!mfaRequested && ( {!mfaRequested && (
<Form {...form}> <Form {...form}>
<form <form
@ -179,7 +179,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`} href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
> >
Forgot password? Forgot your password?
</Link> </Link>
</div> </div>
</div> </div>
@ -279,7 +279,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
disabled={loading} disabled={loading}
> >
<LockIcon className="w-4 h-4 mr-2" /> <LockIcon className="w-4 h-4 mr-2" />
Login Log In
</Button> </Button>
)} )}
@ -293,7 +293,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
mfaForm.reset(); mfaForm.reset();
}} }}
> >
Back to Login Back to Log In
</Button> </Button>
)} )}
</div> </div>

View file

@ -10,9 +10,9 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success: success:
"border-green-500/50 text-green-500 dark:border-success [&>svg]:text-green-500", "border-green-500/50 bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
}, },
}, },
defaultVariants: { defaultVariants: {