diff --git a/README.md b/README.md
index 61a76e5..af2cfda 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
# web
-
[](http://creativecommons.org/publicdomain/zero/1.0/)
[](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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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
-
-
-
- );
-}
-
-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",