497 lines
19 KiB
TypeScript
497 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|