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

@ -84,7 +84,7 @@ export default function GeneralPage() {
const res = await api.get<AxiosResponse<ListOrgsResponse>>( const res = await api.get<AxiosResponse<ListOrgsResponse>>(
`/orgs` `/orgs`
); );
if (res.status === 200) { if (res.status === 200) {
if (res.data.data.orgs.length > 0) { if (res.data.data.orgs.length > 0) {
const orgId = res.data.data.orgs[0].orgId; const orgId = res.data.data.orgs[0].orgId;

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,120 +412,134 @@ export default function ResourceAuthenticationPage() {
)} )}
className="space-y-4" className="space-y-4"
> >
<FormField {ssoEnabled && (
control={usersRolesForm.control} <>
name="roles" <FormField
render={({ field }) => ( control={usersRolesForm.control}
<FormItem className="flex flex-col items-start"> name="roles"
<FormLabel>Roles</FormLabel> render={({ field }) => (
<FormControl> <FormItem className="flex flex-col items-start">
{/* @ts-ignore */} <FormLabel>Roles</FormLabel>
<TagInput <FormControl>
{...field} {/* @ts-ignore */}
activeTagIndex={ <TagInput
activeRolesTagIndex {...field}
} activeTagIndex={
setActiveTagIndex={ activeRolesTagIndex
setActiveRolesTagIndex }
} setActiveTagIndex={
placeholder="Enter a role" setActiveRolesTagIndex
tags={ }
usersRolesForm.getValues() placeholder="Enter a role"
.roles tags={
} usersRolesForm.getValues()
setTags={(newRoles) => { .roles
usersRolesForm.setValue( }
"roles", setTags={(newRoles) => {
newRoles as [ usersRolesForm.setValue(
Tag, "roles",
...Tag[] newRoles as [
] Tag,
); ...Tag[]
}} ]
enableAutocomplete={true} );
autocompleteOptions={allRoles} }}
allowDuplicates={false} enableAutocomplete={
restrictTagsToAutocompleteOptions={ true
true }
} autocompleteOptions={
sortTags={true} allRoles
styleClasses={{ }
tag: { allowDuplicates={false}
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" restrictTagsToAutocompleteOptions={
}, true
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", }
inlineTagsContainer: sortTags={true}
"bg-transparent p-2" styleClasses={{
}} tag: {
/> body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
</FormControl> },
<FormDescription> input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
These roles will be able to access inlineTagsContainer:
this resource. Admins can always "bg-transparent p-2"
access this resource. }}
</FormDescription> />
<FormMessage /> </FormControl>
</FormItem> <FormDescription>
)} These roles will be able to
/> access this resource. Admins
<FormField can always access this
control={usersRolesForm.control} resource.
name="users" </FormDescription>
render={({ field }) => ( <FormMessage />
<FormItem className="flex flex-col items-start"> </FormItem>
<FormLabel>Users</FormLabel> )}
<FormControl> />
{/* @ts-ignore */} <FormField
<TagInput control={usersRolesForm.control}
{...field} name="users"
activeTagIndex={ render={({ field }) => (
activeUsersTagIndex <FormItem className="flex flex-col items-start">
} <FormLabel>Users</FormLabel>
setActiveTagIndex={ <FormControl>
setActiveUsersTagIndex {/* @ts-ignore */}
} <TagInput
placeholder="Enter a user" {...field}
tags={ activeTagIndex={
usersRolesForm.getValues() activeUsersTagIndex
.users }
} setActiveTagIndex={
setTags={(newUsers) => { setActiveUsersTagIndex
usersRolesForm.setValue( }
"users", placeholder="Enter a user"
newUsers as [ tags={
Tag, usersRolesForm.getValues()
...Tag[] .users
] }
); setTags={(newUsers) => {
}} usersRolesForm.setValue(
enableAutocomplete={true} "users",
autocompleteOptions={allUsers} newUsers as [
allowDuplicates={false} Tag,
restrictTagsToAutocompleteOptions={ ...Tag[]
true ]
} );
sortTags={true} }}
styleClasses={{ enableAutocomplete={
tag: { true
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" }
}, autocompleteOptions={
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", allUsers
inlineTagsContainer: }
"bg-transparent p-2" allowDuplicates={false}
}} restrictTagsToAutocompleteOptions={
/> true
</FormControl> }
<FormDescription> sortTags={true}
Users added here will be able to styleClasses={{
access this resource. A user will tag: {
always have access to a resource if body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
they have a role that has access to },
it. input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
</FormDescription> inlineTagsContainer:
<FormMessage /> "bg-transparent p-2"
</FormItem> }}
)} />
/> </FormControl>
<FormDescription>
Users added here will be
able to access this
resource. A user will always
have access to a resource if
they have a role that has
access to it.
</FormDescription>
<FormMessage />
</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,101 +629,108 @@ 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>
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
<Switch <Switch
id="whitelist-toggle" id="whitelist-toggle"
defaultChecked={ defaultChecked={
resource.emailWhitelistEnabled resource.emailWhitelistEnabled
} }
onCheckedChange={(val) => onCheckedChange={(val) =>
setWhitelistEnabled(val) setWhitelistEnabled(val)
} }
/>
<Label htmlFor="whitelist-toggle">
Email Whitelist
</Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require email-based
authentication (one-time passwords) for resource
access.
</span>
</div>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-4">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(tag) => {
return z
.string()
.email()
.safeParse(tag)
.success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(newRoles) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={false}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
</FormItem>
)}
/> />
</form> <Label htmlFor="whitelist-toggle">
</Form> Email Whitelist
)} </Label>
</div>
<span className="text-muted-foreground text-sm">
Enable resource whitelist to require
email-based authentication (one-time
passwords) for resource access.
</span>
</div>
<Button {whitelistEnabled && (
loading={loadingSaveWhitelist} <Form {...whitelistForm}>
disabled={loadingSaveWhitelist} <form className="space-y-4">
onClick={saveWhitelist} <FormField
> control={whitelistForm.control}
Save Whitelist name="emails"
</Button> render={({ field }) => (
</> <FormItem className="flex flex-col items-start">
)} <FormLabel>
Whitelisted Emails
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
validateTag={(
tag
) => {
return z
.string()
.email()
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)}
<Button
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
onClick={saveWhitelist}
>
Save Whitelist
</Button>
</>
)}
</section> </section>
</div> </div>
</> </>

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

@ -401,7 +401,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 PIN Log in with PIN
</Button> </Button>
</form> </form>
</Form> </Form>
@ -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: {