Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { ActionButton } from "@/components/ActionButton";
import { apiFetch, errorMessage } from "@/lib/client-api";
import type { Account, Role } from "@/lib/types";
const ROLES: { value: Role; label: string }[] = [
{ value: "viewer", label: "leitura" },
{ value: "admin", label: "admin" },
{ value: "owner", label: "owner" },
];
export type UserOption = { id: number; name: string };
function UserSelect({
users,
value,
onChange,
label,
}: {
users: UserOption[];
value: number | null;
onChange: (id: number | null) => void;
label: string;
}) {
return (
<select value={value ?? ""} onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)} aria-label={label}>
<option value="">Sem usuário ligado</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
);
}
export function CreateAccountForm({ users }: { users: UserOption[] }) {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("admin");
const [userId, setUserId] = useState<number | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await apiFetch("/accounts", { method: "POST", body: { username, password, role, user_id: userId } });
setUsername("");
setUserId(null);
setPassword("");
setMessage({ ok: true, text: "Conta criada." });
router.refresh();
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
return (
<form className="inline-form" onSubmit={submit} autoComplete="off">
<label className="field">
<span>Usuário</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} pattern="[a-zA-Z0-9._-]{3,50}" required />
</label>
<label className="field">
<span>Senha (mínimo 12 caracteres)</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={12} autoComplete="new-password" required />
</label>
<label className="field">
<span>Papel</span>
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</label>
<label className="field">
<span>Usuário do Telegram (leitura só vê este)</span>
<UserSelect users={users} value={userId} onChange={setUserId} label="Usuário do Telegram" />
</label>
<button className="btn primary" type="submit" disabled={busy}>
Criar
</button>
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
</form>
);
}
export function AccountActions({ account, isSelf, users }: { account: Account; isSelf: boolean; users: UserOption[] }) {
const router = useRouter();
const [error, setError] = useState("");
async function changeRole(role: Role) {
setError("");
try {
await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { role } });
router.refresh();
} catch (err) {
setError(errorMessage(err));
}
}
async function changeUser(userId: number | null) {
setError("");
try {
await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { user_id: userId } });
router.refresh();
} catch (err) {
setError(errorMessage(err));
}
}
async function resetPassword() {
const password = window.prompt(`Nova senha para ${account.username} (mínimo 12 caracteres):`);
if (!password) return;
setError("");
try {
await apiFetch(`/accounts/${account.id}/reset-password`, { method: "POST", body: { password } });
window.alert("Senha redefinida. As sessões abertas dessa conta foram encerradas.");
} catch (err) {
setError(errorMessage(err));
}
}
return (
<div className="actions">
<select value={account.role} onChange={(e) => changeRole(e.target.value as Role)} aria-label="Papel">
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<UserSelect users={users} value={account.user_id} onChange={changeUser} label="Usuário do Telegram" />
<button type="button" className="btn secondary" onClick={resetPassword}>
Redefinir senha
</button>
{!isSelf &&
(account.disabled ? (
<ActionButton path={`/accounts/${account.id}`} method="PATCH" body={{ disabled: false }} label="Reativar" />
) : (
<ActionButton
path={`/accounts/${account.id}`}
method="PATCH"
body={{ disabled: true }}
label="Desativar"
variant="danger"
confirm={`Desativar a conta ${account.username}? As sessões abertas serão encerradas.`}
/>
))}
{error && <span className="error-text">{error}</span>}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { ForbiddenError, apiGet } from "@/lib/server-api";
import { fmtDateTime } from "@/lib/format";
import type { Account, Me, UserSummary } from "@/lib/types";
import { AccountActions, CreateAccountForm } from "./forms";
export default async function AccountsPage() {
const me = await apiGet<Me>("/auth/me");
if (me.account.role !== "owner") {
return (
<>
<h1>Contas do painel</h1>
<p className="muted">Somente contas owner gerenciam as contas do painel.</p>
</>
);
}
let accounts: Account[] = [];
let users: UserSummary[] = [];
try {
[accounts, users] = await Promise.all([
apiGet<{ accounts: Account[] }>("/accounts").then((r) => r.accounts),
apiGet<{ users: UserSummary[] }>("/users").then((r) => r.users),
]);
} catch (err) {
if (!(err instanceof ForbiddenError)) throw err;
}
const userOptions = users.map((u) => ({ id: u.id, name: u.display_name }));
return (
<>
<h1>Contas do painel</h1>
<p className="muted">
<strong>owner</strong> gerencia contas; <strong>admin</strong> altera usuários, lembretes, feriados e
configurações; <strong>leitura</strong> só consulta. Uma conta de leitura ligada a um usuário do Telegram enxerga e
altera somente os dados desse usuário. Sempre existe pelo menos um owner ativo.
</p>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Usuário</th>
<th>Papel</th>
<th>Usuário do Telegram</th>
<th>Situação</th>
<th>Último login</th>
<th></th>
</tr>
</thead>
<tbody>
{accounts.map((a) => (
<tr key={a.id}>
<td>
{a.username}
{a.id === me.account.id && <span className="muted"> (você)</span>}
</td>
<td>{a.role === "viewer" ? "leitura" : a.role}</td>
<td>{userOptions.find((u) => u.id === a.user_id)?.name ?? <span className="muted">—</span>}</td>
<td>
{a.disabled ? (
<span className="badge error">desativada</span>
) : a.locked_until && new Date(a.locked_until) > new Date() ? (
<span className="badge warn">bloqueada até {fmtDateTime(a.locked_until)}</span>
) : (
<span className="badge ok">ativa</span>
)}
</td>
<td>{fmtDateTime(a.last_login_at)}</td>
<td>
<AccountActions account={a} isSelf={a.id === me.account.id} users={userOptions} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<section className="card">
<h2>Nova conta</h2>
<CreateAccountForm users={userOptions} />
</section>
</>
);
}