add: backend api auth by apikey/apikey gen/apikey revoke
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
summerizer.py
|
summerizer.py
|
||||||
|
node_modules
|
||||||
|
|||||||
164
src/components/Modals/ApiKeyCreationModal.js
Normal file
164
src/components/Modals/ApiKeyCreationModal.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useCreateApiKey } from '../../utils/queries/apikey-queries';
|
||||||
|
|
||||||
|
const AVAILABLE_ROLES = ['guest', 'creator', 'admin'];
|
||||||
|
|
||||||
|
const ApiKeyCreationModal = ({ isOpen, onClose }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [roles, setRoles] = useState(['guest']);
|
||||||
|
const [generatedKey, setGeneratedKey] = useState(null);
|
||||||
|
const createApiKeyMutation = useCreateApiKey();
|
||||||
|
|
||||||
|
const handleAddRole = () => {
|
||||||
|
const availableRoles = AVAILABLE_ROLES.filter(role => !roles.includes(role));
|
||||||
|
if (availableRoles.length > 0) {
|
||||||
|
setRoles([...roles, availableRoles[0]]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = (index, value) => {
|
||||||
|
if (roles.includes(value) && roles.findIndex(r => r === value) !== index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newRoles = [...roles];
|
||||||
|
newRoles[index] = value;
|
||||||
|
setRoles(newRoles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveRole = (index) => {
|
||||||
|
const newRoles = roles.filter((_, i) => i !== index);
|
||||||
|
setRoles(newRoles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
alert('API key name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await createApiKeyMutation.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
roles: roles
|
||||||
|
});
|
||||||
|
setGeneratedKey(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('failed to create api key', error);
|
||||||
|
alert('failed to create api key');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(generatedKey)
|
||||||
|
.then(() => alert('API key copied to clipboard'))
|
||||||
|
.catch(err => console.error('failed to copy api key:', err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemainingRoles = (currentIndex) => {
|
||||||
|
return AVAILABLE_ROLES.filter(role =>
|
||||||
|
!roles.find((r, i) => r === role && i !== currentIndex)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal is-active">
|
||||||
|
<div className="modal-background" onClick={onClose}></div>
|
||||||
|
<div className="modal-card">
|
||||||
|
<header className="modal-card-head">
|
||||||
|
<p className="modal-card-title">Create API Key</p>
|
||||||
|
<button className="delete" aria-label="close" onClick={onClose}></button>
|
||||||
|
</header>
|
||||||
|
<section className="modal-card-body">
|
||||||
|
{!generatedKey ? (
|
||||||
|
<div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Name</label>
|
||||||
|
<div className="control">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="API key name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Roles</label>
|
||||||
|
{roles.map((role, index) => (
|
||||||
|
<div key={index} className="field has-addons">
|
||||||
|
<div className="control">
|
||||||
|
<div className="select">
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => handleRoleChange(index, e.target.value)}
|
||||||
|
>
|
||||||
|
{getRemainingRoles(index).map(availableRole => (
|
||||||
|
<option key={availableRole} value={availableRole}>
|
||||||
|
{availableRole}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button
|
||||||
|
className="button is-danger"
|
||||||
|
onClick={() => handleRemoveRole(index)}
|
||||||
|
disabled={roles.length === 1}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="button is-info mt-2"
|
||||||
|
onClick={handleAddRole}
|
||||||
|
disabled={roles.length === AVAILABLE_ROLES.length}
|
||||||
|
>
|
||||||
|
Add Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="notification is-warning">
|
||||||
|
Please copy your API key immediately! It will only be displayed once!
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">Your API Key:</label>
|
||||||
|
<div className="control">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={generatedKey.key}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="button is-info" onClick={handleCopy}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<footer className="modal-card-foot">
|
||||||
|
{!generatedKey && (
|
||||||
|
<button
|
||||||
|
className="button is-primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={createApiKeyMutation.isLoading || !name.trim()}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="button" onClick={onClose}>Close</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyCreationModal;
|
||||||
63
src/components/Modals/ApiKeyRevokeModal.js
Normal file
63
src/components/Modals/ApiKeyRevokeModal.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRevokeApiKey } from '../../utils/queries/apikey-queries';
|
||||||
|
|
||||||
|
const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const revokeApiKeyMutation = useRevokeApiKey();
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
alert('Please enter an API key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeApiKeyMutation.mutateAsync(apiKey);
|
||||||
|
alert('API key revoked successfully');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to revoke API key:', error);
|
||||||
|
alert('Failed to revoke API key');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal is-active">
|
||||||
|
<div className="modal-background" onClick={onClose}></div>
|
||||||
|
<div className="modal-card">
|
||||||
|
<header className="modal-card-head">
|
||||||
|
<p className="modal-card-title">Revoke API Key</p>
|
||||||
|
<button className="delete" aria-label="close" onClick={onClose}></button>
|
||||||
|
</header>
|
||||||
|
<section className="modal-card-body">
|
||||||
|
<div className="field">
|
||||||
|
<label className="label">API Key</label>
|
||||||
|
<div className="control">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter API key to revoke"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<footer className="modal-card-foot">
|
||||||
|
<button
|
||||||
|
className="button is-danger"
|
||||||
|
onClick={handleRevoke}
|
||||||
|
disabled={revokeApiKeyMutation.isLoading}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
<button className="button" onClick={onClose}>Cancel</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyRevokeModal;
|
||||||
@@ -4,11 +4,16 @@ import { AuthContext } from "../../AuthProvider";
|
|||||||
import "bulma/css/bulma.min.css";
|
import "bulma/css/bulma.min.css";
|
||||||
import {useConfig} from "../../ConfigProvider";
|
import {useConfig} from "../../ConfigProvider";
|
||||||
import "./MainNavigation.css";
|
import "./MainNavigation.css";
|
||||||
|
import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal";
|
||||||
|
import ApiKeyRevokeModal from "../Modals/ApiKeyRevokeModal";
|
||||||
|
|
||||||
const MainNavigation = () => {
|
const MainNavigation = () => {
|
||||||
const { user, login, logout } = useContext(AuthContext);
|
const { user, login, logout } = useContext(AuthContext);
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
|
||||||
|
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
|
||||||
|
|
||||||
if (config===undefined) {
|
if (config===undefined) {
|
||||||
return <div>Loading ...</div>;
|
return <div>Loading ...</div>;
|
||||||
}
|
}
|
||||||
@@ -88,102 +93,124 @@ const MainNavigation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar is-dark" role="navigation" aria-label="main navigation">
|
<>
|
||||||
<div className="navbar-brand">
|
<nav className="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||||
<Link className="navbar-item" to="/">
|
<div className="navbar-brand">
|
||||||
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} />
|
<Link className="navbar-item" to="/">
|
||||||
Home
|
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} />
|
||||||
</Link>
|
Home
|
||||||
<a
|
</Link>
|
||||||
role="button"
|
|
||||||
className="navbar-burger"
|
|
||||||
aria-label="menu"
|
|
||||||
aria-expanded="false"
|
|
||||||
data-target="navbarBasicExample"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.currentTarget.classList.toggle("is-active");
|
|
||||||
document
|
|
||||||
.getElementById("navbarBasicExample")
|
|
||||||
.classList.toggle("is-active");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true"></span>
|
|
||||||
<span aria-hidden="true"></span>
|
|
||||||
<span aria-hidden="true"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="navbarBasicExample" className="navbar-menu">
|
|
||||||
<div className="navbar-start">
|
|
||||||
<a
|
<a
|
||||||
href="https://mail.hangman-lab.top"
|
role="button"
|
||||||
className="navbar-item"
|
className="navbar-burger"
|
||||||
target="_blank"
|
aria-label="menu"
|
||||||
rel="noopener noreferrer"
|
aria-expanded="false"
|
||||||
|
data-target="navbarBasicExample"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.currentTarget.classList.toggle("is-active");
|
||||||
|
document
|
||||||
|
.getElementById("navbarBasicExample")
|
||||||
|
.classList.toggle("is-active");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
MailBox
|
<span aria-hidden="true"></span>
|
||||||
</a>
|
<span aria-hidden="true"></span>
|
||||||
<a
|
<span aria-hidden="true"></span>
|
||||||
href="https://git.hangman-lab.top"
|
|
||||||
className="navbar-item"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Git
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="navbar-end">
|
<div id="navbarBasicExample" className="navbar-menu">
|
||||||
{user && user.profile ? (
|
<div className="navbar-start">
|
||||||
<div className="navbar-item has-dropdown is-hoverable">
|
<a
|
||||||
<div className="buttons">
|
href="https://mail.hangman-lab.top"
|
||||||
<span
|
className="navbar-item"
|
||||||
className="button is-primary is-light"
|
target="_blank"
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{user.profile.name}
|
MailBox
|
||||||
</span>
|
</a>
|
||||||
<div className={`navbar-dropdown ${isDropdownOpen ? "is-active" : ""}`}>
|
<a
|
||||||
<button
|
href="https://git.hangman-lab.top"
|
||||||
className="button is-primary dropdown-option"
|
className="navbar-item"
|
||||||
onClick={handleGetBackup}
|
target="_blank"
|
||||||
type="button"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Get Backup
|
Git
|
||||||
</button>
|
</a>
|
||||||
<button
|
</div>
|
||||||
className="button is-primary dropdown-option"
|
|
||||||
onClick={handleLoadBackup}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Load Backup
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="button is-danger dropdown-option"
|
|
||||||
onClick={logout}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<div className="navbar-end">
|
||||||
|
{user && user.profile ? (
|
||||||
|
<div className="navbar-item has-dropdown is-hoverable">
|
||||||
|
<div className="buttons">
|
||||||
|
<span
|
||||||
|
className="button is-primary is-light"
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
>
|
||||||
|
{user.profile.name}
|
||||||
|
</span>
|
||||||
|
<div className={`navbar-dropdown ${isDropdownOpen ? "is-active" : ""}`}>
|
||||||
|
<button
|
||||||
|
className="button is-primary dropdown-option"
|
||||||
|
onClick={handleGetBackup}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Get Backup
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button is-primary dropdown-option"
|
||||||
|
onClick={handleLoadBackup}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Load Backup
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button is-info dropdown-option"
|
||||||
|
onClick={() => setIsApiKeyModalOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Create API Key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button is-warning dropdown-option"
|
||||||
|
onClick={() => setIsRevokeModalOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Revoke API Key
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button is-danger dropdown-option"
|
||||||
|
onClick={logout}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="navbar-item">
|
||||||
<div className="navbar-item">
|
<button
|
||||||
<button
|
className="button is-primary"
|
||||||
className="button is-primary"
|
onClick={login}
|
||||||
onClick={login}
|
type="button"
|
||||||
type="button"
|
>
|
||||||
>
|
Login
|
||||||
Login
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
<ApiKeyCreationModal
|
||||||
|
isOpen={isApiKeyModalOpen}
|
||||||
|
onClose={() => setIsApiKeyModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<ApiKeyRevokeModal
|
||||||
|
isOpen={isRevokeModalOpen}
|
||||||
|
onClose={() => setIsRevokeModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
32
src/utils/queries/apikey-queries.js
Normal file
32
src/utils/queries/apikey-queries.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useConfig } from "../../ConfigProvider";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { fetch_ } from "../request-utils";
|
||||||
|
|
||||||
|
export const useCreateApiKey = () => {
|
||||||
|
const config = useConfig();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ name, roles }) => {
|
||||||
|
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, roles }),
|
||||||
|
});
|
||||||
|
console.log("response", response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
cacheTime: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRevokeApiKey = () => {
|
||||||
|
const config = useConfig();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (apiKey) => {
|
||||||
|
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ apiKey }),
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
cacheTime: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user