Add developer API, Prometheus metrics, and community links
This commit is contained in:
496
components/DeveloperPanel.tsx
Normal file
496
components/DeveloperPanel.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Code2,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
import { developerScopes } from "@/shared/developer-docs";
|
||||
import styles from "@/app/dev/api/page.module.css";
|
||||
import type { DeveloperKey, DeveloperScope } from "@/shared/developer-api";
|
||||
|
||||
type Scope = DeveloperScope;
|
||||
type Key = DeveloperKey;
|
||||
|
||||
async function request<T>(
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch("/api/dev/keys", {
|
||||
method,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: signal ?? AbortSignal.timeout(30000),
|
||||
headers:
|
||||
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: (result.error?.message ?? "Request failed. Please try again."),
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function DeveloperPanel({ signedIn }: { signedIn: boolean }) {
|
||||
const { locale } = useI18n();
|
||||
const ru = locale === "ru";
|
||||
const l = (en: string, russian: string) => (ru ? russian : en);
|
||||
const [keys, setKeys] = useState<Key[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(signedIn);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [expiresDays, setExpiresDays] = useState(90);
|
||||
const [scopes, setScopes] = useState<Scope[]>(["maps:read"]);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
const controller = new AbortController();
|
||||
void request<{ keys: Key[] }>("GET", undefined, controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: ru
|
||||
? "Не удалось загрузить ключи API."
|
||||
: "Could not load API keys.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [signedIn, ru]);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await request<{ keys: Key[] }>();
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not load API keys.", "Не удалось загрузить ключи API."),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function create(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await request<{ key: Key; token: string }>("POST", {
|
||||
name: name.trim(),
|
||||
scopes,
|
||||
expiresDays,
|
||||
});
|
||||
setToken(result.token);
|
||||
setKeys((list) => [result.key, ...list]);
|
||||
setName("");
|
||||
setMessage(
|
||||
l(
|
||||
"Key created. Copy it before leaving or hiding it.",
|
||||
"Ключ создан. Скопируйте его перед уходом со страницы или скрытием.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not create the key.", "Не удалось создать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await request("DELETE", { id });
|
||||
setKeys((list) =>
|
||||
list.map((key) =>
|
||||
key.id === id ? { ...key, revokedAt: new Date().toISOString() } : key,
|
||||
),
|
||||
);
|
||||
setConfirmRevoke(null);
|
||||
setToken(null);
|
||||
setMessage(
|
||||
l(
|
||||
"Key revoked. Requests using it will no longer work.",
|
||||
"Ключ отозван. Запросы с ним больше не работают.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not revoke the key.", "Не удалось отозвать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage(l("Copied to clipboard.", "Скопировано в буфер обмена."));
|
||||
setError("");
|
||||
} catch {
|
||||
setError(
|
||||
l(
|
||||
"Could not copy. Select the key below and copy it manually.",
|
||||
"Не удалось скопировать. Выделите ключ ниже и скопируйте вручную.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const activeCount = keys.filter(
|
||||
(key) => !key.revokedAt && new Date(key.expiresAt).getTime() > Date.now(),
|
||||
).length;
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(ru ? "ru-RU" : "en-US");
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.intro}>
|
||||
<span className="eyebrow">
|
||||
{l("BUILD WITH MINEGEO", "СОЗДАВАЙТЕ С MINEGEO")}
|
||||
</span>
|
||||
<h1>{l("Developer API.", "API для разработчиков.")}</h1>
|
||||
<p className="lead">
|
||||
{l(
|
||||
"Use MineGeo maps and your own account data in scripts and integrations. Choose exactly what each key can read.",
|
||||
"Используйте карты MineGeo и данные своего аккаунта в скриптах и интеграциях. Выберите, что именно сможет читать каждый ключ.",
|
||||
)}
|
||||
</p>
|
||||
<nav
|
||||
className={styles.tabs}
|
||||
aria-label={l("Developer sections", "Разделы для разработчиков")}
|
||||
>
|
||||
<a className="button" href="#api-keys">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{l("API keys", "Ключи API")}
|
||||
</a>
|
||||
<Link className="button" href="/docs?page=developer-api">
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
{l("Documentation", "Документация")}
|
||||
</Link>
|
||||
<a className="button" href="/api/dev/openapi.json">
|
||||
<Code2 size={16} aria-hidden="true" />
|
||||
OpenAPI JSON
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="api-keys"
|
||||
className={styles.panel}
|
||||
aria-labelledby="api-keys-title"
|
||||
>
|
||||
<div className={styles.heading}>
|
||||
<KeyRound size={24} aria-hidden="true" />
|
||||
<h2 id="api-keys-title">{l("Your API keys", "Ваши ключи API")}</h2>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Keys belong to your account. Keep them private and grant only the access your integration needs. This API is read-only: keys cannot upload, publish, change maps or use moderation tools.",
|
||||
"Ключи принадлежат вашему аккаунту. Храните их в секрете и выдавайте только необходимые права. Этот API доступен только для чтения: ключи не позволяют загружать, публиковать, изменять карты или использовать инструменты модерации.",
|
||||
)}
|
||||
</p>
|
||||
{!signedIn ? (
|
||||
<div className={styles.notice}>
|
||||
<p>
|
||||
{l(
|
||||
"Everyone can read the documentation. Log in to create and manage your own keys; no Admin permission is required.",
|
||||
"Документация доступна всем. Войдите, чтобы создавать свои ключи и управлять ими; права администратора не нужны.",
|
||||
)}
|
||||
</p>
|
||||
<Link className="button primary" href="/log-in?next=%2Fdev%2Fapi">
|
||||
{l("Log in to manage keys", "Войти для управления ключами")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p role="alert" className={`${styles.notice} ${styles.error}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className={styles.notice}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{token && (
|
||||
<div className={styles.token}>
|
||||
<h3>
|
||||
{l("Copy your new key now", "Скопируйте новый ключ сейчас")}
|
||||
</h3>
|
||||
<p>
|
||||
{l(
|
||||
"This is the only time the full key is shown. Hiding it or leaving this page removes it from view. MineGeo stores only its hash.",
|
||||
"Полный ключ показывается только один раз. После скрытия или ухода со страницы его нельзя посмотреть снова. MineGeo хранит только хеш ключа.",
|
||||
)}
|
||||
</p>
|
||||
<label className="field">
|
||||
{l("New API key", "Новый ключ API")}
|
||||
<input
|
||||
readOnly
|
||||
value={token}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button className="button" onClick={() => void copy(token)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{l("Copy key", "Копировать ключ")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
{l("Hide key", "Скрыть ключ")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status">{l("Loading keys…", "Загрузка ключей…")}</p>
|
||||
)}
|
||||
{!loading && !loaded && (
|
||||
<button className="button" onClick={() => void reload()}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Try again", "Повторить")}
|
||||
</button>
|
||||
)}
|
||||
{loaded && (
|
||||
<>
|
||||
<form onSubmit={(event) => void create(event)}>
|
||||
<h3>{l("Create a key", "Создать ключ")}</h3>
|
||||
<p className="muted">
|
||||
{l(
|
||||
`${activeCount} of 5 active keys. Each key expires after the number of days you choose.`,
|
||||
`Активных ключей: ${activeCount} из 5. Срок действия каждого ключа задаётся в днях.`,
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.fields}>
|
||||
<label className="field">
|
||||
{l("Key name", "Название ключа")}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={60}
|
||||
placeholder={l(
|
||||
"My Python project",
|
||||
"Мой проект на Python",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
{l("Expires in days", "Срок действия, дней")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
required
|
||||
value={expiresDays}
|
||||
onChange={(e) => setExpiresDays(Number(e.target.value))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className={styles.fieldset} disabled={busy}>
|
||||
<legend>{l("Permissions", "Права доступа")}</legend>
|
||||
<div className={styles.scopes}>
|
||||
{developerScopes.map((scope) => (
|
||||
<label className={styles.scope} key={scope.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(scope.id)}
|
||||
onChange={(e) =>
|
||||
setScopes((selected) =>
|
||||
e.target.checked
|
||||
? [...selected, scope.id]
|
||||
: selected.filter((id) => id !== scope.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{scope.name[ru ? 1 : 0]}</strong>
|
||||
<small>
|
||||
<code>{scope.id}</code> ·{" "}
|
||||
{scope.description[ru ? 1 : 0]}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={
|
||||
busy ||
|
||||
loading ||
|
||||
scopes.length === 0 ||
|
||||
activeCount >= 5 ||
|
||||
token !== null
|
||||
}
|
||||
>
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{busy
|
||||
? l("Working…", "Подождите…")
|
||||
: l("Create API key", "Создать ключ API")}
|
||||
</button>
|
||||
{token && (
|
||||
<p className="muted">
|
||||
{l(
|
||||
"Copy and hide the current key before creating another.",
|
||||
"Скопируйте и скройте текущий ключ перед созданием следующего.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Refresh keys", "Обновить ключи")}
|
||||
</button>
|
||||
</div>
|
||||
{keys.length === 0 && (
|
||||
<p className={styles.notice}>
|
||||
{l(
|
||||
"You have no API keys yet.",
|
||||
"У вас пока нет ключей API.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles.keyList}>
|
||||
{keys.map((key) => {
|
||||
const expired =
|
||||
new Date(key.expiresAt).getTime() <= Date.now();
|
||||
return (
|
||||
<li key={key.id} className={styles.key}>
|
||||
<div className={styles.heading}>
|
||||
<h3>{key.name}</h3>
|
||||
<span className="badge">
|
||||
{key.revokedAt
|
||||
? l("Revoked", "Отозван")
|
||||
: expired
|
||||
? l("Expired", "Истёк")
|
||||
: l("Active", "Активен")}
|
||||
</span>
|
||||
</div>
|
||||
<code>{key.prefix}…</code>
|
||||
<dl>
|
||||
<dt>{l("Permissions", "Права")}</dt>
|
||||
<dd>{key.scopes.join(", ")}</dd>
|
||||
<dt>{l("Created", "Создан")}</dt>
|
||||
<dd>{date(key.createdAt)}</dd>
|
||||
<dt>{l("Expires", "Истекает")}</dt>
|
||||
<dd>{date(key.expiresAt)}</dd>
|
||||
<dt>{l("Last used", "Последнее использование")}</dt>
|
||||
<dd>
|
||||
{key.lastUsedAt
|
||||
? date(key.lastUsedAt)
|
||||
: l("Not used yet", "Ещё не использован")}
|
||||
</dd>
|
||||
</dl>
|
||||
{!key.revokedAt &&
|
||||
(confirmRevoke === key.id ? (
|
||||
<div>
|
||||
<p>
|
||||
{l(
|
||||
"Revoke this key? Every integration using it will lose access immediately.",
|
||||
"Отозвать этот ключ? Все интеграции с ним сразу потеряют доступ.",
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => void revoke(key.id)}
|
||||
>
|
||||
{l("Confirm revocation", "Подтвердить отзыв")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(null)}
|
||||
>
|
||||
{l("Cancel", "Отмена")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(key.id)}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
{l("Revoke key", "Отозвать ключ")}
|
||||
</button>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user