mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-27 14:15:50 +02:00
Merge branch 'dev' into auth-providers-clients
This commit is contained in:
commit
160a7ff3db
20 changed files with 188 additions and 90 deletions
|
@ -122,8 +122,6 @@ You can use Pangolin as an easy way to expose your business applications to your
|
||||||
**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.
|
||||||
|
|
||||||
_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**:
|
||||||
|
|
|
@ -29,9 +29,12 @@ const configSchema = z.object({
|
||||||
.optional()
|
.optional()
|
||||||
.pipe(z.string().url())
|
.pipe(z.string().url())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
log_level: z
|
||||||
save_logs: z.boolean(),
|
.enum(["debug", "info", "warn", "error"])
|
||||||
log_failed_attempts: z.boolean().optional()
|
.optional()
|
||||||
|
.default("info"),
|
||||||
|
save_logs: z.boolean().optional().default(false),
|
||||||
|
log_failed_attempts: z.boolean().optional().default(false)
|
||||||
}),
|
}),
|
||||||
domains: z
|
domains: z
|
||||||
.record(
|
.record(
|
||||||
|
@ -41,8 +44,8 @@ const configSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.nonempty("base_domain must not be empty")
|
.nonempty("base_domain must not be empty")
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
cert_resolver: z.string().optional(),
|
cert_resolver: z.string().optional().default("letsencrypt"),
|
||||||
prefer_wildcard_cert: z.boolean().optional()
|
prefer_wildcard_cert: z.boolean().optional().default(false)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
|
@ -62,19 +65,42 @@ const configSchema = z.object({
|
||||||
server: z.object({
|
server: z.object({
|
||||||
integration_port: portSchema
|
integration_port: portSchema
|
||||||
.optional()
|
.optional()
|
||||||
|
.default(3003)
|
||||||
.transform(stoi)
|
.transform(stoi)
|
||||||
.pipe(portSchema.optional()),
|
.pipe(portSchema.optional()),
|
||||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
external_port: portSchema
|
||||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
.optional()
|
||||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
.default(3000)
|
||||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
.transform(stoi)
|
||||||
session_cookie_name: z.string(),
|
.pipe(portSchema),
|
||||||
resource_access_token_param: z.string(),
|
internal_port: portSchema
|
||||||
resource_access_token_headers: z.object({
|
.optional()
|
||||||
id: z.string(),
|
.default(3001)
|
||||||
token: z.string()
|
.transform(stoi)
|
||||||
}),
|
.pipe(portSchema),
|
||||||
resource_session_request_param: z.string(),
|
next_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.default(3002)
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
|
internal_hostname: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("pangolin")
|
||||||
|
.transform((url) => url.toLowerCase()),
|
||||||
|
session_cookie_name: z.string().optional().default("p_session_token"),
|
||||||
|
resource_access_token_param: z.string().optional().default("p_token"),
|
||||||
|
resource_access_token_headers: z
|
||||||
|
.object({
|
||||||
|
id: z.string().optional().default("P-Access-Token-Id"),
|
||||||
|
token: z.string().optional().default("P-Access-Token")
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
resource_session_request_param: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("resource_session_request_param"),
|
||||||
dashboard_session_length_hours: z
|
dashboard_session_length_hours: z
|
||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.3.0";
|
export const APP_VERSION = "1.3.2";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -208,8 +208,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||||
const ipVersion = detectIpVersion(ip);
|
const ipVersion = detectIpVersion(ip);
|
||||||
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||||
|
|
||||||
|
// If IP versions don't match, the IP cannot be in the CIDR range
|
||||||
if (ipVersion !== cidrVersion) {
|
if (ipVersion !== cidrVersion) {
|
||||||
throw new Error('IP address and CIDR must be of the same version');
|
// throw new Erorr
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
|
|
|
@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidUrlGlobPattern(pattern: string): boolean {
|
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
|
if (pattern === "/") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove leading slash if present
|
// Remove leading slash if present
|
||||||
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,78 @@
|
||||||
import { isPathAllowed } from './verifySession';
|
|
||||||
import { assertEquals } from '@test/assert';
|
import { assertEquals } from '@test/assert';
|
||||||
|
|
||||||
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
|
|
||||||
|
// Normalize and split paths into segments
|
||||||
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
|
|
||||||
|
// Recursive function to try different wildcard matches
|
||||||
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
|
const currentPathPart = pathParts[pathIndex];
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For full segment wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||||
|
if (currentPatternPart.includes("*")) {
|
||||||
|
// Convert the pattern segment to a regex pattern
|
||||||
|
const regexPattern = currentPatternPart
|
||||||
|
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||||
|
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`);
|
||||||
|
|
||||||
|
if (regex.test(currentPathPart)) {
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log('Running path matching tests...');
|
console.log('Running path matching tests...');
|
||||||
|
|
||||||
|
@ -56,6 +128,9 @@ function runTests() {
|
||||||
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
|
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
|
||||||
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
|
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
|
||||||
|
|
||||||
|
assertEquals(isPathAllowed('/', '/'), true, 'Root path should match root path');
|
||||||
|
assertEquals(isPathAllowed('/', '/test'), false, 'Root path should not match non-root path');
|
||||||
|
|
||||||
console.log('All tests passed!');
|
console.log('All tests passed!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ const bodySchema = z
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url.endsWith('/') ? url : `${url}/`;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerateOidcUrlResponse = {
|
export type GenerateOidcUrlResponse = {
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision";
|
||||||
import license from "@server/license/license";
|
import license from "@server/license/license";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url.endsWith("/") ? url : `${url}/`;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
|
@ -160,7 +160,9 @@ export async function validateOidcCallback(
|
||||||
);
|
);
|
||||||
|
|
||||||
const idToken = tokens.idToken();
|
const idToken = tokens.idToken();
|
||||||
|
logger.debug("ID token", { idToken });
|
||||||
const claims = arctic.decodeIdToken(idToken);
|
const claims = arctic.decodeIdToken(idToken);
|
||||||
|
logger.debug("ID token claims", { claims });
|
||||||
|
|
||||||
const userIdentifier = jmespath.search(
|
const userIdentifier = jmespath.search(
|
||||||
claims,
|
claims,
|
||||||
|
@ -243,7 +245,7 @@ export async function validateOidcCallback(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.UNAUTHORIZED,
|
HttpCode.UNAUTHORIZED,
|
||||||
"User not provisioned in the system"
|
`User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -318,8 +318,8 @@ async function updateHttpResource(
|
||||||
domainId: updatePayload.domainId,
|
domainId: updatePayload.domainId,
|
||||||
enabled: updatePayload.enabled,
|
enabled: updatePayload.enabled,
|
||||||
stickySession: updatePayload.stickySession,
|
stickySession: updatePayload.stickySession,
|
||||||
tlsServerName: updatePayload.tlsServerName || null,
|
tlsServerName: updatePayload.tlsServerName,
|
||||||
setHostHeader: updatePayload.setHostHeader || null,
|
setHostHeader: updatePayload.setHostHeader,
|
||||||
fullDomain: updatePayload.fullDomain
|
fullDomain: updatePayload.fullDomain
|
||||||
})
|
})
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
|
|
@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
|
||||||
AxiosResponse<CreateTargetResponse>
|
AxiosResponse<CreateTargetResponse>
|
||||||
>(`/resource/${params.resourceId}/target`, data);
|
>(`/resource/${params.resourceId}/target`, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
|
target.new = false;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
await api.post(`/target/${target.targetId}`, data);
|
await api.post(`/target/${target.targetId}`, data);
|
||||||
|
target.updated = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -363,12 +365,12 @@ export default function ReverseProxyTargets(props: {
|
||||||
setHttpsTlsLoading(true);
|
setHttpsTlsLoading(true);
|
||||||
await api.post(`/resource/${params.resourceId}`, {
|
await api.post(`/resource/${params.resourceId}`, {
|
||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || undefined
|
tlsServerName: data.tlsServerName || null
|
||||||
});
|
});
|
||||||
updateResource({
|
updateResource({
|
||||||
...resource,
|
...resource,
|
||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || undefined
|
tlsServerName: data.tlsServerName || null
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: "TLS settings updated",
|
title: "TLS settings updated",
|
||||||
|
@ -393,11 +395,11 @@ export default function ReverseProxyTargets(props: {
|
||||||
try {
|
try {
|
||||||
setProxySettingsLoading(true);
|
setProxySettingsLoading(true);
|
||||||
await api.post(`/resource/${params.resourceId}`, {
|
await api.post(`/resource/${params.resourceId}`, {
|
||||||
setHostHeader: data.setHostHeader || undefined
|
setHostHeader: data.setHostHeader || null
|
||||||
});
|
});
|
||||||
updateResource({
|
updateResource({
|
||||||
...resource,
|
...resource,
|
||||||
setHostHeader: data.setHostHeader || undefined
|
setHostHeader: data.setHostHeader || null
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: "Proxy settings updated",
|
title: "Proxy settings updated",
|
||||||
|
@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outlinePrimary"
|
variant="outlinePrimary"
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
|
disabled={
|
||||||
|
!(
|
||||||
|
addTargetForm.getValues("ip") &&
|
||||||
|
addTargetForm.getValues("port")
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Add Target
|
Add Target
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -64,7 +64,6 @@ import {
|
||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
|
|
|
@ -173,13 +173,15 @@ export default function Page() {
|
||||||
if (httpData.isBaseDomain) {
|
if (httpData.isBaseDomain) {
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId,
|
||||||
isBaseDomain: true
|
isBaseDomain: true,
|
||||||
|
protocol: "tcp"
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
subdomain: httpData.subdomain,
|
subdomain: httpData.subdomain,
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId,
|
||||||
isBaseDomain: false
|
isBaseDomain: false,
|
||||||
|
protocol: "tcp"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -137,8 +137,8 @@ export function SitePriceCalculator({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm mt-2 text-center">
|
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||||
For the most up-to-date pricing, please visit
|
For the most up-to-date pricing and discounts,
|
||||||
our{" "}
|
please visit the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.fossorial.io/pricing"
|
href="https://docs.fossorial.io/pricing"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
@ -452,6 +452,12 @@ export default function LicensePage() {
|
||||||
in system
|
in system
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!licenseStatus?.isHostLicensed && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
There is no limit on the number of sites
|
||||||
|
using an unlicensed host.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{licenseStatus?.maxSites && (
|
{licenseStatus?.maxSites && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
|
|
|
@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{user && (
|
{user && (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<div className="p-3">
|
<div className="p-3 ml-auto">
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|
|
@ -16,33 +16,7 @@ export function Breadcrumbs() {
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
|
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
|
||||||
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
||||||
let label = segment;
|
let label = decodeURIComponent(segment);
|
||||||
|
|
||||||
// // Format labels
|
|
||||||
// if (segment === "settings") {
|
|
||||||
// label = "Settings";
|
|
||||||
// } else if (segment === "sites") {
|
|
||||||
// label = "Sites";
|
|
||||||
// } else if (segment === "resources") {
|
|
||||||
// label = "Resources";
|
|
||||||
// } else if (segment === "access") {
|
|
||||||
// label = "Access Control";
|
|
||||||
// } else if (segment === "general") {
|
|
||||||
// label = "General";
|
|
||||||
// } else if (segment === "share-links") {
|
|
||||||
// label = "Shareable Links";
|
|
||||||
// } else if (segment === "users") {
|
|
||||||
// label = "Users";
|
|
||||||
// } else if (segment === "roles") {
|
|
||||||
// label = "Roles";
|
|
||||||
// } else if (segment === "invitations") {
|
|
||||||
// label = "Invitations";
|
|
||||||
// } else if (segment === "proxy") {
|
|
||||||
// label = "proxy";
|
|
||||||
// } else if (segment === "authentication") {
|
|
||||||
// label = "Authentication";
|
|
||||||
// }
|
|
||||||
|
|
||||||
return { label, href };
|
return { label, href };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -250,7 +250,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
if (e.target.value.length === 6) {
|
if (e.length === 6) {
|
||||||
mfaForm.handleSubmit(onSubmit)();
|
mfaForm.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -55,7 +55,7 @@ export function SettingsSectionFooter({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
|
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionGrid({
|
export function SettingsSectionGrid({
|
||||||
|
|
|
@ -189,10 +189,12 @@ export default function SupporterStatus() {
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<p>
|
<p>
|
||||||
Purchase a supporter key to help us continue
|
Purchase a supporter key to help us continue
|
||||||
developing Pangolin. Your contribution allows us
|
developing Pangolin for the community. Your
|
||||||
commit more time to maintain and add new features to
|
contribution allows us to commit more time to
|
||||||
the application for everyone. We will never use this
|
maintain and add new features to the application for
|
||||||
to paywall features.
|
everyone. We will never use this to paywall
|
||||||
|
features. This is separate from the Professional
|
||||||
|
Edition.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
"fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue