diff --git a/README.md b/README.md index 61a76e5..af2cfda 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # web -![Last Update](https://img.shields.io/badge/last_update-8_Apr_2025-purple) [![License: CC0-1.0](https://img.shields.io/badge/License-CC0_1.0-lightgrey.svg)](http://creativecommons.org/publicdomain/zero/1.0/) [![Build and Push Docker Image](https://github.com/ihatenodejs/librecloud-web/actions/workflows/docker.yml/badge.svg)](https://github.com/ihatenodejs/librecloud-web/actions/workflows/docker.yml) diff --git a/app/account/dashboard/settings/page.tsx b/app/account/dashboard/settings/page.tsx index ee02964..71526c8 100644 --- a/app/account/dashboard/settings/page.tsx +++ b/app/account/dashboard/settings/page.tsx @@ -1,19 +1,12 @@ "use client" -import { motion } from "motion/react" import { Switch } from "@/components/ui/switch" import { Label } from "@/components/ui/label" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { ChangePassword } from "@/components/cards/dashboard/Settings/ChangePassword" +import MyAccount from "@/components/cards/dashboard/Settings/MyAccount" import { useState, useEffect } from "react" import { LayoutDashboard } from "lucide-react" -const fadeIn = { - initial: { opacity: 0, y: 20 }, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0.4 }, -} - export default function Settings() { const [settings, setSettings] = useState({ hideGenAI: false, @@ -85,10 +78,10 @@ export default function Settings() { }; return ( - -

Settings

+ <> +

Settings

- + @@ -130,7 +123,7 @@ export default function Settings() {
-
+ ) } diff --git a/app/account/layout.tsx b/app/account/layout.tsx index 17d8dee..300f896 100644 --- a/app/account/layout.tsx +++ b/app/account/layout.tsx @@ -1,4 +1,5 @@ -import React from "react"; +import React from "react" +import { Toaster } from "@/components/ui/sonner" export default function AccountLayout({ children, @@ -6,7 +7,12 @@ export default function AccountLayout({ children: React.ReactNode }) { return ( -
{children}
+ <> +
+ {children} +
+ + ) } diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts new file mode 100644 index 0000000..7ac92d2 --- /dev/null +++ b/app/api/auth/password/route.ts @@ -0,0 +1,62 @@ +import { auth } from "@/auth" +import axios from "axios" +import { NextResponse } from "next/server" + +export async function POST(request: Request) { + try { + const session = await auth() + const body = await request.json() + const { password } = body + + if (!session || !session.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } else if (!password || typeof password !== "string") { + return NextResponse.json({ error: "Invalid password" }, { status: 400 }) + } + + // Get user ID from email + const user = await axios.request({ + method: "get", + url: `${process.env.AUTHENTIK_API_URL}/core/users/?email=${session.user.email}`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`, + }, + validateStatus: () => true, + }) + + const userId = user.data.results[0].pk + + if (!userId) { + console.error(`[!] User ID not found in response: ${session.user.email}`) + return NextResponse.json({ error: "User not found" }, { status: 404 }) + } + + 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: { password }, + validateStatus: () => true, + }) + + if (updCfg.data?.detail) { + console.error("[!] Password setting issue:", updCfg.data.detail) + return NextResponse.json({ error: "Failed to change password" }, { status: 400 }) + } + + if (updCfg.status === 204) { + return NextResponse.json({ success: true }) + } else { + return NextResponse.json({ error: "Failed to change password" }, { status: 400 }) + } + + } catch (error) { + console.error("[!]", error) + return NextResponse.json({ error: "Server error" }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 3bcb11f..659882f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -20,6 +20,10 @@ --color-accent-foreground: oklch(var(--accent-foreground)); --color-destructive: oklch(var(--destructive)); --color-destructive-foreground: oklch(var(--destructive-foreground)); + --color-error: oklch(var(--error)); + --color-error-foreground: oklch(var(--error-foreground)); + --color-success: oklch(var(--success)); + --color-success-foreground: oklch(var(--success-foreground)); --color-border: oklch(var(--border)); --color-input: oklch(var(--input)); --color-ring: oklch(var(--ring)); @@ -105,6 +109,10 @@ --accent-foreground: 0.208 0.042 265.755; --destructive: 0.577 0.245 27.325; --destructive-foreground: 0.984 0.003 247.858; + --error: 0.577 0.245 27.325; + --error-foreground: 0.984 0.003 247.858; + --success: 0.6 0.118 184.704; + --success-foreground: 0.984 0.003 247.858; --border: 0.929 0.013 255.508; --input: 0.929 0.013 255.508; --ring: 0.704 0.04 256.788; @@ -145,6 +153,10 @@ --accent-foreground: 0.984 0.003 247.858; --destructive: 0.704 0.191 22.216; --destructive-foreground: 0.984 0.003 247.858; + --error: 0.704 0.191 22.216; + --error-foreground: 0.984 0.003 247.858; + --success: 0.696 0.17 162.48; + --success-foreground: 0.984 0.003 247.858; --border: 1 0 0 / 10%; --input: 1 0 0 / 15%; --ring: 0.551 0.027 264.364; diff --git a/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx b/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx new file mode 100644 index 0000000..59c2fe7 --- /dev/null +++ b/components/cards/dashboard/Settings/ChangeAuthentikPassword.tsx @@ -0,0 +1,253 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Key, Loader2, CheckCircleIcon, XCircleIcon } from "lucide-react" +import Link from "next/link" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { toast } from "sonner" +import { motion, useAnimationControls } from "framer-motion" + +export function ChangeAuthentikPassword() { + const [newPassword, setNewPassword] = useState("") + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const holdTimeoutRef = useRef(null) + const intervalRef = useRef(null) + const controls = useAnimationControls() + const [isHolding, setIsHolding] = useState(false) + const holdDuration = 10 + const [remainingTime, setRemainingTime] = useState(holdDuration) + + const submitPasswordChange = async () => { + setLoading(true) + try { + const response = await fetch("/api/auth/password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ password: newPassword }), + }) + const resData = await response.json() + + if (response.ok && resData.success) { + toast("Password updated successfully!", { + icon: , + style: { + backgroundColor: "oklch(var(--success))", + color: "oklch(var(--success-foreground))", + }, + }) + setTimeout(() => { + setOpen(false) + setNewPassword("") + controls.set({ "--progress": "0%" }) + }, 1500) + } else if (resData.error) { + toast("An error occurred", { + description: resData.error, + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } else { + toast("Failed to Update", { + description: "An unknown error occurred [1]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } + } catch (error) { + console.log(error) + toast("Failed to Update", { + description: "An unknown error occurred [2]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } finally { + setLoading(false) + setIsHolding(false) + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + } + + const holdDurationMs = holdDuration * 1000 + + const handleHoldStart = () => { + if (loading || newPassword.length < 8) return + + setIsHolding(true) + controls.set({ "--progress": "0%" }) + controls.start( + { "--progress": "100%" }, + { duration: holdDuration, ease: "linear" } + ) + + holdTimeoutRef.current = setTimeout(() => { + console.log("[i] Hold complete, submitting...") + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + submitPasswordChange() + holdTimeoutRef.current = null + }, holdDurationMs) + + setRemainingTime(holdDuration) + if (intervalRef.current) clearInterval(intervalRef.current) + intervalRef.current = setInterval(() => { + setRemainingTime((prevTime) => { + if (prevTime <= 1) { + if (intervalRef.current) clearInterval(intervalRef.current) + return 0 + } + return prevTime - 1 + }) + }, 1000) + } + + const handleHoldEnd = () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (isHolding) { + console.log("[i] Hold interrupted") + controls.stop() + controls.set({ "--progress": "0%" }) + setIsHolding(false) + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + useEffect(() => { + return () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + useEffect(() => { + if (!open) { + handleHoldEnd() + setLoading(false) + setNewPassword("") + } + }, [open]) + + return ( + + + + + + + Change Your Password + + This only applies to your + + Authentik + account. + Make sure it's secure, and consider using + + LibreCloud Pass + to keep it safe! + + +
+
+ + setNewPassword(e.target.value)} + className="mt-1.5" + /> +

+ Password must be at least 8 characters long. +

+
+ + + + + +
+
+
+ ) +} + +export default ChangeAuthentikPassword \ No newline at end of file diff --git a/components/cards/dashboard/Settings/ChangeEmailPassword.tsx b/components/cards/dashboard/Settings/ChangeEmailPassword.tsx new file mode 100644 index 0000000..ebe7dac --- /dev/null +++ b/components/cards/dashboard/Settings/ChangeEmailPassword.tsx @@ -0,0 +1,246 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { CheckCircleIcon, Key, Loader2, XCircleIcon } from "lucide-react" +import Link from "next/link" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { toast } from "sonner" +import { motion, useAnimationControls } from "framer-motion" + +export function ChangeEmailPassword() { + const [newPassword, setNewPassword] = useState("") + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const holdTimeoutRef = useRef(null) + const intervalRef = useRef(null) + const controls = useAnimationControls() + const [isHolding, setIsHolding] = useState(false) + const holdDuration = 10 + const [remainingTime, setRemainingTime] = useState(holdDuration) + + const submitPasswordChange = async () => { + setLoading(true) + try { + const response = await fetch("/api/mail/password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ password: newPassword }), + }) + const resData = await response.json() + + if (response.ok && resData.success) { + toast("Password updated successfully!", { + icon: , + style: { + backgroundColor: "oklch(var(--success))", + color: "oklch(var(--success-foreground))", + }, + }) + setTimeout(() => { + setOpen(false) + setNewPassword("") + controls.set({ "--progress": "0%" }) + }, 1500) + } else if (resData.error) { + toast("An error occurred", { + description: resData.error, + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } else { + toast("Failed to Update", { + description: "An unknown error occurred [1]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } + } catch (error) { + console.log(error) + toast("Failed to Update", { + description: "An unknown error occurred [2]", + icon: , + style: { + backgroundColor: "oklch(var(--error))", + color: "oklch(var(--error-foreground))", + }, + }) + controls.set({ "--progress": "0%" }) + } finally { + setLoading(false) + setIsHolding(false) + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + } + + const holdDurationMs = holdDuration * 1000 + + const handleHoldStart = () => { + if (loading || newPassword.length < 8) return + + setIsHolding(true) + controls.set({ "--progress": "0%" }) + controls.start( + { "--progress": "100%" }, + { duration: holdDuration, ease: "linear" } + ) + + holdTimeoutRef.current = setTimeout(() => { + console.log("[i] Hold complete, submitting...") + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + submitPasswordChange() + holdTimeoutRef.current = null + }, holdDurationMs) + + setRemainingTime(holdDuration) + if (intervalRef.current) clearInterval(intervalRef.current) + intervalRef.current = setInterval(() => { + setRemainingTime((prevTime) => { + if (prevTime <= 1) { + if (intervalRef.current) clearInterval(intervalRef.current) + return 0 + } + return prevTime - 1 + }) + }, 1000) + } + + const handleHoldEnd = () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + holdTimeoutRef.current = null + } + if (isHolding) { + console.log("[i] Hold interrupted") + controls.stop() + controls.set({ "--progress": "0%" }) + setIsHolding(false) + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + } + + useEffect(() => { + return () => { + if (holdTimeoutRef.current) { + clearTimeout(holdTimeoutRef.current) + } + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + useEffect(() => { + if (!open) { + handleHoldEnd() + setLoading(false) + setNewPassword("") + } + }, [open]) + + return ( + + + + + + + Change Your Password + + This only applies to your email account. + Make sure it's secure, and consider using + + LibreCloud Pass + to keep it safe! + + +
+
+ + setNewPassword(e.target.value)} + className="mt-1.5" + /> +

+ Password must be at least 8 characters long. +

+
+ + + + + +
+
+
+ ) +} + +export default ChangeEmailPassword \ No newline at end of file diff --git a/components/cards/dashboard/Settings/ChangePassword.tsx b/components/cards/dashboard/Settings/ChangePassword.tsx deleted file mode 100644 index 25af8c1..0000000 --- a/components/cards/dashboard/Settings/ChangePassword.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client" - -import React, { useState } from "react" -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { Button } from "@/components/ui/button" -import { Label } from "@/components/ui/label" -import { Key, Loader2, User } from "lucide-react" -import Link from "next/link" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger -} from "@/components/ui/dialog" - -export function ChangePassword() { - const [newPassword, setNewPassword] = useState(""); - const [loading, setLoading] = useState(false); - const [message, setMessage] = useState(null); - const [open, setOpen] = useState(false); - - const handlePasswordChange = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setMessage(null); - try { - const response = await fetch("/api/mail/password", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ password: newPassword }), - }); - const resData = await response.json(); - - if (response.ok && resData.success) { - setMessage("Password Updated"); - setLoading(false); - // Close dialog after change - setTimeout(() => { - setOpen(false); - setNewPassword(""); - }, 1500); - } else if (resData.error) { - setMessage(resData.error); - setLoading(false); - } else { - setMessage("[1] Failed to Update"); - setLoading(false); - } - } catch (error) { - console.log(error) - setMessage("[2] Failed to Update"); - setLoading(false); - } - }; - - return ( - - - - - My Account - - LibreCloud makes it easy to manage your account - - -

Actions

- - - - - - - Change Your Password - - This only applies to your Authentik account. - Make sure it's secure, and consider using - - LibreCloud Pass - to keep it safe. - - -
-
- - setNewPassword(e.target.value)} - className="mt-1.5" - /> -

- Password must be at least 8 characters long. -

-
- {message && ( -

- {message} -

- )} - - - -
-
-
-
-
- ); -} - -export default ChangePassword; \ No newline at end of file diff --git a/components/cards/dashboard/Settings/MyAccount.tsx b/components/cards/dashboard/Settings/MyAccount.tsx new file mode 100644 index 0000000..2cfc299 --- /dev/null +++ b/components/cards/dashboard/Settings/MyAccount.tsx @@ -0,0 +1,24 @@ +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card" +import ChangeAuthentikPassword from "@/components/cards/dashboard/Settings/ChangeAuthentikPassword" +import ChangeEmailPassword from "@/components/cards/dashboard/Settings/ChangeEmailPassword" +import { User } from "lucide-react" + +export default function MyAccount() { + return ( + + + + + My Account + + LibreCloud makes it easy to manage your account + + +

Email

+ +

Authentik

+ +
+
+ ) +} \ No newline at end of file diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 0757140..8905458 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< {children} - + Close diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..92e7cf9 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/package.json b/package.json index d126579..f09acff 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@prisma/client": "^6.6.0", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-label": "^2.1.3", "@radix-ui/react-popover": "^1.1.7", @@ -45,6 +46,7 @@ "react-hook-form": "^7.55.0", "react-icons": "^5.5.0", "react-typed": "^2.0.12", + "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tw-animate-css": "^1.2.5", "validator": "^13.15.0",