Commit 4b5d694e authored by ThinhNC's avatar ThinhNC

feat(api-keys): add API key management interface

parent 060f545a
......@@ -10,6 +10,7 @@ import { JobDetail } from './pages/JobDetail';
import { Users } from './pages/Users';
import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile';
import { ApiKeys } from './pages/ApiKeys';
import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail';
......@@ -59,6 +60,7 @@ export default function App() {
{/* Profile */}
<Route path="profile" element={<Profile />} />
<Route path="api-keys" element={<ApiKeys />} />
{/* Admin Only Routes */}
<Route
......
......@@ -9,6 +9,7 @@ const TITLES: Record<string, string> = {
'/reset-password': 'Đặt lại mật khẩu | Data Crawler',
'/verify-email': 'Xác minh email | Data Crawler',
'/profile': 'Hồ sơ cá nhân | Data Crawler',
'/api-keys': 'Quản lý API Key | Data Crawler',
'/users': 'Quản lý người dùng | Data Crawler',
'/logs': 'Nhật ký hệ thống | Data Crawler',
};
......
import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass } from 'lucide-react';
import { Database, Users, History, User, LogOut, Compass, KeyRound } from 'lucide-react';
export const DashboardLayout: React.FC = () => {
const { user, logout, isAdmin } = useAuth();
......@@ -20,6 +20,12 @@ export const DashboardLayout: React.FC = () => {
icon: <Database className="h-5 w-5" />,
allowed: true,
},
{
name: 'Quản lý API Key',
path: '/api-keys',
icon: <KeyRound className="h-5 w-5" />,
allowed: true,
},
{
name: 'Quản lý Users',
path: '/users',
......
import { useCallback, useEffect, useState } from 'react';
import {
AlertTriangle,
Check,
Copy,
Eye,
EyeOff,
KeyRound,
Loader2,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
X,
} from 'lucide-react';
import { apiKeysApi, type ApiKeyRecord, type CreatedApiKey } from '../services/api';
import { getApiErrorMessages } from '../utils/apiError';
type ExpirationPreset = 'none' | '7' | '30' | '90' | 'custom';
const relativeTime = new Intl.RelativeTimeFormat('vi', { numeric: 'auto' });
function formatRelativeTime(value: string | null) {
if (!value) return 'Chưa sử dụng';
const differenceInSeconds = Math.round((new Date(value).getTime() - Date.now()) / 1000);
const ranges: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['year', 31_536_000],
['month', 2_592_000],
['week', 604_800],
['day', 86_400],
['hour', 3_600],
['minute', 60],
];
for (const [unit, seconds] of ranges) {
if (Math.abs(differenceInSeconds) >= seconds) {
return relativeTime.format(Math.round(differenceInSeconds / seconds), unit);
}
}
return relativeTime.format(differenceInSeconds, 'second');
}
function formatDate(value: string | null) {
if (!value) return 'Không hết hạn';
return new Intl.DateTimeFormat('vi-VN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function isExpired(key: ApiKeyRecord) {
return Boolean(key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now());
}
function getExpiration(preset: ExpirationPreset, customDate: string) {
if (preset === 'none') return null;
if (preset === 'custom') {
if (!customDate) throw new Error('Vui lòng chọn ngày hết hạn.');
const expiration = new Date(`${customDate}T23:59:59.999`);
if (expiration.getTime() <= Date.now()) {
throw new Error('Ngày hết hạn phải ở trong tương lai.');
}
return expiration.toISOString();
}
const expiration = new Date();
expiration.setDate(expiration.getDate() + Number(preset));
return expiration.toISOString();
}
function todayInputValue() {
const now = new Date();
const offset = now.getTimezoneOffset();
return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function ApiKeys() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [pageError, setPageError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('none');
const [customDate, setCustomDate] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState('');
const [generatedKey, setGeneratedKey] = useState<CreatedApiKey | null>(null);
const [showRawKey, setShowRawKey] = useState(false);
const [copied, setCopied] = useState(false);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ApiKeyRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const loadKeys = useCallback(async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
setKeys(await apiKeysApi.list());
setPageError('');
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể tải danh sách API Key.').join('\n'));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
void loadKeys();
}, [loadKeys]);
const resetCreateForm = () => {
setName('');
setExpirationPreset('none');
setCustomDate('');
setCreateError('');
setCopied(false);
setShowRawKey(false);
};
const closeCreateForm = () => {
if (generatedKey) return;
resetCreateForm();
setShowCreate(false);
};
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setCreateError('Vui lòng nhập tên API Key.');
return;
}
let expiresAt: string | null;
try {
expiresAt = getExpiration(expirationPreset, customDate);
} catch (error) {
setCreateError(error instanceof Error ? error.message : 'Ngày hết hạn không hợp lệ.');
return;
}
setCreating(true);
setCreateError('');
try {
const created = await apiKeysApi.create({ name: normalizedName, expiresAt });
const publicKey: ApiKeyRecord = {
id: created.id,
userId: created.userId,
name: created.name,
keyPrefix: created.keyPrefix,
isActive: created.isActive,
expiresAt: created.expiresAt,
lastUsedAt: created.lastUsedAt,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
};
setKeys((current) => [publicKey, ...current]);
setGeneratedKey(created);
} catch (error: unknown) {
setCreateError(getApiErrorMessages(error, 'Không thể tạo API Key.').join('\n'));
} finally {
setCreating(false);
}
};
const acknowledgeGeneratedKey = () => {
setGeneratedKey(null);
resetCreateForm();
setShowCreate(false);
};
const copyGeneratedKey = async () => {
if (!generatedKey) return;
try {
await navigator.clipboard.writeText(generatedKey.rawKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCreateError('Không thể sao chép tự động. Vui lòng chọn và sao chép key thủ công.');
setShowRawKey(true);
}
};
const handleStatusChange = async (key: ApiKeyRecord) => {
setUpdatingId(key.id);
setPageError('');
try {
const updated = await apiKeysApi.setActive(key.id, !key.isActive);
setKeys((current) => current.map((item) => item.id === key.id ? updated : item));
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể cập nhật trạng thái API Key.').join('\n'));
} finally {
setUpdatingId(null);
}
};
const handleRevoke = async () => {
if (!revokeTarget) return;
setRevoking(true);
setPageError('');
try {
await apiKeysApi.revoke(revokeTarget.id);
setKeys((current) => current.filter((key) => key.id !== revokeTarget.id));
setRevokeTarget(null);
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể thu hồi API Key.').join('\n'));
} finally {
setRevoking(false);
}
};
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-700">
<ShieldCheck className="h-3.5 w-3.5" />
Tích hợp an toàn
</div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Quản lý API Key</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Tạo và kiểm soát khóa truy cập dành cho ứng dụng bên ngoài. Khóa đầy đủ chỉ hiển thị một lần.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void loadKeys(true)}
disabled={refreshing}
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-600 shadow-sm transition hover:bg-slate-50 disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700"
>
<Plus className="mr-2 h-4 w-4" />
Tạo API Key
</button>
</div>
</div>
{pageError && (
<div className="whitespace-pre-line rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700" role="alert">
{pageError}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-5 py-4">
<h2 className="font-semibold text-slate-900">API Key của bạn</h2>
<p className="mt-0.5 text-xs text-slate-500">{keys.length} khóa đang được lưu</p>
</div>
{loading ? (
<div className="flex min-h-64 items-center justify-center text-sm text-slate-500">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-indigo-500" />
Đang tải API Key...
</div>
) : keys.length === 0 ? (
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<KeyRound className="h-7 w-7" />
</div>
<h3 className="font-semibold text-slate-900">Chưa có API Key</h3>
<p className="mt-1 max-w-md text-sm text-slate-500">
Tạo khóa đầu tiên để xác thực các request từ ứng dụng hoặc quy trình tự động của bạn.
</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="mt-4 inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
<Plus className="mr-2 h-4 w-4" />
Tạo API Key
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-100 text-left text-sm">
<thead className="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3">Tên</th>
<th className="px-5 py-3">API Key</th>
<th className="px-5 py-3">Trạng thái</th>
<th className="px-5 py-3">Hết hạn</th>
<th className="px-5 py-3">Dùng lần cuối</th>
<th className="px-5 py-3">Ngày tạo</th>
<th className="px-5 py-3 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{keys.map((key) => {
const expired = isExpired(key);
return (
<tr key={key.id} className="transition hover:bg-slate-50/70">
<td className="whitespace-nowrap px-5 py-4 font-semibold text-slate-900">{key.name}</td>
<td className="whitespace-nowrap px-5 py-4">
<code className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-700">
{key.keyPrefix}••••••••
</code>
</td>
<td className="whitespace-nowrap px-5 py-4">
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={key.isActive}
aria-label={`${key.isActive ? 'Tắt' : 'Bật'} API Key ${key.name}`}
onClick={() => void handleStatusChange(key)}
disabled={updatingId === key.id}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 ${
key.isActive ? 'bg-indigo-600' : 'bg-slate-300'
}`}
>
<span
className={`mt-0.5 inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
key.isActive ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<span className={`text-xs font-semibold ${key.isActive ? 'text-emerald-700' : 'text-slate-500'}`}>
{key.isActive ? 'Đang bật' : 'Đã tắt'}
</span>
</div>
</td>
<td className="whitespace-nowrap px-5 py-4">
<span className={expired ? 'font-semibold text-rose-600' : 'text-slate-500'}>
{expired ? `Đã hết hạn · ${formatDate(key.expiresAt)}` : formatDate(key.expiresAt)}
</span>
</td>
<td className="whitespace-nowrap px-5 py-4 text-slate-500" title={key.lastUsedAt ? formatDate(key.lastUsedAt) : undefined}>
{formatRelativeTime(key.lastUsedAt)}
</td>
<td className="whitespace-nowrap px-5 py-4 text-slate-500">{formatDate(key.createdAt)}</td>
<td className="whitespace-nowrap px-5 py-4 text-right">
<button
type="button"
onClick={() => setRevokeTarget(key)}
className="inline-flex items-center gap-1.5 font-semibold text-rose-600 transition hover:text-rose-800"
>
<Trash2 className="h-4 w-4" />
Thu hồi
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{showCreate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeCreateForm();
}}
>
<div
className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="api-key-dialog-title"
>
{generatedKey ? (
<div className="space-y-5">
<div>
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700">
<Check className="h-6 w-6" />
</div>
<h2 id="api-key-dialog-title" className="text-xl font-bold text-slate-900">API Key đã được tạo</h2>
<p className="mt-1 text-sm text-slate-500">Khóa “{generatedKey.name}” đã sẵn sàng để sử dụng.</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div>
<p className="font-bold">Hãy sao chép key này ngay bây giờ</p>
<p className="mt-1 text-amber-800">
Vì lý do bảo mật, hệ thống sẽ không hiển thị lại khóa đầy đủ sau khi bạn đóng cửa sổ này.
</p>
</div>
</div>
</div>
{createError && (
<div className="whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{createError}
</div>
)}
<div>
<label htmlFor="generated-api-key" className="mb-1.5 block text-sm font-semibold text-slate-700">
API Key
</label>
<div className="flex rounded-lg border border-slate-300 bg-slate-50 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<input
id="generated-api-key"
type={showRawKey ? 'text' : 'password'}
value={generatedKey.rawKey}
readOnly
className="min-w-0 flex-1 bg-transparent px-3 py-2.5 font-mono text-sm text-slate-900 outline-none"
/>
<button
type="button"
onClick={() => setShowRawKey((current) => !current)}
className="px-3 text-slate-500 hover:text-slate-800"
aria-label={showRawKey ? 'Ẩn API Key' : 'Hiện API Key'}
>
{showRawKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => void copyGeneratedKey()}
className="m-1 inline-flex items-center rounded-md bg-indigo-600 px-3 text-sm font-semibold text-white hover:bg-indigo-700"
>
{copied ? <Check className="mr-1.5 h-4 w-4" /> : <Copy className="mr-1.5 h-4 w-4" />}
{copied ? 'Đã chép' : 'Sao chép'}
</button>
</div>
</div>
<button
type="button"
onClick={acknowledgeGeneratedKey}
className="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Tôi đã sao chép key này
</button>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<div>
<h2 id="api-key-dialog-title" className="text-xl font-bold text-slate-900">Tạo API Key mới</h2>
<p className="mt-1 text-sm text-slate-500">Đặt tên dễ nhận biết và chọn thời hạn phù hợp.</p>
</div>
<button
type="button"
onClick={closeCreateForm}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label="Đóng"
>
<X className="h-5 w-5" />
</button>
</div>
{createError && (
<div className="mt-4 whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="mt-5 space-y-4">
<div>
<label htmlFor="api-key-name" className="mb-1.5 block text-sm font-semibold text-slate-700">
Tên key <span className="text-rose-500">*</span>
</label>
<input
id="api-key-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={100}
required
autoFocus
placeholder="Ví dụ: Production App"
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="api-key-expiration" className="mb-1.5 block text-sm font-semibold text-slate-700">
Thời hạn
</label>
<select
id="api-key-expiration"
value={expirationPreset}
onChange={(event) => setExpirationPreset(event.target.value as ExpirationPreset)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
>
<option value="none">Không hết hạn</option>
<option value="7">7 ngày</option>
<option value="30">30 ngày</option>
<option value="90">90 ngày</option>
<option value="custom">Chọn ngày cụ thể</option>
</select>
</div>
{expirationPreset === 'custom' && (
<div>
<label htmlFor="api-key-custom-date" className="mb-1.5 block text-sm font-semibold text-slate-700">
Ngày hết hạn
</label>
<input
id="api-key-custom-date"
type="date"
min={todayInputValue()}
value={customDate}
onChange={(event) => setCustomDate(event.target.value)}
required
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
)}
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<button
type="button"
onClick={closeCreateForm}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
>
Hủy
</button>
<button
type="submit"
disabled={creating}
className="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <KeyRound className="mr-2 h-4 w-4" />}
{creating ? 'Đang tạo...' : 'Tạo key'}
</button>
</div>
</form>
</div>
)}
</div>
</div>
)}
{revokeTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
role="alertdialog"
aria-modal="true"
aria-labelledby="revoke-api-key-title"
>
<div className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100 text-rose-600">
<AlertTriangle className="h-6 w-6" />
</div>
<h2 id="revoke-api-key-title" className="text-lg font-bold text-slate-900">Thu hồi API Key?</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Key <strong className="text-slate-900">{revokeTarget.name}</strong> sẽ bị xóa vĩnh viễn.
Mọi ứng dụng đang sử dụng key này sẽ mất quyền truy cập ngay lập tức.
</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={() => setRevokeTarget(null)}
disabled={revoking}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-60"
>
Hủy
</button>
<button
type="button"
onClick={() => void handleRevoke()}
disabled={revoking}
className="inline-flex items-center rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700 disabled:opacity-60"
>
{revoking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
{revoking ? 'Đang thu hồi...' : 'Thu hồi vĩnh viễn'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
......@@ -42,6 +42,9 @@ const ACTION_COLORS: Record<string, string> = {
RESET_PASSWORD: 'bg-sky-100 text-sky-700',
VERIFY_EMAIL: 'bg-teal-100 text-teal-700',
RESEND_VERIFICATION: 'bg-teal-100 text-teal-700',
CREATE_API_KEY: 'bg-emerald-100 text-emerald-700',
UPDATE_API_KEY_STATUS: 'bg-indigo-100 text-indigo-700',
REVOKE_API_KEY: 'bg-rose-100 text-rose-700',
};
const ACTION_OPTIONS = [
......@@ -61,6 +64,7 @@ const ACTION_OPTIONS = [
'ADMIN_UPDATE_USER',
'ADMIN_DELETE_USER',
'CREATE_API_KEY',
'UPDATE_API_KEY_STATUS',
'REVOKE_API_KEY',
'CREATE_WEBHOOK_CONFIG',
'DELETE_WEBHOOK_CONFIG',
......
import axios from 'axios';
export interface ApiKeyRecord {
id: string;
userId: string;
name: string;
keyPrefix: string;
isActive: boolean;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreatedApiKey extends ApiKeyRecord {
rawKey: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://171.247.68.96:4011/api/v1')
.replace(/\/$/, '');
......@@ -73,6 +94,27 @@ api.interceptors.response.use(
export const getApiUrl = (path: string) =>
`${API_BASE_URL}/${path.replace(/^\/+/, '')}`;
export const apiKeysApi = {
async list(): Promise<ApiKeyRecord[]> {
const response = await api.get<ApiResponse<ApiKeyRecord[]>>('/api-keys');
return response.data.data;
},
async create(input: { name: string; expiresAt: string | null }): Promise<CreatedApiKey> {
const response = await api.post<ApiResponse<CreatedApiKey>>('/api-keys', input);
return response.data.data;
},
async setActive(id: string, isActive: boolean): Promise<ApiKeyRecord> {
const response = await api.patch<ApiResponse<ApiKeyRecord>>(`/api-keys/${id}`, { isActive });
return response.data.data;
},
async revoke(id: string): Promise<void> {
await api.delete(`/api-keys/${id}`);
},
};
function notifyAuthExpired() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
......
......@@ -15,6 +15,11 @@ const MESSAGE_TRANSLATIONS: Record<string, string> = {
'Password must contain at least one uppercase letter': 'Mật khẩu phải có ít nhất một chữ hoa.',
'Password must contain at least one number': 'Mật khẩu phải có ít nhất một chữ số.',
'Password must contain at least one special character': 'Mật khẩu phải có ít nhất một ký tự đặc biệt.',
'API Key expiration must be in the future': 'Thời điểm hết hạn của API Key phải ở trong tương lai.',
'Invalid ISO datetime format for expiration': 'Thời điểm hết hạn của API Key không đúng định dạng.',
'API Key status is required': 'Vui lòng chọn trạng thái cho API Key.',
'API Key status must be a boolean': 'Trạng thái API Key không hợp lệ.',
'API key not found': 'Không tìm thấy API Key.',
};
interface ApiErrorShape {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment