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.Post("/email", h.requestEmailOtp)
|
||||
protected.Post("/email/otp", h.confirmOtp)
|
||||
protected.Post("/verify", h.finishVerification)
|
||||
})
|
||||
|
||||
r.Post("/login", h.login)
|
||||
|
@ -97,3 +97,30 @@ func (h *AuthHandler) confirmOtp(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
AvatarID string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
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;
|
||||
};
|
||||
|
||||
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 &&
|
||||
!location.pathname.startsWith("/verify")
|
||||
) {
|
||||
return <Navigate to="/verify" />;
|
||||
return <Navigate to="/verify" state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -3,7 +3,7 @@ import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { Eye, MailCheck, ScanFace } from "lucide-react";
|
||||
import { useEffect, type FC } from "react";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
@ -33,10 +33,27 @@ const VerificationLayout: FC = () => {
|
||||
const step = useVerify((s) => s.step);
|
||||
const loadStep = useVerify((s) => s.loadStep);
|
||||
|
||||
const redirect = useVerify((s) => s.redirect);
|
||||
const setRedirect = useVerify((s) => s.setRedirect);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (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")) {
|
||||
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-full sm:w-auto sm:h-auto">
|
||||
{location.pathname.replace(/\/$/i, "") !== "/verify" &&
|
||||
step != null && <Stepper steps={steps} currentStep={step} />}
|
||||
typeof step === "string" && (
|
||||
<Stepper steps={steps} currentStep={step} />
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -76,7 +76,7 @@ const AdminUsersPage: FC = () => {
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No services found.
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import { User } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type FC } from "react";
|
||||
import { Link } from "react-router";
|
||||
@ -16,6 +17,9 @@ const VerifyAvatarPage: FC = () => {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const uploadAvatar = useVerify((s) => s.uploadAvatar);
|
||||
const uploading = useVerify((s) => s.uploading);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile?.profile_picture) setAvatar(profile.profile_picture);
|
||||
}, [profile?.profile_picture]);
|
||||
@ -97,6 +101,17 @@ const VerifyAvatarPage: FC = () => {
|
||||
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 (
|
||||
<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">
|
||||
@ -161,7 +176,14 @@ const VerifyAvatarPage: FC = () => {
|
||||
{avatar && (
|
||||
<>
|
||||
<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>
|
||||
<Button
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import Avatar from "@/feature/Avatar";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useVerify } from "@/store/verify";
|
||||
import type { FC } from "react";
|
||||
|
||||
const VerifyReviewPage: FC = () => {
|
||||
const profile = useAuth((s) => s.profile);
|
||||
|
||||
const verifying = useVerify((s) => s.verifying);
|
||||
const finishVerify = useVerify((s) => s.verify);
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -29,7 +32,14 @@ const VerifyReviewPage: FC = () => {
|
||||
<p className="dark:text-gray-400 text-sm">{profile?.email}</p>
|
||||
</div>
|
||||
|
||||
<Button className="mt-4">Back Home</Button>
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
loading={verifying}
|
||||
disabled={verifying}
|
||||
onClick={finishVerify}
|
||||
>
|
||||
Back Home
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -48,7 +48,7 @@ export const useUsers = create<IUsersState>((set) => ({
|
||||
|
||||
try {
|
||||
const response = await adminGetUsersApi();
|
||||
set({ users: response.items });
|
||||
set({ users: response.items ?? [] });
|
||||
} catch (err) {
|
||||
console.log("ERR: Failed to fetch users for admin:", err);
|
||||
} finally {
|
||||
|
@ -2,15 +2,19 @@ import { create } from "zustand";
|
||||
import type { UserProfile } from "@/types";
|
||||
import {
|
||||
confirmEmailApi,
|
||||
finishVerificationApi,
|
||||
requestEmailOtpApi,
|
||||
type ConfirmEmailRequest,
|
||||
} from "@/api/verify";
|
||||
import { useAuth } from "./auth";
|
||||
import { uploadAvatarApi } from "@/api/avatar";
|
||||
|
||||
export type VerifyStep = "email" | "avatar" | "review";
|
||||
|
||||
export interface IVerifyState {
|
||||
step: VerifyStep | null;
|
||||
step: VerifyStep | null | false;
|
||||
|
||||
redirect: string | null;
|
||||
|
||||
loadStep: (profile: UserProfile) => void;
|
||||
|
||||
@ -20,14 +24,25 @@ export interface IVerifyState {
|
||||
|
||||
confirming: boolean;
|
||||
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) => ({
|
||||
step: null,
|
||||
redirect: null,
|
||||
|
||||
requesting: false,
|
||||
requested: false,
|
||||
confirming: false,
|
||||
uploading: false,
|
||||
verifying: false,
|
||||
|
||||
loadStep: (profile) => {
|
||||
if (!profile.email_verified) {
|
||||
@ -45,7 +60,7 @@ export const useVerify = create<IVerifyState>((set) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
set({ step: null });
|
||||
set({ step: false });
|
||||
},
|
||||
|
||||
requestOTP: async () => {
|
||||
@ -73,4 +88,34 @@ export const useVerify = create<IVerifyState>((set) => ({
|
||||
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