Files
MineGeo/components/DeveloperPanel.tsx

497 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}