improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 7s
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 7s
This commit is contained in:
@@ -17,6 +17,7 @@ const formSchema = z.object({
|
|||||||
export type FilterUserResponse = {
|
export type FilterUserResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
status?: string;
|
||||||
};
|
};
|
||||||
export type FilterTempUserResponse = {
|
export type FilterTempUserResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -53,12 +54,49 @@ export async function signin(_previousState: ActionState, formData: FormData) {
|
|||||||
const userData = user.data as FilterUserResponse;
|
const userData = user.data as FilterUserResponse;
|
||||||
|
|
||||||
if (!userData.ok) {
|
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 {
|
return {
|
||||||
status: "redirect",
|
status: "redirect",
|
||||||
redirectTo: `/auth/signup?phone_number=${phoneNumber}`,
|
redirectTo: `/auth/signup?phone_number=${phoneNumber}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!userData.verified) {
|
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 {
|
return {
|
||||||
message:
|
message:
|
||||||
"Your account is on pending verification. Please wait for a response from admin or contact shihaam.",
|
"Your account is on pending verification. Please wait for a response from admin or contact shihaam.",
|
||||||
|
|||||||
+36
-34
@@ -1,4 +1,3 @@
|
|||||||
import type { RejectUserFormState } from "@/components/user/user-reject-dialog";
|
|
||||||
import type { ApiError } from "@/lib/backend-types";
|
import type { ApiError } from "@/lib/backend-types";
|
||||||
import type { User } from "@/lib/types/user";
|
import type { User } from "@/lib/types/user";
|
||||||
import apiClient from "@/lib/api-client";
|
import apiClient from "@/lib/api-client";
|
||||||
@@ -44,47 +43,50 @@ export async function getProfile() {
|
|||||||
return handleApiResponse<User>(response, "getProfile");
|
return handleApiResponse<User>(response, "getProfile");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectUser(
|
export type ActionResult = { ok: boolean; message: string };
|
||||||
_prevState: RejectUserFormState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<RejectUserFormState> {
|
|
||||||
const userId = formData.get("userId") as string;
|
|
||||||
const rejection_details = formData.get("rejection_details") as string;
|
|
||||||
|
|
||||||
if (!rejection_details?.trim()) {
|
/**
|
||||||
return {
|
* Admin: ask the user to (re)upload their ID/passport photo. Non-destructive.
|
||||||
message: "Rejection details are required.",
|
* `message` is the editable SMS body; the greeting, the secure upload link, and
|
||||||
fieldErrors: { rejection_details: ["Rejection details are required."] },
|
* the signature are added by the backend (the link is never shown to admins).
|
||||||
payload: formData,
|
*/
|
||||||
};
|
export async function requestIdUpload(
|
||||||
}
|
userId: string,
|
||||||
|
message: string,
|
||||||
const response = await apiClient.delete(
|
): Promise<ActionResult> {
|
||||||
`/api/auth/users/${userId}/reject/`,
|
const response = await apiClient.post(
|
||||||
{ data: { rejection_details } },
|
`/api/auth/users/${userId}/request-id-card/`,
|
||||||
|
{ message },
|
||||||
);
|
);
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
if (response.status === 204) {
|
const error = (response.data ?? {}) as ApiError;
|
||||||
return {
|
return {
|
||||||
message: "User rejected successfully!",
|
ok: false,
|
||||||
fieldErrors: {},
|
message: error.message || error.detail || "Failed to request ID upload.",
|
||||||
payload: formData,
|
|
||||||
redirectTo: "/users",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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<ActionResult> {
|
||||||
|
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 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 {
|
return {
|
||||||
message,
|
ok: false,
|
||||||
fieldErrors: isReasonError
|
message: error.message || error.detail || "Failed to reject user.",
|
||||||
? { rejection_details: [message] }
|
|
||||||
: {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function SignUpForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [atoll, setAtoll] = React.useState<Atoll>();
|
const [atoll, setAtoll] = React.useState<Atoll>();
|
||||||
|
const [islandId, setIslandId] = React.useState<string>("");
|
||||||
|
|
||||||
const [actionState, action, isPending] = React.useActionState(signup, {
|
const [actionState, action, isPending] = React.useActionState(signup, {
|
||||||
message: "",
|
message: "",
|
||||||
@@ -36,9 +37,23 @@ export default function SignUpForm() {
|
|||||||
payload: new FormData(),
|
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(() => {
|
React.useEffect(() => {
|
||||||
console.log(atoll);
|
const payloadAtollId = actionState?.payload?.get("atoll_id") as
|
||||||
}, [atoll]);
|
| 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(() => {
|
React.useEffect(() => {
|
||||||
if (actionState?.status === "redirect" && actionState.redirectTo) {
|
if (actionState?.status === "redirect" && actionState.redirectTo) {
|
||||||
@@ -172,12 +187,13 @@ export default function SignUpForm() {
|
|||||||
<Select
|
<Select
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
console.log({ v });
|
|
||||||
setAtoll(
|
setAtoll(
|
||||||
atolls?.data.find(
|
atolls?.data.find(
|
||||||
(atoll) => atoll.id === Number.parseInt(v),
|
(atoll) => atoll.id === Number.parseInt(v),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Reset island — its options depend on the atoll.
|
||||||
|
setIslandId("");
|
||||||
}}
|
}}
|
||||||
name="atoll_id"
|
name="atoll_id"
|
||||||
value={atoll?.id?.toString() ?? ""}
|
value={atoll?.id?.toString() ?? ""}
|
||||||
@@ -209,7 +225,12 @@ export default function SignUpForm() {
|
|||||||
>
|
>
|
||||||
Island
|
Island
|
||||||
</label>
|
</label>
|
||||||
<Select disabled={isPending} name="island_id">
|
<Select
|
||||||
|
disabled={isPending}
|
||||||
|
name="island_id"
|
||||||
|
value={islandId}
|
||||||
|
onValueChange={setIslandId}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Select island" />
|
<SelectValue placeholder="Select island" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useActionState } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { Link, Navigate, useSearchParams } from "react-router-dom";
|
import {
|
||||||
|
Link,
|
||||||
|
Navigate,
|
||||||
|
useNavigate,
|
||||||
|
useSearchParams,
|
||||||
|
} from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -16,11 +21,19 @@ export default function VerifyRegistrationOTPForm({
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const mobile = searchParams.get("phone_number");
|
const mobile = searchParams.get("phone_number");
|
||||||
|
const navigate = useNavigate();
|
||||||
const [state, formAction, isPending] = useActionState(VerifyRegistrationOTP, {
|
const [state, formAction, isPending] = useActionState(VerifyRegistrationOTP, {
|
||||||
message: "",
|
message: "",
|
||||||
status: "",
|
status: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-verify failed -> backend asked for an ID upload; go to the upload page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "id_required" && state.redirectTo) {
|
||||||
|
navigate(state.redirectTo);
|
||||||
|
}
|
||||||
|
}, [state, navigate]);
|
||||||
|
|
||||||
if (!mobile) return <Navigate to="/auth/signin" replace />;
|
if (!mobile) return <Navigate to="/auth/signin" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,122 +1,139 @@
|
|||||||
import { UserX } from "lucide-react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useActionState, useEffect, useState } from "react";
|
import { IdCard, Loader2, UserX } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { rejectUser } from "@/actions/user-actions";
|
import { rejectAndDeleteUser, requestIdUpload } from "@/actions/user-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { UserProfile } from "@/lib/types/user";
|
import type { UserProfile } from "@/lib/types/user";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
|
|
||||||
export type RejectUserFormState = {
|
/**
|
||||||
message: string;
|
* Fixed body of the "please upload your ID" SMS shown to the admin (read-only).
|
||||||
fieldErrors: Record<string, string[] | string>;
|
* The greeting, the one-time upload link, and the "- SAR Link" signature are
|
||||||
payload?: FormData;
|
* added by the backend, so the link is never exposed to the admin.
|
||||||
redirectTo?: string;
|
*/
|
||||||
};
|
const ID_UPLOAD_BODY = `We're sorry, but your SAR Link account registration could not be approved.
|
||||||
|
|
||||||
export const initialState: RejectUserFormState = {
|
Please upload a clear photo of your ID card / passport at the link below.`;
|
||||||
message: "",
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserRejectDialog({ user }: { user: UserProfile }) {
|
export default function UserRejectDialog({ user }: { user: UserProfile }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [pending, setPending] = useState<null | "upload" | "reject">(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState(
|
const fullName = `${user.first_name} ${user.last_name}`.trim();
|
||||||
rejectUser,
|
|
||||||
initialState,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function handleRequestUpload() {
|
||||||
if (state.message && state !== initialState) {
|
setPending("upload");
|
||||||
if (state.fieldErrors && Object.keys(state.fieldErrors).length > 0) {
|
const result = await requestIdUpload(String(user.id), ID_UPLOAD_BODY);
|
||||||
toast.error(state.message);
|
setPending(null);
|
||||||
} else if (state.redirectTo) {
|
if (result.ok) {
|
||||||
|
toast.success(result.message);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["profile", String(user.id)],
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectAndDelete() {
|
||||||
|
if (!reason.trim()) {
|
||||||
|
toast.error("A reason is required to reject and delete this user.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending("reject");
|
||||||
|
const result = await rejectAndDeleteUser(String(user.id), reason);
|
||||||
|
setPending(null);
|
||||||
|
if (result.ok) {
|
||||||
toast.success("User rejected successfully!");
|
toast.success("User rejected successfully!");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate(state.redirectTo);
|
navigate("/users");
|
||||||
} else {
|
} else {
|
||||||
toast.error(state.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state, navigate]);
|
|
||||||
|
|
||||||
const rejectionErrors = state.fieldErrors?.rejection_details;
|
const isBusy = pending !== null;
|
||||||
const rejectionError = Array.isArray(rejectionErrors)
|
|
||||||
? rejectionErrors[0]
|
|
||||||
: rejectionErrors;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button disabled={isPending} variant="destructive">
|
<Button variant="destructive">
|
||||||
<UserX />
|
<UserX />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-muted-foreground">
|
<DialogTitle className="text-muted-foreground">
|
||||||
Are you sure?
|
Reject {fullName || "user"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="py-2">
|
<DialogDescription className="py-1">
|
||||||
<li>
|
Ask the customer to upload their ID/passport, or reject and delete
|
||||||
Name: {user.first_name} {user.last_name}
|
the registration permanently.
|
||||||
</li>
|
|
||||||
<li>ID Card: {user.id_card}</li>
|
|
||||||
<li>Address: {user.address}</li>
|
|
||||||
<li>
|
|
||||||
DOB:{" "}
|
|
||||||
{new Date(user.dob ?? "").toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
<li>Phone Number: {user.mobile}</li>
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={formAction}>
|
|
||||||
<div className="grid gap-4 py-4">
|
{/* Request ID upload — fixed message sent by the backend. */}
|
||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col gap-2 py-2 border-b">
|
||||||
<input type="hidden" name="userId" value={user.id} />
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ask the customer to upload their ID / passport photo. They'll get an
|
||||||
|
SMS with a secure one-time upload link.
|
||||||
|
</p>
|
||||||
|
<Button type="button" disabled={isBusy} onClick={handleRequestUpload}>
|
||||||
|
{pending === "upload" ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IdCard />
|
||||||
|
)}
|
||||||
|
Request ID Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reject & delete — needs a reason. */}
|
||||||
|
<div className="flex flex-col gap-2 py-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="reason"
|
htmlFor="reject-reason"
|
||||||
className="text-right text-muted-foreground"
|
className="text-muted-foreground text-sm"
|
||||||
>
|
>
|
||||||
Rejection details
|
Reason (required to reject & delete)
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={10}
|
id="reject-reason"
|
||||||
name="rejection_details"
|
rows={3}
|
||||||
id="reason"
|
value={reason}
|
||||||
defaultValue={state.payload?.get("rejection_details") as string}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
className={cn(
|
placeholder="Why is this registration being rejected?"
|
||||||
"col-span-5",
|
disabled={isBusy}
|
||||||
rejectionError && "ring-2 ring-red-500",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-red-500">{rejectionError}</span>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
variant="destructive"
|
||||||
<DialogFooter>
|
disabled={isBusy}
|
||||||
<Button variant={"destructive"} disabled={isPending} type="submit">
|
onClick={handleRejectAndDelete}
|
||||||
Reject
|
>
|
||||||
|
{pending === "reject" ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserX />
|
||||||
|
)}
|
||||||
|
Reject & Delete
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,6 +53,40 @@ export default function UserUpdateForm({ user }: { user: UserProfile }) {
|
|||||||
Update User Information
|
Update User Information
|
||||||
</h4>
|
</h4>
|
||||||
<div className="border border-dashed border-sarLinkOrange p-4 rounded-lg max-w-2xl">
|
<div className="border border-dashed border-sarLinkOrange p-4 rounded-lg max-w-2xl">
|
||||||
|
{user.id_card_photo && (
|
||||||
|
<div className="mb-4 flex flex-col items-start gap-1">
|
||||||
|
<p className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Uploaded ID / Passport
|
||||||
|
</p>
|
||||||
|
{user.id_card_photo.toLowerCase().endsWith(".pdf") ? (
|
||||||
|
<embed
|
||||||
|
src={user.id_card_photo}
|
||||||
|
type="application/pdf"
|
||||||
|
className="w-full h-80 rounded border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={user.id_card_photo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.id_card_photo}
|
||||||
|
alt="Uploaded ID"
|
||||||
|
className="max-h-64 w-auto rounded-lg border object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={user.id_card_photo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-700 underline"
|
||||||
|
>
|
||||||
|
Open full size
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
{state.fieldErrors && Object.keys(state.fieldErrors).length > 0 ? (
|
{state.fieldErrors && Object.keys(state.fieldErrors).length > 0 ? (
|
||||||
<div className="text-red-500 mb-5 border border-red-500 p-2 rounded">
|
<div className="text-red-500 mb-5 border border-red-500 p-2 rounded">
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export interface User {
|
|||||||
agreement?: string;
|
agreement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Registration workflow status, mirrors the backend User.STATUS_* choices. */
|
||||||
|
export type UserStatus =
|
||||||
|
| "pending"
|
||||||
|
| "verified"
|
||||||
|
| "id_required"
|
||||||
|
| "id_submitted";
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -46,6 +53,9 @@ export interface UserProfile {
|
|||||||
acc_no: string;
|
acc_no: string;
|
||||||
id_card: string;
|
id_card: string;
|
||||||
agreement: string;
|
agreement: string;
|
||||||
|
status?: UserStatus;
|
||||||
|
/** Absolute URL of the ID/passport photo the user uploaded, if any. */
|
||||||
|
id_card_photo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import UserRejectDialog from "@/components/user/user-reject-dialog";
|
import UserRejectDialog from "@/components/user/user-reject-dialog";
|
||||||
import { UserVerifyDialog } from "@/components/user/user-verify-dialog";
|
import { UserVerifyDialog } from "@/components/user/user-verify-dialog";
|
||||||
import { getNationalPerson } from "@/lib/person";
|
import { getNationalPerson } from "@/lib/person";
|
||||||
|
import type { UserProfile } from "@/lib/types/user";
|
||||||
import { getProfileById } from "@/queries/users";
|
import { getProfileById } from "@/queries/users";
|
||||||
|
|
||||||
export default function UserDetails() {
|
export default function UserDetails() {
|
||||||
@@ -71,11 +72,7 @@ export default function UserDetails() {
|
|||||||
View Agreement
|
View Agreement
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
{dbUser?.verified && (
|
{dbUser && <StatusBadge user={dbUser} />}
|
||||||
<Badge variant={"secondary"} className="bg-lime-500">
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start justify-start">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start justify-start">
|
||||||
@@ -206,6 +203,59 @@ export default function UserDetails() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{dbUser?.id_card_photo && (
|
||||||
|
<div id="uploaded-id" className="mt-4">
|
||||||
|
<h4 className="p-2 rounded font-semibold">Uploaded ID / Passport</h4>
|
||||||
|
<div className="shadow-md p-4 bg-blue-800/5 border border-dashed border-blue-800 rounded-lg my-2 flex flex-col items-center gap-2">
|
||||||
|
{dbUser.id_card_photo.toLowerCase().endsWith(".pdf") ? (
|
||||||
|
<embed
|
||||||
|
src={dbUser.id_card_photo}
|
||||||
|
type="application/pdf"
|
||||||
|
className="w-full h-96 rounded border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={dbUser.id_card_photo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={dbUser.id_card_photo}
|
||||||
|
alt="Uploaded ID"
|
||||||
|
className="max-h-80 w-auto rounded-lg border object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={dbUser.id_card_photo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-700 underline"
|
||||||
|
>
|
||||||
|
Open full size
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Uploaded by the customer for manual verification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ user }: { user: UserProfile }) {
|
||||||
|
const status = user.status ?? (user.verified ? "verified" : "pending");
|
||||||
|
const map: Record<string, { label: string; className: string }> = {
|
||||||
|
verified: { label: "Verified", className: "bg-lime-500" },
|
||||||
|
id_required: { label: "ID required", className: "bg-amber-500" },
|
||||||
|
id_submitted: { label: "ID submitted — review", className: "bg-blue-500" },
|
||||||
|
pending: { label: "Pending", className: "bg-gray-400" },
|
||||||
|
};
|
||||||
|
const { label, className } = map[status] ?? map.pending;
|
||||||
|
return (
|
||||||
|
<Badge variant={"secondary"} className={className}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getUploadTokenInfo, submitIdUpload } from "@/queries/id-upload";
|
||||||
|
|
||||||
|
type Step = "checking" | "upload" | "done";
|
||||||
|
|
||||||
|
const ACCEPTED = ["image/jpeg", "image/jpg", "image/png", "application/pdf"];
|
||||||
|
|
||||||
|
export default function UploadId() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("checking");
|
||||||
|
const [name, setName] = useState<string>("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Access is magic-link only. No token, or a used/expired/invalid one, goes home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
const result = await getUploadTokenInfo(token);
|
||||||
|
if (!active) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setName(`${result.data.first_name} ${result.data.last_name}`.trim());
|
||||||
|
setStep("upload");
|
||||||
|
} else {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [token, navigate]);
|
||||||
|
|
||||||
|
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = e.target.files?.[0] ?? null;
|
||||||
|
if (selected && !ACCEPTED.includes(selected.type)) {
|
||||||
|
toast.error("Please choose a JPG, PNG or PDF file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected && selected.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error("File is larger than 10 MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(selected);
|
||||||
|
setPreview(selected ? URL.createObjectURL(selected) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!token || !file) {
|
||||||
|
toast.error("Please choose a file first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await submitIdUpload(token, file);
|
||||||
|
setBusy(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setStep("done");
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPdf = file?.type === "application/pdf";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-clip title-bg w-full max-w-xs mx-auto rounded-lg shadow border-2 border-sarLinkOrange/50 dark:border-sarLinkOrange/50 my-4">
|
||||||
|
<div className="grid pb-4 pt-4 gap-4 px-4">
|
||||||
|
<p className="bg-sarLinkOrange border border-yellow-900/50 dark:border-sarLinkOrange/50 rounded p-2 text-center text-sm text-gray-900 dark:text-orange-950">
|
||||||
|
Upload your ID / Passport
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{step === "checking" && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-muted-foreground py-4 text-sm">
|
||||||
|
<Loader2 className="animate-spin" /> Checking your link…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "upload" && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{name ? `Hi ${name}, ` : ""}please upload a clear photo or scan of
|
||||||
|
your ID card or passport (JPG, PNG or PDF, max 10 MB).
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,image/jpeg,image/png,application/pdf"
|
||||||
|
className="bg-white text-black dark:text-white dark:bg-gray-950"
|
||||||
|
onChange={handleFile}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{preview && !isPdf && (
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="ID preview"
|
||||||
|
className="max-h-56 w-auto self-center rounded-lg border object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{preview && isPdf && (
|
||||||
|
<p className="text-xs text-center text-muted-foreground border rounded p-2">
|
||||||
|
{file?.name} (PDF selected)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className={cn("w-full")}
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={busy || !file}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="animate-spin" /> : "Submit for review"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="text-center flex flex-col gap-2 py-2">
|
||||||
|
<p className="bg-green-100 dark:bg-dark-green-800 border border-green-900/50 text-green-700 rounded p-2 text-sm">
|
||||||
|
Thank you! Your ID has been submitted for review. We'll notify you
|
||||||
|
by SMS once it has been checked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,7 +93,12 @@ export async function VerifyRegistrationOTP(
|
|||||||
mobile,
|
mobile,
|
||||||
otp: otp as string,
|
otp: otp as string,
|
||||||
});
|
});
|
||||||
const data = response.data as { message: string; verified: boolean };
|
const data = response.data as {
|
||||||
|
message: string;
|
||||||
|
verified: boolean;
|
||||||
|
status?: string;
|
||||||
|
upload_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (data.verified) {
|
if (data.verified) {
|
||||||
return {
|
return {
|
||||||
@@ -102,6 +107,18 @@ export async function VerifyRegistrationOTP(
|
|||||||
status: "verify_success",
|
status: "verify_success",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-verification failed: the backend flagged this account as needing an
|
||||||
|
// ID/passport photo and returned a one-time upload token. Send the user
|
||||||
|
// straight to the upload page.
|
||||||
|
if (data.status === "id_required" && data.upload_token) {
|
||||||
|
return {
|
||||||
|
message: "We couldn't verify you automatically. Redirecting you to upload your ID…",
|
||||||
|
status: "id_required",
|
||||||
|
redirectTo: `/upload-id?token=${data.upload_token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message:
|
message:
|
||||||
"Your account could not be verified. Please wait for you verification to be processed.",
|
"Your account could not be verified. Please wait for you verification to be processed.",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ApiError } from "@/lib/backend-types";
|
||||||
|
import apiClient from "@/lib/api-client";
|
||||||
|
|
||||||
|
export type TokenInfo = {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result<T> = { ok: true; data: T } | { ok: false; message: string };
|
||||||
|
|
||||||
|
function errorMessage(data: unknown, fallback: string): string {
|
||||||
|
const e = (data ?? {}) as ApiError;
|
||||||
|
return e.message || e.detail || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate a magic-link token and get whose upload it is. */
|
||||||
|
export async function getUploadTokenInfo(
|
||||||
|
token: string,
|
||||||
|
): Promise<Result<TokenInfo>> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/auth/id-upload/token/?token=${encodeURIComponent(token)}`,
|
||||||
|
);
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
return { ok: true, data: response.data as TokenInfo };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: errorMessage(response.data, "This upload link is invalid."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit the ID/passport photo for a valid token. */
|
||||||
|
export async function submitIdUpload(
|
||||||
|
token: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<Result<{ message: string }>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("token", token);
|
||||||
|
formData.append("id_card_photo", file);
|
||||||
|
const response = await apiClient.post(
|
||||||
|
"/api/auth/id-upload/submit/",
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
return { ok: true, data: response.data as { message: string } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: errorMessage(response.data, "Upload failed. Please try again."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { isAuthenticated } from "@/lib/auth-store";
|
|||||||
// Auth pages
|
// Auth pages
|
||||||
import SignIn from "@/pages/auth/SignIn";
|
import SignIn from "@/pages/auth/SignIn";
|
||||||
import SignUp from "@/pages/auth/SignUp";
|
import SignUp from "@/pages/auth/SignUp";
|
||||||
|
import UploadId from "@/pages/auth/UploadId";
|
||||||
import VerifyOtp from "@/pages/auth/VerifyOtp";
|
import VerifyOtp from "@/pages/auth/VerifyOtp";
|
||||||
import VerifyOtpRegistration from "@/pages/auth/VerifyOtpRegistration";
|
import VerifyOtpRegistration from "@/pages/auth/VerifyOtpRegistration";
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ export const router = createBrowserRouter([
|
|||||||
path: "/auth/verify-otp-registration",
|
path: "/auth/verify-otp-registration",
|
||||||
element: <VerifyOtpRegistration />,
|
element: <VerifyOtpRegistration />,
|
||||||
},
|
},
|
||||||
|
// Public self-service ID upload (SMS magic link or OTP fallback).
|
||||||
|
{ path: "/upload-id", element: <UploadId /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user