diff --git a/eslint.config.mjs b/eslint.config.mjs index 82d6ff4..29407a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,15 +11,15 @@ const compat = new FlatCompat({ const eslintConfig = [ { - ignores: ["src/components/ui/**/*"] + ignores: ["src/components/ui/**/*"], }, ...compat.extends("next/core-web-vitals", "next/typescript"), { rules: { "no-unused-vars": "error", - "@typescript-eslint/no-unused-vars": "error" - } - } + "@typescript-eslint/no-unused-vars": "error", + }, + }, ]; export default eslintConfig; diff --git a/public/profile_fallback.png b/public/profile_fallback.png new file mode 100644 index 0000000..e125c32 Binary files /dev/null and b/public/profile_fallback.png differ diff --git a/src/app/(authenticated)/profile/page.tsx b/src/app/(authenticated)/profile/page.tsx index 89dace3..c61afe1 100644 --- a/src/app/(authenticated)/profile/page.tsx +++ b/src/app/(authenticated)/profile/page.tsx @@ -1,10 +1,25 @@ +import DangerZone from "@/components/application/profile/danger-zone"; +import ProfileDetails from "@/components/application/profile/details"; import React from "react"; const Profile = () => { return ( -
- Profile -
+
+

+ User Profile +

+ +
+ +
+ +
+

+ Danger Zone +

+ +
+
); }; diff --git a/src/app/(unauthenticated)/reset-password/page.tsx b/src/app/(unauthenticated)/reset-password/page.tsx new file mode 100644 index 0000000..21c7734 --- /dev/null +++ b/src/app/(unauthenticated)/reset-password/page.tsx @@ -0,0 +1,28 @@ +"use client"; +import ResetPasswordForm from "@/components/application/authentication/reset-password-form"; +import React from "react"; +import Image from "next/image"; + +const ResetPassword = () => { + return ( +
+
+
+ +
+ +
+
+ hero img +
+
+ ); +}; + +export default ResetPassword; diff --git a/src/app/actions/revalidate-user.action.ts b/src/app/actions/revalidate-user.action.ts new file mode 100644 index 0000000..cc51d98 --- /dev/null +++ b/src/app/actions/revalidate-user.action.ts @@ -0,0 +1,12 @@ +"use server"; + +import { auth, reverificationError } from "@clerk/nextjs/server"; + +export const reverifyUser = async () => { + const { has } = await auth.protect(); + const shouldUserRevalidate = !has({ reverification: "strict" }); + if (shouldUserRevalidate) { + return reverificationError("strict"); + } + return { success: true }; +}; diff --git a/src/components/application/authentication/authentication-form.tsx b/src/components/application/authentication/authentication-form.tsx index 8286573..828a07d 100644 --- a/src/components/application/authentication/authentication-form.tsx +++ b/src/components/application/authentication/authentication-form.tsx @@ -383,10 +383,13 @@ const AuthenticationForm: React.FC = ({ type }) => { )} -
+
{footerText} + {type === 'login' && + Forgot Password? + }
diff --git a/src/components/application/authentication/reset-password-form.tsx b/src/components/application/authentication/reset-password-form.tsx new file mode 100644 index 0000000..f070830 --- /dev/null +++ b/src/components/application/authentication/reset-password-form.tsx @@ -0,0 +1,259 @@ +"use client"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import React, { useEffect, useState } from "react"; +import Logo from "../logo"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Eye, EyeClosed, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { useAuth, useSignIn } from "@clerk/nextjs"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { useRouter } from "next/navigation"; +import { showError, showSuccess, showWarning } from "@/lib/sonner"; + +const ResetPasswordForm = () => { + // states + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [code, setCode] = useState('') + const [successfulCreation, setSuccessfulCreation] = useState(false) + const [secondFactor, setSecondFactor] = useState(false) + const [error, setError] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [cooldown, setCooldown] = useState(60); + const [loaders, setLoaders] = useState({ + createLoader: false, + resetLoader: false + }) + + + // hooks + const router = useRouter() + const { isSignedIn } = useAuth() + const { isLoaded, signIn, setActive } = useSignIn() + + useEffect(() => { + if (isSignedIn) { + router.push('/') + } + }, [isSignedIn, router]) + + useEffect(() => { + if (cooldown <= 0) { + return; + } + const timer = setInterval(() => { + setCooldown((prev) => prev - 1); + }, 1000); + + return () => clearInterval(timer); + }, [cooldown]); + + + if (!isLoaded) { + return null + } + + async function create(e: React.FormEvent) { + e.preventDefault() + if (!email) { + showWarning("Email is required") + return; + } + setLoaders((prev) => ({ ...prev, createLoader: true })) + await signIn + ?.create({ + strategy: 'reset_password_email_code', + identifier: email, + }) + .then(() => { + setSuccessfulCreation(true) + showSuccess("Code sent to your E-mail") + setError('') + }) + .catch((err) => { + console.error('error', err.errors[0].longMessage) + setError(err.errors[0].longMessage) + showError(err.errors[0].longMessage) + }).finally(() => setLoaders((prev) => ({ ...prev, createLoader: false }))) + } + + // Reset the user's password. + // Upon successful reset, the user will be + // signed in and redirected to the home page + async function reset(e: React.FormEvent) { + e.preventDefault() + setLoaders((prev) => ({ ...prev, resetLoader: true })) + await signIn + ?.attemptFirstFactor({ + strategy: 'reset_password_email_code', + code, + password, + }) + .then((result) => { + // Check if 2FA is required + if (result.status === 'needs_second_factor') { + setSecondFactor(true) + setError('') + } else if (result.status === 'complete') { + // Set the active session to + // the newly created session (user is now signed in) + setActive({ + session: result.createdSessionId, + navigate: async ({ session }) => { + if (session?.currentTask) { + // Check for tasks and navigate to custom UI to help users resolve them + // See https://clerk.com/docs/custom-flows/overview#session-tasks + console.log(session?.currentTask) + return + } + + router.push('/workplace') + }, + }) + showSuccess("Password reset success. Go to Dashboard!") + setError('') + } else { + console.log(result) + } + }) + .catch((err) => { + console.error('error', err.errors[0].longMessage) + setError(err.errors[0].longMessage) + showError(err.errors[0].longMessage) + }).finally(() => setLoaders((prev) => ({ ...prev, resetLoader: false }))) + } + + return ( + + + + Reset your password + + + + + + +
+ {!successfulCreation && ( +
+
+ + setEmail(e.target.value)} + /> +
+ + + {error &&

{error}

} +
+ )} + + {successfulCreation && ( +
+
+ + { + e.preventDefault() + setPassword(e.target.value) + }} + required + /> + +
+ +
+ + + + + + + + + + + +
+ + + {error &&

{error}

} +
+ )} + + {secondFactor &&

2FA is required, but this UI does not handle that

} +
+
+ + +
+ + Back to login + +
+
+
+ ); +}; + +export default ResetPasswordForm; diff --git a/src/components/application/profile/danger-zone.tsx b/src/components/application/profile/danger-zone.tsx new file mode 100644 index 0000000..6ab8ae1 --- /dev/null +++ b/src/components/application/profile/danger-zone.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import ChangePassword from "./danger-zone/change-password"; +import DeleteAccount from "./danger-zone/delete-account"; + +const DangerZone = () => { + return ( +
+
+
+

+ Update Password +

+

+ Change your old password with a new one. +

+
+ +
+ +
+
+

+ Delete Account +

+

+ Permanently delete your account from flag0ut. +

+
+ +
+
+ ); +}; + +export default DangerZone; diff --git a/src/components/application/profile/danger-zone/change-password.tsx b/src/components/application/profile/danger-zone/change-password.tsx new file mode 100644 index 0000000..735ffd7 --- /dev/null +++ b/src/components/application/profile/danger-zone/change-password.tsx @@ -0,0 +1,195 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { showError, showSuccess } from "@/lib/sonner"; +import { useUser } from "@clerk/nextjs"; +import { useReverification } from "@clerk/nextjs"; +import { reverifyUser } from "@/app/actions/revalidate-user.action"; +import { Eye, EyeClosed, Loader2 } from "lucide-react"; +import { useState } from "react"; + +const ChangePassword = () => { + const [passwords, setPasswords] = useState({ + // oldPassword: "", + newPassword: "", + }); + const [showPassword, setShowPassword] = useState({ + // showOldPass: false, + showNewPass: false, + }); + const [loader, setLoader] = useState(false); + + const { isLoaded, user } = useUser(); + + // This is still in public BETA fallback + const performAction = useReverification(reverifyUser); + + if (!isLoaded) { + return ; + } + + const handleChangePassword = async () => { + setLoader(true); + try { + const res = await performAction(); + if (!res.success) { + showError("Verification Failed!"); + return; + } + + await user?.updatePassword({ + newPassword: passwords.newPassword, + // currentPassword: passwords.oldPassword, + signOutOfOtherSessions: false, + }); + showSuccess("Password updated"); + } catch (error) { + if (error instanceof Error) { + showError(error.message as string); + } + } finally { + setLoader(false); + } + }; + + return ( + +
+ + + + + + + {user?.passwordEnabled ? "Update" : "Add"} your + password + + + {user?.passwordEnabled + ? "Change your password to a new one." + : "Add a password to your account"} + + +
+ {/*
+ + + setPasswords((prev) => ({ + ...prev, + oldPassword: e.target.value, + })) + } + type={ + showPassword.showOldPass + ? "text" + : "password" + } + /> + +
*/} +
+ + + setPasswords((prev) => ({ + ...prev, + newPassword: e.target.value, + })) + } + type={ + showPassword.showNewPass + ? "text" + : "password" + } + /> + +
+
+ + + + + + +
+
+
+ ); +}; + +export default ChangePassword; diff --git a/src/components/application/profile/danger-zone/delete-account.tsx b/src/components/application/profile/danger-zone/delete-account.tsx new file mode 100644 index 0000000..676f545 --- /dev/null +++ b/src/components/application/profile/danger-zone/delete-account.tsx @@ -0,0 +1,102 @@ +"use client"; +import { reverifyUser } from "@/app/actions/revalidate-user.action"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { showError, showSuccess } from "@/lib/sonner"; +import { useReverification, useUser } from "@clerk/nextjs"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +const DeleteAccount = () => { + const [loading, setLoading] = useState(false); + + const { isLoaded, user } = useUser(); + const router = useRouter(); + const performAction = useReverification(reverifyUser); + + if (!isLoaded) { + return ; + } + + const handleAccountDelete = async () => { + setLoading(true); + try { + const res = await performAction(); + if (!res.success) { + showError("Verification Failed!"); + return; + } + await user?.delete(); + showSuccess("Account Deleted"); + router.replace("/"); + } catch (error) { + if (error instanceof Error) { + showError(error.message); + } + } finally { + setLoading(false); + } + }; + return ( + +
+ + + + + + Delete your account + + Are you absolutely sure? All data will be lost. + + + {/*
+
+ + +
+
+ + +
+
*/} + + + + + + +
+
+
+ ); +}; + +export default DeleteAccount; diff --git a/src/components/application/profile/details.tsx b/src/components/application/profile/details.tsx new file mode 100644 index 0000000..7bff4fb --- /dev/null +++ b/src/components/application/profile/details.tsx @@ -0,0 +1,37 @@ +import { currentUser } from "@clerk/nextjs/server"; +import Image from "next/image"; +import React from "react"; +import Metrics from "./metrics"; + +const ProfileDetails = async () => { + const user = await currentUser(); + + return ( +
+
+ {`${user?.firstName}'s +
+ +
+

+ {user?.fullName} +

+
+ {user?.primaryEmailAddress?.emailAddress} +
+
+ +
+ +
+
+ ); +}; + +export default ProfileDetails; diff --git a/src/components/application/profile/metrics.tsx b/src/components/application/profile/metrics.tsx new file mode 100644 index 0000000..0294491 --- /dev/null +++ b/src/components/application/profile/metrics.tsx @@ -0,0 +1,38 @@ +"use client"; +import { useGetMetrics } from "@/lib/tanstack/hooks/metrics"; +import { ChartBarIncreasing, Loader2 } from "lucide-react"; +import React from "react"; +import MetricCard from "../workplace/metric-cards"; +import EmptyState from "../emtpy-state"; + +const Metrics = () => { + const { data: metrics, isLoading: metricsLoading } = useGetMetrics(); + + return ( +
+ {metricsLoading ? ( + + ) : metrics ? ( + Object.entries(metrics.data).map(([key, data]) => ( + str.toUpperCase())} + value={data.value} + growth={data.change} + /> + )) + ) : ( + } + title="No metrics yet" + description="Not enough data to generate metrics" + /> + )} +
+ ); +}; + +export default Metrics; diff --git a/src/middleware.ts b/src/middleware.ts index aa3cdf6..d194419 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; const isPublicRoute = createRouteMatcher([ "/login(.*)", "/register(.*)", + "/reset-password(.*)", "/", "/oauth-callback(.*)", "/api/v1/flags/evaluate",