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 />
Sign in with Authentik
</Button>
<Link href="/account/signup" className="text-sm underline">
<Button variant="outline" className="w-full">
{process.env.SIGNUP_ENABLED === "true" ? (
<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 />
Create an Account
Registration is Closed
</Button>
</Link>
)}
</form>
</CardContent>
<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)
async function createEmail(email: string, password: string, migrate: boolean) {
try {
if (!process.env.MAIL_CONNECT_API_URL) {
console.error("[!] Missing MAIL_CONNECT_API_URL environment variable")
return { success: false, message: "Server configuration error" }
}
const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, {
email,
password,
migrate,
})
const responseData = response.data
if (responseData.success) {
return response.data
} else if (responseData.error) {
console.error("[!] Email creation failed:", responseData.error)
return { success: false, message: responseData.error }
} else {
console.error("[!] Email creation failed with unknown error")
// Signup status check
if (process.env.SIGNUP_ENABLED === "false") {
return { success: false, message: "Signups are disabled" }
} else if (process.env.SIGNUP_ENABLED === "true") {
try {
if (!process.env.MAIL_CONNECT_API_URL) {
console.error("[!] Missing MAIL_CONNECT_API_URL environment variable")
return { success: false, message: "Server configuration error" }
} else {
const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, {
email,
password,
migrate,
})
const responseData = response.data
if (responseData.success) {
return response.data
} else if (responseData.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" }
}
} 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" }
} else {
return { success: false, message: "Account signup is not configured in your environment variables!" }
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, email, password, migrate, token } = body
if (process.env.SIGNUP_ENABLED === "true") {
try {
const body = await request.json()
const { name, email, password, migrate, token } = body
// Validate fields
if (!name || !email || !password) {
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 })
// Validate fields
if (!name || !email || !password) {
return NextResponse.json({ success: false, message: "The form you submitted is incomplete" }, { status: 400 })
}
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)
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 })
}
return NextResponse.json(emailRes, {
status: emailRes.success ? 200 : 500,
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
})
} 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 })
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)
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"
import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react"
import { ArrowRight, XCircle } from "lucide-react"
import { ReactTyped } from "react-typed"
import Link from "next/link";
@ -23,12 +23,19 @@ const Hero = () => {
</p>
<div className="mt-10 max-w-md mx-auto sm:flex sm:justify-center">
<div className="rounded-md shadow-sm">
<Link href="/account/login">
<Button className="py-6 px-8">
Get started
<ArrowRight className="ml-2 h-5 w-5" />
{process.env.SIGNUP_ENABLED === "true" ? (
<Link href="/account/login">
<Button className="py-6 px-8">
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>
</Link>
)}
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
"use client"
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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -145,6 +145,21 @@ const PricingCard: React.FC<PricingCardProps> = ({
)}
</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
variants={textVariants}
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>
{isComingSoon ? (
<Button className="w-full" size="lg" variant="outline" disabled>
<Clock className="mr-2" /> Coming Soon
<Clock /> Coming Soon
</Button>
) : buttonText ? (
<Link href="/account/login" className="block">
<Button className="w-full group" size="lg">
{buttonText}
{buttonIcon}
process.env.SIGNUP_ENABLED === "true" ? (
<Link href="/account/login" className="block">
<Button className="w-full group" size="lg">
{buttonText}
{buttonIcon}
</Button>
</Link>
) : (
<Button className="w-full" size="lg" variant="outline" disabled>
<XCircle className="h-5 w-5" />
Registration Closed
</Button>
</Link>
)
) : null}
</div>
<Separator className="bg-border" />
@ -215,7 +237,7 @@ export default function Pricing(): React.ReactElement {
features={features.everything}
badge="Most Popular"
buttonText="Get Started"
buttonIcon={<ChevronRight className="ml-2 h-4 w-4" />}
buttonIcon={<ChevronRight className="h-4 w-4" />}
/>
<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.
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
We use [Auth.js](https://authjs.dev) to provide authentication for users through Authentik.

View File

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