mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: add password form to account
This commit is contained in:
@@ -5,6 +5,7 @@ export const URL_CONSTANTS = {
|
||||
REFRESH_TOKEN: "/api/auth/refresh-token",
|
||||
LOGOUT: "/api/auth/logout",
|
||||
PROFILE: "/auth/profile",
|
||||
CHANGE_PASSWORD: "/api/auth/change-password",
|
||||
FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request",
|
||||
FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8,
|
||||
* ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with
|
||||
* the server so the user gets a precise message inline instead of a generic 400.
|
||||
*/
|
||||
const strongPassword = z
|
||||
.string()
|
||||
.min(8, "At least 8 characters")
|
||||
.regex(/[a-z]/, "Include a lowercase letter")
|
||||
.regex(/\d/, "Include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Include a symbol");
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
oldPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: strongPassword,
|
||||
confirmPassword: z.string().min(1, "Confirm your new password"),
|
||||
})
|
||||
.refine((d) => d.newPassword === d.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
})
|
||||
.refine((d) => d.newPassword !== d.oldPassword, {
|
||||
path: ["newPassword"],
|
||||
message: "New password must be different from your current one",
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
/** IAM returns bare error codes; turn them into something a customer can act on. */
|
||||
const MESSAGES: Record<string, string> = {
|
||||
unable_to_change_password: "Your current password is incorrect.",
|
||||
new_password_same_as_old:
|
||||
"New password must be different from your current one.",
|
||||
new_passwords_do_not_match: "The new passwords do not match.",
|
||||
user_credentials_not_found: "This account has no password set.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Password changes go straight to IAM's `PATCH /api/auth/change-password`, which
|
||||
* verifies the old password and owns the credential write (argon hashing,
|
||||
* retiring the previous credential). The freight app deliberately implements no
|
||||
* part of that — it only collects the fields.
|
||||
*/
|
||||
export default function ChangePasswordCard() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) => api.auth.changePassword.call(data),
|
||||
// Never leave the old password sitting in component state after a change.
|
||||
onSuccess: () => reset(),
|
||||
});
|
||||
|
||||
const errorMessage = (err: unknown): string => {
|
||||
const raw = (
|
||||
err as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data?.message;
|
||||
const code = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!code) return "Could not change your password. Please try again.";
|
||||
return MESSAGES[code] ?? code;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<KeyRound size={20} />
|
||||
<Title order={3}>Password</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Change the password you use to sign in. You'll need your current one.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit((d) => mutation.mutate(d))}>
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
autoComplete="current-password"
|
||||
error={errors.oldPassword?.message}
|
||||
{...register("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
description="At least 8 characters, with a lowercase letter, a number and a symbol."
|
||||
autoComplete="new-password"
|
||||
error={errors.newPassword?.message}
|
||||
{...register("newPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
autoComplete="new-password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{mutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Password changed
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(mutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
toEthiopianE164,
|
||||
} from "@/components/PhoneField";
|
||||
import type { AuthUser, ContactChannel } from "@/types/auth";
|
||||
import ChangePasswordCard from "./ChangePasswordCard";
|
||||
|
||||
const schema = z.object({
|
||||
phoneNumber: z
|
||||
@@ -110,13 +117,16 @@ export default function TabAccount({ user }: TabAccountProps) {
|
||||
/** Name needs no proof of possession, so it saves straight through. */
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }),
|
||||
api.account.updateName.call({
|
||||
name: { en: data.nameEn, am: data.nameAm },
|
||||
}),
|
||||
onSuccess: refreshUser,
|
||||
});
|
||||
|
||||
/** Step 1 of a contact change: ask the server to code the new value. */
|
||||
const otpMutation = useMutation({
|
||||
mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change),
|
||||
mutationFn: (change: PendingChange) =>
|
||||
api.account.sendContactOtp.call(change),
|
||||
onSuccess: (res) => {
|
||||
setOtp("");
|
||||
setSentTo(res.sentTo);
|
||||
@@ -183,14 +193,17 @@ export default function TabAccount({ user }: TabAccountProps) {
|
||||
};
|
||||
|
||||
const errorMessage = (err: unknown): string => {
|
||||
const res = (err as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
const res = (
|
||||
err as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data?.message;
|
||||
if (Array.isArray(res)) return res[0];
|
||||
return res ?? "Something went wrong. Please try again.";
|
||||
};
|
||||
|
||||
const busy =
|
||||
otpMutation.isPending || contactMutation.isPending || nameMutation.isPending;
|
||||
otpMutation.isPending ||
|
||||
contactMutation.isPending ||
|
||||
nameMutation.isPending;
|
||||
|
||||
const savedSummary =
|
||||
completed.length && !queue.length
|
||||
@@ -200,175 +213,187 @@ export default function TabAccount({ user }: TabAccountProps) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<UserCog size={20} />
|
||||
<Title order={3}>Account</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Your login details. Verification codes and SMS notifications are sent to
|
||||
the phone number below.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@company.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bekele"
|
||||
error={errors.nameEn?.message}
|
||||
{...register("nameEn")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name (Amharic)"
|
||||
placeholder="አበበ በቀለ"
|
||||
error={errors.nameAm?.message}
|
||||
{...register("nameAm")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Text c="edr-muted" size="xs" mt="sm">
|
||||
Changing your phone number or email requires a verification code sent
|
||||
to the new one.
|
||||
<Stack gap="lg">
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<UserCog size={20} />
|
||||
<Title order={3}>Account</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Your login details. Verification codes and SMS notifications are sent
|
||||
to the phone number below.
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{savedSummary && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>{savedSummary}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{nameMutation.isSuccess && !savedSummary && !queue.length && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
||||
</Group>
|
||||
)}
|
||||
{(otpMutation.isError || nameMutation.isError) && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(otpMutation.error ?? nameMutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" leftSection={<Save size={16} />} loading={busy}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
opened={current !== null}
|
||||
onClose={cancelQueue}
|
||||
title="Verify your new contact details"
|
||||
centered
|
||||
>
|
||||
{current && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{totalSteps > 1 && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
Step {step} of {totalSteps}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Alert icon={<ShieldCheck size={16} />} color="blue">
|
||||
{sentTo ? (
|
||||
<>
|
||||
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to confirm
|
||||
your new {CHANNEL_LABEL[current.channel]}.
|
||||
</>
|
||||
) : (
|
||||
<>Sending a code to your new {CHANNEL_LABEL[current.channel]}…</>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{completed.length > 0 && queue.length > 0 && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm">
|
||||
{CHANNEL_LABEL[completed[completed.length - 1]]} updated — one
|
||||
more to confirm.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
disabled={!sentTo || otpMutation.isPending}
|
||||
aria-label="Verification code"
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phoneNumber"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
|
||||
{contactMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@company.com"
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bekele"
|
||||
error={errors.nameEn?.message}
|
||||
{...register("nameEn")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Full Name (Amharic)"
|
||||
placeholder="አበበ በቀለ"
|
||||
error={errors.nameAm?.message}
|
||||
{...register("nameAm")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Text c="edr-muted" size="xs" mt="sm">
|
||||
Changing your phone number or email requires a verification code
|
||||
sent to the new one.
|
||||
</Text>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{savedSummary && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{savedSummary}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{nameMutation.isSuccess && !savedSummary && !queue.length && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Saved successfully
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{(otpMutation.isError || nameMutation.isError) && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{errorMessage(otpMutation.error ?? nameMutation.error)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={otpMutation.isPending || !sentTo}
|
||||
onClick={() => {
|
||||
// Clear any "invalid code" error first: the user is asking for
|
||||
// a fresh code, not retrying the old one, so leaving the
|
||||
// failure on screen would describe a request they didn't make.
|
||||
contactMutation.reset();
|
||||
otpMutation.mutate(current);
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Resend code
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
loading={contactMutation.isPending}
|
||||
disabled={otp.length !== 6 || !sentTo}
|
||||
onClick={() =>
|
||||
contactMutation.mutate({ ...current, otp })
|
||||
}
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={busy}
|
||||
>
|
||||
Verify & Save
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Modal
|
||||
opened={current !== null}
|
||||
onClose={cancelQueue}
|
||||
title="Verify your new contact details"
|
||||
centered
|
||||
>
|
||||
{current && (
|
||||
<Stack gap="md">
|
||||
{totalSteps > 1 && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
Step {step} of {totalSteps}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Alert icon={<ShieldCheck size={16} />} color="blue">
|
||||
{sentTo ? (
|
||||
<>
|
||||
We sent a 6-digit code to <b>{sentTo}</b>. Enter it to
|
||||
confirm your new {CHANNEL_LABEL[current.channel]}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sending a code to your new {CHANNEL_LABEL[current.channel]}…
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{completed.length > 0 && queue.length > 0 && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm">
|
||||
{CHANNEL_LABEL[completed[completed.length - 1]]} updated —
|
||||
one more to confirm.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
disabled={!sentTo || otpMutation.isPending}
|
||||
aria-label="Verification code"
|
||||
/>
|
||||
|
||||
{contactMutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm">{errorMessage(contactMutation.error)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={otpMutation.isPending || !sentTo}
|
||||
onClick={() => {
|
||||
// Clear any "invalid code" error first: the user is asking for
|
||||
// a fresh code, not retrying the old one, so leaving the
|
||||
// failure on screen would describe a request they didn't make.
|
||||
contactMutation.reset();
|
||||
otpMutation.mutate(current);
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
<Button
|
||||
loading={contactMutation.isPending}
|
||||
disabled={otp.length !== 6 || !sentTo}
|
||||
onClick={() => contactMutation.mutate({ ...current, otp })}
|
||||
>
|
||||
Verify & Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordCard />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
@@ -149,6 +150,11 @@ export const api = {
|
||||
"verifyOTP",
|
||||
authService.verifyOTP,
|
||||
),
|
||||
changePassword: endpoint<ChangePasswordPayload, void>(
|
||||
"auth",
|
||||
"changePassword",
|
||||
authService.changePassword,
|
||||
),
|
||||
logout: endpoint<void, void>("auth", "logout", authService.logout),
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
ChangePasswordPayload,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
ForgotPasswordRequestPayload,
|
||||
@@ -111,6 +112,15 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password via IAM's own route: it verifies the
|
||||
* old password with argon and owns the credential write (deactivating the
|
||||
* previous one), so this app never touches password material.
|
||||
*/
|
||||
changePassword: async (body: ChangePasswordPayload) => {
|
||||
await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body);
|
||||
},
|
||||
|
||||
// The three calls below manage the signed-in user's own account record
|
||||
// (`/api/me`), which is what OTPs and SMS notifications are delivered to.
|
||||
// Changing phone/email is OTP-gated server-side: the code goes to the NEW
|
||||
|
||||
@@ -45,6 +45,17 @@ export interface OtpResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the signed-in user's password. The old password is the proof of
|
||||
* possession — IAM verifies it server-side and owns the credential write, so the
|
||||
* freight app never hashes or stores a password itself.
|
||||
*/
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/** The contact channel being changed on the signed-in user's own account. */
|
||||
export type ContactChannel = "email" | "phone";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user