diff --git a/app/account/dashboard/settings/page.tsx b/app/account/dashboard/settings/page.tsx index f8f6194..44d84e4 100644 --- a/app/account/dashboard/settings/page.tsx +++ b/app/account/dashboard/settings/page.tsx @@ -5,7 +5,18 @@ import { Label } from "@/components/ui/label" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import MyAccount from "@/components/cards/dashboard/Settings/MyAccount" import { useState, useEffect } from "react" -import { LayoutDashboard } from "lucide-react" +import { LayoutDashboard, Trash, Loader2, Check, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Dialog } from "@/components/ui/dialog" +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { signOut } from "next-auth/react" +import { useRouter } from "next/navigation" export default function Settings() { const [settings, setSettings] = useState({ @@ -14,6 +25,49 @@ export default function Settings() { hideCrypto: false }); const [loading, setLoading] = useState(false) + const [deleteOTP, setDeleteOTP] = useState('') + const [deleteOTPLoading, setDeleteOTPLoading] = useState(false) + const [cooldownSeconds, setCooldownSeconds] = useState(0) + const [otpError, setOtpError] = useState('') + const [showSuccess, setShowSuccess] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [nextcloudError, setNextcloudError] = useState('') + const [deleteSteps, setDeleteSteps] = useState({ + nextcloud: { status: 'pending', message: 'Pending' }, + database: { status: 'pending', message: 'Pending' } + }) + const [deleteStep, setDeleteStep] = useState<'initial' | 'otp' | 'confirm'>('initial') + + const router = useRouter() + + const handleOTPChange = (value: string) => { + const numericValue = value.replace(/[^0-9]/g, '') + setDeleteOTP(numericValue) + } + + useEffect(() => { + let successTimer: NodeJS.Timeout + if (showSuccess) { + successTimer = setTimeout(() => { + setShowSuccess(false) + }, 5000) + } + return () => { + if (successTimer) clearTimeout(successTimer) + } + }, [showSuccess]) + + useEffect(() => { + let cooldownTimer: NodeJS.Timeout + if (cooldownSeconds > 0) { + cooldownTimer = setInterval(() => { + setCooldownSeconds(prev => prev - 1) + }, 1000) + } + return () => { + if (cooldownTimer) clearInterval(cooldownTimer) + } + }, [cooldownSeconds]) useEffect(() => { const fetchSettings = async () => { @@ -77,6 +131,145 @@ export default function Settings() { } }; + const deleteAccount = async () => { + setIsDeleting(true) + setOtpError('') + setNextcloudError('') + setDeleteSteps({ + nextcloud: { status: 'pending', message: 'Pending' }, + database: { status: 'pending', message: 'Pending' } + }) + + try { + // 1. Delete Nextcloud account + const nextcloudResponse = await fetch('/api/users/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + otp: deleteOTP, + step: 'nextcloud' + }), + }) + + const nextcloudData = await nextcloudResponse.json() + + if (nextcloudData.steps) { + setDeleteSteps(prev => ({ + ...prev, + nextcloud: nextcloudData.steps.nextcloud + })) + } + + if (!nextcloudResponse.ok || nextcloudData.steps?.nextcloud.status === 'error') { + if (nextcloudData.error === 'Failed to delete user from Nextcloud') { + setNextcloudError('Failed to delete your Nextcloud account. Please try again or contact support.') + } else { + setOtpError(nextcloudData.error || 'Failed to delete Nextcloud account') + } + setIsDeleting(false) + return + } + + const databaseResponse = await fetch('/api/users/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + otp: deleteOTP, + step: 'database' + }), + }) + + const databaseData = await databaseResponse.json() + + if (databaseData.steps) { + setDeleteSteps(prev => ({ + ...prev, + database: databaseData.steps.database + })) + } + + if (databaseResponse.ok && databaseData.success) { + setDeleteSteps({ + nextcloud: { status: 'success', message: 'Completed' }, + database: { status: 'success', message: 'Completed' } + }) + + setTimeout(() => { + signOut() + router.push('/') + }, 1500) + } else { + if (databaseData.error === 'Failed to delete user from database') { + setOtpError('Failed to delete your account from the database. Please try again or contact support.') + } else { + setOtpError(databaseData.error || 'Failed to delete account') + } + setIsDeleting(false) + } + } catch (error) { + console.error('[!] Error deleting account:', error) + setOtpError(error instanceof Error ? error.message : 'Failed to delete account') + setIsDeleting(false) + } + } + + const sendOTP = async () => { + setDeleteOTPLoading(true) + setOtpError('') + try { + const response = await fetch('/api/users/otp', { + method: 'GET' + }) + const data = await response.json() + + if (response.ok) { + console.log(data) + setShowSuccess(true) + setCooldownSeconds(60) + } else { + if (response.status === 429) { + setCooldownSeconds(data.remainingCooldown) + setOtpError(`Please wait ${data.remainingCooldown} seconds before requesting another OTP`) + } else { + setOtpError(data.error || 'Failed to send OTP') + } + } + } catch (error) { + console.error('[!] Error sending OTP:', error) + setOtpError('Failed to send OTP') + } finally { + setDeleteOTPLoading(false) + } + } + + const renderStepIcon = (status: string) => { + switch (status) { + case 'pending': + return + case 'success': + return + case 'error': + return + default: + return + } + } + + const renderStepStatus = (step: { status: string, message: string }) => { + return ( +
+ {renderStepIcon(step.status)} + + {step.message} + +
+ ) + } + return ( <>

Settings

@@ -122,8 +315,152 @@ export default function Settings() { + + + + + Delete Account + + Permanently delete your account + + + + + + + {deleteStep === 'initial' ? ( + + + Delete Account + + + Are you sure you want to delete your account? This action cannot be undone. + + +

This will remove your data from the following services:

+
    +
  • Nextcloud
  • +
  • LibreCloud Internal DB
  • +
+ + + + +
+ ) : deleteStep === 'otp' ? ( + + + Confirm your identity + + + Please confirm with a 6 digit code sent to your email. + + +
+ + + + + + + + + + + + + + {deleteOTPLoading ? ( + + ) : showSuccess ? ( + + ) : cooldownSeconds > 0 ? ( + + ) : ( + + )} +
+ + {otpError && ( +

{otpError}

+ )} + + + + +
+ ) : ( + + + Final Confirmation + + + Please review the deletion process carefully. + + +

Deleting your account will permanently remove all data associated with your account and email. This action cannot be undone.

+ +
+
+ Nextcloud + {renderStepStatus(deleteSteps.nextcloud)} +
+
+ Database + {renderStepStatus(deleteSteps.database)} +
+
+ + {nextcloudError && ( +

{nextcloudError}

+ )} + + + {isDeleting ? ( + + ) : ( + + )} + +
+ )} +
+
+
) -} - +} \ No newline at end of file diff --git a/app/api/users/delete/route.ts b/app/api/users/delete/route.ts new file mode 100644 index 0000000..6ea8200 --- /dev/null +++ b/app/api/users/delete/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { verifyOTP } from "@/lib/otp" +import { syncUserWithNextcloud, deleteNextcloudUser } from "@/lib/nextcloud" + +export async function POST(request: Request) { + const session = await auth() + if (!session || !session.user?.email) { + return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 }) + } + + const { otp, step } = await request.json() + + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }) + + if (!user) { + return NextResponse.json({ success: false, error: "User not found" }, { status: 404 }) + } + + if (!step || step === 'nextcloud') { + if (!otp) { + return NextResponse.json({ success: false, error: "OTP is required" }, { status: 400 }) + } + + const isOTPValid = await verifyOTP(user.id, otp) + if (!isOTPValid) { + return NextResponse.json({ success: false, error: "Invalid OTP" }, { status: 401 }) + } + + try { + const nextcloudId = await syncUserWithNextcloud(user.email, true) // true bypasses the cache + + if (nextcloudId) { + const nextcloudDeleted = await deleteNextcloudUser(nextcloudId) + + if (!nextcloudDeleted) { + console.error("[!] Failed to delete user from Nextcloud") + return NextResponse.json({ + success: false, + error: "Failed to delete user from Nextcloud", + details: "The Nextcloud service is currently unavailable or the user could not be deleted. Please try again later or contact support.", + steps: { + nextcloud: { status: 'error', message: 'Failed to delete Nextcloud account' }, + database: { status: 'pending', message: 'Not started' } + } + }, { status: 500 }) + } + + return NextResponse.json({ + success: false, + message: "Nextcloud account deleted successfully", + steps: { + nextcloud: { status: 'success', message: 'Nextcloud account deleted' }, + database: { status: 'pending', message: 'Not started' } + } + }, { status: 200 }) + } else { + console.log("[i] No Nextcloud ID found for user, skipping Nextcloud deletion") + + return NextResponse.json({ + success: false, + message: "No Nextcloud account found, skipping Nextcloud deletion", + steps: { + nextcloud: { status: 'success', message: 'No Nextcloud account found' }, + database: { status: 'pending', message: 'Not started' } + } + }, { status: 200 }) + } + } catch (error) { + console.error("[!] Error in Nextcloud deletion:", error) + return NextResponse.json({ + success: false, + error: "Failed to delete Nextcloud account", + details: error instanceof Error ? error.message : "Unknown error", + steps: { + nextcloud: { status: 'error', message: 'Error during Nextcloud deletion' }, + database: { status: 'pending', message: 'Not started' } + } + }, { status: 500 }) + } + } + + else if (step === 'database') { + try { + await prisma.oTP.deleteMany({ + where: { userId: user.id } + }) + + await prisma.oTPRequest.deleteMany({ + where: { userId: user.id } + }) + + await prisma.user.delete({ + where: { id: user.id } + }) + + return NextResponse.json({ + success: true, + message: "User deleted successfully", + steps: { + nextcloud: { status: 'success', message: 'Completed' }, + database: { status: 'success', message: 'Completed' } + } + }, { status: 200 }) + } catch (dbError) { + console.error("[!] Database deletion error:", dbError) + return NextResponse.json({ + success: false, + error: "Failed to delete user from database", + details: dbError instanceof Error ? dbError.message : "Unknown database error", + steps: { + nextcloud: { status: 'success', message: 'Completed' }, + database: { status: 'error', message: 'Failed to delete database records' } + } + }, { status: 500 }) + } + } + + else { + return NextResponse.json({ + success: false, + error: "Invalid step specified", + steps: { + nextcloud: { status: 'pending', message: 'Not started' }, + database: { status: 'pending', message: 'Not started' } + } + }, { status: 400 }) + } +} \ No newline at end of file diff --git a/app/api/users/otp/route.ts b/app/api/users/otp/route.ts new file mode 100644 index 0000000..4cf569d --- /dev/null +++ b/app/api/users/otp/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { generateOTP, verifyOTP } from "@/lib/otp" + +const OTP_COOLDOWN_SECONDS = 60 + +export async function GET() { + try { + const session = await auth() + + if (!session || !session.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }) + + if (!user) { + console.log("[!] User not found in database") + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + const lastOtpRequest = await prisma.oTP.findFirst({ + where: { + userId: user.id, + createdAt: { + gt: new Date(Date.now() - OTP_COOLDOWN_SECONDS * 1000) + } + }, + orderBy: { createdAt: 'desc' }, + }) + + if (lastOtpRequest) { + const timeSinceLastRequest = Math.floor((Date.now() - lastOtpRequest.createdAt.getTime()) / 1000) + const remainingCooldown = OTP_COOLDOWN_SECONDS - timeSinceLastRequest + + console.log(`[OTP] Cooldown active: ${remainingCooldown}s remaining`) + if (remainingCooldown > 0) { + console.log(`[OTP] Returning cooldown response: ${remainingCooldown}s`) + return NextResponse.json({ + error: "Please wait before requesting another OTP", + remainingCooldown + }, { status: 429 }) + } + } + + await generateOTP(user.id, user.email) + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[!] Error in OTP route:", error) + return NextResponse.json({ error: "Failed to generate OTP" }, { status: 500 }) + } +} + +export async function POST(request: Request) { + try { + const session = await auth() + + if (!session || !session.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }) + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + const { code } = await request.json() + console.log(`[OTP] Verifying code for user ${user.id}`) + + if (!code || typeof code !== 'string') { + console.log("[OTP] Invalid code format") + return NextResponse.json({ error: "Invalid OTP code" }, { status: 400 }) + } + + const isValid = await verifyOTP(user.id, code) + console.log(`[OTP] Verification result: ${isValid ? 'success' : 'failed'}`) + + if (!isValid) { + return NextResponse.json({ error: "Invalid or expired OTP" }, { status: 400 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[OTP] Error in OTP verification:", error) + return NextResponse.json({ error: "Failed to verify OTP" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/legal/page.tsx b/app/legal/page.tsx index 30a33bd..96cf4f6 100644 --- a/app/legal/page.tsx +++ b/app/legal/page.tsx @@ -30,6 +30,9 @@ export default function Legal() {
  • Privacy Policy
  • +
  • + Terms of Service +
  • diff --git a/app/legal/terms/page.tsx b/app/legal/terms/page.tsx new file mode 100644 index 0000000..1d49045 --- /dev/null +++ b/app/legal/terms/page.tsx @@ -0,0 +1,121 @@ +import Navbar from "@/components/pages/main/Navbar" +import Footer from "@/components/pages/main/Footer" +import { Scale } from "lucide-react" +import { Separator } from "@/components/ui/separator" +import Link from "next/link" + +export default function Terms() { + return ( +
    + +
    +
    +
    +
    + +

    + Terms of Service +

    +
    +

    + LibreCloud is a community-driven project to provide free and open-source cloud services to the public. +

    +

    Date Effective: 21 April 2025

    + + +

    I. Introduction & Agreement

    +

    By accessing or using LibreCloud services ("Services"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, please do not use our Services.

    +

    LibreCloud ("LibreCloud", "We", "Us") is operated by individual volunteers and is not a business. We offer self-hosted open-source software services, including but not limited to Email, Gitea, NextCloud, and Vaultwarden, connected through a unified dashboard.

    +

    A user ("User", "You", "Your") refers to an individual who has created an account with LibreCloud and agreed to these Terms.

    +

    These Terms should be read in conjunction with our Privacy Policy, which explains how we handle your data.

    +

    LibreCloud is not intended for users under 16 years of age. By using our Services, you declare that you are at least 16 years old.

    + +

    II. User Accounts

    +

    To access most features of LibreCloud, you must create an account. When creating your account, you must provide accurate and complete information. You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account.

    +

    Account responsibilities:

    +
      +
    • Maintaining the security of your account credentials
    • +
    • Notifying LibreCloud immediately of any unauthorized use of your account
    • +
    • Ensuring all account information is accurate and up-to-date
    • +
    • Managing the content you store and share through our Services
    • +
    +

    We reserve the right to suspend or terminate accounts that violate these Terms or that have been inactive for an extended period.

    + +

    III. Acceptable Use

    +

    LibreCloud Services are designed to help you manage and share your data in a privacy-respecting way. By using our Services, you agree not to:

    +
      +
    • Use the Services for any illegal purpose or to violate any laws
    • +
    • Upload, store, or share content that infringes intellectual property rights
    • +
    • Distribute malware, viruses, or other harmful computer code
    • +
    • Attempt to gain unauthorized access to any part of the Services
    • +
    • Interfere with or disrupt the integrity or performance of the Services
    • +
    • Harass, abuse, or harm others through our Services
    • +
    • Use our Services to send unsolicited communications (spam)
    • +
    • Attempt to bypass any usage limitations or quotas implemented in the Services
    • +
    +

    We reserve the right to remove content and/or suspend accounts that violate these policies.

    + +

    Resource Usage

    +

    LibreCloud operates off of donated resources. We may implement fair usage policies to ensure service availability for all users. These policies may include storage limits, bandwidth restrictions, or other resource constraints as needed. We will provide reasonable notice before implementing or changing such policies.

    + +

    IV. Service Availability and Modifications

    +

    As a volunteer-run project, LibreCloud makes reasonable efforts to maintain service availability, but we do not guarantee uninterrupted access to our Services.

    +
      +
    • We may perform maintenance that could temporarily limit access to the Services
    • +
    • We will make reasonable efforts to notify users of planned maintenance
    • +
    • We reserve the right to modify, suspend, or discontinue any part of our Services at any time
    • +
    • We may add, remove, or change features as needed to improve our Services
    • +
    +

    In the event we decide to discontinue a service, we will provide at least 30 days' notice and reasonable opportunity for you to export your data.

    + +

    V. User Content

    +

    LibreCloud allows you to store, share, and manage various types of content ("User Content"). You retain all rights to your User Content, subject to the limited license granted below.

    + +

    Content Ownership

    +

    You are the owner of the content you create and store on LibreCloud. Your data will not be used by LibreCloud for any purpose other than to provide you with the services you have requested.

    + +

    Content Responsibility

    +

    You are solely responsible for your User Content and the consequences of storing or sharing it through our Services. LibreCloud is not responsible for the accuracy, quality, integrity, legality, reliability, or appropriateness of User Content.

    + +

    Content Removal

    +

    While we do not actively monitor User Content, we reserve the right to remove any content that violates these Terms or that we are obligated to remove by law. We will notify you if your content is removed, unless prohibited by law.

    + +

    VI. Intellectual Property

    +

    LibreCloud Services utilize various open-source software components, each governed by their respective licenses. We respect the intellectual property of others and expect users to do the same.

    + +

    VII. Disclaimers and Limitations

    +

    LibreCloud Services are provided "as is" and "as available" without warranties of any kind, either express or implied. To the fullest extent permitted by law, we disclaim all warranties, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement.

    +

    While we implement reasonable security measures to protect your data, we cannot guarantee that your data will always be secure or that our Services will be error-free.

    +

    To the fullest extent permitted by law, LibreCloud shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of or inability to use the Services.

    +

    Our total liability for any claim arising from or related to these Terms or the Services shall not exceed the amount you have paid us, if any, for the Services in the twelve months preceding the claim.

    + +

    VIII. Termination

    +

    You may terminate your account at any time by following the instructions provided in your account settings. Upon termination, your right to access and use the Services will cease immediately.

    +

    We may terminate or suspend your access to the Services at any time, with or without cause, and with or without notice. Reasons for termination may include:

    +
      +
    • Violation of these Terms
    • +
    • Requests by law enforcement or government agencies
    • +
    • Extended periods of inactivity
    • +
    • Unexpected technical issues or service discontinuation
    • +
    +

    Upon termination, we will provide an opportunity for you to download your data where feasible, unless prohibited by law.

    + +

    IX. Changes to Terms

    +

    We may update these Terms from time to time to reflect changes in our Services, organization, or for other reasons. We will notify you of any material changes by posting the updated Terms on our website and sending an email to the address associated with your account.

    +

    Your continued use of LibreCloud after the changes take effect constitutes your acceptance of the revised Terms. If you do not agree to the revised Terms, you should discontinue use of our Services and close your account.

    + +

    X. Governing Law

    +

    These Terms shall be governed by and construed in accordance with the laws of the jurisdiction where LibreCloud has its principal operations, without regard to its conflict of law provisions.

    +

    Any disputes arising under these Terms that cannot be resolved amicably shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.

    + +

    XI. Contact Information

    +

    If you have any questions about these Terms, please contact us through the support options provided in the user dashboard on LibreCloud, or through the methods provided below:

    +

    Email: support@librecloud.cc

    +

    Mailing address available upon request.

    +
    +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx new file mode 100644 index 0000000..614f70e --- /dev/null +++ b/components/ui/input-otp.tsx @@ -0,0 +1,77 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { MinusIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string +}) { + return ( + + ) +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number +}) { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + + return ( +
    + {char} + {hasFakeCaret && ( +
    +
    +
    + )} +
    + ) +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
    + +
    + ) +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/docs/_sidebar.md b/docs/_sidebar.md index fff827d..72d629b 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -16,4 +16,5 @@ * Updates - * [v1.2.0](updates/1.2.0.md) \ No newline at end of file + * [v1.3.0](updates/1.3.0.md) + * [v1.2.0](updates/1.2.0.md) diff --git a/docs/img/1.3.0-1.png b/docs/img/1.3.0-1.png new file mode 100644 index 0000000..f6e99cc Binary files /dev/null and b/docs/img/1.3.0-1.png differ diff --git a/docs/img/1.3.0-2.png b/docs/img/1.3.0-2.png new file mode 100644 index 0000000..92c1d28 Binary files /dev/null and b/docs/img/1.3.0-2.png differ diff --git a/docs/img/1.3.0-3.png b/docs/img/1.3.0-3.png new file mode 100644 index 0000000..dc8463a Binary files /dev/null and b/docs/img/1.3.0-3.png differ diff --git a/docs/img/1.3.0-4.png b/docs/img/1.3.0-4.png new file mode 100644 index 0000000..481062f Binary files /dev/null and b/docs/img/1.3.0-4.png differ diff --git a/docs/reference/env.md b/docs/reference/env.md index 46cf985..f2fb864 100644 --- a/docs/reference/env.md +++ b/docs/reference/env.md @@ -49,6 +49,30 @@ If you need more help doing this, there is a fantastic guide [on Authentik's wik | AUTHENTIK_API_KEY | API key for authenticating with Authentik's API | N/A | | AUTHENTIK_API_URL | Authentik's API endpoint URL | `http://authentik.local/api/v3` | +## Email 2FA + +For deleting user accounts, two-factor authentication via email is enforced. Thus, you must set your mailserver information: + +| Environment Variable | Description | Example | +|----------------------|----------------------------------------|-----------------------| +| EMAIL_HOST | Hostname of your email server | `mail.example.com` | +| EMAIL_PORT | The port to use for sending | `465` | +| EMAIL_SSL | Whether SSL should be used for sending | `true` / `false` | +| NOREPLY_EMAIL | Email account to send from | `noreply@example.com` | +| NOREPLY_PASSWORD | Password for the account given | Your password | + +## Nextcloud + +The Nextcloud integration requires an admin or service account credentials for the OCS API. + +It is highly recommended that you create a service account. + +| Environment Variable | Description | Example | +|--------------------------|--------------------------------------------------------|-------------------------------| +| NEXTCLOUD_URL | The URL of your Nextcloud instance (no trailing slash) | `https://files.librecloud.cc` | +| NEXTCLOUD_ADMIN_USERNAME | Admin/service account username | `service-account` | +| NEXTCLOUD_ADMIN_PASSWORD | Corresponding password to given admin/service account | Password | + ## Gitea Next, you will need to configure `web` with your Gitea instance. diff --git a/docs/updates/1.2.0.md b/docs/updates/1.2.0.md index 597cc10..23ddfba 100644 --- a/docs/updates/1.2.0.md +++ b/docs/updates/1.2.0.md @@ -37,4 +37,4 @@ ![LibreCloud Login (v1.2.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.2.0-2.png) -![LibreCloud's home page (v1.2.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.2.0-3.png) \ No newline at end of file +![LibreCloud's home page (v1.2.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.2.0-3.png) diff --git a/docs/updates/1.3.0.md b/docs/updates/1.3.0.md new file mode 100644 index 0000000..a4f1d28 --- /dev/null +++ b/docs/updates/1.3.0.md @@ -0,0 +1,47 @@ +# web v1.3.0 + +`web` 1.3.0 brings many new improvements and fixes to improve user experience. + +## What's new? + +### Additions + +- New "Legal" page (`/legal`) for better organization +- Added more "Quick Links" buttons +- Telegram support card +- Infrastructure card on the "Updates" page +- Introduction of Nextcloud service +- Support for deleting user accounts via dashboard + - Supports internal DB and Nextcloud only, for now +- Basic automatic security scan implemented + +### Updates + +- [Altcha](https://altcha.org) will be used for CAPTCHAs instead of Cloudflare Turnstile +- Monthly cost updates on "Updates" page + +### Improvements + +- UI tweaks and fixes + - Password change flow + - Sidebar is now easier to use on all device types + - More synchronized design +- Additional animations +- Better session checking +- Linting + +### Fixes + +- Fixed [#5 - Clean up signup page](https://git.pontusmail.org/librecloud/web/issues/5) +- Fixed [#6 - Correct light/dark mode on public-facing pages](https://git.pontusmail.org/librecloud/web/issues/6) +- Fixed [#7 - Prefer Altcha over Cloudflare Turnstile](https://git.pontusmail.org/librecloud/web/issues/7) + +## Screenshots + +![LibreCloud Dashboard (v1.3.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.3.0-1.png) + +![LibreCloud Automatic Security Scan feature (v1.3.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.3.0-2.png) + +![LibreCloud's Statistics page (v1.3.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.3.0-3.png) + +![LibreCloud's Settings page (v1.3.0)](https://git.pontusmail.org/librecloud/web/raw/branch/main/docs/img/1.3.0-4.png) diff --git a/lib/email.ts b/lib/email.ts new file mode 100644 index 0000000..93a4da0 --- /dev/null +++ b/lib/email.ts @@ -0,0 +1,37 @@ +import { createTransport } from 'nodemailer' + +const transporter = createTransport({ + host: process.env.EMAIL_HOST, + port: parseInt(process.env.EMAIL_PORT || '587'), + secure: process.env.EMAIL_SSL === 'true', + auth: { + user: process.env.NOREPLY_EMAIL, + pass: process.env.NOREPLY_PASSWORD, + }, +}) + +export async function sendOTPEmail(email: string, otp: string) { + try { + const result = await transporter.sendMail({ + from: process.env.NOREPLY_EMAIL || 'noreply@librecloud.cc', + to: email, + subject: 'LibreCloud OTP', + text: `Your OTP code is: ${otp}\n\nThis code will expire in 10 minutes.`, + html: ` +
    +

    LibreCloud OTP

    +

    Your OTP code is: ${otp}

    +

    This code will expire in 10 minutes.

    +

    If you didn't request this code, please ignore this email.

    +
    + `, + }) + console.log('[!] OTP sent to:', email) + console.log('[i] Send result:', result) + + return true + } catch (error) { + console.error('[!] Error sending OTP:', error) + return false + } +} \ No newline at end of file diff --git a/lib/nextcloud.ts b/lib/nextcloud.ts new file mode 100644 index 0000000..65d5875 --- /dev/null +++ b/lib/nextcloud.ts @@ -0,0 +1,150 @@ +import { prisma } from "@/lib/prisma" +import { XMLParser } from "fast-xml-parser" + +let lastSyncTime: number | null = null +const SYNC_INTERVAL = 60 * 60 * 1000 // 1h + +async function fetchNextcloudUsers() { + const nextcloudUsername = process.env.NEXTCLOUD_ADMIN_USERNAME + const nextcloudPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD + const nextcloudUrl = process.env.NEXTCLOUD_URL + + if (!nextcloudUsername || !nextcloudPassword || !nextcloudUrl) { + throw new Error("[!] Missing Nextcloud credentials or URL") + } + + const basicAuth = Buffer.from(`${nextcloudUsername}:${nextcloudPassword}`).toString('base64') + + const response = await fetch(`${nextcloudUrl}/ocs/v2.php/cloud/users`, { + headers: { + "Authorization": `Basic ${basicAuth}`, + "OCS-APIRequest": "true" + } + }) + + if (!response.ok) { + throw new Error(`[!] Failed to fetch Nextcloud users: ${response.statusText}`) + } + + const xmlText = await response.text() + const parser = new XMLParser() + const result = parser.parse(xmlText) + + const users = result.ocs.data.users.element || [] + return Array.isArray(users) ? users : [users] +} + +async function fetchNextcloudUserDetails(userId: string) { + const nextcloudUsername = process.env.NEXTCLOUD_ADMIN_USERNAME + const nextcloudPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD + const nextcloudUrl = process.env.NEXTCLOUD_URL + + if (!nextcloudUsername || !nextcloudPassword || !nextcloudUrl) { + throw new Error("[!] Missing Nextcloud credentials or URL") + } + + const basicAuth = Buffer.from(`${nextcloudUsername}:${nextcloudPassword}`).toString('base64') + + const response = await fetch(`${nextcloudUrl}/ocs/v2.php/cloud/users/${userId}`, { + headers: { + "Authorization": `Basic ${basicAuth}`, + "OCS-APIRequest": "true" + } + }) + + if (!response.ok) { + throw new Error(`[!] Failed to fetch Nextcloud user details: ${response.statusText}`) + } + + const xmlText = await response.text() + const parser = new XMLParser() + const result = parser.parse(xmlText) + + return result.ocs.data +} + +export async function syncUserWithNextcloud(email: string, bypassCache: boolean = false): Promise { + try { + const now = Date.now() + if (lastSyncTime === null || now - lastSyncTime > SYNC_INTERVAL || bypassCache) { + await syncAllNextcloudUsers() + lastSyncTime = now + } + + const user = await prisma.user.findUnique({ + where: { email } + }) + + return user?.nextcloudId || null + } catch (error) { + console.error(`[!] Error syncing user ${email} with Nextcloud:`, error) + return null + } +} + +async function syncAllNextcloudUsers() { + try { + const nextcloudUserIds = await fetchNextcloudUsers() + + for (const userId of nextcloudUserIds) { + try { + const userDetails = await fetchNextcloudUserDetails(userId) + + if (!userDetails.email) { + console.log(`[i] Skipping user ${userId} - no email found`) + continue + } + + await prisma.user.upsert({ + where: { email: userDetails.email }, + update: { nextcloudId: userId }, + create: { + email: userDetails.email, + nextcloudId: userId, + username: userDetails.id + } + }) + } catch (error) { + console.error(`[!] Error processing user ${userId}:`, error) + } + } + + console.log("Nextcloud users synced successfully") + } catch (error) { + console.error("[!] Error syncing Nextcloud users:", error) + throw error + } +} + +export async function deleteNextcloudUser(nextcloudId: string): Promise { + try { + const nextcloudUsername = process.env.NEXTCLOUD_ADMIN_USERNAME + const nextcloudPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD + const nextcloudUrl = process.env.NEXTCLOUD_URL + + if (!nextcloudUsername || !nextcloudPassword || !nextcloudUrl) { + console.error("[!] Missing Nextcloud credentials or URL") + return false + } + + const basicAuth = Buffer.from(`${nextcloudUsername}:${nextcloudPassword}`).toString('base64') + + const response = await fetch(`${nextcloudUrl}/ocs/v2.php/cloud/users/${nextcloudId}`, { + method: 'DELETE', + headers: { + "Authorization": `Basic ${basicAuth}`, + "OCS-APIRequest": "true" + } + }) + + if (!response.ok) { + console.error(`[!] Failed to delete Nextcloud user: ${response.statusText} (${response.status})`) + return false + } + + return true + } catch (error) { + console.error(`[!] Error deleting Nextcloud user ${nextcloudId}:`, error) + return false + } +} \ No newline at end of file diff --git a/lib/otp.ts b/lib/otp.ts new file mode 100644 index 0000000..a2d8e6c --- /dev/null +++ b/lib/otp.ts @@ -0,0 +1,66 @@ +import { prisma } from '@/lib/prisma' +import { sendOTPEmail } from './email' + +export async function generateOTP(userId: string, email: string) { + const otp = Math.floor(100000 + Math.random() * 900000).toString() + + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + + try { + const storedOTP = await prisma.oTP.create({ + data: { + code: otp, + userId, + expiresAt, + }, + }) + + const emailSent = await sendOTPEmail(email, otp) + + if (!emailSent) { + await prisma.oTP.delete({ + where: { id: storedOTP.id }, + }) + } + + return storedOTP + } catch (error) { + throw error + } +} + +export async function verifyOTP(userId: string, code: string) { + const otp = await prisma.oTP.findFirst({ + where: { + userId, + code, + used: false, + expiresAt: { + gt: new Date(), + }, + }, + }) + + if (!otp) { + return false + } + + await prisma.oTP.update({ + where: { id: otp.id }, + data: { used: true }, + }) + + return true +} + +export async function cleanupExpiredOTPs() { + const result = await prisma.oTP.deleteMany({ + where: { + OR: [ + { expiresAt: { lt: new Date() } }, + { used: true }, + ], + }, + }) + console.log('[!] Expired OTPs cleaned up:', result) +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts index df98742..ff440aa 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,8 @@ export { auth as middleware } from "@/auth" export const config = { - matcher: "/account/dashboard/:path*", + matcher: [ + "/account/dashboard/:path*", + "/api/users/otp", + ], }; \ No newline at end of file diff --git a/package.json b/package.json index 7bc34e3..729e23f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.2.0", + "version": "1.3.0", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -11,19 +11,20 @@ "dependencies": { "@hookform/resolvers": "^3.10.0", "@prisma/client": "^6.6.0", - "@radix-ui/react-avatar": "^1.1.6", - "@radix-ui/react-collapsible": "^1.1.7", - "@radix-ui/react-dialog": "^1.1.10", - "@radix-ui/react-dropdown-menu": "^2.1.11", + "@radix-ui/react-avatar": "^1.1.7", + "@radix-ui/react-collapsible": "^1.1.8", + "@radix-ui/react-dialog": "^1.1.11", + "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", - "@radix-ui/react-popover": "^1.1.10", + "@radix-ui/react-popover": "^1.1.11", "@radix-ui/react-progress": "^1.1.4", - "@radix-ui/react-radio-group": "^1.3.3", + "@radix-ui/react-radio-group": "^1.3.4", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-switch": "^1.2.2", - "@radix-ui/react-tabs": "^1.1.8", - "@radix-ui/react-tooltip": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.9", + "@radix-ui/react-tooltip": "^1.2.4", + "@types/nodemailer": "^6.4.17", "@web3icons/react": "^4.0.13", "altcha": "^1.4.2", "altcha-lib": "^1.2.0", @@ -32,19 +33,22 @@ "clsx": "^2.1.1", "cmdk": "1.0.0", "cookies-next": "^5.1.0", + "fast-xml-parser": "^5.2.1", "framer-motion": "^12.7.4", "geist": "^1.3.1", + "input-otp": "^1.4.2", "js-cookie": "^3.0.5", "lucide-react": "^0.487.0", "motion": "^12.7.4", "next": "^15.3.1", "next-auth": "^5.0.0-beta.26", "next-themes": "^0.4.6", + "nodemailer": "^6.10.1", "password-validator": "^5.3.0", "prisma": "^6.6.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-hook-form": "^7.56.0", + "react-hook-form": "^7.56.1", "react-icons": "^5.5.0", "react-typed": "^2.0.12", "sonner": "^2.0.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4d70715..e0e1513 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,12 +8,37 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - username String? @unique - hideGenAI Boolean @default(false) - hideUpgrades Boolean @default(false) - hideCrypto Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + username String? @unique + nextcloudId String? @unique + hideGenAI Boolean @default(false) + hideUpgrades Boolean @default(false) + hideCrypto Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + otps OTP[] + otpRequests OTPRequest[] } + +model OTP { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + code String + expiresAt DateTime + createdAt DateTime @default(now()) + used Boolean @default(false) + + @@index([userId]) + @@index([expiresAt]) +} + +model OTPRequest { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@index([userId]) +} \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..0bea6e4 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + extend: { + keyframes: { + "caret-blink": { + "0%,70%,100%": { opacity: "1" }, + "20%,50%": { opacity: "0" }, + }, + }, + animation: { + "caret-blink": "caret-blink 1.25s ease-out infinite", + }, + }, + }, +} \ No newline at end of file