feat release v1.3.0: add terms of service, partial nextcloud integration, delete account process (nextcloud, internal db for now), email sending fxns
This commit is contained in:
parent
611506a103
commit
36d2d16b45
@ -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 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
case 'success':
|
||||
return <Check className="h-4 w-4 text-green-500" />
|
||||
case 'error':
|
||||
return <X className="h-4 w-4 text-destructive" />
|
||||
default:
|
||||
return <Loader2 className="h-4 w-4 animate-spin" />
|
||||
}
|
||||
}
|
||||
|
||||
const renderStepStatus = (step: { status: string, message: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{renderStepIcon(step.status)}
|
||||
<span className={step.status === 'error' ? 'text-destructive' : ''}>
|
||||
{step.message}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl xl:text-3xl font-bold mb-6 text-foreground">Settings</h1>
|
||||
@ -122,8 +315,152 @@ export default function Settings() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center text-xl">
|
||||
<Trash size={18} className="mr-1" />
|
||||
Delete Account
|
||||
</CardTitle>
|
||||
<CardDescription>Permanently delete your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="cursor-pointer">
|
||||
<Trash size={18} />
|
||||
Delete Account
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{deleteStep === 'initial' ? (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Account</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete your account? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
|
||||
<p className="text-sm mt-2">This will remove your data from the following services:</p>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
<li>Nextcloud</li>
|
||||
<li>LibreCloud Internal DB</li>
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<Button className="cursor-pointer" variant="destructive" onClick={() => setDeleteStep('otp')}>
|
||||
I understand, continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
) : deleteStep === 'otp' ? (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm your identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
Please confirm with a 6 digit code sent to your email.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex items-center justify-center gap-6 mt-4">
|
||||
<InputOTP maxLength={6} value={deleteOTP} onChange={handleOTPChange}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{deleteOTPLoading ? (
|
||||
<Button className="cursor-pointer" variant="outline" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</Button>
|
||||
) : showSuccess ? (
|
||||
<Button className="cursor-pointer" variant="outline" disabled>
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
Sent!
|
||||
</Button>
|
||||
) : cooldownSeconds > 0 ? (
|
||||
<Button className="cursor-pointer" variant="outline" disabled>
|
||||
{cooldownSeconds}s
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer" variant="outline" onClick={() => sendOTP()}>
|
||||
Send OTP
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{otpError && (
|
||||
<p className="text-sm text-destructive mt-2">{otpError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="destructive"
|
||||
disabled={!deleteOTP || deleteOTP.length !== 6}
|
||||
onClick={() => setDeleteStep('confirm')}
|
||||
>
|
||||
Verify OTP
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Final Confirmation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
Please review the deletion process carefully.
|
||||
</DialogDescription>
|
||||
|
||||
<p className="text-sm mt-2">Deleting your account will permanently remove all data associated with your account and email. This action cannot be undone.</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Nextcloud</span>
|
||||
{renderStepStatus(deleteSteps.nextcloud)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Database</span>
|
||||
{renderStepStatus(deleteSteps.database)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nextcloudError && (
|
||||
<p className="text-sm text-destructive mt-2">{nextcloudError}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
{isDeleting ? (
|
||||
<Button className="cursor-pointer" variant="destructive" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deleting data...
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
variant="destructive"
|
||||
disabled={deleteSteps.nextcloud.status === 'error' || deleteSteps.database.status === 'error'}
|
||||
onClick={() => deleteAccount()}
|
||||
>
|
||||
<Trash size={18} />
|
||||
Delete Account
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
132
app/api/users/delete/route.ts
Normal file
132
app/api/users/delete/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
93
app/api/users/otp/route.ts
Normal file
93
app/api/users/otp/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
@ -30,6 +30,9 @@ export default function Legal() {
|
||||
<li className="mb-1">
|
||||
<Link href="/legal/privacy" className="underline">Privacy Policy</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link href="/legal/terms" className="underline">Terms of Service</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="my-8">
|
||||
|
121
app/legal/terms/page.tsx
Normal file
121
app/legal/terms/page.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900">
|
||||
<Navbar />
|
||||
<main>
|
||||
<div className="pt-4 lg:pt-20 pb-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Scale className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex-shrink-0" />
|
||||
<h1 className="text-4xl tracking-tight font-extrabold sm:text-5xl md:text-6xl">
|
||||
Terms of Service
|
||||
</h1>
|
||||
</div>
|
||||
<h3 className="text-2xl text-muted-foreground my-4">
|
||||
LibreCloud is a community-driven project to provide free and open-source cloud services to the public.
|
||||
</h3>
|
||||
<h4 className="text-xl"><span className="font-bold">Date Effective:</span> 21 April 2025</h4>
|
||||
<Separator className="my-4" />
|
||||
|
||||
<h2 className="text-3xl font-bold">I. Introduction & Agreement</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">A user ("User", "You", "Your") refers to an individual who has created an account with LibreCloud and agreed to these Terms.</p>
|
||||
<p className="my-2">These Terms should be read in conjunction with our <Link href="/legal/privacy" className="underline">Privacy Policy</Link>, which explains how we handle your data.</p>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">II. User Accounts</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2 font-bold">Account responsibilities:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="mb-1">Maintaining the security of your account credentials</li>
|
||||
<li className="my-1">Notifying LibreCloud immediately of any unauthorized use of your account</li>
|
||||
<li className="my-1">Ensuring all account information is accurate and up-to-date</li>
|
||||
<li className="mt-1">Managing the content you store and share through our Services</li>
|
||||
</ul>
|
||||
<p className="my-2">We reserve the right to suspend or terminate accounts that violate these Terms or that have been inactive for an extended period.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">III. Acceptable Use</h2>
|
||||
<p className="my-2">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:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="mb-1">Use the Services for any illegal purpose or to violate any laws</li>
|
||||
<li className="my-1">Upload, store, or share content that infringes intellectual property rights</li>
|
||||
<li className="my-1">Distribute malware, viruses, or other harmful computer code</li>
|
||||
<li className="my-1">Attempt to gain unauthorized access to any part of the Services</li>
|
||||
<li className="my-1">Interfere with or disrupt the integrity or performance of the Services</li>
|
||||
<li className="my-1">Harass, abuse, or harm others through our Services</li>
|
||||
<li className="my-1">Use our Services to send unsolicited communications (spam)</li>
|
||||
<li className="mt-1">Attempt to bypass any usage limitations or quotas implemented in the Services</li>
|
||||
</ul>
|
||||
<p className="my-2">We reserve the right to remove content and/or suspend accounts that violate these policies.</p>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-2 mt-3.5">Resource Usage</h3>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">IV. Service Availability and Modifications</h2>
|
||||
<p className="my-2">As a volunteer-run project, LibreCloud makes reasonable efforts to maintain service availability, but we do not guarantee uninterrupted access to our Services.</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="mb-1">We may perform maintenance that could temporarily limit access to the Services</li>
|
||||
<li className="my-1">We will make reasonable efforts to notify users of planned maintenance</li>
|
||||
<li className="my-1">We reserve the right to modify, suspend, or discontinue any part of our Services at any time</li>
|
||||
<li className="mt-1">We may add, remove, or change features as needed to improve our Services</li>
|
||||
</ul>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">V. User Content</h2>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-2 mt-3.5">Content Ownership</h3>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-2 mt-3.5">Content Responsibility</h3>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-2 mt-3.5">Content Removal</h3>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">VI. Intellectual Property</h2>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">VII. Disclaimers and Limitations</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">VIII. Termination</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="mb-1">Violation of these Terms</li>
|
||||
<li className="my-1">Requests by law enforcement or government agencies</li>
|
||||
<li className="my-1">Extended periods of inactivity</li>
|
||||
<li className="mt-1">Unexpected technical issues or service discontinuation</li>
|
||||
</ul>
|
||||
<p className="my-2">Upon termination, we will provide an opportunity for you to download your data where feasible, unless prohibited by law.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">IX. Changes to Terms</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">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.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">X. Governing Law</h2>
|
||||
<p className="my-2">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.</p>
|
||||
<p className="my-2">Any disputes arising under these Terms that cannot be resolved amicably shall be subject to the exclusive jurisdiction of the courts in that jurisdiction.</p>
|
||||
|
||||
<h2 className="text-3xl font-bold mt-8">XI. Contact Information</h2>
|
||||
<p className="my-2">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:</p>
|
||||
<p className="my-2"><span className="font-bold">Email:</span> <Link href="mailto:support@librecloud.cc" className="underline hover:text-muted-foreground transition-all">support@librecloud.cc</Link></p>
|
||||
<p className="my-2">Mailing address available upon request.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
77
components/ui/input-otp.tsx
Normal file
77
components/ui/input-otp.tsx
Normal file
@ -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<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
@ -16,4 +16,5 @@
|
||||
|
||||
* Updates
|
||||
|
||||
* [v1.3.0](updates/1.3.0.md)
|
||||
* [v1.2.0](updates/1.2.0.md)
|
BIN
docs/img/1.3.0-1.png
Normal file
BIN
docs/img/1.3.0-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 106 KiB |
BIN
docs/img/1.3.0-2.png
Normal file
BIN
docs/img/1.3.0-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 108 KiB |
BIN
docs/img/1.3.0-3.png
Normal file
BIN
docs/img/1.3.0-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 142 KiB |
BIN
docs/img/1.3.0-4.png
Normal file
BIN
docs/img/1.3.0-4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
@ -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.
|
||||
|
47
docs/updates/1.3.0.md
Normal file
47
docs/updates/1.3.0.md
Normal file
@ -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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
37
lib/email.ts
Normal file
37
lib/email.ts
Normal file
@ -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: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>LibreCloud OTP</h2>
|
||||
<p>Your OTP code is: <strong>${otp}</strong></p>
|
||||
<p>This code will expire in 10 minutes.</p>
|
||||
<p>If you didn't request this code, please ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
console.log('[!] OTP sent to:', email)
|
||||
console.log('[i] Send result:', result)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[!] Error sending OTP:', error)
|
||||
return false
|
||||
}
|
||||
}
|
150
lib/nextcloud.ts
Normal file
150
lib/nextcloud.ts
Normal file
@ -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<string | null> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
66
lib/otp.ts
Normal file
66
lib/otp.ts
Normal file
@ -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)
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
export { auth as middleware } from "@/auth"
|
||||
|
||||
export const config = {
|
||||
matcher: "/account/dashboard/:path*",
|
||||
matcher: [
|
||||
"/account/dashboard/:path*",
|
||||
"/api/users/otp",
|
||||
],
|
||||
};
|
24
package.json
24
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",
|
||||
|
@ -11,9 +11,34 @@ model User {
|
||||
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])
|
||||
}
|
16
tailwind.config.ts
Normal file
16
tailwind.config.ts
Normal file
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user