tema 675f7d5cfd Remove cached profile fields migration and update CSRF middleware for new public auth endpoints
- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS.
- Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking.
- Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience.
- Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
2025-11-21 17:12:34 +09:00

350 lines
14 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
interface SimFeatureTogglesProps {
subscriptionId: number;
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
onChanged?: () => void;
embedded?: boolean; // when true, render without outer card wrappers
}
export function SimFeatureToggles({
subscriptionId,
voiceMailEnabled,
callWaitingEnabled,
internationalRoamingEnabled,
networkType,
onChanged,
embedded = false,
}: SimFeatureTogglesProps) {
// Initial values
const initial = useMemo(
() => ({
vm: !!voiceMailEnabled,
cw: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled,
nt: networkType === "5G" ? "5G" : "4G",
}),
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
);
// Working values
const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as "4G" | "5G");
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => {
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as "4G" | "5G");
setError(null);
setSuccess(null);
};
const applyChanges = async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
const featurePayload: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: "4G" | "5G";
} = {};
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) {
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
params: { path: { id: subscriptionId } },
body: featurePayload,
});
}
setSuccess("Changes submitted successfully");
onChanged?.();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit changes");
} finally {
setLoading(false);
setTimeout(() => setSuccess(null), 3000);
}
};
return (
<div className="space-y-6">
{/* Service Options */}
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
{/* Voice Mail */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">Voice Mail</div>
<div className="text-xs text-slate-500">¥300/month</div>
</div>
<button
type="button"
role="switch"
aria-checked={vm}
onClick={() => setVm(!vm)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
vm ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
vm ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* Call Waiting */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">Call Waiting</div>
<div className="text-xs text-slate-500">¥300/month</div>
</div>
<button
type="button"
role="switch"
aria-checked={cw}
onClick={() => setCw(!cw)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
cw ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
cw ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* International Roaming */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">International Roaming</div>
<div className="text-xs text-slate-500">Global connectivity</div>
</div>
<button
type="button"
role="switch"
aria-checked={ir}
onClick={() => setIr(!ir)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
ir ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
ir ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
<div className="border-t border-gray-200 pt-6">
<div className="mb-4">
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
<div className="text-xs text-red-600 mt-1">
Voice, network, and plan changes must be requested at least 30 minutes apart. If you just changed another option, you may need to wait before submitting.
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<input
type="radio"
id="4g"
name="networkType"
value="4G"
checked={nt === "4G"}
onChange={() => setNt("4G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="4g" className="text-sm text-slate-700">
4G
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="5g"
name="networkType"
value="5G"
checked={nt === "5G"}
onChange={() => setNt("5G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="5g" className="text-sm text-slate-700">
5G
</label>
</div>
</div>
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
</div>
</div>
</div>
{/* Notes and Actions */}
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Important Notes
</h4>
<ul className="text-xs text-blue-800 space-y-1">
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes will take effect instantaneously (approx. 30min)
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
May require smartphone device restart after changes are applied
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
</li>
</ul>
</div>
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm font-medium text-green-800">{success}</p>
</div>
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg
className="h-5 w-5 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-sm font-medium text-red-800">{error}</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => void applyChanges()}
disabled={loading}
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
className="opacity-25"
></circle>
<path
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
className="opacity-75"
></path>
</svg>
Applying Changes...
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Apply Changes
</>
)}
</button>
<button
onClick={() => reset()}
disabled={loading}
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset
</button>
</div>
</div>
</div>
);
}