deleçao de contas web

This commit is contained in:
2026-10-30 23:25:38 -03:00
parent 61fde19133
commit f0560b1be4
16 changed files with 1250 additions and 10 deletions

View File

@@ -6,6 +6,10 @@
# Desenvolvimento local: 127.0.0.1 # Desenvolvimento local: 127.0.0.1
BP_HOST_LAN_IP=127.0.0.1 BP_HOST_LAN_IP=127.0.0.1
# Porta do painel no servidor (padrão 3000). Troque se outra aplicação já usa a 3000;
# use a mesma no firewall (deploy/botpontos-firewall.service) e no Forward Port do NPM.
BP_WEB_PORT=3000
# debug | info | warn | error # debug | info | warn | error
BP_LOG_LEVEL=info BP_LOG_LEVEL=info

View File

@@ -0,0 +1,35 @@
-- Permite excluir contas do painel sem perder a auditoria.
-- Sessões da conta são apagadas junto; auditoria e configurações perdem só o
-- vínculo, e a auditoria guarda o nome da conta em actor_account_name.
-- +goose Up
ALTER TABLE audit_logs ADD COLUMN actor_account_name text;
UPDATE audit_logs a SET actor_account_name = wa.username::text
FROM web_accounts wa WHERE wa.id = a.actor_account;
ALTER TABLE web_sessions DROP CONSTRAINT web_sessions_account_id_fkey;
ALTER TABLE web_sessions ADD CONSTRAINT web_sessions_account_id_fkey
FOREIGN KEY (account_id) REFERENCES web_accounts(id) ON DELETE CASCADE;
ALTER TABLE audit_logs DROP CONSTRAINT audit_logs_actor_account_fkey;
ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_actor_account_fkey
FOREIGN KEY (actor_account) REFERENCES web_accounts(id) ON DELETE SET NULL;
ALTER TABLE app_settings DROP CONSTRAINT app_settings_updated_by_account_fkey;
ALTER TABLE app_settings ADD CONSTRAINT app_settings_updated_by_account_fkey
FOREIGN KEY (updated_by_account) REFERENCES web_accounts(id) ON DELETE SET NULL;
-- +goose Down
ALTER TABLE app_settings DROP CONSTRAINT app_settings_updated_by_account_fkey;
ALTER TABLE app_settings ADD CONSTRAINT app_settings_updated_by_account_fkey
FOREIGN KEY (updated_by_account) REFERENCES web_accounts(id);
ALTER TABLE audit_logs DROP CONSTRAINT audit_logs_actor_account_fkey;
ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_actor_account_fkey
FOREIGN KEY (actor_account) REFERENCES web_accounts(id);
ALTER TABLE web_sessions DROP CONSTRAINT web_sessions_account_id_fkey;
ALTER TABLE web_sessions ADD CONSTRAINT web_sessions_account_id_fkey
FOREIGN KEY (account_id) REFERENCES web_accounts(id);
ALTER TABLE audit_logs DROP COLUMN actor_account_name;

View File

@@ -1,3 +1,22 @@
module botpontos module botpontos
go 1.25 go 1.25.0
require (
github.com/jackc/pgx/v5 v5.11.0
github.com/pressly/goose/v3 v3.27.0
golang.org/x/crypto v0.55.0
golang.org/x/term v0.45.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)

60
backend/go.sum Normal file
View File

@@ -0,0 +1,60 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=

View File

@@ -18,8 +18,8 @@ func (s *Store) InsertAudit(ctx context.Context, e domain.AuditEntry) error {
return err return err
} }
_, err = s.pool.Exec(ctx, ` _, err = s.pool.Exec(ctx, `
INSERT INTO audit_logs (source, actor_user_id, actor_account, target_user_id, event_type, success, details) INSERT INTO audit_logs (source, actor_user_id, actor_account, actor_account_name, target_user_id, event_type, success, details)
VALUES ($1::text::audit_source, $2, $3, $4, $5, $6, $7::text::jsonb)`, VALUES ($1::text::audit_source, $2, $3, (SELECT username::text FROM web_accounts WHERE id = $3), $4, $5, $6, $7::text::jsonb)`,
string(e.Source), e.ActorUserID, e.ActorAccount, e.TargetUserID, e.EventType, e.Success, string(raw)) string(e.Source), e.ActorUserID, e.ActorAccount, e.TargetUserID, e.EventType, e.Success, string(raw))
return err return err
} }
@@ -41,7 +41,7 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]domain.AuditLog
f.Limit = 50 f.Limit = 50
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT a.id, a.occurred_at, a.source, a.actor_user_id, au.display_name, a.actor_account, wa.username::text, SELECT a.id, a.occurred_at, a.source, a.actor_user_id, au.display_name, a.actor_account, COALESCE(wa.username::text, a.actor_account_name),
a.target_user_id, tu.display_name, a.event_type, a.success, a.details::text a.target_user_id, tu.display_name, a.event_type, a.success, a.details::text
FROM audit_logs a FROM audit_logs a
LEFT JOIN users au ON au.id = a.actor_user_id LEFT JOIN users au ON au.id = a.actor_user_id

View File

@@ -82,6 +82,11 @@ func (s *Store) SetWebAccountPassword(ctx context.Context, id int64, passwordHas
WHERE id = $1`, id, passwordHash)) WHERE id = $1`, id, passwordHash))
} }
// DeleteWebAccount apaga a conta; as sessões vão junto e a auditoria mantém o nome (migration 00003).
func (s *Store) DeleteWebAccount(ctx context.Context, id int64) error {
return expectOne(s.pool.Exec(ctx, `DELETE FROM web_accounts WHERE id = $1`, id))
}
func (s *Store) CountActiveOwners(ctx context.Context) (int, error) { func (s *Store) CountActiveOwners(ctx context.Context) (int, error) {
var n int var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n) err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n)

View File

@@ -246,6 +246,29 @@ func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id int64, role
return nil return nil
} }
// DeleteAccount exclui a conta do painel, mantendo sempre um owner ativo (9.6).
func (s *Service) DeleteAccount(ctx context.Context, actor Actor, id int64) error {
account, err := s.store.GetWebAccount(ctx, id)
if err != nil {
return err
}
if account.Role == domain.RoleOwner && !account.IsDisabled() {
owners, err := s.store.CountActiveOwners(ctx)
if err != nil {
return err
}
if owners <= 1 {
return domain.ErrLastOwner
}
}
if err := s.store.DeleteWebAccount(ctx, id); err != nil {
return err
}
s.audit(ctx, actor, account.UserID, domain.EventWebAccountDeleted, true,
map[string]any{"account_id": id, "username": account.Username, "role": string(account.Role)})
return nil
}
// ResetPassword troca a senha e encerra todas as sessões da conta. // ResetPassword troca a senha e encerra todas as sessões da conta.
func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error { func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error {
if err := crypto.ValidatePassword(password); err != nil { if err := crypto.ValidatePassword(password); err != nil {

View File

@@ -49,6 +49,7 @@ const (
EventWebLogout = "web_logout" EventWebLogout = "web_logout"
EventWebAccountCreated = "web_account_created" EventWebAccountCreated = "web_account_created"
EventWebAccountChanged = "web_account_changed" EventWebAccountChanged = "web_account_changed"
EventWebAccountDeleted = "web_account_deleted"
EventWebPasswordReset = "web_password_reset" EventWebPasswordReset = "web_password_reset"
EventRetentionApplied = "retention_applied" EventRetentionApplied = "retention_applied"
EventEncryptionKeyRotated = "encryption_key_rotated" EventEncryptionKeyRotated = "encryption_key_rotated"

View File

@@ -71,6 +71,7 @@ func NewHandler(db Pinger, svc *app.Service, cfg config.Config, logger *slog.Log
mux.Handle("GET /api/v1/accounts", a.auth(domain.RoleOwner, a.listAccounts)) mux.Handle("GET /api/v1/accounts", a.auth(domain.RoleOwner, a.listAccounts))
mux.Handle("POST /api/v1/accounts", a.auth(domain.RoleOwner, a.createAccount)) mux.Handle("POST /api/v1/accounts", a.auth(domain.RoleOwner, a.createAccount))
mux.Handle("PATCH /api/v1/accounts/{id}", a.auth(domain.RoleOwner, a.updateAccount)) mux.Handle("PATCH /api/v1/accounts/{id}", a.auth(domain.RoleOwner, a.updateAccount))
mux.Handle("DELETE /api/v1/accounts/{id}", a.auth(domain.RoleOwner, a.deleteAccount))
mux.Handle("POST /api/v1/accounts/{id}/reset-password", a.auth(domain.RoleOwner, a.resetPassword)) mux.Handle("POST /api/v1/accounts/{id}/reset-password", a.auth(domain.RoleOwner, a.resetPassword))
mux.HandleFunc("/", notFound) mux.HandleFunc("/", notFound)

View File

@@ -340,6 +340,22 @@ func (a *API) updateAccount(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, toAccount(account)) httpx.WriteJSON(w, http.StatusOK, toAccount(account))
} }
func (a *API) deleteAccount(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
if id == sessionFrom(r).AccountID {
a.writeError(w, domain.InvalidState("você não pode excluir a própria conta"))
return
}
if err := a.svc.DeleteAccount(r.Context(), actorOf(r), id); err != nil {
a.writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r) id, ok := pathID(w, r)
if !ok { if !ok {

View File

@@ -1,6 +1,7 @@
# Restringe a porta 3000 (painel Next.js) ao IP do Nginx Proxy Manager. # Restringe a porta do painel Next.js (3000 ou BP_WEB_PORT) ao IP do Nginx Proxy Manager.
# ARQUITETURA.md, seção 11.4. Instalação no servidor Debian: # ARQUITETURA.md, seção 11.4. Instalação no servidor Debian:
# 1. Trocar IP_DO_NPM abaixo pelo IP real do NPM. # 1. Trocar IP_DO_NPM abaixo pelo IP real do NPM e, se BP_WEB_PORT no .env
# não for 3000, trocar WEB_PORT pela mesma porta.
# 2. Copiar para /etc/systemd/system/botpontos-firewall.service # 2. Copiar para /etc/systemd/system/botpontos-firewall.service
# 3. systemctl daemon-reload && systemctl enable --now botpontos-firewall # 3. systemctl daemon-reload && systemctl enable --now botpontos-firewall
# #
@@ -8,7 +9,7 @@
# vai na cadeia DOCKER-USER, aplicada depois que o Docker sobe. # vai na cadeia DOCKER-USER, aplicada depois que o Docker sobe.
[Unit] [Unit]
Description=Restringe a porta 3000 do Bot de Pontos ao NPM Description=Restringe a porta do painel do Bot de Pontos ao NPM
After=docker.service After=docker.service
Requires=docker.service Requires=docker.service
@@ -16,7 +17,8 @@ Requires=docker.service
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
Environment=NPM_IP=IP_DO_NPM Environment=NPM_IP=IP_DO_NPM
ExecStart=/bin/sh -c 'iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s $NPM_IP -j DROP 2>/dev/null || iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 3000 ! -s $NPM_IP -j DROP' Environment=WEB_PORT=3000
ExecStart=/bin/sh -c 'iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport $WEB_PORT ! -s $NPM_IP -j DROP 2>/dev/null || iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport $WEB_PORT ! -s $NPM_IP -j DROP'
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -121,7 +121,7 @@ services:
# máquina) aponta para cá, e o firewall libera a 3000 só para o IP do NPM # máquina) aponta para cá, e o firewall libera a 3000 só para o IP do NPM
# (deploy/botpontos-firewall.service). Em desenvolvimento, use 127.0.0.1. # (deploy/botpontos-firewall.service). Em desenvolvimento, use 127.0.0.1.
ports: ports:
- "${BP_HOST_LAN_IP:?defina BP_HOST_LAN_IP no arquivo .env}:3000:3000" - "${BP_HOST_LAN_IP:?defina BP_HOST_LAN_IP no arquivo .env}:${BP_WEB_PORT:-3000}:3000"
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp

View File

@@ -158,6 +158,15 @@ export function AccountActions({ account, isSelf, users }: { account: Account; i
confirm={`Desativar a conta ${account.username}? As sessões abertas serão encerradas.`} confirm={`Desativar a conta ${account.username}? As sessões abertas serão encerradas.`}
/> />
))} ))}
{!isSelf && (
<ActionButton
path={`/accounts/${account.id}`}
method="DELETE"
label="Excluir"
variant="danger"
confirm={`Excluir a conta ${account.username} de vez? Não dá para desfazer; a auditoria mantém o nome da conta.`}
/>
)}
{error && <span className="error-text">{error}</span>} {error && <span className="error-text">{error}</span>}
</div> </div>
); );

View File

@@ -32,7 +32,8 @@ export default async function AccountsPage() {
<p className="muted"> <p className="muted">
<strong>owner</strong> gerencia contas; <strong>admin</strong> altera usuários, lembretes, feriados e <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 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. altera somente os dados desse usuário. Sempre existe pelo menos um owner ativo. <strong>Desativar</strong> bloqueia o
acesso e pode ser revertido; <strong>Excluir</strong> apaga a conta de vez.
</p> </p>
<div className="table-wrap"> <div className="table-wrap">

1063
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
web/tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long