Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
frontend-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
BangNSK
frontend-data-crawler-be
Commits
4b5d694e
Commit
4b5d694e
authored
Jul 30, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(api-keys): add API key management interface
parent
060f545a
Changes
7
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
643 additions
and
1 deletion
+643
-1
App.tsx
src/App.tsx
+2
-0
PageTitle.tsx
src/components/PageTitle.tsx
+1
-0
DashboardLayout.tsx
src/layouts/DashboardLayout.tsx
+7
-1
ApiKeys.tsx
src/pages/ApiKeys.tsx
+582
-0
Logs.tsx
src/pages/Logs.tsx
+4
-0
api.ts
src/services/api.ts
+42
-0
apiError.ts
src/utils/apiError.ts
+5
-0
No files found.
src/App.tsx
View file @
4b5d694e
...
...
@@ -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
...
...
src/components/PageTitle.tsx
View file @
4b5d694e
...
...
@@ -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'
,
};
...
...
src/layouts/DashboardLayout.tsx
View file @
4b5d694e
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'
,
...
...
src/pages/ApiKeys.tsx
0 → 100644
View file @
4b5d694e
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
>
);
}
src/pages/Logs.tsx
View file @
4b5d694e
...
...
@@ -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'
,
...
...
src/services/api.ts
View file @
4b5d694e
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'
);
...
...
src/utils/apiError.ts
View file @
4b5d694e
...
...
@@ -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
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment