Compare commits

...

3 Commits

Author SHA1 Message Date
8ff4f9be1c docs: add documentation on registration disable feature 2025-04-08 16:54:55 -04:00
192e38cd67 feat: allow registration disabling 2025-04-08 16:54:38 -04:00
064b17fc88 chore: bump 2025-04-08 16:53:44 -04:00
7 changed files with 266 additions and 191 deletions

View File

@ -31,12 +31,19 @@ export default async function Login() {
<SiAuthentik /> <SiAuthentik />
Sign in with Authentik Sign in with Authentik
</Button> </Button>
<Link href="/account/signup" className="text-sm underline"> {process.env.SIGNUP_ENABLED === "true" ? (
<Button variant="outline" className="w-full"> <Link href="/account/signup">
<Button variant="outline" className="w-full">
<UserPlus />
Create an Account
</Button>
</Link>
) : (
<Button variant="outline" className="w-full cursor-not-allowed" disabled>
<UserPlus /> <UserPlus />
Create an Account Registration is Closed
</Button> </Button>
</Link> )}
</form> </form>
</CardContent> </CardContent>
<CardFooter className="justify-center"> <CardFooter className="justify-center">

View File

@ -0,0 +1,17 @@
import React from "react"
export default function SignupLayout({
children,
}: {
children: React.ReactNode
}) {
if (process.env.SIGNUP_ENABLED === "false") {
return <div>Signup is disabled</div>
} else if (process.env.SIGNUP_ENABLED === "true") {
return (
<div className="min-h-screen bg-background">{children}</div>
)
} else {
return <div>Invalid SIGNUP_ENABLED environment variable</div>
}
}

View File

@ -7,166 +7,179 @@ import { validateToken } from "@/lib/utils"
// (2) Migrate a p0ntus mail account to a LibreCloud account (creates/migrates Authentik/Email) // (2) Migrate a p0ntus mail account to a LibreCloud account (creates/migrates Authentik/Email)
async function createEmail(email: string, password: string, migrate: boolean) { async function createEmail(email: string, password: string, migrate: boolean) {
try { // Signup status check
if (!process.env.MAIL_CONNECT_API_URL) { if (process.env.SIGNUP_ENABLED === "false") {
console.error("[!] Missing MAIL_CONNECT_API_URL environment variable") return { success: false, message: "Signups are disabled" }
return { success: false, message: "Server configuration error" } } else if (process.env.SIGNUP_ENABLED === "true") {
} try {
if (!process.env.MAIL_CONNECT_API_URL) {
const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, { console.error("[!] Missing MAIL_CONNECT_API_URL environment variable")
email, return { success: false, message: "Server configuration error" }
password, } else {
migrate, const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, {
}) email,
password,
const responseData = response.data migrate,
if (responseData.success) { })
return response.data
} else if (responseData.error) { const responseData = response.data
console.error("[!] Email creation failed:", responseData.error) if (responseData.success) {
return { success: false, message: responseData.error } return response.data
} else { } else if (responseData.error) {
console.error("[!] Email creation failed with unknown error") console.error("[!] Email creation failed:", responseData.error)
return { success: false, message: responseData.error }
} else {
console.error("[!] Email creation failed with unknown error")
return { success: false, message: "Failed to create email account" }
}
}
} catch (error) {
console.error("[!] Email creation error:", error)
if (axios.isAxiosError(error)) {
return {
success: false,
message: error.response?.data?.error || "Failed to connect to email service",
}
}
return { success: false, message: "Failed to create email account" } return { success: false, message: "Failed to create email account" }
} }
} catch (error) { } else {
console.error("[!] Email creation error:", error) return { success: false, message: "Account signup is not configured in your environment variables!" }
if (axios.isAxiosError(error)) {
return {
success: false,
message: error.response?.data?.error || "Failed to connect to email service",
}
}
return { success: false, message: "Failed to create email account" }
} }
} }
export async function POST(request: Request) { export async function POST(request: Request) {
try { if (process.env.SIGNUP_ENABLED === "true") {
const body = await request.json() try {
const { name, email, password, migrate, token } = body const body = await request.json()
const { name, email, password, migrate, token } = body
// Validate fields // Validate fields
if (!name || !email || !password) { if (!name || !email || !password) {
return NextResponse.json({ success: false, message: "The form you submitted is incomplete" }, { status: 400 }) return NextResponse.json({ success: false, message: "The form you submitted is incomplete" }, { status: 400 })
}
const tokenValidation = await validateToken(token)
if (!tokenValidation.success) {
console.error("Turnstile validation failed:", tokenValidation.error)
return NextResponse.json({ success: false, message: "Robot check failed, try refreshing" }, { status: 400 })
}
if (!process.env.AUTHENTIK_API_URL || !process.env.AUTHENTIK_API_KEY) {
console.error("Missing Authentik environment variables")
return NextResponse.json({ success: false, message: "Server configuration error" }, { status: 500 })
}
// Create Authentik user
const genUser = email.split("@")[0]
const userData = {
username: genUser,
name,
is_active: true,
groups: [
"b2c38bad-1d15-4ffd-b6d4-d95370a092ca" // this represents the "Users" group in Authentik
],
email,
type: "internal",
}
console.log("[i] Creating user in Authentik:", { username: genUser, email })
const response = await axios.request({
method: "post",
maxBodyLength: Infinity,
url: `${process.env.AUTHENTIK_API_URL}/core/users/`,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`,
},
data: JSON.stringify(userData),
validateStatus: () => true, // capture response even for error status codes
})
if (response.data?.detail) {
console.error("[!] Authentik user creation issue:", response.data.detail)
}
if (response.status !== 201) {
if (response.data.username && response.data.username[0] === "This field must be unique.") {
return NextResponse.json({ success: false, message: "Username already exists" }, { status: 409 })
} }
console.error("Failed to create user in Authentik:", response.status, response.data) const tokenValidation = await validateToken(token)
return NextResponse.json({ success: false, message: "Failed to create user account" }, { status: 500 }) if (!tokenValidation.success) {
} console.error("Turnstile validation failed:", tokenValidation.error)
return NextResponse.json({ success: false, message: "Robot check failed, try refreshing" }, { status: 400 })
// User created successfully, now set password
const userID = response.data.pk
const updData = {
password,
}
console.log("[i] Setting password for user:", userID)
const updCfg = await axios.request({
method: "post",
maxBodyLength: Number.POSITIVE_INFINITY,
url: `${process.env.AUTHENTIK_API_URL}/core/users/${userID}/set_password/`,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`,
},
data: updData,
validateStatus: () => true, // capture response even for error status codes
})
if (updCfg.data?.detail) {
console.error("[!] Password setting issue:", updCfg.data.detail)
}
if (updCfg.status === 204) {
// account created successfully, now create email
console.log("[i] Creating email account for:", email)
const emailRes = await createEmail(email, password, migrate)
if (emailRes.success) {
console.log("[S] Account creation successful for:", email)
} else {
console.error("[!] Email creation failed for:", email, emailRes.message)
} }
return NextResponse.json(emailRes, { if (!process.env.AUTHENTIK_API_URL || !process.env.AUTHENTIK_API_KEY) {
status: emailRes.success ? 200 : 500, console.error("Missing Authentik environment variables")
return NextResponse.json({ success: false, message: "Server configuration error" }, { status: 500 })
}
// Create Authentik user
const genUser = email.split("@")[0]
const userData = {
username: genUser,
name,
is_active: true,
groups: [
"b2c38bad-1d15-4ffd-b6d4-d95370a092ca" // this represents the "Users" group in Authentik
],
email,
type: "internal",
}
console.log("[i] Creating user in Authentik:", { username: genUser, email })
const response = await axios.request({
method: "post",
maxBodyLength: Infinity,
url: `${process.env.AUTHENTIK_API_URL}/core/users/`,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`,
},
data: JSON.stringify(userData),
validateStatus: () => true, // capture response even for error status codes
}) })
} else if (updCfg.status === 400) {
console.error("[!] Failed to set password:", updCfg.data)
return NextResponse.json({ success: false, message: "Invalid password format" }, { status: 400 })
} else {
console.error("[!] Unknown error setting password:", updCfg.status, updCfg.data)
return NextResponse.json({ success: false, message: "Failed to complete account setup" }, { status: 500 })
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
if (error.response?.data?.detail) {
console.error("[!] Request error with detail:", error.response.data.detail)
return NextResponse.json({ success: false, message: "Server error - Failed to create user" }, { status: 500 })
} else if (error.response?.data?.error) {
console.error("[!] Request error (passed from Authentik):", error.response.data.error)
return NextResponse.json({ success: false, message: error.response.data.error }, { status: 500 })
} else if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
console.error("[!] Connection error:", error.message)
return NextResponse.json(
{ success: false, message: "Failed to connect to authentication service" },
{ status: 503 },
)
}
}
console.error("[!] Unhandled error while creating user:", error) if (response.data?.detail) {
return NextResponse.json({ success: false, message: "An unexpected error occurred" }, { status: 500 }) console.error("[!] Authentik user creation issue:", response.data.detail)
}
if (response.status !== 201) {
if (response.data.username && response.data.username[0] === "This field must be unique.") {
return NextResponse.json({ success: false, message: "Username already exists" }, { status: 409 })
}
console.error("Failed to create user in Authentik:", response.status, response.data)
return NextResponse.json({ success: false, message: "Failed to create user account" }, { status: 500 })
}
// User created successfully, now set password
const userID = response.data.pk
const updData = {
password,
}
console.log("[i] Setting password for user:", userID)
const updCfg = await axios.request({
method: "post",
maxBodyLength: Number.POSITIVE_INFINITY,
url: `${process.env.AUTHENTIK_API_URL}/core/users/${userID}/set_password/`,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`,
},
data: updData,
validateStatus: () => true, // capture response even for error status codes
})
if (updCfg.data?.detail) {
console.error("[!] Password setting issue:", updCfg.data.detail)
}
if (updCfg.status === 204) {
// account created successfully, now create email
console.log("[i] Creating email account for:", email)
const emailRes = await createEmail(email, password, migrate)
if (emailRes.success) {
console.log("[S] Account creation successful for:", email)
} else {
console.error("[!] Email creation failed for:", email, emailRes.message)
}
return NextResponse.json(emailRes, {
status: emailRes.success ? 200 : 500,
})
} else if (updCfg.status === 400) {
console.error("[!] Failed to set password:", updCfg.data)
return NextResponse.json({ success: false, message: "Invalid password format" }, { status: 400 })
} else {
console.error("[!] Unknown error setting password:", updCfg.status, updCfg.data)
return NextResponse.json({ success: false, message: "Failed to complete account setup" }, { status: 500 })
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
if (error.response?.data?.detail) {
console.error("[!] Request error with detail:", error.response.data.detail)
return NextResponse.json({ success: false, message: "Server error - Failed to create user" }, { status: 500 })
} else if (error.response?.data?.error) {
console.error("[!] Request error (passed from Authentik):", error.response.data.error)
return NextResponse.json({ success: false, message: error.response.data.error }, { status: 500 })
} else if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
console.error("[!] Connection error:", error.message)
return NextResponse.json(
{ success: false, message: "Failed to connect to authentication service" },
{ status: 503 },
)
}
}
console.error("[!] Unhandled error while creating user:", error)
return NextResponse.json({ success: false, message: "An unexpected error occurred" }, { status: 500 })
}
} else if (process.env.SIGNUP_ENABLED === "false") {
return NextResponse.json({ success: false, message: "Signups are disabled" }, { status: 403 })
} else {
return NextResponse.json({ success: false, message: "Account signup is not configured in your environment variables!" }, { status: 500 })
} }
} }

View File

@ -1,7 +1,7 @@
"use client" "use client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react" import { ArrowRight, XCircle } from "lucide-react"
import { ReactTyped } from "react-typed" import { ReactTyped } from "react-typed"
import Link from "next/link"; import Link from "next/link";
@ -23,12 +23,19 @@ const Hero = () => {
</p> </p>
<div className="mt-10 max-w-md mx-auto sm:flex sm:justify-center"> <div className="mt-10 max-w-md mx-auto sm:flex sm:justify-center">
<div className="rounded-md shadow-sm"> <div className="rounded-md shadow-sm">
<Link href="/account/login"> {process.env.SIGNUP_ENABLED === "true" ? (
<Button className="py-6 px-8"> <Link href="/account/login">
Get started <Button className="py-6 px-8">
<ArrowRight className="ml-2 h-5 w-5" /> Get Started
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
) : (
<Button className="py-6 px-8 cursor-not-allowed" disabled>
<XCircle className="mr-2 h-5 w-5" />
Registration Closed
</Button> </Button>
</Link> )}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { Check, ChevronRight, Clock } from "lucide-react" import { Check, ChevronRight, Clock, XCircle } from "lucide-react"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -145,6 +145,21 @@ const PricingCard: React.FC<PricingCardProps> = ({
)} )}
</div> </div>
<div> <div>
{title !== "Everything" ? (
<motion.span
variants={textVariants}
className="text-sm text-muted-foreground block mb-1 group-hover:text-muted-foreground/90 transition-colors duration-300"
>
Starting at
</motion.span>
) : (
<motion.span
variants={textVariants}
className="text-sm text-muted-foreground italic block mb-1 group-hover:text-muted-foreground/90 transition-colors duration-300"
>
Always
</motion.span>
)}
<motion.span <motion.span
variants={textVariants} variants={textVariants}
className="text-6xl font-bold text-foreground group-hover:text-foreground/90 transition-colors duration-300" className="text-6xl font-bold text-foreground group-hover:text-foreground/90 transition-colors duration-300"
@ -166,15 +181,22 @@ const PricingCard: React.FC<PricingCardProps> = ({
</motion.p> </motion.p>
{isComingSoon ? ( {isComingSoon ? (
<Button className="w-full" size="lg" variant="outline" disabled> <Button className="w-full" size="lg" variant="outline" disabled>
<Clock className="mr-2" /> Coming Soon <Clock /> Coming Soon
</Button> </Button>
) : buttonText ? ( ) : buttonText ? (
<Link href="/account/login" className="block"> process.env.SIGNUP_ENABLED === "true" ? (
<Button className="w-full group" size="lg"> <Link href="/account/login" className="block">
{buttonText} <Button className="w-full group" size="lg">
{buttonIcon} {buttonText}
{buttonIcon}
</Button>
</Link>
) : (
<Button className="w-full" size="lg" variant="outline" disabled>
<XCircle className="h-5 w-5" />
Registration Closed
</Button> </Button>
</Link> )
) : null} ) : null}
</div> </div>
<Separator className="bg-border" /> <Separator className="bg-border" />
@ -215,7 +237,7 @@ export default function Pricing(): React.ReactElement {
features={features.everything} features={features.everything}
badge="Most Popular" badge="Most Popular"
buttonText="Get Started" buttonText="Get Started"
buttonIcon={<ChevronRight className="ml-2 h-4 w-4" />} buttonIcon={<ChevronRight className="h-4 w-4" />}
/> />
<PricingCard <PricingCard

View File

@ -4,6 +4,15 @@ At the time of writing, LibreCloud is not in the state of perfection,
and as such we are expecting that you have a setup exact to ours. and as such we are expecting that you have a setup exact to ours.
While this will change in the future, we still suggest that provide all the listed environment variables. While this will change in the future, we still suggest that provide all the listed environment variables.
## Primary
These are the environment variables which handle how `librecloud/web` functions.
With these variables, you can disable entire parts of the dashboard, such as registration.
| Environment Variable | Description | Expected Value |
|----------------------|-----------------------------------------------------------|----------------------------------------|
| SIGNUP_ENABLED | Controls if the signup page and APIs are enabled/disabled | `true` (Enabled) or `false` (Disabled) |
## Authentik ## Authentik
We use [Auth.js](https://authjs.dev) to provide authentication for users through Authentik. We use [Auth.js](https://authjs.dev) to provide authentication for users through Authentik.

View File

@ -10,20 +10,20 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.6.0",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-radio-group": "^1.2.4",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.2.0",
"@web3icons/react": "^4.0.10", "@web3icons/react": "^4.0.13",
"axios": "^1.8.4", "axios": "^1.8.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -32,16 +32,16 @@
"geist": "^1.3.1", "geist": "^1.3.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"motion": "^12.6.2", "motion": "^12.6.3",
"next": "^15.2.4", "next": "^15.2.4",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"next-turnstile": "^1.0.2", "next-turnstile": "^1.0.2",
"password-validator": "^5.3.0", "password-validator": "^5.3.0",
"prisma": "^6.5.0", "prisma": "^6.6.0",
"react": "^19.0.0", "react": "^19.1.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.55.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-typed": "^2.0.12", "react-typed": "^2.0.12",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
@ -50,16 +50,16 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.17", "@tailwindcss/postcss": "^4.1.3",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.28", "@types/node": "^20.17.30",
"@types/react": "^19.0.12", "@types/react": "^19.1.0",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.1",
"@types/validator": "^13.12.3", "@types/validator": "^13.12.3",
"eslint": "^9.23.0", "eslint": "^9.24.0",
"eslint-config-next": "15.1.6", "eslint-config-next": "15.1.6",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.0.17", "tailwindcss": "^4.1.3",
"typescript": "^5.8.2" "typescript": "^5.8.3"
} }
} }