From b3ea1bf48f210d72e46bbb4518a3aa6a7fb6a9d1 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 4 Aug 2026 01:11:33 +0500 Subject: [PATCH] improve registration flows --- src/actions/auth-actions.ts | 38 ++++ src/actions/user-actions.ts | 70 +++---- src/components/auth/signup-form.tsx | 29 ++- .../auth/verify-registration-otp-form.tsx | 17 +- src/components/user/user-reject-dialog.tsx | 181 ++++++++++-------- src/components/user/user-update-form.tsx | 34 ++++ src/lib/types/user.ts | 10 + src/pages/admin/UserDetails.tsx | 60 +++++- src/pages/auth/UploadId.tsx | 137 +++++++++++++ src/queries/authentication.ts | 19 +- src/queries/id-upload.ts | 52 +++++ src/router.tsx | 3 + 12 files changed, 522 insertions(+), 128 deletions(-) create mode 100644 src/pages/auth/UploadId.tsx create mode 100644 src/queries/id-upload.ts diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts index 33ab1b3..72926a8 100644 --- a/src/actions/auth-actions.ts +++ b/src/actions/auth-actions.ts @@ -17,6 +17,7 @@ const formSchema = z.object({ export type FilterUserResponse = { ok: boolean; verified: boolean; + status?: string; }; export type FilterTempUserResponse = { ok: boolean; @@ -53,12 +54,49 @@ export async function signin(_previousState: ActionState, formData: FormData) { const userData = user.data as FilterUserResponse; if (!userData.ok) { + // No real account yet. If a registration is pending OTP verification + // (user registered but never entered the OTP), resend the code and send + // them to the OTP page instead of the signup form — otherwise they'd be + // stuck: signup rejects the already-registered mobile. + const temp = await checkTempIdOrPhone({ + phone_number: FORMATTED_MOBILE_NUMBER, + }); + if (temp.ok && !temp.otp_verified) { + await apiClient.post("/api/auth/register/resend-otp/", { + mobile: FORMATTED_MOBILE_NUMBER, + }); + return { + status: "redirect", + redirectTo: `/auth/verify-otp-registration?phone_number=${FORMATTED_MOBILE_NUMBER}`, + }; + } return { status: "redirect", redirectTo: `/auth/signup?phone_number=${phoneNumber}`, }; } if (!userData.verified) { + // Admin asked this user to upload an ID -> take them straight to the + // upload page (mint a fresh single-use token) instead of a dead-end. + if (userData.status === "id_required") { + const startRes = await apiClient.post("/api/auth/id-upload/start/", { + mobile: FORMATTED_MOBILE_NUMBER, + }); + const token = (startRes.data as { token?: string })?.token; + if (startRes.status >= 200 && startRes.status < 300 && token) { + return { + status: "redirect", + redirectTo: `/upload-id?token=${token}`, + }; + } + } + if (userData.status === "id_submitted") { + return { + message: + "Your ID has been submitted and is awaiting review. We'll notify you by SMS once it's checked.", + status: "error", + }; + } return { message: "Your account is on pending verification. Please wait for a response from admin or contact shihaam.", diff --git a/src/actions/user-actions.ts b/src/actions/user-actions.ts index d1444e2..54d4aa7 100644 --- a/src/actions/user-actions.ts +++ b/src/actions/user-actions.ts @@ -1,4 +1,3 @@ -import type { RejectUserFormState } from "@/components/user/user-reject-dialog"; import type { ApiError } from "@/lib/backend-types"; import type { User } from "@/lib/types/user"; import apiClient from "@/lib/api-client"; @@ -44,47 +43,50 @@ export async function getProfile() { return handleApiResponse(response, "getProfile"); } -export async function rejectUser( - _prevState: RejectUserFormState, - formData: FormData, -): Promise { - const userId = formData.get("userId") as string; - const rejection_details = formData.get("rejection_details") as string; +export type ActionResult = { ok: boolean; message: string }; - if (!rejection_details?.trim()) { - return { - message: "Rejection details are required.", - fieldErrors: { rejection_details: ["Rejection details are required."] }, - payload: formData, - }; - } - - const response = await apiClient.delete( - `/api/auth/users/${userId}/reject/`, - { data: { rejection_details } }, +/** + * Admin: ask the user to (re)upload their ID/passport photo. Non-destructive. + * `message` is the editable SMS body; the greeting, the secure upload link, and + * the signature are added by the backend (the link is never shown to admins). + */ +export async function requestIdUpload( + userId: string, + message: string, +): Promise { + const response = await apiClient.post( + `/api/auth/users/${userId}/request-id-card/`, + { message }, ); - - if (response.status === 204) { + if (response.status < 200 || response.status >= 300) { + const error = (response.data ?? {}) as ApiError; return { - message: "User rejected successfully!", - fieldErrors: {}, - payload: formData, - redirectTo: "/users", + ok: false, + message: error.message || error.detail || "Failed to request ID upload.", }; } + const data = (response.data ?? {}) as ApiError; + return { ok: true, message: data.message || "ID upload request sent." }; +} +/** + * Admin: reject the registration permanently — deletes the user and forces a + * fresh registration. A reason is required and is SMSed to the customer. + */ +export async function rejectAndDeleteUser( + userId: string, + reason: string, +): Promise { + const response = await apiClient.delete(`/api/auth/users/${userId}/reject/`, { + data: { rejection_details: reason }, + }); + if (response.status === 204) { + return { ok: true, message: "User rejected." }; + } const error = (response.data ?? {}) as ApiError; - const message = - error.message || error.detail || "Failed to reject user."; - // The backend requires a non-empty reason; surface that as a field error so - // the textarea highlights, otherwise just toast the message. - const isReasonError = /rejection details/i.test(message); return { - message, - fieldErrors: isReasonError - ? { rejection_details: [message] } - : {}, - payload: formData, + ok: false, + message: error.message || error.detail || "Failed to reject user.", }; } diff --git a/src/components/auth/signup-form.tsx b/src/components/auth/signup-form.tsx index f42204e..fc86ef8 100644 --- a/src/components/auth/signup-form.tsx +++ b/src/components/auth/signup-form.tsx @@ -28,6 +28,7 @@ export default function SignUpForm() { }); const [atoll, setAtoll] = React.useState(); + const [islandId, setIslandId] = React.useState(""); const [actionState, action, isPending] = React.useActionState(signup, { message: "", @@ -36,9 +37,23 @@ export default function SignUpForm() { payload: new FormData(), }); + // After a failed submit, restore the atoll/island the user had picked (the + // FormData payload carries atoll_id / island_id) so the dropdowns don't reset. React.useEffect(() => { - console.log(atoll); - }, [atoll]); + const payloadAtollId = actionState?.payload?.get("atoll_id") as + | string + | null; + const payloadIslandId = actionState?.payload?.get("island_id") as + | string + | null; + if (payloadAtollId && atolls?.data) { + const found = atolls.data.find( + (a) => a.id === Number.parseInt(payloadAtollId), + ); + if (found) setAtoll(found); + } + if (payloadIslandId) setIslandId(payloadIslandId); + }, [actionState, atolls]); React.useEffect(() => { if (actionState?.status === "redirect" && actionState.redirectTo) { @@ -172,12 +187,13 @@ export default function SignUpForm() { + - -