This commit is contained in:
Mikan
2026-06-19 16:31:45 +03:00
parent d0d1f003ae
commit 5a78def096
21 changed files with 1250 additions and 548 deletions

View File

@@ -46,8 +46,9 @@ export const authApi = {
const { data } = await api.post("/auth/register", { email, username, password });
return data;
},
login: async (email: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/login", { email, password });
login: async (login: string, password: string): Promise<TokenOut> => {
// `login` accepts either email or username.
const { data } = await api.post("/auth/login", { login, password });
return data;
},
me: async (): Promise<User> => {
@@ -93,6 +94,10 @@ export const adminApi = {
const { data } = await api.get("/admin/users");
return data;
},
setUserActive: async (userId: string, isActive: boolean): Promise<any> => {
const { data } = await api.post(`/admin/users/${userId}/set-active`, { is_active: isActive });
return data;
},
};
export const presetsApi = {

View File

@@ -17,6 +17,7 @@ export const en = {
register_title: "Register",
email: "Email",
username: "Username",
login_or_email: "Email or username",
password: "Password",
login_btn: "Login",
register_btn: "Register",
@@ -102,8 +103,9 @@ export const en = {
summary_messages: "Messages per summary",
max_tokens_total: "Context token budget",
trigger_settings: "Deferred triggers",
trigger_settings_desc: "Fire when in-world time advances (no polling)",
triggers_enabled: "Enabled",
triggers_check_interval: "Check interval (sec)",
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
embedding_settings: "Embeddings (RAG)",
embedding_provider: "Provider",
embedding_provider_hash: "Hash (offline fallback, no semantics)",
@@ -121,6 +123,9 @@ export const en = {
saved: "Saved!",
llm_logs: "LLM logs",
users: "Users",
users_actions: "Actions",
users_ban: "Ban",
users_unban: "Unban",
back: "Back",
},
glossary: {

View File

@@ -12,8 +12,11 @@ i18n
ru: { translation: ru },
en: { translation: en },
},
fallbackLng: "ru",
supportedLngs: ["ru", "en"],
// English is the default. The user can switch via the language picker
// in the navbar; the choice is cached in localStorage and overrides
// browser settings on subsequent visits.
fallbackLng: "en",
supportedLngs: ["en", "ru"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator"],

View File

@@ -17,6 +17,7 @@ export const ru = {
register_title: "Регистрация",
email: "Email",
username: "Имя пользователя",
login_or_email: "Email или имя пользователя",
password: "Пароль",
login_btn: "Войти",
register_btn: "Зарегистрироваться",
@@ -102,8 +103,9 @@ export const ru = {
summary_messages: "Сообщений на сводку",
max_tokens_total: "Бюджет токенов контекста",
trigger_settings: "Отложенные триггеры",
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
triggers_enabled: "Включены",
triggers_check_interval: "Интервал проверки (сек)",
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
embedding_settings: "Эмбеддинги (RAG)",
embedding_provider: "Провайдер",
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
@@ -121,6 +123,9 @@ export const ru = {
saved: "Сохранено!",
llm_logs: "Логи LLM",
users: "Пользователи",
users_actions: "Действия",
users_ban: "Забанить",
users_unban: "Разбанить",
back: "Назад",
},
glossary: {

View File

@@ -6,7 +6,7 @@ import type { LlmLog, SettingsOut } from "@/types";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react";
import { Save, ArrowLeft, Activity, Users, Zap, Ban, CheckCircle2 } from "lucide-react";
export function AdminPanelPage() {
const { t } = useTranslation();
@@ -22,6 +22,7 @@ export function AdminPanelPage() {
const [error, setError] = useState("");
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
const [testingEmbeddings, setTestingEmbeddings] = useState(false);
const [userActionError, setUserActionError] = useState("");
useEffect(() => {
(async () => {
@@ -110,6 +111,16 @@ export function AdminPanelPage() {
}
};
const toggleUserActive = async (userId: string, currentActive: boolean) => {
setUserActionError("");
try {
const updated = await adminApi.setUserActive(userId, !currentActive);
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: updated.is_active } : u)));
} catch (err: any) {
setUserActionError(err.response?.data?.detail || t("errors.unknown"));
}
};
if (loading) {
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
}
@@ -302,23 +313,17 @@ export function AdminPanelPage() {
</Card>
<Card>
<CardHeader title={t("admin.trigger_settings")} />
<CardHeader title={t("admin.trigger_settings")} subtitle={t("admin.trigger_settings_desc")} />
<CardBody>
<div className="grid grid-cols-2 gap-3 items-end">
<label className="flex items-center gap-2 text-xs text-ink-300">
<input
type="checkbox"
checked={!!values["triggers.enabled"]}
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
/>
{t("admin.triggers_enabled")}
</label>
<NumberInput
label={t("admin.triggers_check_interval")}
value={values["triggers.check_interval"]}
onChange={(v) => setValues({ ...values, "triggers.check_interval": v })}
<label className="flex items-center gap-2 text-xs text-ink-300">
<input
type="checkbox"
checked={!!values["triggers.enabled"]}
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
/>
</div>
{t("admin.triggers_enabled")}
</label>
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
</CardBody>
</Card>
@@ -382,6 +387,9 @@ export function AdminPanelPage() {
<Card>
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
<CardBody>
{userActionError && (
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -390,7 +398,8 @@ export function AdminPanelPage() {
<th className="py-2 pr-3">Username</th>
<th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Active</th>
<th className="py-2">Created</th>
<th className="py-2 pr-3">Created</th>
<th className="py-2 text-right">{t("admin.users_actions")}</th>
</tr>
</thead>
<tbody>
@@ -412,9 +421,32 @@ export function AdminPanelPage() {
<span className="text-red-400"></span>
)}
</td>
<td className="py-2 text-ink-400">
<td className="py-2 pr-3 text-ink-400">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="py-2 text-right">
{u.is_admin ? (
<span className="text-ink-500 text-xs"></span>
) : u.is_active ? (
<Button
size="sm"
variant="ghost"
onClick={() => toggleUserActive(u.id, u.is_active)}
>
<Ban size={12} className="mr-1" />
{t("admin.users_ban")}
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => toggleUserActive(u.id, u.is_active)}
>
<CheckCircle2 size={12} className="mr-1" />
{t("admin.users_unban")}
</Button>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -55,12 +55,6 @@ export function HomePage() {
desc="RU / EN"
/>
</div>
<div className="mt-12 text-center text-sm text-ink-500">
<Link to="/admin/setup" className="hover:text-accent-400 underline">
{t("auth.admin_setup_title")}
</Link>
</div>
</div>
);
}

View File

@@ -11,7 +11,7 @@ export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [email, setEmail] = useState("");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -21,7 +21,8 @@ export function LoginPage() {
setError("");
setLoading(true);
try {
const { access_token, user } = await authApi.login(email, password);
// `login` accepts either email or username.
const { access_token, user } = await authApi.login(login, password);
setAuth(access_token, user);
navigate("/dashboard");
} catch (err: any) {
@@ -38,13 +39,14 @@ export function LoginPage() {
<CardBody>
<form onSubmit={onSubmit} className="space-y-4">
<Input
label={t("auth.email")}
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
label={t("auth.login_or_email")}
type="text"
name="login"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
autoComplete="email"
autoComplete="username"
placeholder="alice / alice@example.com"
/>
<Input
label={t("auth.password")}

View File

@@ -24,7 +24,7 @@ export function WorldBuilderPage() {
const fromScratch = location.state?.fromScratch as boolean | undefined;
const [worldName, setWorldName] = useState("");
const [language, setLanguage] = useState(i18n.language === "en" ? "en" : "ru");
const [language, setLanguage] = useState(i18n.language === "ru" ? "ru" : "en");
const [settingBrief, setSettingBrief] = useState("");
const [characterBrief, setCharacterBrief] = useState("");
const [rulesBrief, setRulesBrief] = useState("");