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/jetbrains-mono": "^5.3.0",
|
||||
"@marsidev/react-turnstile": "^1.6.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/browser": "^14.0.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||
"axios": "^1.20.0",
|
||||
"country-list": "^2.4.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.4.0",
|
||||
"i18next": "^26.4.2",
|
||||
"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",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-is": "^19.2.8",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-i18next": "^17.0.13",
|
||||
"react-is": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1",
|
||||
@ -40,18 +41,18 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/country-list": "^2.1.4",
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.5",
|
||||
"globals": "^17.11.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.6",
|
||||
"globals": "^17.12.0",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"vite": "^8.2.2"
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^8.2.2"
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,15 +337,230 @@ const ProfilePage = () => {
|
||||
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 [apiKeysLoading, setApiKeysLoading] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90);
|
||||
const [selectedScopes, setSelectedScopes] = useState<ApiKeyScope[]>([API_KEY_SCOPES.PLUGINS_DOWNLOAD]);
|
||||
const [createdRawKey, setCreatedRawKey] = useState<string | null>(null);
|
||||
const [creatingKey, setCreatingKey] = useState(false);
|
||||
const [revokingKeyId, setRevokingKeyId] = useState<number | null>(null);
|
||||
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 () => {
|
||||
setApiKeysLoading(true);
|
||||
try {
|
||||
@ -361,16 +576,21 @@ const ProfilePage = () => {
|
||||
const handleCreateApiKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
if (selectedScopes.length === 0) {
|
||||
showToast('Please select at least one permission scope for this key.', 'error');
|
||||
return;
|
||||
}
|
||||
setCreatingKey(true);
|
||||
try {
|
||||
const res = await api.post('/user/api-keys', {
|
||||
name: newKeyName.trim(),
|
||||
expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null,
|
||||
scopes: ['plugins:download'],
|
||||
scopes: selectedScopes,
|
||||
});
|
||||
setCreatedRawKey(res.data.rawKey);
|
||||
setNewKeyName('');
|
||||
setNewKeyExpiresIn(90);
|
||||
setSelectedScopes([API_KEY_SCOPES.PLUGINS_DOWNLOAD]);
|
||||
await fetchApiKeys();
|
||||
showToast('API Key created successfully!');
|
||||
} catch (err: any) {
|
||||
@ -1420,10 +1640,158 @@ const ProfilePage = () => {
|
||||
</Field>
|
||||
</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
|
||||
type="submit"
|
||||
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' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
@ -1478,6 +1846,26 @@ const ProfilePage = () => {
|
||||
<span>Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}</span>
|
||||
<span>Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}</span>
|
||||
</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>
|
||||
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user