fix: api scopes
This commit is contained in:
parent
d623dea1e1
commit
08fb16146c
568
package-lock.json
generated
568
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -17,20 +17,21 @@
|
|||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
"@marsidev/react-turnstile": "^1.6.1",
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^14.0.0",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"country-list": "^2.4.1",
|
"country-list": "^2.4.1",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.4.0",
|
"i18next": "^26.4.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.37.0",
|
"lucide-react": "^1.45.0",
|
||||||
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-i18next": "^17.0.12",
|
"react-i18next": "^17.0.13",
|
||||||
"react-is": "^19.2.8",
|
"react-is": "^19.3.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.3",
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
@ -40,18 +41,18 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/country-list": "^2.1.4",
|
"@types/country-list": "^2.1.4",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"eslint": "^10.9.1",
|
"eslint": "^10.10.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.5",
|
"eslint-plugin-react-refresh": "^0.5.6",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.12.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.68.0",
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.3.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -337,15 +337,230 @@ const ProfilePage = () => {
|
|||||||
isRevoked: boolean;
|
isRevoked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_KEY_SCOPES = {
|
||||||
|
// Plugins
|
||||||
|
PLUGINS_READ: 'plugins:read',
|
||||||
|
PLUGINS_DOWNLOAD: 'plugins:download',
|
||||||
|
PLUGINS_CREATE: 'plugins:create',
|
||||||
|
PLUGINS_UPDATE: 'plugins:update',
|
||||||
|
PLUGINS_VERSIONS_UPLOAD: 'plugins:versions:upload',
|
||||||
|
PLUGINS_MEDIA_UPLOAD: 'plugins:media:upload',
|
||||||
|
PLUGINS_PUBLISH: 'plugins:publish',
|
||||||
|
PLUGINS_DELETE: 'plugins:delete',
|
||||||
|
|
||||||
|
// Licenses
|
||||||
|
LICENSES_READ: 'licenses:read',
|
||||||
|
LICENSES_CREATE: 'licenses:create',
|
||||||
|
LICENSES_REVOKE: 'licenses:revoke',
|
||||||
|
LICENSES_DELETE: 'licenses:delete',
|
||||||
|
|
||||||
|
// Coupons
|
||||||
|
COUPONS_READ: 'coupons:read',
|
||||||
|
COUPONS_WRITE: 'coupons:write',
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
REVIEWS_READ: 'reviews:read',
|
||||||
|
REVIEWS_REPLY: 'reviews:reply',
|
||||||
|
|
||||||
|
// Analytics & Telemetry
|
||||||
|
ANALYTICS_READ: 'analytics:read',
|
||||||
|
TELEMETRY_READ: 'telemetry:read',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ApiKeyScope = (typeof API_KEY_SCOPES)[keyof typeof API_KEY_SCOPES];
|
||||||
|
|
||||||
|
interface ScopeItem {
|
||||||
|
key: ApiKeyScope;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
devOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScopeCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
scopes: ScopeItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_CATEGORIES: ScopeCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'plugins',
|
||||||
|
name: 'Plugins',
|
||||||
|
description: 'Permissions for reading, downloading, creating, updating, and publishing plugins',
|
||||||
|
scopes: [
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_READ,
|
||||||
|
name: 'Read Plugins',
|
||||||
|
description: 'View plugin metadata, draft status, and version history',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_DOWNLOAD,
|
||||||
|
name: 'Download Plugins',
|
||||||
|
description: 'Download free and owned plugin WebAssembly binaries and release artifacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_CREATE,
|
||||||
|
name: 'Create Plugins',
|
||||||
|
description: 'Register and create new plugin listings on the marketplace',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_UPDATE,
|
||||||
|
name: 'Update Details',
|
||||||
|
description: 'Edit plugin descriptions, categories, links, icons, and pricing',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_VERSIONS_UPLOAD,
|
||||||
|
name: 'Upload Versions',
|
||||||
|
description: 'Upload new WebAssembly release binaries and version archives',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_MEDIA_UPLOAD,
|
||||||
|
name: 'Upload Media',
|
||||||
|
description: 'Upload screenshots, promotional images, and media gallery assets',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_PUBLISH,
|
||||||
|
name: 'Publish & Unpublish',
|
||||||
|
description: 'Publish draft plugins or toggle release visibility status',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.PLUGINS_DELETE,
|
||||||
|
name: 'Delete Plugins',
|
||||||
|
description: 'Permanently remove owned plugins and associated artifacts',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'licenses',
|
||||||
|
name: 'Licenses',
|
||||||
|
description: 'Permissions for customer licenses on paid/premium plugins',
|
||||||
|
scopes: [
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.LICENSES_READ,
|
||||||
|
name: 'View Licenses',
|
||||||
|
description: 'List issued customer licenses and inspect activation status',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.LICENSES_CREATE,
|
||||||
|
name: 'Issue Licenses',
|
||||||
|
description: 'Generate and issue new customer license keys',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.LICENSES_REVOKE,
|
||||||
|
name: 'Revoke Licenses',
|
||||||
|
description: 'Toggle revocation status for issued customer licenses',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.LICENSES_DELETE,
|
||||||
|
name: 'Delete Licenses',
|
||||||
|
description: 'Permanently remove license records from the database',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coupons',
|
||||||
|
name: 'Coupons',
|
||||||
|
description: 'Permissions for promotional discount codes',
|
||||||
|
scopes: [
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.COUPONS_READ,
|
||||||
|
name: 'View Coupons',
|
||||||
|
description: 'List active, scheduled, and expired promotional discount codes',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.COUPONS_WRITE,
|
||||||
|
name: 'Manage Coupons',
|
||||||
|
description: 'Create, update, and delete promotional discount codes',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reviews',
|
||||||
|
name: 'Reviews',
|
||||||
|
description: 'Permissions for reading and replying to community reviews',
|
||||||
|
scopes: [
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.REVIEWS_READ,
|
||||||
|
name: 'View Reviews',
|
||||||
|
description: 'Read plugin user ratings and feedback reviews',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.REVIEWS_REPLY,
|
||||||
|
name: 'Reply to Reviews',
|
||||||
|
description: 'Post official developer replies to customer reviews',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
name: 'Analytics & Telemetry',
|
||||||
|
description: 'Permissions for developer statistics, revenue metrics, and runtime telemetry',
|
||||||
|
scopes: [
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.ANALYTICS_READ,
|
||||||
|
name: 'Read Analytics',
|
||||||
|
description: 'Query developer download charts, visitor statistics, and revenue telemetry',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: API_KEY_SCOPES.TELEMETRY_READ,
|
||||||
|
name: 'Read Telemetry',
|
||||||
|
description: 'Query runtime heartbeat metrics, active player counts, and server distributions',
|
||||||
|
devOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||||
const [apiKeysLoading, setApiKeysLoading] = useState(false);
|
const [apiKeysLoading, setApiKeysLoading] = useState(false);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90);
|
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90);
|
||||||
|
const [selectedScopes, setSelectedScopes] = useState<ApiKeyScope[]>([API_KEY_SCOPES.PLUGINS_DOWNLOAD]);
|
||||||
const [createdRawKey, setCreatedRawKey] = useState<string | null>(null);
|
const [createdRawKey, setCreatedRawKey] = useState<string | null>(null);
|
||||||
const [creatingKey, setCreatingKey] = useState(false);
|
const [creatingKey, setCreatingKey] = useState(false);
|
||||||
const [revokingKeyId, setRevokingKeyId] = useState<number | null>(null);
|
const [revokingKeyId, setRevokingKeyId] = useState<number | null>(null);
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
|
||||||
|
const toggleCategory = (cat: ScopeCategory) => {
|
||||||
|
const keys = cat.scopes.map(s => s.key);
|
||||||
|
const allSelected = keys.every(k => selectedScopes.includes(k));
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedScopes(prev => prev.filter(k => !keys.includes(k)));
|
||||||
|
} else {
|
||||||
|
setSelectedScopes(prev => Array.from(new Set([...prev, ...keys])));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleScope = (key: ApiKeyScope) => {
|
||||||
|
setSelectedScopes(prev =>
|
||||||
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllScopes = () => {
|
||||||
|
const allKeys = SCOPE_CATEGORIES.flatMap(c => c.scopes.map(s => s.key));
|
||||||
|
setSelectedScopes(allKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAllScopes = () => {
|
||||||
|
setSelectedScopes([]);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchApiKeys = async () => {
|
const fetchApiKeys = async () => {
|
||||||
setApiKeysLoading(true);
|
setApiKeysLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -361,16 +576,21 @@ const ProfilePage = () => {
|
|||||||
const handleCreateApiKey = async (e: React.FormEvent) => {
|
const handleCreateApiKey = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newKeyName.trim()) return;
|
if (!newKeyName.trim()) return;
|
||||||
|
if (selectedScopes.length === 0) {
|
||||||
|
showToast('Please select at least one permission scope for this key.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreatingKey(true);
|
setCreatingKey(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/user/api-keys', {
|
const res = await api.post('/user/api-keys', {
|
||||||
name: newKeyName.trim(),
|
name: newKeyName.trim(),
|
||||||
expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null,
|
expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null,
|
||||||
scopes: ['plugins:download'],
|
scopes: selectedScopes,
|
||||||
});
|
});
|
||||||
setCreatedRawKey(res.data.rawKey);
|
setCreatedRawKey(res.data.rawKey);
|
||||||
setNewKeyName('');
|
setNewKeyName('');
|
||||||
setNewKeyExpiresIn(90);
|
setNewKeyExpiresIn(90);
|
||||||
|
setSelectedScopes([API_KEY_SCOPES.PLUGINS_DOWNLOAD]);
|
||||||
await fetchApiKeys();
|
await fetchApiKeys();
|
||||||
showToast('API Key created successfully!');
|
showToast('API Key created successfully!');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -1420,10 +1640,158 @@ const ProfilePage = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scopes & Permissions Selection */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.88rem', fontWeight: 600, color: 'var(--mp-text, #fff)', display: 'block' }}>
|
||||||
|
Permission Scopes
|
||||||
|
</label>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)' }}>
|
||||||
|
Configure permissions granted to this token. External tools will only be able to perform authorized actions.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectAllScopes}
|
||||||
|
className="settings-btn settings-btn-secondary"
|
||||||
|
style={{ padding: '0.25rem 0.65rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deselectAllScopes}
|
||||||
|
className="settings-btn settings-btn-secondary"
|
||||||
|
style={{ padding: '0.25rem 0.65rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||||
|
{SCOPE_CATEGORIES.map(cat => {
|
||||||
|
const catScopeKeys = cat.scopes.map(s => s.key);
|
||||||
|
const selectedCount = catScopeKeys.filter(k => selectedScopes.includes(k)).length;
|
||||||
|
const allSelected = selectedCount === catScopeKeys.length;
|
||||||
|
const someSelected = selectedCount > 0 && !allSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.02)',
|
||||||
|
border: '1px solid var(--mp-border, rgba(255, 255, 255, 0.08))',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Category Header with Master Toggle */}
|
||||||
|
<div
|
||||||
|
onClick={() => toggleCategory(cat)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: selectedCount > 0 ? 'rgba(249, 115, 22, 0.06)' : 'rgba(255, 255, 255, 0.01)',
|
||||||
|
borderBottom: '1px solid var(--mp-border, rgba(255, 255, 255, 0.06))',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = someSelected;
|
||||||
|
}}
|
||||||
|
onChange={() => toggleCategory(cat)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ cursor: 'pointer', accentColor: '#f97316', width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--mp-text, #fff)' }}>
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--mp-muted, #94a3b8)', marginLeft: '8px' }}>
|
||||||
|
{cat.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: selectedCount > 0 ? 'rgba(249, 115, 22, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||||
|
color: selectedCount > 0 ? '#f97316' : '#64748b',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCount} / {catScopeKeys.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual Scopes */}
|
||||||
|
<div style={{ padding: '0.5rem 1rem', display: 'flex', flexDirection: 'column', gap: '0.45rem' }}>
|
||||||
|
{cat.scopes.map(scope => {
|
||||||
|
const isChecked = selectedScopes.includes(scope.key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={scope.key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '0.45rem 0.5rem',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isChecked ? 'rgba(255, 255, 255, 0.03)' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleScope(scope.key)}
|
||||||
|
style={{ marginTop: '3px', cursor: 'pointer', accentColor: '#f97316', width: 15, height: 15 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
<code style={{ fontSize: '0.76rem', background: isChecked ? 'rgba(56, 189, 248, 0.12)' : 'rgba(255, 255, 255, 0.05)', color: isChecked ? '#38bdf8' : '#94a3b8', padding: '1px 6px', borderRadius: 4, fontFamily: 'monospace' }}>
|
||||||
|
{scope.key}
|
||||||
|
</code>
|
||||||
|
<span style={{ fontSize: '0.84rem', fontWeight: 500, color: isChecked ? 'var(--mp-text, #fff)' : '#94a3b8' }}>
|
||||||
|
{scope.name}
|
||||||
|
</span>
|
||||||
|
{scope.devOnly && (
|
||||||
|
<span style={{ fontSize: '0.68rem', background: 'rgba(168, 85, 247, 0.12)', color: '#c084fc', border: '1px solid rgba(168, 85, 247, 0.25)', padding: '0 5px', borderRadius: 4 }}>
|
||||||
|
Developer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: '0.75rem', color: '#64748b' }}>
|
||||||
|
{scope.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="settings-btn settings-btn-primary"
|
className="settings-btn settings-btn-primary"
|
||||||
disabled={creatingKey || !newKeyName.trim()}
|
disabled={creatingKey || !newKeyName.trim() || selectedScopes.length === 0}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
@ -1478,6 +1846,26 @@ const ProfilePage = () => {
|
|||||||
<span>Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}</span>
|
<span>Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}</span>
|
||||||
<span>Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}</span>
|
<span>Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{k.scopes && k.scopes.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
|
||||||
|
{k.scopes.map(s => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
background: 'rgba(56, 189, 248, 0.08)',
|
||||||
|
color: '#7dd3fc',
|
||||||
|
border: '1px solid rgba(56, 189, 248, 0.2)',
|
||||||
|
padding: '1px 7px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user