Compare commits
12 Commits
849403a137
...
016879b53f
Author | SHA1 | Date | |
---|---|---|---|
016879b53f | |||
70bba15cda | |||
57daf175ab | |||
0817a65272 | |||
13f9da1a67 | |||
83535acf1c | |||
441ce2daca | |||
f9848d2110 | |||
7f0511b0d4 | |||
a27f2ad593 | |||
715a984241 | |||
66e1756ade |
@ -72,6 +72,7 @@ func (h *AuthHandler) RegisterRoutes(api chi.Router) {
|
|||||||
protected.Get("/profile", h.getProfile)
|
protected.Get("/profile", h.getProfile)
|
||||||
protected.Post("/email", h.requestEmailOtp)
|
protected.Post("/email", h.requestEmailOtp)
|
||||||
protected.Post("/email/otp", h.confirmOtp)
|
protected.Post("/email/otp", h.confirmOtp)
|
||||||
|
protected.Post("/verify", h.finishVerification)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Post("/login", h.login)
|
r.Post("/login", h.login)
|
||||||
|
@ -97,3 +97,30 @@ func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) finishVerification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, ok := util.GetRequestUserId(r.Context())
|
||||||
|
if !ok {
|
||||||
|
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.EmailVerified || !user.AvatarVerified {
|
||||||
|
web.Error(w, "finish other verification steps before final verify", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.UserVerifyComplete(r.Context(), user.ID); err != nil {
|
||||||
|
log.Println("ERR: Failed to update verified on user:", err)
|
||||||
|
web.Error(w, "failed to verify user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
@ -180,8 +180,16 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !user.AvatarVerified {
|
||||||
|
if err := h.repo.UserVerifyAvatar(r.Context(), user.ID); err != nil {
|
||||||
|
log.Println("ERR: Failed to update avatar_verified:", err)
|
||||||
|
web.Error(w, "failed to verify avatar", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
AvatarID string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -190,7 +198,7 @@ func (h *UserHandler) uploadAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if err := encoder.Encode(Response{AvatarID: uploadInfo.Key}); err != nil {
|
if err := encoder.Encode(Response{URL: fmt.Sprintf("%s/avatar/%s", h.cfg.Uri, uploadInfo.Key)}); err != nil {
|
||||||
web.Error(w, "failed to write response", http.StatusInternalServerError)
|
web.Error(w, "failed to write response", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
web/src/api/avatar.ts
Normal file
17
web/src/api/avatar.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { axios, handleApiError } from ".";
|
||||||
|
|
||||||
|
export const uploadAvatarApi = async (imageFile: File): Promise<string> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", imageFile);
|
||||||
|
|
||||||
|
const response = await axios.put("/api/v1/avatar", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200 && response.status !== 201)
|
||||||
|
throw await handleApiError(response);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
@ -23,3 +23,12 @@ export const confirmEmailApi = async (
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const finishVerificationApi = async (): Promise<void> => {
|
||||||
|
const response = await axios.post("/api/v1/auth/verify");
|
||||||
|
|
||||||
|
if (response.status !== 200 && response.status !== 201)
|
||||||
|
throw await handleApiError(response);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
@ -155,7 +155,7 @@ const AuthLayout = () => {
|
|||||||
verificationRequired &&
|
verificationRequired &&
|
||||||
!location.pathname.startsWith("/verify")
|
!location.pathname.startsWith("/verify")
|
||||||
) {
|
) {
|
||||||
return <Navigate to="/verify" />;
|
return <Navigate to="/verify" state={{ from: location.pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -3,7 +3,7 @@ import { useAuth } from "@/store/auth";
|
|||||||
import { useVerify } from "@/store/verify";
|
import { useVerify } from "@/store/verify";
|
||||||
import { Eye, MailCheck, ScanFace } from "lucide-react";
|
import { Eye, MailCheck, ScanFace } from "lucide-react";
|
||||||
import { useEffect, type FC } from "react";
|
import { useEffect, type FC } from "react";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router";
|
import { Navigate, Outlet, useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@ -33,10 +33,27 @@ const VerificationLayout: FC = () => {
|
|||||||
const step = useVerify((s) => s.step);
|
const step = useVerify((s) => s.step);
|
||||||
const loadStep = useVerify((s) => s.loadStep);
|
const loadStep = useVerify((s) => s.loadStep);
|
||||||
|
|
||||||
|
const redirect = useVerify((s) => s.redirect);
|
||||||
|
const setRedirect = useVerify((s) => s.setRedirect);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile) loadStep(profile);
|
if (profile) loadStep(profile);
|
||||||
}, [loadStep, profile]);
|
}, [loadStep, profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.state?.from) {
|
||||||
|
setRedirect(location.state.from);
|
||||||
|
}
|
||||||
|
}, [location.state?.from, setRedirect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === false) {
|
||||||
|
navigate(redirect ?? "/", { state: { reset: true } });
|
||||||
|
}
|
||||||
|
}, [navigate, redirect, step]);
|
||||||
|
|
||||||
if (step === "email" && !location.pathname.startsWith("/verify/email")) {
|
if (step === "email" && !location.pathname.startsWith("/verify/email")) {
|
||||||
return <Navigate to="/verify/email" />;
|
return <Navigate to="/verify/email" />;
|
||||||
}
|
}
|
||||||
@ -53,7 +70,9 @@ const VerificationLayout: FC = () => {
|
|||||||
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
|
<div className="w-full h-screen max-h-screen overflow-y-auto flex flex-col items-center sm:justify-center bg-white/50 dark:bg-black/50">
|
||||||
<div className="w-full h-full sm:w-auto sm:h-auto">
|
<div className="w-full h-full sm:w-auto sm:h-auto">
|
||||||
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
|
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
|
||||||
step != null && <Stepper steps={steps} currentStep={step} />}
|
typeof step === "string" && (
|
||||||
|
<Stepper steps={steps} currentStep={step} />
|
||||||
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -76,7 +76,7 @@ const AdminUsersPage: FC = () => {
|
|||||||
colSpan={5}
|
colSpan={5}
|
||||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
No services found.
|
No users found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useVerify } from "@/store/verify";
|
||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState, type FC } from "react";
|
import { useCallback, useEffect, useRef, useState, type FC } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
@ -16,6 +17,9 @@ const VerifyAvatarPage: FC = () => {
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const uploadAvatar = useVerify((s) => s.uploadAvatar);
|
||||||
|
const uploading = useVerify((s) => s.uploading);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile?.profile_picture) setAvatar(profile.profile_picture);
|
if (profile?.profile_picture) setAvatar(profile.profile_picture);
|
||||||
}, [profile?.profile_picture]);
|
}, [profile?.profile_picture]);
|
||||||
@ -97,6 +101,17 @@ const VerifyAvatarPage: FC = () => {
|
|||||||
setTakingPicture(false);
|
setTakingPicture(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async () => {
|
||||||
|
if (!avatar) return;
|
||||||
|
|
||||||
|
const res = await fetch(avatar);
|
||||||
|
const blob = await res.blob();
|
||||||
|
|
||||||
|
const file = new File([blob], "avatar.png", { type: blob.type });
|
||||||
|
|
||||||
|
uploadAvatar(file);
|
||||||
|
}, [avatar, uploadAvatar]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full sm:max-w-sm mx-auto p-4">
|
<div className="w-full sm:max-w-sm mx-auto p-4">
|
||||||
<div className="flex flex-col gap-2 w-full max-w-xs mx-auto">
|
<div className="flex flex-col gap-2 w-full max-w-xs mx-auto">
|
||||||
@ -161,7 +176,14 @@ const VerifyAvatarPage: FC = () => {
|
|||||||
{avatar && (
|
{avatar && (
|
||||||
<>
|
<>
|
||||||
<Link to="/verify/review" className="w-full">
|
<Link to="/verify/review" className="w-full">
|
||||||
<Button className="w-full">Next</Button>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
loading={uploading}
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
className="border-yellow-500 text-yellow-500 hover:border-yellow-600 hover:text-yellow-600"
|
className="border-yellow-500 text-yellow-500 hover:border-yellow-600 hover:text-yellow-600"
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import Avatar from "@/feature/Avatar";
|
import Avatar from "@/feature/Avatar";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useVerify } from "@/store/verify";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
const VerifyReviewPage: FC = () => {
|
const VerifyReviewPage: FC = () => {
|
||||||
const profile = useAuth((s) => s.profile);
|
const profile = useAuth((s) => s.profile);
|
||||||
|
|
||||||
|
const verifying = useVerify((s) => s.verifying);
|
||||||
|
const finishVerify = useVerify((s) => s.verify);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
|
<div className="flex flex-col items-stretch gap-2 sm:max-w-sm mx-auto p-4">
|
||||||
<h1 className="text-xl font-medium dark:text-gray-200">
|
<h1 className="text-xl font-medium dark:text-gray-200">
|
||||||
@ -29,7 +32,14 @@ const VerifyReviewPage: FC = () => {
|
|||||||
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
|
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="mt-4">Back Home</Button>
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
loading={verifying}
|
||||||
|
disabled={verifying}
|
||||||
|
onClick={finishVerify}
|
||||||
|
>
|
||||||
|
Back Home
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -48,7 +48,7 @@ export const useUsers = create<IUsersState>((set) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await adminGetUsersApi();
|
const response = await adminGetUsersApi();
|
||||||
set({ users: response.items });
|
set({ users: response.items ?? [] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("ERR: Failed to fetch users for admin:", err);
|
console.log("ERR: Failed to fetch users for admin:", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -2,15 +2,19 @@ import { create } from "zustand";
|
|||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
import {
|
import {
|
||||||
confirmEmailApi,
|
confirmEmailApi,
|
||||||
|
finishVerificationApi,
|
||||||
requestEmailOtpApi,
|
requestEmailOtpApi,
|
||||||
type ConfirmEmailRequest,
|
type ConfirmEmailRequest,
|
||||||
} from "@/api/verify";
|
} from "@/api/verify";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
|
import { uploadAvatarApi } from "@/api/avatar";
|
||||||
|
|
||||||
export type VerifyStep = "email" | "avatar" | "review";
|
export type VerifyStep = "email" | "avatar" | "review";
|
||||||
|
|
||||||
export interface IVerifyState {
|
export interface IVerifyState {
|
||||||
step: VerifyStep | null;
|
step: VerifyStep | null | false;
|
||||||
|
|
||||||
|
redirect: string | null;
|
||||||
|
|
||||||
loadStep: (profile: UserProfile) => void;
|
loadStep: (profile: UserProfile) => void;
|
||||||
|
|
||||||
@ -20,14 +24,25 @@ export interface IVerifyState {
|
|||||||
|
|
||||||
confirming: boolean;
|
confirming: boolean;
|
||||||
confirmOTP: (req: ConfirmEmailRequest) => Promise<void>;
|
confirmOTP: (req: ConfirmEmailRequest) => Promise<void>;
|
||||||
|
|
||||||
|
uploading: boolean;
|
||||||
|
uploadAvatar: (image: File) => Promise<void>;
|
||||||
|
|
||||||
|
verifying: boolean;
|
||||||
|
verify: () => Promise<void>;
|
||||||
|
|
||||||
|
setRedirect: (redirect: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useVerify = create<IVerifyState>((set) => ({
|
export const useVerify = create<IVerifyState>((set) => ({
|
||||||
step: null,
|
step: null,
|
||||||
|
redirect: null,
|
||||||
|
|
||||||
requesting: false,
|
requesting: false,
|
||||||
requested: false,
|
requested: false,
|
||||||
confirming: false,
|
confirming: false,
|
||||||
|
uploading: false,
|
||||||
|
verifying: false,
|
||||||
|
|
||||||
loadStep: (profile) => {
|
loadStep: (profile) => {
|
||||||
if (!profile.email_verified) {
|
if (!profile.email_verified) {
|
||||||
@ -45,7 +60,7 @@ export const useVerify = create<IVerifyState>((set) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ step: null });
|
set({ step: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
requestOTP: async () => {
|
requestOTP: async () => {
|
||||||
@ -73,4 +88,34 @@ export const useVerify = create<IVerifyState>((set) => ({
|
|||||||
set({ confirming: false });
|
set({ confirming: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadAvatar: async (image) => {
|
||||||
|
set({ uploading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadAvatarApi(image);
|
||||||
|
await useAuth.getState().authenticate();
|
||||||
|
} catch (err) {
|
||||||
|
console.log("ERR: Failed to request OTP:", err);
|
||||||
|
} finally {
|
||||||
|
set({ uploading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setRedirect: (redirect) => {
|
||||||
|
set({ redirect });
|
||||||
|
},
|
||||||
|
|
||||||
|
verify: async () => {
|
||||||
|
set({ verifying: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finishVerificationApi();
|
||||||
|
useAuth.getState().authenticate();
|
||||||
|
} catch (err) {
|
||||||
|
console.log("ERR: Failed to finish verification:", err);
|
||||||
|
} finally {
|
||||||
|
set({ verifying: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
Reference in New Issue
Block a user