Compare commits

...

12 Commits

12 changed files with 170 additions and 12 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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
View 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;
};

View File

@ -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;
};

View File

@ -155,7 +155,7 @@ const AuthLayout = () => {
verificationRequired &&
!location.pathname.startsWith("/verify")
) {
return <Navigate to="/verify" />;
return <Navigate to="/verify" state={{ from: location.pathname }} />;
}
return (

View File

@ -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>

View File

@ -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>
) : (

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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 {

View File

@ -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 });
}
},
}));