app / src /pages /UserToken.tsx
AZILS's picture
Upload 323 files
a21c316 verified
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, RefreshCw, Copy, Activity, User, Settings, Shield, Clock, Users } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { request as invoke } from '../utils/request';
import { showToast } from '../components/common/ToastContainer';
import { copyToClipboard } from '../utils/clipboard';
interface UserToken {
id: string;
token: string;
username: string;
description?: string;
enabled: boolean;
expires_type: string;
expires_at?: number;
max_ips: number;
curfew_start?: string;
curfew_end?: string;
created_at: number;
updated_at: number;
last_used_at?: number;
total_requests: number;
total_tokens_used: number;
}
interface UserTokenStats {
total_tokens: number;
active_tokens: number;
total_users: number;
today_requests: number;
}
// interface CreateTokenRequest omitted as it's not explicitly used for typing variables
const UserToken: React.FC = () => {
const { t } = useTranslation();
const [tokens, setTokens] = useState<UserToken[]>([]);
const [stats, setStats] = useState<UserTokenStats | null>(null);
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [creating, setCreating] = useState(false);
// Edit State
const [showEditModal, setShowEditModal] = useState(false);
const [editingToken, setEditingToken] = useState<UserToken | null>(null);
const [editUsername, setEditUsername] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editMaxIps, setEditMaxIps] = useState(0);
const [editCurfewStart, setEditCurfewStart] = useState('');
const [editCurfewEnd, setEditCurfewEnd] = useState('');
const [updating, setUpdating] = useState(false);
// Create Form State
const [newUsername, setNewUsername] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newExpiresType, setNewExpiresType] = useState('month'); // day, week, month, never, custom
const [newMaxIps, setNewMaxIps] = useState(0);
const [newCurfewStart, setNewCurfewStart] = useState('');
const [newCurfewEnd, setNewCurfewEnd] = useState('');
const [newCustomExpires, setNewCustomExpires] = useState(''); // datetime-local value
const loadData = async () => {
setLoading(true);
try {
const [tokensData, statsData] = await Promise.all([
invoke<UserToken[]>('list_user_tokens'),
invoke<UserTokenStats>('get_user_token_summary')
]);
setTokens(tokensData);
setStats(statsData);
} catch (e) {
console.error('Failed to load user tokens', e);
showToast(t('common.load_failed') || 'Failed to load data', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleCreate = async () => {
if (!newUsername) {
showToast(t('user_token.username_required') || 'Username is required', 'error');
return;
}
// 验证自定义时间
if (newExpiresType === 'custom' && !newCustomExpires) {
showToast(t('user_token.custom_expires_required') || 'Please select a custom expiration time', 'error');
return;
}
setCreating(true);
try {
// 计算自定义过期时间戳
const customExpiresAt = newExpiresType === 'custom' && newCustomExpires
? Math.floor(new Date(newCustomExpires).getTime() / 1000)
: undefined;
await invoke('create_user_token', {
request: {
username: newUsername,
expires_type: newExpiresType,
description: newDesc || null,
max_ips: newMaxIps,
curfew_start: newCurfewStart || null,
curfew_end: newCurfewEnd || null,
custom_expires_at: customExpiresAt || null
}
});
showToast(t('common.create_success') || 'Created successfully', 'success');
setShowCreateModal(false);
setNewUsername('');
setNewDesc('');
setNewExpiresType('month');
setNewMaxIps(0);
setNewCurfewStart('');
setNewCurfewEnd('');
setNewCustomExpires('');
loadData();
} catch (e) {
console.error('Failed to create token', e);
showToast(String(e), 'error');
} finally {
setCreating(false);
}
};
const handleDelete = async (id: string) => {
try {
await invoke('delete_user_token', { id });
showToast(t('common.delete_success') || 'Deleted successfully', 'success');
loadData();
} catch (e) {
showToast(String(e), 'error');
}
};
const handleEdit = (token: UserToken) => {
console.log('Editing token:', token); // 调试日志
setEditingToken(token);
setEditUsername(token.username);
setEditDesc(token.description || '');
setEditMaxIps(token.max_ips ?? 0); // 使用 ?? 确保 null/undefined 变为 0
setEditCurfewStart(token.curfew_start ?? '');
setEditCurfewEnd(token.curfew_end ?? '');
setShowEditModal(true);
};
const handleUpdate = async () => {
if (!editingToken) return;
if (!editUsername) {
showToast(t('user_token.username_required') || 'Username is required', 'error');
return;
}
setUpdating(true);
try {
await invoke('update_user_token', {
id: editingToken.id,
request: {
username: editUsername,
description: editDesc || undefined,
max_ips: editMaxIps,
// 使用双层包装: undefined = 不更新, null = 清空, string = 设置值
curfew_start: editCurfewStart === '' ? null : editCurfewStart,
curfew_end: editCurfewEnd === '' ? null : editCurfewEnd
}
});
showToast(t('common.update_success') || 'Updated successfully', 'success');
setShowEditModal(false);
setEditingToken(null);
loadData();
} catch (e) {
console.error('Failed to update token', e);
showToast(String(e), 'error');
} finally {
setUpdating(false);
}
};
const handleRenew = async (id: string, type: string) => {
try {
await invoke('renew_user_token', { id, expiresType: type });
showToast(t('user_token.renew_success') || 'Renewed successfully', 'success');
loadData();
} catch (e) {
showToast(String(e), 'error');
}
};
const handleCopyToken = async (text: string) => {
const success = await copyToClipboard(text);
if (success) {
showToast(t('common.copied') || 'Copied to clipboard', 'success');
} else {
showToast(t('common.copy_failed') || 'Failed to copy to clipboard', 'error');
}
};
const formatTime = (ts?: number) => {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString();
};
const getExpiresLabel = (type: string) => {
switch (type) {
case 'day': return t('user_token.expires_day', { defaultValue: '1 Day' });
case 'week': return t('user_token.expires_week', { defaultValue: '1 Week' });
case 'month': return t('user_token.expires_month', { defaultValue: '1 Month' });
case 'never': return t('user_token.expires_never', { defaultValue: 'Never' });
case 'custom': return t('user_token.expires_custom', { defaultValue: 'Custom' });
default: return type;
}
};
// Calculate expiration status style
const getExpiresStatus = (expiresAt?: number) => {
if (!expiresAt) return 'text-green-500';
const now = Date.now() / 1000;
if (expiresAt < now) return 'text-red-500 font-bold';
if (expiresAt - now < 86400 * 3) return 'text-orange-500'; // Less than 3 days
return 'text-green-500';
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="h-full flex flex-col p-5 gap-5 max-w-7xl mx-auto w-full"
>
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<User className="text-purple-500 w-5 h-5" />
</div>
{t('user_token.title', { defaultValue: 'User Tokens' })}
</h1>
<div className="flex items-center gap-2">
<button
onClick={() => loadData()}
className={`p-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg transition-colors ${loading ? 'text-blue-500' : 'text-gray-500'}`}
title={t('common.refresh') || 'Refresh'}
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all flex items-center gap-2 shadow-sm shadow-blue-500/20"
>
<Plus size={16} />
<span>{t('user_token.create', { defaultValue: 'Create Token' })}</span>
</button>
</div>
</div>
{/* Stats Cards Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<motion.div
whileHover={{ y: -2 }}
className="bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200"
>
<div className="flex items-center justify-between mb-2">
<div className="p-1.5 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<Users className="w-4 h-4 text-blue-500" />
</div>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5">{stats?.total_users || 0}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('user_token.total_users', { defaultValue: 'Total Users' })}</div>
</motion.div>
<motion.div
whileHover={{ y: -2 }}
className="bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200"
>
<div className="flex items-center justify-between mb-2">
<div className="p-1.5 bg-green-50 dark:bg-green-900/20 rounded-md">
<Activity className="w-4 h-4 text-green-500" />
</div>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5">{stats?.active_tokens || 0}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('user_token.active_tokens', { defaultValue: 'Active Tokens' })}</div>
</motion.div>
<motion.div
whileHover={{ y: -2 }}
className="bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200"
>
<div className="flex items-center justify-between mb-2">
<div className="p-1.5 bg-purple-50 dark:bg-purple-900/20 rounded-md">
<Clock className="w-4 h-4 text-purple-500" />
</div>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5">{stats?.total_tokens || 0}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('user_token.total_created', { defaultValue: 'Total Tokens' })}</div>
</motion.div>
<motion.div
whileHover={{ y: -2 }}
className="bg-white dark:bg-base-100 rounded-xl p-4 shadow-sm border border-gray-100 dark:border-base-200"
>
<div className="flex items-center justify-between mb-2">
<div className="p-1.5 bg-orange-50 dark:bg-orange-900/20 rounded-md">
<Shield className="w-4 h-4 text-orange-500" />
</div>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-base-content mb-0.5">{stats?.today_requests || 0}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('user_token.today_requests', { defaultValue: 'Today Requests' })}</div>
</motion.div>
</div>
{/* Token List */}
<div className="flex-1 overflow-auto bg-white dark:bg-base-100 rounded-2xl shadow-sm border border-gray-100 dark:border-base-200">
<table className="table table-pin-rows">
<thead>
<tr className="bg-gray-50/50 dark:bg-base-200/50">
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.username', { defaultValue: 'Username' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.token', { defaultValue: 'Token' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.expires', { defaultValue: 'Expires' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.usage', { defaultValue: 'Usage' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.ip_limit', { defaultValue: 'IP Limit' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4">{t('user_token.created', { defaultValue: 'Created' })}</th>
<th className="bg-transparent text-gray-500 font-medium py-4 text-right">{t('common.actions', { defaultValue: 'Actions' })}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-base-200">
<AnimatePresence mode="popLayout">
{tokens.map((token, index) => (
<motion.tr
key={token.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: index * 0.03 }}
className="hover:bg-gray-50/80 dark:hover:bg-base-200/50 transition-colors group"
>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center text-purple-600 font-bold text-xs">
{token.username.substring(0, 2).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white uppercase tracking-wider text-xs">{token.username}</div>
<div className="text-[10px] text-gray-500">{token.description || '-'}</div>
</div>
</div>
</td>
<td>
<div className="flex items-center gap-2 group/token">
<code className="bg-gray-50 dark:bg-base-200 px-2 py-1 rounded border border-gray-100 dark:border-base-300 text-[11px] font-mono text-gray-600 dark:text-gray-400">
{token.token.substring(0, 8)}••••••••
</code>
<button
onClick={() => handleCopyToken(token.token)}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-base-300 rounded-md transition-all text-gray-400 hover:text-gray-600 dark:hover:text-white"
>
<Copy size={13} />
</button>
</div>
</td>
<td>
<div className={`text-xs font-medium mb-1 ${getExpiresStatus(token.expires_at)}`}>
{token.expires_at ? formatTime(token.expires_at) : t('user_token.never', { defaultValue: 'Never' })}
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-base-200 text-gray-500 rounded lowercase">
{getExpiresLabel(token.expires_type)}
</span>
{token.expires_at && token.expires_at < Date.now() / 1000 && (
<button
onClick={() => handleRenew(token.id, token.expires_type)}
className="text-[10px] text-blue-500 hover:underline font-medium"
>
{t('user_token.renew_button', { defaultValue: 'Renew' })}
</button>
)}
</div>
</td>
<td>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-300">{token.total_requests} <span className="text-[10px] font-normal text-gray-400">reqs</span></div>
<div className="text-[10px] text-gray-400 mt-0.5">
{(token.total_tokens_used / 1000).toFixed(1)}k tokens
</div>
</td>
<td>
{token.max_ips === 0
? <span className="px-2 py-0.5 bg-gray-100 dark:bg-base-200 text-gray-500 text-[10px] rounded-full">{t('user_token.unlimited', { defaultValue: 'Unlimited' })}</span>
: <span className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 text-[10px] font-medium rounded-full border border-orange-100 dark:border-orange-900/30">{token.max_ips} IPs</span>
}
{token.curfew_start && token.curfew_end && (
<div className="text-[10px] text-gray-400 mt-1.5 flex items-center gap-1 bg-gray-50 dark:bg-base-200 w-fit px-1.5 py-0.5 rounded">
<Clock size={10} className="text-orange-500" />
<span>{token.curfew_start} - {token.curfew_end}</span>
</div>
)}
</td>
<td className="text-[10px] text-gray-400 italic">
{formatTime(token.created_at)}
</td>
<td className="text-right">
<div className="flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(token)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-gray-500 hover:text-blue-500 transition-colors"
title={t('common.edit', { defaultValue: 'Edit' })}
>
<Settings size={14} />
</button>
<div className="dropdown dropdown-end">
<label tabIndex={0} className="p-1.5 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-gray-500 hover:text-green-500 transition-colors inline-block cursor-pointer">
<RefreshCw size={14} />
</label>
<ul tabIndex={0} className="dropdown-content z-[10] menu p-2 shadow-xl bg-white dark:bg-base-100 rounded-xl w-32 border border-gray-100 dark:border-base-200 mt-1">
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-widest">{t('user_token.renew')}</div>
<li><a className="text-xs py-2" onClick={() => handleRenew(token.id, 'day')}>{t('user_token.expires_day', { defaultValue: '1 Day' })}</a></li>
<li><a className="text-xs py-2" onClick={() => handleRenew(token.id, 'week')}>{t('user_token.expires_week', { defaultValue: '1 Week' })}</a></li>
<li><a className="text-xs py-2" onClick={() => handleRenew(token.id, 'month')}>{t('user_token.expires_month', { defaultValue: '1 Month' })}</a></li>
</ul>
</div>
<button
onClick={() => handleDelete(token.id)}
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-gray-400 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</td>
</motion.tr>
))}
</AnimatePresence>
{tokens.length === 0 && !loading && (
<tr>
<td colSpan={7} className="py-20">
<div className="flex flex-col items-center justify-center text-gray-400 gap-3">
<div className="p-4 bg-gray-50 dark:bg-base-200 rounded-full">
<Users size={40} className="opacity-20" />
</div>
<p className="text-sm">{t('user_token.no_data', { defaultValue: 'No tokens found' })}</p>
<button
onClick={() => setShowCreateModal(true)}
className="text-xs text-blue-500 hover:underline"
>
{t('user_token.create', { defaultValue: 'Create your first token' })}
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">{t('user_token.create_title', { defaultValue: 'Create New Token' })}</h3>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.username', { defaultValue: 'Username' })} *</span>
</label>
<input
type="text"
className="input input-bordered w-full"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
placeholder={t('user_token.placeholder_username', { defaultValue: 'e.g. user1' })}
/>
</div>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.description', { defaultValue: 'Description' })}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
placeholder={t('user_token.placeholder_desc', { defaultValue: 'Optional notes' })}
/>
</div>
<div className="grid grid-cols-2 gap-4 mb-3">
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t('user_token.expires', { defaultValue: 'Expires In' })}</span>
</label>
<select
className="select select-bordered w-full"
value={newExpiresType}
onChange={e => setNewExpiresType(e.target.value)}
>
<option value="day">{t('user_token.expires_day', { defaultValue: '1 Day' })}</option>
<option value="week">{t('user_token.expires_week', { defaultValue: '1 Week' })}</option>
<option value="month">{t('user_token.expires_month', { defaultValue: '1 Month' })}</option>
<option value="custom">{t('user_token.expires_custom', { defaultValue: 'Custom' })}</option>
<option value="never">{t('user_token.expires_never', { defaultValue: 'Never' })}</option>
</select>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t('user_token.ip_limit', { defaultValue: 'Max IPs' })}</span>
</label>
<input
type="number"
className="input input-bordered w-full"
value={newMaxIps}
onChange={e => setNewMaxIps(parseInt(e.target.value) || 0)}
min="0"
placeholder={t('user_token.placeholder_max_ips', { defaultValue: '0 = Unlimited' })}
/>
<label className="label">
<span className="label-text-alt text-gray-500">{t('user_token.hint_max_ips', { defaultValue: '0 = Unlimited' })}</span>
</label>
</div>
</div>
{/* Custom Expiration Time Picker */}
{newExpiresType === 'custom' && (
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.custom_expires_at', { defaultValue: 'Expiration Date & Time' })} *</span>
</label>
<input
type="datetime-local"
className="input input-bordered w-full"
value={newCustomExpires}
onChange={e => setNewCustomExpires(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
/>
<label className="label">
<span className="label-text-alt text-gray-500">{t('user_token.hint_custom_expires', { defaultValue: 'Select the exact date and hour when this token expires' })}</span>
</label>
</div>
)}
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.curfew', { defaultValue: 'Curfew (Service Unavailable Time)' })}</span>
</label>
<div className="flex gap-2 items-center">
<input
type="time"
className="input input-bordered w-full"
value={newCurfewStart}
onChange={e => setNewCurfewStart(e.target.value)}
/>
<span className="text-gray-400">to</span>
<input
type="time"
className="input input-bordered w-full"
value={newCurfewEnd}
onChange={e => setNewCurfewEnd(e.target.value)}
/>
</div>
<label className="label">
<span className="label-text-alt text-gray-500">{t('user_token.hint_curfew', { defaultValue: 'Leave empty to disable. Based on Beijing time (UTC+8).' })}</span>
</label>
</div>
<div className="modal-action">
<button className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-sm transition-colors" onClick={() => setShowCreateModal(false)}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</button>
<button
className={`px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all shadow-sm shadow-blue-500/20 flex items-center gap-2 ${creating ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleCreate}
disabled={creating}
>
{creating && <RefreshCw size={14} className="animate-spin" />}
{t('common.create', { defaultValue: 'Create' })}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && editingToken && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">{t('user_token.edit_title', { defaultValue: 'Edit Token' })}</h3>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.username', { defaultValue: 'Username' })} *</span>
</label>
<input
type="text"
className="input input-bordered w-full"
value={editUsername}
onChange={e => setEditUsername(e.target.value)}
placeholder={t('user_token.placeholder_username', { defaultValue: 'e.g. user1' })}
/>
</div>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.description', { defaultValue: 'Description' })}</span>
</label>
<input
type="text"
className="input input-bordered w-full"
value={editDesc}
onChange={e => setEditDesc(e.target.value)}
placeholder={t('user_token.placeholder_desc', { defaultValue: 'Optional notes' })}
/>
</div>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.ip_limit', { defaultValue: 'Max IPs' })}</span>
</label>
<input
type="number"
className="input input-bordered w-full"
value={editMaxIps}
onChange={e => setEditMaxIps(parseInt(e.target.value) || 0)}
min="0"
placeholder={t('user_token.placeholder_max_ips', { defaultValue: '0 = Unlimited' })}
/>
<label className="label">
<span className="label-text-alt text-gray-500">{t('user_token.hint_max_ips', { defaultValue: '0 = Unlimited' })}</span>
</label>
</div>
<div className="form-control w-full mb-3">
<label className="label">
<span className="label-text">{t('user_token.curfew', { defaultValue: 'Curfew (Service Unavailable Time)' })}</span>
</label>
<div className="flex gap-2 items-center">
<input
type="time"
className="input input-bordered w-full"
value={editCurfewStart}
onChange={e => setEditCurfewStart(e.target.value)}
/>
<span className="text-gray-400">to</span>
<input
type="time"
className="input input-bordered w-full"
value={editCurfewEnd}
onChange={e => setEditCurfewEnd(e.target.value)}
/>
</div>
<label className="label">
<span className="label-text-alt text-gray-500">{t('user_token.hint_curfew', { defaultValue: 'Leave empty to disable. Based on Beijing time (UTC+8).' })}</span>
</label>
</div>
<div className="modal-action">
<button className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-base-200 rounded-lg text-sm transition-colors" onClick={() => setShowEditModal(false)}>
{t('common.cancel', { defaultValue: 'Cancel' })}
</button>
<button
className={`px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-all shadow-sm shadow-blue-500/20 flex items-center gap-2 ${updating ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleUpdate}
disabled={updating}
>
{updating && <RefreshCw size={14} className="animate-spin" />}
{t('common.update', { defaultValue: 'Update' })}
</button>
</div>
</div>
</div>
)}
</motion.div>
);
};
export default UserToken;