Spaces:
Sleeping
Sleeping
FauziIsyrinApridal
commited on
Commit
·
241884b
1
Parent(s):
b7a53a7
revisi 1
Browse files- app/(auth)/forgot-password/page.tsx +24 -0
- app/(auth)/reset-password/page.tsx +54 -0
- components/ForgotPasswordForm.tsx +56 -0
- components/ResetPasswordForm.tsx +72 -0
- components/SignInForm.tsx +15 -4
- utils/forgotPassword.ts +27 -0
- utils/resetPassword.ts +18 -0
- utils/signIn.ts +19 -8
app/(auth)/forgot-password/page.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import ForgotPasswordForm from "@/components/ForgotPasswordForm";
|
2 |
+
import Image from "next/image";
|
3 |
+
import PnpLogo from "../../../public/logo-pnp.webp";
|
4 |
+
import Link from "next/link";
|
5 |
+
|
6 |
+
export default function ForgotPasswordPage() {
|
7 |
+
return (
|
8 |
+
<div className="flex min-h-screen flex-col items-center justify-center">
|
9 |
+
<div className="w-10/12 rounded-md border p-5 text-center shadow-md sm:w-8/12 md:w-6/12 lg:w-4/12 xl:w-3/12 2xl:w-3/12">
|
10 |
+
<div className="flex justify-center py-5">
|
11 |
+
<Image src={PnpLogo} width={120} height={120} alt="Logo Politeknik Negeri Padang" />
|
12 |
+
</div>
|
13 |
+
<h1 className="pb-3 text-xl font-semibold">Lupa Password</h1>
|
14 |
+
<p className="mb-5 text-sm">Masukkan email Anda untuk menerima tautan reset password.</p>
|
15 |
+
<ForgotPasswordForm />
|
16 |
+
<div className="mt-4 text-xs">
|
17 |
+
<Link href="/login" className="text-blue-600 hover:underline">
|
18 |
+
Kembali ke Login
|
19 |
+
</Link>
|
20 |
+
</div>
|
21 |
+
</div>
|
22 |
+
</div>
|
23 |
+
);
|
24 |
+
}
|
app/(auth)/reset-password/page.tsx
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
import { useEffect, useState } from "react";
|
3 |
+
import Image from "next/image";
|
4 |
+
import PnpLogo from "../../../public/logo-pnp.webp";
|
5 |
+
import ResetPasswordForm from "@/components/ResetPasswordForm";
|
6 |
+
import { createClient } from "@/utils/supabase/client";
|
7 |
+
|
8 |
+
export default function ResetPasswordPage() {
|
9 |
+
const supabase = createClient();
|
10 |
+
const [ready, setReady] = useState(false);
|
11 |
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
12 |
+
|
13 |
+
useEffect(() => {
|
14 |
+
// When redirected from the email link, Supabase puts tokens in the hash.
|
15 |
+
// Exchange for a session so we can call updateUser.
|
16 |
+
const run = async () => {
|
17 |
+
try {
|
18 |
+
if (typeof window !== "undefined" && window.location.hash) {
|
19 |
+
const { error } = await supabase.auth.exchangeCodeForSession(window.location.hash);
|
20 |
+
if (error) {
|
21 |
+
setErrorMsg("Tautan tidak valid atau sudah kedaluwarsa.");
|
22 |
+
}
|
23 |
+
}
|
24 |
+
} catch (e) {
|
25 |
+
setErrorMsg("Gagal memproses tautan reset.");
|
26 |
+
} finally {
|
27 |
+
setReady(true);
|
28 |
+
}
|
29 |
+
};
|
30 |
+
run();
|
31 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
32 |
+
}, []);
|
33 |
+
|
34 |
+
return (
|
35 |
+
<div className="flex min-h-screen flex-col items-center justify-center">
|
36 |
+
<div className="w-10/12 rounded-md border p-5 text-center shadow-md sm:w-8/12 md:w-6/12 lg:w-4/12 xl:w-3/12 2xl:w-3/12">
|
37 |
+
<div className="flex justify-center py-5">
|
38 |
+
<Image src={PnpLogo} width={120} height={120} alt="Logo Politeknik Negeri Padang" />
|
39 |
+
</div>
|
40 |
+
<h1 className="pb-3 text-xl font-semibold">Reset Password</h1>
|
41 |
+
{!ready ? (
|
42 |
+
<p className="text-sm">Menyiapkan formulir...</p>
|
43 |
+
) : errorMsg ? (
|
44 |
+
<p className="text-sm text-red-600">{errorMsg}</p>
|
45 |
+
) : (
|
46 |
+
<>
|
47 |
+
<p className="mb-5 text-sm">Masukkan password baru Anda.</p>
|
48 |
+
<ResetPasswordForm />
|
49 |
+
</>
|
50 |
+
)}
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
);
|
54 |
+
}
|
components/ForgotPasswordForm.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
3 |
+
import { useForm, SubmitHandler } from "react-hook-form";
|
4 |
+
import { z } from "zod";
|
5 |
+
import { Input } from "./ui/input";
|
6 |
+
import { Button } from "./ui/button";
|
7 |
+
import { Label } from "./ui/label";
|
8 |
+
import { sendResetEmail } from "@/utils/forgotPassword";
|
9 |
+
import { useState } from "react";
|
10 |
+
|
11 |
+
const schema = z.object({
|
12 |
+
email: z.string().email("Email tidak valid"),
|
13 |
+
});
|
14 |
+
|
15 |
+
type FormFields = z.infer<typeof schema>;
|
16 |
+
|
17 |
+
export default function ForgotPasswordForm() {
|
18 |
+
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
19 |
+
const {
|
20 |
+
register,
|
21 |
+
handleSubmit,
|
22 |
+
setError,
|
23 |
+
formState: { errors, isSubmitting },
|
24 |
+
} = useForm<FormFields>({ resolver: zodResolver(schema) });
|
25 |
+
|
26 |
+
const onSubmit: SubmitHandler<FormFields> = async ({ email }) => {
|
27 |
+
setSuccessMsg(null);
|
28 |
+
const res = await sendResetEmail(email);
|
29 |
+
if (!res.ok) {
|
30 |
+
setError("root", { message: res.message });
|
31 |
+
return;
|
32 |
+
}
|
33 |
+
setSuccessMsg(res.message);
|
34 |
+
};
|
35 |
+
|
36 |
+
return (
|
37 |
+
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
|
38 |
+
<Label htmlFor="email" className="text-left">
|
39 |
+
Email
|
40 |
+
</Label>
|
41 |
+
<Input id="email" type="email" placeholder="Email" {...register("email")} />
|
42 |
+
{errors.email && (
|
43 |
+
<div className="text-left text-xs text-red-500">{errors.email.message}</div>
|
44 |
+
)}
|
45 |
+
<Button disabled={isSubmitting} type="submit" className="mt-4 bg-orange-600 hover:bg-orange-800">
|
46 |
+
{isSubmitting ? "Mengirim..." : "Kirim tautan reset"}
|
47 |
+
</Button>
|
48 |
+
{errors.root && (
|
49 |
+
<div className="text-xs text-red-500">{errors.root.message}</div>
|
50 |
+
)}
|
51 |
+
{successMsg && (
|
52 |
+
<div className="text-xs text-green-600">{successMsg}</div>
|
53 |
+
)}
|
54 |
+
</form>
|
55 |
+
);
|
56 |
+
}
|
components/ResetPasswordForm.tsx
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client";
|
2 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
3 |
+
import { useForm, SubmitHandler } from "react-hook-form";
|
4 |
+
import { z } from "zod";
|
5 |
+
import { Input } from "./ui/input";
|
6 |
+
import { Button } from "./ui/button";
|
7 |
+
import { Label } from "./ui/label";
|
8 |
+
import { useState } from "react";
|
9 |
+
import { createClient } from "@/utils/supabase/client";
|
10 |
+
|
11 |
+
const schema = z
|
12 |
+
.object({
|
13 |
+
password: z.string().min(8, "Password minimal 8 karakter"),
|
14 |
+
confirm: z.string().min(8, "Konfirmasi minimal 8 karakter"),
|
15 |
+
})
|
16 |
+
.refine((data) => data.password === data.confirm, {
|
17 |
+
message: "Password dan konfirmasi tidak sama",
|
18 |
+
path: ["confirm"],
|
19 |
+
});
|
20 |
+
|
21 |
+
type FormFields = z.infer<typeof schema>;
|
22 |
+
|
23 |
+
export default function ResetPasswordForm() {
|
24 |
+
const supabase = createClient();
|
25 |
+
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
26 |
+
const {
|
27 |
+
register,
|
28 |
+
handleSubmit,
|
29 |
+
setError,
|
30 |
+
formState: { errors, isSubmitting },
|
31 |
+
} = useForm<FormFields>({ resolver: zodResolver(schema) });
|
32 |
+
|
33 |
+
const onSubmit: SubmitHandler<FormFields> = async ({ password }) => {
|
34 |
+
setSuccessMsg(null);
|
35 |
+
const { error } = await supabase.auth.updateUser({ password });
|
36 |
+
if (error) {
|
37 |
+
setError("root", { message: "Gagal mengubah password. Coba lagi." });
|
38 |
+
return;
|
39 |
+
}
|
40 |
+
setSuccessMsg("Password berhasil diubah. Silakan login kembali.");
|
41 |
+
};
|
42 |
+
|
43 |
+
return (
|
44 |
+
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
|
45 |
+
<Label htmlFor="password" className="text-left">
|
46 |
+
Password Baru
|
47 |
+
</Label>
|
48 |
+
<Input id="password" type="password" placeholder="Password baru" {...register("password")} />
|
49 |
+
{errors.password && (
|
50 |
+
<div className="text-left text-xs text-red-500">{errors.password.message}</div>
|
51 |
+
)}
|
52 |
+
|
53 |
+
<Label htmlFor="confirm" className="text-left">
|
54 |
+
Konfirmasi Password
|
55 |
+
</Label>
|
56 |
+
<Input id="confirm" type="password" placeholder="Konfirmasi password" {...register("confirm")} />
|
57 |
+
{errors.confirm && (
|
58 |
+
<div className="text-left text-xs text-red-500">{errors.confirm.message}</div>
|
59 |
+
)}
|
60 |
+
|
61 |
+
<Button disabled={isSubmitting} type="submit" className="mt-4 bg-orange-600 hover:bg-orange-800">
|
62 |
+
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
|
63 |
+
</Button>
|
64 |
+
{errors.root && (
|
65 |
+
<div className="text-xs text-red-500">{errors.root.message}</div>
|
66 |
+
)}
|
67 |
+
{successMsg && (
|
68 |
+
<div className="text-xs text-green-600">{successMsg}</div>
|
69 |
+
)}
|
70 |
+
</form>
|
71 |
+
);
|
72 |
+
}
|
components/SignInForm.tsx
CHANGED
@@ -8,6 +8,7 @@ import { login } from "@/utils/signIn";
|
|
8 |
import { Label } from "./ui/label";
|
9 |
import { useState } from "react";
|
10 |
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
|
|
11 |
|
12 |
const schema = z.object({
|
13 |
email: z.string().email(),
|
@@ -31,11 +32,13 @@ const SignInForm = () => {
|
|
31 |
|
32 |
const onSubmit: SubmitHandler<FormFields> = async (data) => {
|
33 |
try {
|
34 |
-
await login(data.email, data.password);
|
|
|
|
|
|
|
|
|
35 |
} catch (error) {
|
36 |
-
setError("root", {
|
37 |
-
message: "Invalid email or password",
|
38 |
-
});
|
39 |
}
|
40 |
};
|
41 |
|
@@ -99,6 +102,14 @@ const SignInForm = () => {
|
|
99 |
{errors.root && (
|
100 |
<div className="text-xs text-red-500">{errors.root.message}</div>
|
101 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
</form>
|
103 |
</>
|
104 |
);
|
|
|
8 |
import { Label } from "./ui/label";
|
9 |
import { useState } from "react";
|
10 |
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
11 |
+
import Link from "next/link";
|
12 |
|
13 |
const schema = z.object({
|
14 |
email: z.string().email(),
|
|
|
32 |
|
33 |
const onSubmit: SubmitHandler<FormFields> = async (data) => {
|
34 |
try {
|
35 |
+
const res = await login(data.email, data.password);
|
36 |
+
if (res && "ok" in res && !res.ok) {
|
37 |
+
setError("root", { message: res.message || "Gagal login" });
|
38 |
+
}
|
39 |
+
// On success, server action will redirect to "/".
|
40 |
} catch (error) {
|
41 |
+
setError("root", { message: "Terjadi kesalahan. Coba lagi." });
|
|
|
|
|
42 |
}
|
43 |
};
|
44 |
|
|
|
102 |
{errors.root && (
|
103 |
<div className="text-xs text-red-500">{errors.root.message}</div>
|
104 |
)}
|
105 |
+
<div className="mt-3 text-center text-xs">
|
106 |
+
<Link
|
107 |
+
href="/forgot-password"
|
108 |
+
className="text-blue-600 hover:underline"
|
109 |
+
>
|
110 |
+
Lupa password?
|
111 |
+
</Link>
|
112 |
+
</div>
|
113 |
</form>
|
114 |
</>
|
115 |
);
|
utils/forgotPassword.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use server";
|
2 |
+
import { createClient } from "@/utils/supabase/server";
|
3 |
+
|
4 |
+
export type ForgotResult = { ok: true; message: string } | { ok: false; message: string };
|
5 |
+
|
6 |
+
export async function sendResetEmail(email: string): Promise<ForgotResult> {
|
7 |
+
const supabase = createClient();
|
8 |
+
|
9 |
+
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
10 |
+
return { ok: false, message: "Email tidak valid" };
|
11 |
+
}
|
12 |
+
|
13 |
+
// Determine redirect URL for the password reset flow
|
14 |
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
15 |
+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000");
|
16 |
+
const redirectTo = `${siteUrl}/reset-password`;
|
17 |
+
|
18 |
+
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
19 |
+
redirectTo,
|
20 |
+
});
|
21 |
+
|
22 |
+
if (error) {
|
23 |
+
return { ok: false, message: "Gagal mengirim email reset. Coba lagi." };
|
24 |
+
}
|
25 |
+
|
26 |
+
return { ok: true, message: "Email reset telah dikirim. Periksa kotak masuk Anda." };
|
27 |
+
}
|
utils/resetPassword.ts
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use server";
|
2 |
+
// Server action kept minimal; reset is better done on client using the recovery session token.
|
3 |
+
// Provided for completeness if needed by API routes in future.
|
4 |
+
import { createClient } from "@/utils/supabase/server";
|
5 |
+
|
6 |
+
export type ResetResult = { ok: true; message: string } | { ok: false; message: string };
|
7 |
+
|
8 |
+
export async function resetPasswordServer(password: string): Promise<ResetResult> {
|
9 |
+
if (!password || password.length < 8) {
|
10 |
+
return { ok: false, message: "Password minimal 8 karakter" };
|
11 |
+
}
|
12 |
+
const supabase = createClient();
|
13 |
+
const { error } = await supabase.auth.updateUser({ password });
|
14 |
+
if (error) {
|
15 |
+
return { ok: false, message: "Gagal memperbarui password." };
|
16 |
+
}
|
17 |
+
return { ok: true, message: "Password berhasil diubah." };
|
18 |
+
}
|
utils/signIn.ts
CHANGED
@@ -4,21 +4,32 @@ import { redirect } from "next/navigation";
|
|
4 |
|
5 |
import { createClient } from "@/utils/supabase/server";
|
6 |
|
7 |
-
|
|
|
|
|
8 |
const supabase = createClient();
|
9 |
|
10 |
-
//
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
|
|
|
|
15 |
|
16 |
-
const { error } = await supabase.auth.signInWithPassword(
|
17 |
|
18 |
if (error) {
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
}
|
21 |
|
|
|
22 |
revalidatePath("/", "layout");
|
23 |
redirect("/");
|
24 |
}
|
|
|
4 |
|
5 |
import { createClient } from "@/utils/supabase/server";
|
6 |
|
7 |
+
type LoginResult = { ok: true } | { ok: false; message: string };
|
8 |
+
|
9 |
+
export async function login(email: string, password: string): Promise<LoginResult> {
|
10 |
const supabase = createClient();
|
11 |
|
12 |
+
// Basic validation (server-side safeguard)
|
13 |
+
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
14 |
+
return { ok: false, message: "Email tidak valid" };
|
15 |
+
}
|
16 |
+
if (!password || password.length < 8) {
|
17 |
+
return { ok: false, message: "Password minimal 8 karakter" };
|
18 |
+
}
|
19 |
|
20 |
+
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
21 |
|
22 |
if (error) {
|
23 |
+
// Surface concise message
|
24 |
+
const msg =
|
25 |
+
error.message?.toLowerCase().includes("invalid") ||
|
26 |
+
error.status === 400
|
27 |
+
? "Email atau password salah"
|
28 |
+
: "Gagal login. Coba lagi.";
|
29 |
+
return { ok: false, message: msg };
|
30 |
}
|
31 |
|
32 |
+
// Success: revalidate and redirect
|
33 |
revalidatePath("/", "layout");
|
34 |
redirect("/");
|
35 |
}
|