From 791736a88d13526e9f08cd025daad7c0dd90e3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Brand=C3=A3o?= Date: Wed, 11 Nov 2026 01:49:47 -0300 Subject: [PATCH 1/2] feat(dashboard): add windows with fixed title and footer, Users toolbar and editor window, Superuser rename, Organization tabs with members limited to existing accounts, new proxy in a floating window --- design-system/registry/components.json | 34 +- design-system/registry/llms.txt | 23 +- docs/adr/0006-organizacoes-e-papeis.md | 2 +- docs/adr/0010-contas-so-pelo-super-usuario.md | 21 + docs/dev/guia.md | 2 +- docs/usuario/organizacoes.md | 4 +- docs/usuario/solucao-de-problemas.md | 2 +- .../organizations.service.spec.ts | 22 +- .../organizations/organizations.service.ts | 19 +- .../components/AnthropicOAuthDetailView.tsx | 38 +- .../src/components/CopilotDeviceLogin.tsx | 22 +- .../src/components/CustomProviderForm.tsx | 63 +- .../src/components/DeviceCodeDetailView.tsx | 46 +- .../src/components/LocalServerForm.tsx | 4 +- .../src/components/OAuthDetailView.tsx | 54 +- .../src/components/ProviderKeyForm.tsx | 33 +- .../src/components/ProviderWizard.tsx | 23 +- packages/frontend/src/components/ui.tsx | 26 +- packages/frontend/src/i18n/locales/en/org.ts | 29 +- .../frontend/src/i18n/locales/en/shell.ts | 2 +- .../frontend/src/i18n/locales/en/triggers.ts | 5 - .../frontend/src/i18n/locales/pt-BR/org.ts | 29 +- .../frontend/src/i18n/locales/pt-BR/shell.ts | 2 +- .../src/i18n/locales/pt-BR/triggers.ts | 5 - packages/frontend/src/pages/AdminUsers.tsx | 703 ++++++++++++------ packages/frontend/src/pages/Organization.tsx | 507 ++++++------- packages/frontend/src/pages/ProxyServer.tsx | 386 ++++++---- packages/frontend/src/pages/ProxyServers.tsx | 17 +- .../providers/ProviderConnectionsPage.tsx | 1 + .../src/services/api/organizations.ts | 5 +- packages/frontend/src/styles/shell.css | 9 + .../tests/components/UserMenu.test.tsx | 6 +- .../frontend/tests/components/ui.test.tsx | 31 +- .../tests/i18n/shell-auth-org.pt-BR.test.tsx | 31 +- .../tests/pages/Organization.test.tsx | 71 +- .../tests/pages/ProxyServers.test.tsx | 112 ++- 36 files changed, 1496 insertions(+), 893 deletions(-) create mode 100644 docs/adr/0010-contas-so-pelo-super-usuario.md diff --git a/design-system/registry/components.json b/design-system/registry/components.json index 55dc2534..2f14336b 100644 --- a/design-system/registry/components.json +++ b/design-system/registry/components.json @@ -2155,6 +2155,10 @@ { "name": "Tabs", "file": "src/components/ui.tsx" + }, + { + "name": "WindowFooter", + "file": "src/components/ui.tsx" } ], "migrationMap": {}, @@ -2166,7 +2170,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1080 + "line": 1089 }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2175,7 +2179,7 @@ "prop": "display", "value": "inline-flex", "file": "shell.css", - "line": 1080 + "line": 1089 }, { "selector": ".app.is-collapsed .user-trigger", @@ -2184,21 +2188,21 @@ "prop": "padding", "value": "6px 8px", "file": "shell.css", - "line": 1089 + "line": 1098 }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-5px", "file": "shell.css", - "line": 1255 + "line": 1264 }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "5px", "file": "shell.css", - "line": 1256 + "line": 1265 } ], "ghostRules": [ @@ -2209,8 +2213,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1073, - "losesTo": "shell.css:1080" + "line": 1082, + "losesTo": "shell.css:1089" }, { "selector": ".app.is-collapsed .user-trigger > svg", @@ -2219,8 +2223,8 @@ "prop": "display", "value": "revert", "file": "shell.css", - "line": 1073, - "losesTo": "shell.css:1080" + "line": 1082, + "losesTo": "shell.css:1089" }, { "selector": ".app.is-collapsed .user-trigger", @@ -2229,24 +2233,24 @@ "prop": "padding", "value": "0 10px", "file": "shell.css", - "line": 1085, - "losesTo": "shell.css:1089" + "line": 1094, + "losesTo": "shell.css:1098" }, { "selector": ".liquid-metal::after", "prop": "inset", "value": "-2px", "file": "shell.css", - "line": 1211, - "losesTo": "shell.css:1255" + "line": 1220, + "losesTo": "shell.css:1264" }, { "selector": ".liquid-metal::after", "prop": "padding", "value": "2px", "file": "shell.css", - "line": 1213, - "losesTo": "shell.css:1256" + "line": 1222, + "losesTo": "shell.css:1265" } ], "componentSheets": { diff --git a/design-system/registry/llms.txt b/design-system/registry/llms.txt index 8e581d4a..953bd68e 100644 --- a/design-system/registry/llms.txt +++ b/design-system/registry/llms.txt @@ -299,7 +299,7 @@ user-trigger (shell.css): user-trigger well (base.css): well wide (shell.css): wide -## Primitives (19) +## Primitives (20) Avatar: src/components/ui.tsx Badge: src/components/ui.tsx Button: src/components/ui.tsx @@ -319,23 +319,24 @@ Skel: src/components/ui.tsx Status: src/components/ui.tsx Switch: src/components/ui.tsx Tabs: src/components/ui.tsx +WindowFooter: src/components/ui.tsx ## Migration map — legacy component → primitive to use instead (0) (empty until primitives exist) ## Canonical rules — same selector+property declared twice with different values; the winner renders (5) -.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1080 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1080 [@media (max-width: 900px)] -.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1089 [@media (max-width: 900px)] -.liquid-metal::after { inset: -5px } ← shell.css:1255 -.liquid-metal::after { padding: 5px } ← shell.css:1256 +.app.is-collapsed .nav-action { display: inline-flex } ← shell.css:1089 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger > svg { display: inline-flex } ← shell.css:1089 [@media (max-width: 900px)] +.app.is-collapsed .user-trigger { padding: 6px 8px } ← shell.css:1098 [@media (max-width: 900px)] +.liquid-metal::after { inset: -5px } ← shell.css:1264 +.liquid-metal::after { padding: 5px } ← shell.css:1265 ## Ghost rules — declared but always losing; edit the canonical rule instead, delete these in the refactor project (5) -.app.is-collapsed .nav-action { display: revert } at shell.css:1073 loses to shell.css:1080 -.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1073 loses to shell.css:1080 -.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1085 loses to shell.css:1089 -.liquid-metal::after { inset: -2px } at shell.css:1211 loses to shell.css:1255 -.liquid-metal::after { padding: 2px } at shell.css:1213 loses to shell.css:1256 +.app.is-collapsed .nav-action { display: revert } at shell.css:1082 loses to shell.css:1089 +.app.is-collapsed .user-trigger > svg { display: revert } at shell.css:1082 loses to shell.css:1089 +.app.is-collapsed .user-trigger { padding: 0 10px } at shell.css:1094 loses to shell.css:1098 +.liquid-metal::after { inset: -2px } at shell.css:1220 loses to shell.css:1264 +.liquid-metal::after { padding: 2px } at shell.css:1222 loses to shell.css:1265 ## Component-loaded sheets — imported by components, not index.css; they load after the global styles (2) canvas.css ← pages/Topology.tsx diff --git a/docs/adr/0006-organizacoes-e-papeis.md b/docs/adr/0006-organizacoes-e-papeis.md index 6e5abb6a..c9b35a0e 100644 --- a/docs/adr/0006-organizacoes-e-papeis.md +++ b/docs/adr/0006-organizacoes-e-papeis.md @@ -1,6 +1,6 @@ # ADR 0006 — Organizações, papéis e contas -- **Status:** aceito +- **Status:** aceito; item 6 alterado pelo [ADR 0010](0010-contas-so-pelo-super-usuario.md), que deixa só o super usuário (admin da instância) criar contas - **Data:** 2026-10-27 - **Issues:** milestone v0.3 (#78, #102, #131); decisões em `docs/decisoes-iniciais.md` §2 diff --git a/docs/adr/0010-contas-so-pelo-super-usuario.md b/docs/adr/0010-contas-so-pelo-super-usuario.md new file mode 100644 index 00000000..62e17e0a --- /dev/null +++ b/docs/adr/0010-contas-so-pelo-super-usuario.md @@ -0,0 +1,21 @@ +# ADR 0010 — Contas criadas só pelo super usuário + +- **Status:** aceito +- **Data:** 2026-11-11 +- **Altera:** [ADR 0006](0006-organizacoes-e-papeis.md), item 6, que deixava owner/admin de uma organização criar contas dentro dela + +## Contexto + +Pelo ADR 0006, adicionar um membro por e-mail na tela da organização criava a conta quando o e-mail não existia. Com isso, havia dois lugares para criar contas: **Usuários**, do admin da instância, e **Membros**, de cada organização. Na tela de membros também era possível trocar o papel de cada membro direto na lista. + +## Decisão + +1. Só o super usuário cria contas, na tela **Usuários**. "Super usuário" é o novo nome, na interface, do admin da instância. No código e na API ele continua sendo `isInstanceAdmin`. +2. `POST /organizations/:id/members` só adiciona uma conta que já existe. Para um e-mail sem conta, devolve 404 com `code: "account_not_found"` e não cria nada. A resposta mantém `temporaryPassword`, sempre `null`. +3. Na guia **Membros** só se adiciona uma conta existente ou se remove um membro. O papel é escolhido ao adicionar, e a lista não edita nada da conta. +4. Ao criar um super usuário com uma organização inicial, ele entra nela como `owner`. O papel é escolhido por quem cria só para contas comuns. + +## Consequências + +- Owner e admin de uma organização dependem do super usuário para criar uma conta nova. +- `PATCH /organizations/:id/members/:userId` (troca de papel) continua na API e no CLI, mas o painel não o usa mais. diff --git a/docs/dev/guia.md b/docs/dev/guia.md index 0f46ec30..594ad616 100644 --- a/docs/dev/guia.md +++ b/docs/dev/guia.md @@ -258,7 +258,7 @@ Usuário (Better Auth) ──→ organization_members ──→ Tenant (organiza - **Resolução** (`TenantCacheService.resolveAccess`, cache de 5 min invalidado em toda mudança de membro ou perfil): cabeçalho `X-Thinker-Organization`, senão `active_tenant_id`, senão a primeira associação. Organização pedida sem associação dá 403, exceto para o admin da instância, que entra como `owner`. Sem organização nenhuma, a primeira mutação cria uma pessoal (`ensureForUser`); remover um membro zera o `owner_user_id` que ele tinha na organização, e `ensureForUser` nunca reaproveita uma organização que tenha outros membros, então quem foi removido não volta como owner. - Os guards anexam um `TenantContext` (`@TenantCtx()`) com `tenantId`, `userId`, `role`, `isInstanceAdmin` e `mustChangePassword`. Toda consulta filtra por tenant (`addTenantFilter`). O filtro por harness (`agent_name`) do log de requisições é resolvido uma vez por requisição para o id do agente vivo (`resolveAgentScope`, em `query-helpers.ts`) e reutilizado em todas as consultas; os caches de resultado usam o tenant mais o id resolvido (`agentScopeCacheKey`), nunca só o nome. **Nunca** escope, cacheie ou autorize por id de usuário. **Nunca** escope, cacheie ou autorize por id de usuário. - Chaves de gestão (`api_keys`, chaves `thk_adm_` e tokens `thk_pat_` da CLI) representam quem as criou (ADR 0006 item 4): nas rotas escopadas valem na organização da chave com o papel atual dele ali; em `/api/v1/organizations/:id/*` e `/api/v1/admin/*` valem com o papel dele em cada organização e com o seu `isInstanceAdmin` (usado por `thinker org` e `thinker user`). Uma chave vazada equivale à conta inteira. O MCP limita as ferramentas de escrita pelo papel (`canWrite` em `mcp/mcp-auth.ts`: agentes pedem `member`; provedores e roteamento, `admin`). -- **Contas** (`organizations/`): o admin da instância e owner/admin de uma organização criam contas com senha temporária (`AccountsService`, via `runAsServerUserCreation`). Enquanto `must_change_password` estiver ligado, o `SessionGuard` só deixa passar `GET /api/v1/me` e `POST /api/v1/me/password` (403 `password_change_required`), e nenhuma chave de gestão da conta autentica (`ApiKeyAuthService` devolve o mesmo 403; no MCP a chave é recusada). A redefinição pelo admin apaga os tokens `thk_pat_` do `thinker login` da conta (as chaves `thk_adm_` continuam, bloqueadas até a troca) e as linhas de `session`, mas o cookie cache do Better Auth (5 min) mantém o cookie antigo válido nesse intervalo; como a flag volta a ligar e a troca exige a senha atual, esse cookie não faz nada além de ler `/me`. +- **Contas** (`organizations/`): só o admin da instância ("super usuário" no painel) cria contas, com senha temporária (`AccountsService`, via `runAsServerUserCreation`); `POST /organizations/:id/members` só adiciona contas existentes e devolve 404 `account_not_found` para e-mail sem conta ([ADR 0010](../adr/0010-contas-so-pelo-super-usuario.md)). Enquanto `must_change_password` estiver ligado, o `SessionGuard` só deixa passar `GET /api/v1/me` e `POST /api/v1/me/password` (403 `password_change_required`), e nenhuma chave de gestão da conta autentica (`ApiKeyAuthService` devolve o mesmo 403; no MCP a chave é recusada). A redefinição pelo admin apaga os tokens `thk_pat_` do `thinker login` da conta (as chaves `thk_adm_` continuam, bloqueadas até a troca) e as linhas de `session`, mas o cookie cache do Better Auth (5 min) mantém o cookie antigo válido nesse intervalo; como a flag volta a ligar e a troca exige a senha atual, esse cookie não faz nada além de ler `/me`. - **Agent** (`agents`): pertence a um tenant; único por `[tenant_id, name]`. - **AgentApiKey** (`agent_api_keys`): a chave `thk_*` do agente. Só autentica se a organização estiver ativa: apagar (desativar) a organização corta o proxy dos seus harnesses na hora, inclusive para chaves no cache do `AgentKeyAuthGuard`. **ApiKey** (`api_keys`): credencial de gestão do tenant, distinta da anterior. diff --git a/docs/usuario/organizacoes.md b/docs/usuario/organizacoes.md index 28022a14..273cae03 100644 --- a/docs/usuario/organizacoes.md +++ b/docs/usuario/organizacoes.md @@ -24,7 +24,9 @@ O primeiro usuário, criado no assistente `/setup`, é o **admin da instância** ## Criar contas -Não há cadastro aberto nem convite por e-mail. O admin da instância (em **Users**) ou um owner/admin da organização (em **Members & settings**) cria a conta com e-mail, nome e papel. O Thinker gera uma **senha temporária**, mostrada uma única vez. Passe-a para a pessoa por um canal seguro. No primeiro login ela precisa definir uma senha nova antes de usar o painel. Se a pessoa esquecer a senha, o admin da instância gera outra senha temporária em **Users**. +Não há cadastro aberto nem convite por e-mail. Só o super usuário (o admin da instância) cria contas, em **Usuários**, com e-mail, nome e, opcionalmente, uma organização e um papel. O Thinker gera uma **senha temporária**, mostrada uma única vez. Passe-a para a pessoa por um canal seguro. No primeiro login ela precisa definir uma senha nova antes de usar o painel. Se a pessoa esquecer a senha, o super usuário abre a conta em **Usuários** e gera outra senha temporária. + +Na guia **Membros** da tela **Organização**, owners e admins só adicionam uma conta que já existe (pelo e-mail, com o papel) ou removem um membro. Um e-mail sem conta é recusado. Sair da conta ou ter a senha redefinida apaga a sessão no servidor, mas o cookie de sessão é validado por assinatura e continua aceito por até 5 minutos. Uma cópia do cookie feita antes disso vale nesse intervalo; depois de uma redefinição pelo admin, ela só consegue ler o próprio perfil até a senha ser trocada. diff --git a/docs/usuario/solucao-de-problemas.md b/docs/usuario/solucao-de-problemas.md index a510705c..cb4ec7d7 100644 --- a/docs/usuario/solucao-de-problemas.md +++ b/docs/usuario/solucao-de-problemas.md @@ -33,7 +33,7 @@ Quando todos os modelos do tier falham, o cliente recebe o erro do provedor da **Não acho o token de setup.** `docker compose logs thinker | grep 'setup token'`. Se o container reiniciou, procure a linha mais recente: cada boot sem usuários imprime um token novo. Com várias réplicas, use o log da réplica que vai receber o setup. Se `THINKER_SETUP_TOKEN` estiver definido, o token é o valor dessa variável e não aparece no log. Copiando de um visualizador que mostra as cores cruas (Portainer, por exemplo), não leve o `[39m` do fim da linha. -**Quero criar mais contas e o cadastro está fechado (`SIGNUP_DISABLED`, HTTP 403).** O cadastro público é fechado de propósito. O admin da instância cria as contas em **Users**, e owners/admins de uma organização em **Members & settings**, com senha temporária. Veja [Organizações](organizacoes.md). +**Quero criar mais contas e o cadastro está fechado (`SIGNUP_DISABLED`, HTTP 403).** O cadastro público é fechado de propósito. Só o super usuário (admin da instância) cria contas, em **Usuários**, com senha temporária. Owners/admins de uma organização só adicionam contas existentes na guia **Membros**. Veja [Organizações](organizacoes.md). **Chamadas MCP respondem `401 no token payload`.** `BETTER_AUTH_URL` não é alcançável de dentro do container. Veja [Instalação](instalacao-docker.md#porta-e-acesso-pela-rede). diff --git a/packages/backend/src/organizations/organizations.service.spec.ts b/packages/backend/src/organizations/organizations.service.spec.ts index 516dc7ec..a8a7724b 100644 --- a/packages/backend/src/organizations/organizations.service.spec.ts +++ b/packages/backend/src/organizations/organizations.service.spec.ts @@ -327,22 +327,14 @@ describe('OrganizationsService.addMember', () => { expect(tenantCache.addMember).not.toHaveBeenCalled(); }); - it('creates an account for an unknown email and returns the temporary password once', async () => { - const { service, accounts, ds, tenantCache } = build({ roles: { [CALLER]: 'owner' } }); + it('refuses an unknown email instead of creating an account', async () => { + const { service, accounts, tenantCache } = build({ roles: { [CALLER]: 'owner' } }); accounts.findByEmail.mockResolvedValue(null); - accounts.createAccount.mockResolvedValue({ - account: { id: 'fresh' }, - temporaryPassword: 'tmp-secret', - }); - ds.query.mockResolvedValue([memberRow('fresh', 'owner')]); - const result = await service.addMember(CALLER, ORG, { - email: 'new@b.test', - name: 'New', - role: 'owner', - }); - expect(accounts.createAccount).toHaveBeenCalledWith('new@b.test', 'New'); - expect(tenantCache.addMember).toHaveBeenCalledWith(ORG, 'fresh', 'owner'); - expect(result.temporaryPassword).toBe('tmp-secret'); + await expect( + service.addMember(CALLER, ORG, { email: 'new@b.test', name: 'New', role: 'owner' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(accounts.createAccount).not.toHaveBeenCalled(); + expect(tenantCache.addMember).not.toHaveBeenCalled(); }); }); diff --git a/packages/backend/src/organizations/organizations.service.ts b/packages/backend/src/organizations/organizations.service.ts index e338284f..69dd919c 100644 --- a/packages/backend/src/organizations/organizations.service.ts +++ b/packages/backend/src/organizations/organizations.service.ts @@ -195,19 +195,20 @@ export class OrganizationsService { this.assertCanGrant(callerRole, role); if (!input.email?.includes('@')) throw new BadRequestException('A valid email is required.'); - let account = await this.accounts.findByEmail(input.email); - let temporaryPassword: string | null = null; + // Only existing accounts join here; creating accounts is a superuser task + // on the Users page. + const account = await this.accounts.findByEmail(input.email); if (!account) { - const created = await this.accounts.createAccount(input.email, input.name ?? ''); - account = created.account; - temporaryPassword = created.temporaryPassword; - } else if ( - await this.memberRepo.exists({ where: { tenant_id: tenantId, user_id: account.id } }) - ) { + throw new NotFoundException({ + message: 'No account uses this email. A superuser must create it first.', + code: 'account_not_found', + }); + } + if (await this.memberRepo.exists({ where: { tenant_id: tenantId, user_id: account.id } })) { throw new ConflictException('This account is already a member.'); } await this.tenantCache.addMember(tenantId, account.id, role); - return { member: await this.member(tenantId, account.id), temporaryPassword }; + return { member: await this.member(tenantId, account.id), temporaryPassword: null }; } /** Adds an existing account (instance admin flow: create user into an org). */ diff --git a/packages/frontend/src/components/AnthropicOAuthDetailView.tsx b/packages/frontend/src/components/AnthropicOAuthDetailView.tsx index 28c98fc4..70788a96 100644 --- a/packages/frontend/src/components/AnthropicOAuthDetailView.tsx +++ b/packages/frontend/src/components/AnthropicOAuthDetailView.tsx @@ -11,7 +11,7 @@ import { import { toast } from '../services/toast-store.js'; import { t } from '../i18n/index.js'; import AuthUrlFallback from './AuthUrlFallback.js'; -import { Button } from './ui.js'; +import { Button, WindowFooter } from './ui.js'; import { SubscriptionAccountList, renameSubscriptionKey } from './OAuthDetailView.js'; interface Props { @@ -200,15 +200,35 @@ export default function AnthropicOAuthDetailView(props: Props) { }} /> {error ?
{error}
: null} - + {props.newConnection ? null : ( + + )} + {props.newConnection ? ( + // A new-connection window: Cancel and Connect sit in its fixed footer. + +
+ + +
+
+ ) : null} {addingAccount ? ( + {props.newConnection ? ( + // A new-connection window: Cancel and Sign in sit in its fixed footer. + +
+ + +
+
+ ) : ( + + )} ) : null} diff --git a/packages/frontend/src/components/CustomProviderForm.tsx b/packages/frontend/src/components/CustomProviderForm.tsx index 4c2260ea..8e96f139 100644 --- a/packages/frontend/src/components/CustomProviderForm.tsx +++ b/packages/frontend/src/components/CustomProviderForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { ChevronLeft, ListPlus, X } from 'lucide-react'; import { deriveCustomProviderAlias, normalizeCustomProviderAlias } from 'thinker-shared'; import { @@ -32,7 +32,7 @@ import type { CustomProviderPrefill } from '../services/routing-params.js'; import { useResource } from '../lib/use-resource.js'; import InfoTooltip from './InfoTooltip.js'; import ProviderKeyForm, { MAX_KEYS_PER_PROVIDER } from './ProviderKeyForm.js'; -import { Button, Modal, cx } from './ui.js'; +import { Button, Modal, cx, WindowFooter } from './ui.js'; import { maskedKeyLabel } from '../services/key-mask.js'; import type { FormLayout, WindowSection } from './TabbedWindow.js'; @@ -100,6 +100,7 @@ const parseContextWindow = (v: string): number | null => { }; export default function CustomProviderForm(props: Props) { + const formId = useId(); const { initialData, agentName } = props; const isEdit = !!initialData; @@ -845,6 +846,7 @@ export default function CustomProviderForm(props: Props) { )}
{ e.preventDefault(); @@ -1147,29 +1149,40 @@ export default function CustomProviderForm(props: Props) { ) : null} -
- {isEdit ? ( - - ) : ( -
- )} - -
+ +
+ {isEdit ? ( + + ) : ( +
+ )} +
+ {props.embedded ? ( + + ) : null} + +
+
+ {showConnections ? ( diff --git a/packages/frontend/src/components/DeviceCodeDetailView.tsx b/packages/frontend/src/components/DeviceCodeDetailView.tsx index 14b29a9e..d97385da 100644 --- a/packages/frontend/src/components/DeviceCodeDetailView.tsx +++ b/packages/frontend/src/components/DeviceCodeDetailView.tsx @@ -15,7 +15,7 @@ import { t } from '../i18n/index.js'; import AuthUrlFallback from './AuthUrlFallback.js'; import { usePageWake } from '../lib/use-page-wake.js'; import Select from './Select.js'; -import { Button, cx } from './ui.js'; +import { Button, WindowFooter, cx } from './ui.js'; import { SubscriptionAccountList, renameSubscriptionKey } from './OAuthDetailView.js'; interface Props { @@ -491,9 +491,11 @@ export default function DeviceCodeDetailView(props: Props) { ) : null} ) : null} - + {props.newConnection ? null : ( + + )}
) : null} {alt && method === 'token' ? ( @@ -521,18 +523,44 @@ export default function DeviceCodeDetailView(props: Props) { }} /> {altError ?
{altError}
: null} + {props.newConnection ? null : ( + + )} +
+ ) : null} + + )} + {props.newConnection ? ( + // A new-connection window: Cancel and the next step sit in its fixed footer. + +
+ + {flow ? null : alt && method === 'token' ? ( -
- ) : null} - - )} + ) : showLogin ? ( + + ) : null} + +
+ ) : null} {addingAccount ? ( + {props.newConnection ? null : ( + + )} ) : ( @@ -429,11 +431,37 @@ export default function OAuthDetailView(props: Props) {

{t('providerSetup.oauth.loginToConnect', { name: props.provDef.name })}

- + {props.newConnection ? null : ( + + )} )} + {props.newConnection ? ( + // A new-connection window: Cancel and the next step sit in its fixed footer. + +
+ + {pasteFlowActive ? ( + + ) : ( + + )} +
+
+ ) : null} {addingAccount ? ( + + + ); + return (
); } diff --git a/packages/frontend/src/components/ProviderWizard.tsx b/packages/frontend/src/components/ProviderWizard.tsx index c148952e..36b1de99 100644 --- a/packages/frontend/src/components/ProviderWizard.tsx +++ b/packages/frontend/src/components/ProviderWizard.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, type ReactNode } from 'react'; import type { AuthType } from 'thinker-shared'; import { t } from '../i18n/index.js'; -import { cx } from './ui.js'; +import { Button, WindowFooter, cx } from './ui.js'; /** * Two-step "add provider" flow inside the floating window: part 1 picks the @@ -109,10 +109,13 @@ export function ProviderWizard({ providerId, authType, render, + onCancel, }: { providerId: string; authType: AuthType; render: (providerId: string) => ReactNode; + /** Shown as Cancel next to Next on the first step. */ + onCancel?: () => void; }) { const variants = providerVariants(providerId, authType); const [chosen, setChosen] = useState(null); @@ -154,11 +157,19 @@ export function ProviderWizard({ ))} -
- -
+ {/* In a window, this row sits in its fixed footer. */} + +
+ {onCancel ? ( + + ) : null} + +
+
); } diff --git a/packages/frontend/src/components/ui.tsx b/packages/frontend/src/components/ui.tsx index 770172a5..71433805 100644 --- a/packages/frontend/src/components/ui.tsx +++ b/packages/frontend/src/components/ui.tsx @@ -3,7 +3,9 @@ * classes in styles/base.css and shell.css; colors only from tokens. */ import { + createContext, useCallback, + useContext, useEffect, useId, useRef, @@ -465,8 +467,20 @@ export function Modal({ * A non-modal window: no backdrop, the page behind stays usable and a click * outside never closes it. Dragged by its title bar; resized from its corner; * closed by its X or Escape. The title bar and `footer` stay put while the - * body scrolls. `icon` sits left of the title. + * body scrolls. `icon` sits left of the title. A form inside the body can put + * its buttons in that fixed footer with `WindowFooter`. */ +const WindowFooterSlot = createContext(null); + +/** + * The action buttons of a form: in a FloatingWindow they render in its fixed + * footer (outside the scrolling body); anywhere else, in place. + */ +export function WindowFooter({ children }: { children: ReactNode }) { + const slot = useContext(WindowFooterSlot); + return slot ? createPortal(children, slot) : <>{children}; +} + export function FloatingWindow({ open, title, @@ -486,6 +500,7 @@ export function FloatingWindow({ }) { const ref = useRef(null); const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [footEl, setFootEl] = useState(null); const drag = useRef<{ dx: number; dy: number } | null>(null); useEffect(() => { if (!open) { @@ -544,8 +559,13 @@ export function FloatingWindow({ -
{children}
- {footer ?
{footer}
: null} +
+ {children} +
+ {/* Empty (no footer, no WindowFooter) collapses through :empty. */} +
+ {footer} +
, document.body, ); diff --git a/packages/frontend/src/i18n/locales/en/org.ts b/packages/frontend/src/i18n/locales/en/org.ts index 6a8191ea..b483f8a5 100644 --- a/packages/frontend/src/i18n/locales/en/org.ts +++ b/packages/frontend/src/i18n/locales/en/org.ts @@ -51,7 +51,6 @@ export default { metaDescription: "Manage the organization's name and members.", subtitle: 'Members, roles and settings of this organization.', renamed: 'Organization renamed', - existingAdded: '{{ email }} already had an account and was added', general: 'General', logRetention: 'Log retention', logRetentionDesc: @@ -61,12 +60,16 @@ export default { nameDesc: 'Shown in the organization switcher.', nameReadOnly: 'Only owners can rename the organization.', members: 'Members', + memberAdded: '{{ email }} was added to the organization', + existingAccount: 'Existing account', + existingAccountHint: 'Accounts are created by a superuser on the Users page.', + accountNotFound: + 'No account uses this email. A superuser must create it on the Users page first.', + alreadyMember: 'This account is already a member.', addMember: 'Add member', - nameNewAccounts: 'Name (new accounts)', member: 'Member', loadingMembers: 'Loading members...', you: '(you)', - roleOf: 'Role of {{ email }}', active: 'Active', pendingPassword: 'Pending password change', confirmRemove: 'Confirm remove', @@ -84,23 +87,31 @@ export default { users: { title: 'Users', metaDescription: 'Manage the accounts of this Thinker instance.', - adminGranted: 'Instance admin granted', - adminRevoked: 'Instance admin revoked', + adminGranted: 'Superuser access granted', + adminRevoked: 'Superuser access revoked', notAvailable: 'Not available', - adminsOnly: 'Only instance administrators can manage user accounts.', + adminsOnly: 'Only superusers can manage user accounts.', subtitle: 'Every account on this Thinker instance. New accounts get a temporary password and must change it at the first sign-in.', create: 'Create user', orgOptional: 'Organization (optional)', - instanceAdmin: 'Instance admin', + instanceAdmin: 'Superuser', + instanceAdminHint: 'Full access: manages users and every organization', + initialOrgOptional: 'Initial organization (optional)', all: 'All users', user: 'User', organizations: 'Organizations', created: 'Created', loading: 'Loading users...', - instanceAdminOf: 'Instance admin: {{ email }}', - cannotRevokeSelf: 'You cannot revoke your own admin access', + instanceAdminOf: 'Superuser: {{ email }}', + cannotRevokeSelf: 'You cannot revoke your own superuser access', resetPassword: 'Reset password', confirmReset: 'Confirm reset', + layout: 'Layout', + gridView: 'Card view', + tableView: 'List view', + editOf: 'Edit {{ email }}', + empty: 'No users yet', + password: 'Password', }, }; diff --git a/packages/frontend/src/i18n/locales/en/shell.ts b/packages/frontend/src/i18n/locales/en/shell.ts index 62f61f25..a21cc133 100644 --- a/packages/frontend/src/i18n/locales/en/shell.ts +++ b/packages/frontend/src/i18n/locales/en/shell.ts @@ -12,7 +12,7 @@ export default { user: { organization: 'Organization', role: 'Your role: {{ role }}', - instanceAdmin: 'instance admin', + instanceAdmin: 'superuser', manageOrganization: 'Organization and members', viewOrganization: 'Organization details', switchOrganization: 'Switch organization', diff --git a/packages/frontend/src/i18n/locales/en/triggers.ts b/packages/frontend/src/i18n/locales/en/triggers.ts index 0d5f2339..27726a48 100644 --- a/packages/frontend/src/i18n/locales/en/triggers.ts +++ b/packages/frontend/src/i18n/locales/en/triggers.ts @@ -297,9 +297,4 @@ export default { missing: 'Deleted trigger', readOnly: 'Only members, admins and owners can change the triggers of a harness.', }, - org: { - title: 'Triggers', - body: 'E-mail, webhook, Telegram, syslog, GELF and RELP targets for harness and workspace events.', - open: 'Open triggers', - }, }; diff --git a/packages/frontend/src/i18n/locales/pt-BR/org.ts b/packages/frontend/src/i18n/locales/pt-BR/org.ts index 1f764efd..c8507274 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/org.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/org.ts @@ -55,7 +55,6 @@ const ptBR: Catalog = { metaDescription: 'Gerencie o nome e os membros da organização.', subtitle: 'Membros, papéis e configurações desta organização.', renamed: 'Organização renomeada', - existingAdded: '{{ email }} já tinha uma conta e foi adicionado', general: 'Geral', logRetention: 'Retenção de logs', logRetentionDesc: @@ -65,12 +64,16 @@ const ptBR: Catalog = { nameDesc: 'Aparece no seletor de organização.', nameReadOnly: 'Só proprietários podem renomear a organização.', members: 'Membros', + memberAdded: '{{ email }} foi adicionado à organização', + existingAccount: 'Conta existente', + existingAccountHint: 'Contas são criadas por um super usuário na tela Usuários.', + accountNotFound: + 'Nenhuma conta usa este e-mail. Um super usuário precisa criá-la antes na tela Usuários.', + alreadyMember: 'Esta conta já é membro.', addMember: 'Adicionar membro', - nameNewAccounts: 'Nome (contas novas)', member: 'Membro', loadingMembers: 'Carregando membros...', you: '(você)', - roleOf: 'Papel de {{ email }}', active: 'Ativo', pendingPassword: 'Troca de senha pendente', confirmRemove: 'Confirmar remoção', @@ -88,24 +91,32 @@ const ptBR: Catalog = { users: { title: 'Usuários', metaDescription: 'Gerencie as contas desta instância do Thinker.', - adminGranted: 'Acesso de administrador da instância concedido', - adminRevoked: 'Acesso de administrador da instância revogado', + adminGranted: 'Acesso de super usuário concedido', + adminRevoked: 'Acesso de super usuário revogado', notAvailable: 'Indisponível', - adminsOnly: 'Só administradores da instância podem gerenciar contas de usuário.', + adminsOnly: 'Só super usuários podem gerenciar contas de usuário.', subtitle: 'Todas as contas desta instância do Thinker. Contas novas recebem uma senha temporária e precisam trocá-la no primeiro acesso.', create: 'Criar usuário', orgOptional: 'Organização (opcional)', - instanceAdmin: 'Administrador da instância', + instanceAdmin: 'Super usuário', + instanceAdminHint: 'Acesso total: gerencia usuários e todas as organizações', + initialOrgOptional: 'Organização inicial (opcional)', all: 'Todos os usuários', user: 'Usuário', organizations: 'Organizações', created: 'Criado', loading: 'Carregando usuários...', - instanceAdminOf: 'Administrador da instância: {{ email }}', - cannotRevokeSelf: 'Você não pode revogar seu próprio acesso de administrador', + instanceAdminOf: 'Super usuário: {{ email }}', + cannotRevokeSelf: 'Você não pode revogar seu próprio acesso de super usuário', resetPassword: 'Redefinir senha', confirmReset: 'Confirmar redefinição', + layout: 'Layout', + gridView: 'Visualização em cards', + tableView: 'Visualização em lista', + editOf: 'Editar {{ email }}', + empty: 'Nenhum usuário ainda', + password: 'Senha', }, }; diff --git a/packages/frontend/src/i18n/locales/pt-BR/shell.ts b/packages/frontend/src/i18n/locales/pt-BR/shell.ts index bc4aa1d7..160574ed 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/shell.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/shell.ts @@ -15,7 +15,7 @@ const shell: Catalog = { user: { organization: 'Organização', role: 'Seu papel: {{ role }}', - instanceAdmin: 'admin da instância', + instanceAdmin: 'super usuário', manageOrganization: 'Organização e membros', viewOrganization: 'Dados da organização', switchOrganization: 'Trocar de organização', diff --git a/packages/frontend/src/i18n/locales/pt-BR/triggers.ts b/packages/frontend/src/i18n/locales/pt-BR/triggers.ts index 79f38bc1..3c6d8cce 100644 --- a/packages/frontend/src/i18n/locales/pt-BR/triggers.ts +++ b/packages/frontend/src/i18n/locales/pt-BR/triggers.ts @@ -302,11 +302,6 @@ const ptBR: Catalog = { readOnly: 'Só membros, administradores e proprietários podem alterar os triggers de um harness.', }, - org: { - title: 'Triggers', - body: 'Destinos de e-mail, webhook, Telegram, syslog, GELF e RELP para eventos de harness e do workspace.', - open: 'Abrir triggers', - }, }; export default ptBR; diff --git a/packages/frontend/src/pages/AdminUsers.tsx b/packages/frontend/src/pages/AdminUsers.tsx index 76aa0bf6..27b2bf7d 100644 --- a/packages/frontend/src/pages/AdminUsers.tsx +++ b/packages/frontend/src/pages/AdminUsers.tsx @@ -1,13 +1,24 @@ import { useState, type FormEvent } from 'react'; +import { LayoutGrid, Plus, Rows3, UserRound } from 'lucide-react'; import ErrorState from '../components/ErrorState.js'; import TemporaryPasswordBox from '../components/TemporaryPasswordBox.js'; -import { Badge, Button, Field, Panel, Switch } from '../components/ui.js'; +import { + Badge, + Button, + EmptyState, + Field, + FloatingWindow, + Skel, + Switch, +} from '../components/ui.js'; import { createAdminUser, listAdminUsers, listOrganizations, resetUserPassword, setInstanceAdmin, + type AdminUser, + type OrganizationRef, type OrgRole, } from '../services/api/organizations.js'; import { formatTimeAgo } from '../services/formatters.js'; @@ -18,10 +29,25 @@ import { useSignal } from '../lib/signal.js'; import { useResource } from '../lib/use-resource.js'; import { t } from '../i18n/index.js'; +type UsersView = 'grid' | 'table'; +const VIEW_STORAGE_KEY = 'thinker_users_view'; + +function readStoredView(): UsersView { + try { + return localStorage.getItem(VIEW_STORAGE_KEY) === 'table' ? 'table' : 'grid'; + } catch { + return 'grid'; + } +} + +/** Which user the floating window shows: a new account, or an existing one by id. */ +type WindowTarget = { mode: 'create' } | { mode: 'edit'; userId: string }; + /** - * Instance-admin account management: every user with their organizations, - * account creation, password reset and the instance-admin flag. Temporary - * passwords are shown once and never stored. + * Instance-admin account management: every user with their organizations. + * Creating and editing an account (instance-admin flag, password reset) + * happens in a floating window. Temporary passwords are shown once and never + * stored. */ export default function AdminUsers() { const profile = useSignal(me); @@ -31,17 +57,225 @@ export default function AdminUsers() { enabled: isAdmin, }); + const [view, setView] = useState(readStoredView); + const switchView = (next: UsersView) => { + setView(next); + try { + localStorage.setItem(VIEW_STORAGE_KEY, next); + } catch { + /* storage unavailable */ + } + }; + const [target, setTarget] = useState(null); + const openEdit = (userId: string) => setTarget({ mode: 'edit', userId }); + + const list = users.data ?? []; + const firstLoad = users.loading && !users.data && !users.error; + const editing = + target?.mode === 'edit' ? (list.find((u) => u.id === target.userId) ?? null) : null; + + const nameOf = (user: AdminUser) => ( + <> + {user.name || user.email} + {user.id === profile?.userId ? ( + {t('org.page.you')} + ) : null} + + ); + const orgBadges = (user: AdminUser) => + user.organizations.length > 0 ? ( +
+ {user.organizations.map((o) => ( + + {o.name} · {roleLabel(o.role)} + + ))} +
+ ) : ( + {t('common.none')} + ); + const status = (user: AdminUser) => + user.mustChangePassword ? ( + {t('org.page.pendingPassword')} + ) : ( + {t('org.page.active')} + ); + + const viewToggle = ( +
+ + +
+ ); + + const tableView = ( +
+ + + + + + + + + + + {list.map((user) => ( + + + + + + + + ))} + +
{t('org.users.user')}{t('org.users.organizations')}{t('common.status')}{t('org.users.created')} +
+ +
{user.email}
+
{orgBadges(user)}{status(user)}{formatTimeAgo(user.createdAt)} +
+ +
+
+
+ ); + + const gridView = ( +
+ {list.map((user) => ( + + ))} +
+ ); + + const skeleton = ( +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ ); + + return ( +
+ {t('common.pageTitle', { page: t('org.users.title') })} + + {!isAdmin ? ( + + ) : ( + <> +
+
+ {viewToggle} + +
+
+ + {firstLoad ? ( + skeleton + ) : users.error ? ( + void users.reload()} /> + ) : list.length === 0 ? ( +
+ +
+ ) : view === 'grid' ? ( + gridView + ) : ( + tableView + )} + + {target?.mode === 'create' ? ( + setTarget(null)} + onCreated={() => void users.reload()} + /> + ) : null} + {editing ? ( + setTarget(null)} + onChanged={() => void users.reload()} + /> + ) : null} + + )} +
+ ); +} + +function CreateUserWindow({ + orgs, + onClose, + onCreated, +}: { + orgs: OrganizationRef[]; + onClose: () => void; + onCreated: () => void; +}) { const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [makeAdmin, setMakeAdmin] = useState(false); const [orgId, setOrgId] = useState(''); const [role, setRole] = useState('member'); const [creating, setCreating] = useState(false); - const [tempPassword, setTempPassword] = useState<{ email: string; password: string } | null>( - null, - ); - const [confirmReset, setConfirmReset] = useState(null); - const [busyUser, setBusyUser] = useState(null); + const [created, setCreated] = useState<{ email: string; password: string } | null>(null); const handleCreate = async (e: FormEvent) => { e.preventDefault(); @@ -51,15 +285,12 @@ export default function AdminUsers() { email: email.trim(), name: name.trim(), ...(makeAdmin ? { isInstanceAdmin: true } : {}), - ...(orgId ? { organizationId: orgId, role } : {}), + // A superuser already acts as owner everywhere; the organization is only + // where they land, so they join it as its owner. + ...(orgId ? { organizationId: orgId, role: makeAdmin ? 'owner' : role } : {}), }); - setTempPassword({ email: res.user.email, password: res.temporaryPassword }); - setEmail(''); - setName(''); - setMakeAdmin(false); - setOrgId(''); - setRole('member'); - await users.reload(); + setCreated({ email: res.user.email, password: res.temporaryPassword }); + onCreated(); } catch { // toasted by fetchMutate } finally { @@ -67,239 +298,243 @@ export default function AdminUsers() { } }; - const handleReset = async (userId: string, userEmail: string) => { - setBusyUser(userId); + return ( + + ); +} + +function EditUserWindow({ + user, + isSelf, + onClose, + onChanged, +}: { + user: AdminUser; + isSelf: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [busy, setBusy] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + const [tempPassword, setTempPassword] = useState(null); + + const handleToggleAdmin = async (next: boolean) => { + setBusy(true); try { - const res = await resetUserPassword(userId); - setConfirmReset(null); - setTempPassword({ email: userEmail, password: res.temporaryPassword }); - window.scrollTo?.({ top: 0, behavior: 'smooth' }); - await users.reload(); + await setInstanceAdmin(user.id, next); + toast.success(next ? t('org.users.adminGranted') : t('org.users.adminRevoked')); } catch { // toasted by fetchMutate } finally { - setBusyUser(null); + setBusy(false); + onChanged(); } }; - const handleToggleAdmin = async (userId: string, next: boolean) => { - setBusyUser(userId); + const handleReset = async () => { + setBusy(true); try { - await setInstanceAdmin(userId, next); - toast.success(next ? t('org.users.adminGranted') : t('org.users.adminRevoked')); + const res = await resetUserPassword(user.id); + setConfirmReset(false); + setTempPassword(res.temporaryPassword); + onChanged(); } catch { // toasted by fetchMutate } finally { - setBusyUser(null); - await users.reload(); + setBusy(false); } }; return ( -
- {t('common.pageTitle', { page: t('org.users.title') })} - - {!isAdmin ? ( - - ) : ( - <> -
- -
- - setEmail(e.target.value)} - required - /> - - - setName(e.target.value)} - required - /> - - - - - - - -
- - -
-
- {tempPassword ? ( - setTempPassword(null)} - /> - ) : null} -
+
+ ); } diff --git a/packages/frontend/src/pages/Organization.tsx b/packages/frontend/src/pages/Organization.tsx index e94aa3bc..d327ff7a 100644 --- a/packages/frontend/src/pages/Organization.tsx +++ b/packages/frontend/src/pages/Organization.tsx @@ -1,16 +1,17 @@ import { useCallback, useState, type FormEvent } from 'react'; +import { useSearchParams } from 'react-router'; import ErrorState from '../components/ErrorState.js'; -import TemporaryPasswordBox from '../components/TemporaryPasswordBox.js'; import ApiKeysPanel from '../components/ApiKeysPanel.js'; -import { Badge, Button, ButtonLink, Field, Modal, Panel } from '../components/ui.js'; +import { ApiError } from '../services/api/core.js'; +import { Badge, Button, Field, Modal, Panel, Tabs, type TabItem } from '../components/ui.js'; import { retentionOptions } from '../components/LogRetentionSelect.js'; import { addMember, deleteOrganization, + listAdminUsers, listMembers, removeMember, renameOrganization, - updateMemberRole, type OrgRole, getLogRetention, updateLogRetention, @@ -23,10 +24,14 @@ import { useSignal } from '../lib/signal.js'; import { useResource } from '../lib/use-resource.js'; import { t } from '../i18n/index.js'; +type OrgTab = 'general' | 'retention' | 'apiKeys' | 'members' | 'danger'; + /** - * Settings of the active organization: name, members and roles, and deletion. - * Owners rename/delete; owners and admins manage members (admins never touch - * owners); everyone else gets a read-only view. + * Settings of the active organization, one tab per section: name, log + * retention, API keys, members and deletion. Owners rename/delete; owners and + * admins add existing accounts as members or remove them (admins never touch + * owners); everyone else gets a read-only view. Accounts are created only by + * a superuser on the Users page. */ export default function Organization() { const profile = useSignal(me); @@ -39,6 +44,33 @@ export default function Organization() { const members = useResource(async () => (await listMembers(orgId!)).members, [orgId], { enabled: !!orgId, }); + // Superusers see every account, so they get suggestions of who isn't a member yet. + const accounts = useResource(async () => (await listAdminUsers()).users, [], { + enabled: canManageMembers && profile?.isInstanceAdmin === true, + }); + + const tabs: TabItem[] = [ + { key: 'general', label: t('org.page.general') }, + { key: 'retention', label: t('org.page.logRetention') }, + ...(canManageMembers && orgId + ? [{ key: 'apiKeys' as const, label: t('org.apiKeys.title') }] + : []), + { key: 'members', label: t('org.page.members'), count: members.data?.length }, + ...(canManageOrg ? [{ key: 'danger' as const, label: t('org.page.dangerZone') }] : []), + ]; + const [searchParams, setSearchParams] = useSearchParams(); + const requested = searchParams.get('tab'); + const tab: OrgTab = tabs.some((i) => i.key === requested) ? (requested as OrgTab) : 'general'; + const switchTab = (next: OrgTab) => + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (next === 'general') params.delete('tab'); + else params.set('tab', next); + return params; + }, + { replace: true }, + ); // ── Rename ─────────────────────────────────────────── const [name, setName] = useState(org?.name ?? ''); @@ -95,12 +127,8 @@ export default function Organization() { // ── Members ────────────────────────────────────────── const [email, setEmail] = useState(''); - const [memberName, setMemberName] = useState(''); const [newRole, setNewRole] = useState('member'); const [adding, setAdding] = useState(false); - const [tempPassword, setTempPassword] = useState<{ email: string; password: string } | null>( - null, - ); const [confirmRemove, setConfirmRemove] = useState(null); const [busyMember, setBusyMember] = useState(null); @@ -109,47 +137,25 @@ export default function Organization() { if (!org) return; setAdding(true); try { - const trimmedName = memberName.trim(); - const res = await addMember(org.id, { - email: email.trim(), - ...(trimmedName ? { name: trimmedName } : {}), - role: newRole, - }); - if (res.temporaryPassword) { - setTempPassword({ email: res.member.email, password: res.temporaryPassword }); - } else { - setTempPassword(null); - toast.success(t('org.page.existingAdded', { email: res.member.email })); - } + const res = await addMember(org.id, { email: email.trim(), role: newRole }, { silent: true }); + toast.success(t('org.page.memberAdded', { email: res.member.email })); setEmail(''); - setMemberName(''); setNewRole('member'); await members.reload(); - } catch { - // toasted by fetchMutate + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + toast.error( + status === 404 + ? t('org.page.accountNotFound') + : status === 409 + ? t('org.page.alreadyMember') + : (err as Error).message, + ); } finally { setAdding(false); } }; - const handleRoleChange = async (userId: string, next: OrgRole) => { - if (!org) return; - setBusyMember(userId); - try { - await updateMemberRole(org.id, userId, next); - if (userId === me()?.userId) { - // Our own role changed: reload so the whole UI follows it. - window.location.reload(); - return; - } - } catch { - // toasted (e.g. 409 when demoting the last owner) - } finally { - setBusyMember(null); - await members.reload(); - } - }; - const handleRemove = async (userId: string) => { if (!org) return; setBusyMember(userId); @@ -170,248 +176,227 @@ export default function Organization() { }; const roleOptions = assignableRoles(role); + const memberIds = new Set((members.data ?? []).map((m) => m.userId)); + const candidates = (accounts.data ?? []).filter((u) => !memberIds.has(u.id)); return (
{t('common.pageTitle', { page: t('org.page.title') })} + +
- -
- - setName(e.target.value)} - readOnly={!canManageOrg} - required - /> - - {canManageOrg ? ( - - ) : null} -
-
+ setName(e.target.value)} + readOnly={!canManageOrg} + required + /> + + {canManageOrg ? ( + + ) : null} + + + ) : null} - - - + void changeRetention(e.currentTarget.value ? Number(e.currentTarget.value) : null) + } + > + - ))} - - - - - {canManageMembers && orgId ? : null} + {retentionOptions(retention.data?.retention_days).map((days) => ( + + ))} + + + + ) : null} - -
-

{t('triggers.org.body')}

- - {t('triggers.org.open')} - -
-
+ {tab === 'apiKeys' && canManageMembers && orgId ? : null} - - {canManageMembers ? ( - <> -
- - setEmail(e.target.value)} - required - /> - - - setMemberName(e.target.value)} - /> - - - - - -
- {tempPassword ? ( - setTempPassword(null)} - /> - ) : null} - - ) : null} + {tab === 'members' ? ( + + {canManageMembers ? ( + <> +
+ + 0 ? 'org-add-candidates' : undefined} + placeholder={t('common.email')} + value={email} + onChange={(e) => setEmail(e.target.value)} + required + /> + {candidates.length > 0 ? ( + + {candidates.map((u) => ( + + ))} + + ) : null} + + + + + +
+

{t('org.page.existingAccountHint')}

+ + ) : null} - {members.error ? ( - void members.reload()} /> - ) : ( -
- - - - - - - - - - {members.loading && !members.data ? ( + {members.error ? ( + void members.reload()} /> + ) : ( +
+
{t('org.page.member')}{t('common.role')}{t('common.status')} -
+ - + + + + - ) : null} - {(members.data ?? []).map((member) => { - const editable = canManageMembers && canEditMember(role, member.role); - const isSelf = member.userId === profile?.userId; - return ( - - + + {members.loading && !members.data ? ( + + - + ) : null} + {(members.data ?? []).map((member) => { + const editable = canManageMembers && canEditMember(role, member.role); + const isSelf = member.userId === profile?.userId; + return ( + + + - - + + - - ); - })} - -
- {t('org.page.loadingMembers')} - {t('org.page.member')}{t('common.role')}{t('common.status')}
-
- {member.name || member.email} - {isSelf ? ( - {t('org.page.you')} - ) : null} -
-
{member.email}
+
+ {t('org.page.loadingMembers')} - {editable ? ( - - ) : ( +
+
+ {member.name || member.email} + {isSelf ? ( + {t('org.page.you')} + ) : null} +
+
{member.email}
+
{roleLabel(member.role)} - )} - - {member.mustChangePassword ? ( - {t('org.page.pendingPassword')} - ) : ( - {t('org.page.active')} - )} - - {editable ? ( -
- {confirmRemove === member.userId ? ( - <> - +
+ {member.mustChangePassword ? ( + {t('org.page.pendingPassword')} + ) : ( + {t('org.page.active')} + )} + + {editable ? ( +
+ {confirmRemove === member.userId ? ( + <> + + + + ) : ( - - ) : ( - - )} -
- ) : null} -
-
- )} -
+ )} +
+ ) : null} + + + ); + })} + + +
+ )} + + ) : null} - {canManageOrg ? ( + {tab === 'danger' && canManageOrg ? ( {t('org.page.dangerZone')}}>
diff --git a/packages/frontend/src/pages/ProxyServer.tsx b/packages/frontend/src/pages/ProxyServer.tsx index aaab13db..8dfa4718 100644 --- a/packages/frontend/src/pages/ProxyServer.tsx +++ b/packages/frontend/src/pages/ProxyServer.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; -import { FlaskConical, Trash2 } from 'lucide-react'; +import { FlaskConical, Network, Trash2 } from 'lucide-react'; import ErrorState from '../components/ErrorState.js'; import LogRetentionSelect from '../components/LogRetentionSelect.js'; import { @@ -11,7 +11,18 @@ import { } from '../components/proxy/ProxyParts.js'; import ProxyLogsTab from '../components/proxy/ProxyLogsTab.js'; import ProxyStatsTab from '../components/proxy/ProxyStatsTab.js'; -import { Badge, Button, Field, Modal, Panel, Skel, Switch, Tabs } from '../components/ui.js'; +import { + Badge, + Button, + Field, + FloatingWindow, + Modal, + Panel, + Skel, + Switch, + Tabs, + WindowFooter, +} from '../components/ui.js'; import { ApiError } from '../services/api/core.js'; import { createProxy, @@ -238,14 +249,49 @@ export default function ProxyServer() { ); } +/** + * Add a proxy server in a floating window over the proxy list, so the list + * stays in place; the window closes once the proxy is created. + */ +export function NewProxyWindow({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (p: ProxyDetail) => void; +}) { + return ( + + ); +} + function SettingsTab({ proxy, onSaved, onDeleted, + embedded = false, + onCancel, }: { proxy: ProxyDetail | null; onSaved: (p: ProxyDetail) => void; onDeleted: () => void; + /** Bare form, without the panel and side column (the new-proxy window). */ + embedded?: boolean; + onCancel?: () => void; }) { const [form, setForm] = useState
(() => formOf(proxy)); const [errors, setErrors] = useState>({}); @@ -277,200 +323,214 @@ function SettingsTab({ const remaining = proxy ? debugRemaining(proxy) : null; - return ( -
- - - + + patch('name', e.currentTarget.value)} + /> + +
+ + + + + patch('host', e.currentTarget.value)} /> -
- - - - + + patch('port', e.currentTarget.value)} + /> + +
+

{t('proxies.form.schemeHint')}

+ +
+ {t('proxies.form.auth')} +
+ + +
+
+ {form.auth ? ( +
+ patch('host', e.currentTarget.value)} - /> - - - patch('port', e.currentTarget.value)} + value={form.username} + onChange={(e) => patch('username', e.currentTarget.value)} /> -
-

{t('proxies.form.schemeHint')}

- -
- {t('proxies.form.auth')} -
- - -
-
- {form.auth ? ( -
- - patch('username', e.currentTarget.value)} - /> - - - patch('password', e.currentTarget.value)} - /> - -
- ) : null} - -
patch('connect_timeout_ms', e.currentTarget.value)} + id="proxy-password" + className="field" + type="password" + autoComplete="new-password" + value={form.password} + onChange={(e) => patch('password', e.currentTarget.value)} /> -
- {t('proxies.form.enabled')} -
- patch('enabled', v)} - /> - - {form.enabled ? t('common.enabled') : t('common.disabled')} - -
-

{t('proxies.form.enabledHint')}

-
+ ) : null} +
+ + patch('connect_timeout_ms', e.currentTarget.value)} + /> +
- {t('proxies.form.debug')} -
+ {t('proxies.form.enabled')} +
{ - patch('debug', v); - if (v && !proxy?.debug_enabled) patch('debugDuration', '24h'); - }} + checked={form.enabled} + label={t('proxies.form.enabled')} + onChange={(v) => patch('enabled', v)} /> - {form.debug ? ( - - ) : null} - {remaining ? {remaining} : null} + + {form.enabled ? t('common.enabled') : t('common.disabled')} +
-

{t('proxies.form.debugHint')}

+

{t('proxies.form.enabledHint')}

+
-
- {t('proxies.form.retention')} - patch('log_retention_days', days)} +
+ {t('proxies.form.debug')} +
+ { + patch('debug', v); + if (v && !proxy?.debug_enabled) patch('debugDuration', '24h'); + }} /> -

{t('proxies.form.retentionHint')}

+ {form.debug ? ( + + ) : null} + {remaining ? {remaining} : null}
+

{t('proxies.form.debugHint')}

+
+
+ {t('proxies.form.retention')} + patch('log_retention_days', days)} + /> +

{t('proxies.form.retentionHint')}

+
+ +
{proxy ? ( + ) : onCancel ? ( + ) : null}
+
+ + ); + + if (embedded) return
{fields}
; + + return ( +
+ + {fields} {proxy ? ( diff --git a/packages/frontend/src/pages/ProxyServers.tsx b/packages/frontend/src/pages/ProxyServers.tsx index 1dbca6ea..ebfdde5d 100644 --- a/packages/frontend/src/pages/ProxyServers.tsx +++ b/packages/frontend/src/pages/ProxyServers.tsx @@ -5,7 +5,8 @@ import ErrorState from '../components/ErrorState.js'; import { endpointOf, ProxyStatusBadge } from '../components/proxy/ProxyParts.js'; import ProviderDefaultsTab from '../components/proxy/ProviderDefaultsTab.js'; import ProxyProfilesTab from '../components/proxy/ProxyProfilesTab.js'; -import { Badge, ButtonLink, EmptyState, Switch, Tabs } from '../components/ui.js'; +import { Badge, Button, EmptyState, Switch, Tabs } from '../components/ui.js'; +import { NewProxyWindow } from './ProxyServer.js'; import { listProxies, proxyPath, updateProxy, type ProxyView } from '../services/api/proxies.js'; import { formatNumber } from '../services/formatters.js'; import { roleCan } from '../services/session-store.js'; @@ -71,6 +72,7 @@ export default function ProxyServers() { function ServersTab() { const res = useResource(listProxies, []); const [toggling, setToggling] = useState(null); + const [creating, setCreating] = useState(false); const list = res.data?.proxies ?? []; const global = res.data?.global ?? null; @@ -99,11 +101,20 @@ function ServersTab() {

{t('proxies.subtitle')}

- +
+ {creating ? ( + setCreating(false)} + onCreated={() => { + setCreating(false); + void res.reload(); + }} + /> + ) : null} <> {global ? ( diff --git a/packages/frontend/src/pages/providers/ProviderConnectionsPage.tsx b/packages/frontend/src/pages/providers/ProviderConnectionsPage.tsx index 319929dc..3305d7f2 100644 --- a/packages/frontend/src/pages/providers/ProviderConnectionsPage.tsx +++ b/packages/frontend/src/pages/providers/ProviderConnectionsPage.tsx @@ -837,6 +837,7 @@ export default function ProviderConnectionsPage({ key={deepLink.providerId} providerId={deepLink.providerId} authType={deepLink.authType ?? copy.authType} + onCancel={handleNewConnectionClose} render={(providerId) => ( { - return fetchMutate(orgPath(orgId, '/members'), json(body)); + return fetchMutate(orgPath(orgId, '/members'), json(body), options); } export function updateMemberRole( diff --git a/packages/frontend/src/styles/shell.css b/packages/frontend/src/styles/shell.css index 5dadebbd..b50c4562 100644 --- a/packages/frontend/src/styles/shell.css +++ b/packages/frontend/src/styles/shell.css @@ -625,12 +625,21 @@ overflow-y: auto; } .floating-window-foot { + flex: none; display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); } + .floating-window-foot:empty { + display: none; + } + /* A form's own action row, moved into the footer by WindowFooter. */ + .floating-window-foot > .actions { + flex: 1; + padding-top: 0; + } /* Variable-height sections inside a floating window scroll on their own. */ .floating-window .scroll-section { max-height: 240px; diff --git a/packages/frontend/tests/components/UserMenu.test.tsx b/packages/frontend/tests/components/UserMenu.test.tsx index 54fee68d..4da437f6 100644 --- a/packages/frontend/tests/components/UserMenu.test.tsx +++ b/packages/frontend/tests/components/UserMenu.test.tsx @@ -154,13 +154,13 @@ describe('UserMenu content by role', () => { expect(menu.textContent).not.toContain('Administration'); expect(screen.queryByRole('menuitem', { name: 'Users' })).toBeNull(); expect(screen.queryByRole('menuitem', { name: 'New organization' })).toBeNull(); - expect(menu.textContent).not.toContain('instance admin'); + expect(menu.textContent).not.toContain('superuser'); }); it('instance admin: sees Users, New organization and the instance-admin tag', () => { setProfile('owner', true); const menu = open(); - expect(menu.textContent).toContain('instance admin'); + expect(menu.textContent).toContain('superuser'); expect(screen.getByRole('menuitem', { name: 'Users' }).getAttribute('href')).toBe( '/admin/users', ); @@ -348,7 +348,7 @@ describe('UserMenu in pt-BR', () => { const menu = screen.getByRole('menu'); expect(menu.textContent).toContain('Organização'); expect(menu.textContent).toContain('Seu papel:'); - expect(menu.textContent).toContain('admin da instância'); + expect(menu.textContent).toContain('super usuário'); expect(screen.getByRole('menuitem', { name: 'Nova organização' })).toBeTruthy(); expect(screen.queryByRole('menuitem', { name: 'New organization' })).toBeNull(); }); diff --git a/packages/frontend/tests/components/ui.test.tsx b/packages/frontend/tests/components/ui.test.tsx index d3b52d19..07d3acda 100644 --- a/packages/frontend/tests/components/ui.test.tsx +++ b/packages/frontend/tests/components/ui.test.tsx @@ -11,6 +11,7 @@ import { EmptyState, Field, FloatingWindow, + WindowFooter, IconButton, Kbd, Menu, @@ -641,7 +642,35 @@ describe('FloatingWindow', () => { c , ); - expect(document.querySelector('.floating-window-foot')).toBeNull(); + // The footer slot stays empty (hidden by CSS :empty) when nothing fills it. + expect(document.querySelector('.floating-window-foot')?.childNodes.length).toBe(0); + }); + + it('moves a WindowFooter into the fixed footer, outside the scrolling body', () => { + render( + {}}> +

content

+ + + +
, + ); + const save = screen.getByRole('button', { name: 'Save' }); + expect(save.closest('.floating-window-foot')).toBeTruthy(); + expect(save.closest('.floating-window-body')).toBeNull(); + }); + + it('renders a WindowFooter in place outside a window', () => { + render( +
+ + + +
, + ); + expect(screen.getByTestId('host').contains(screen.getByRole('button', { name: 'Save' }))).toBe( + true, + ); }); it('closes with the X and with Escape; an outside click does not close it', () => { diff --git a/packages/frontend/tests/i18n/shell-auth-org.pt-BR.test.tsx b/packages/frontend/tests/i18n/shell-auth-org.pt-BR.test.tsx index 2d95c0ec..afb94ed3 100644 --- a/packages/frontend/tests/i18n/shell-auth-org.pt-BR.test.tsx +++ b/packages/frontend/tests/i18n/shell-auth-org.pt-BR.test.tsx @@ -114,25 +114,42 @@ describe('shell, auth and organization screens in pt-BR', () => { it('renders the Organization page in Portuguese', async () => { render( - + , ); await screen.findByText('v@x.io'); - expect(screen.getByText('Membros')).toBeTruthy(); + expect(screen.getByRole('tab', { name: /Membros/ })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Zona de perigo' })).toBeTruthy(); + expect(screen.queryByRole('tab', { name: 'Triggers' })).toBeNull(); expect(screen.getByText('Troca de senha pendente')).toBeTruthy(); - expect(screen.getByLabelText('Papel de v@x.io')).toBeTruthy(); - expect(screen.getByText('Zona de perigo')).toBeTruthy(); + expect(screen.getByLabelText(/Conta existente/)).toBeTruthy(); }); it('renders the Users admin page in Portuguese', async () => { render(); await screen.findByText('Acme · Proprietário'); - expect(screen.getByText('Todos os usuários')).toBeTruthy(); - expect(screen.getAllByText('Criar usuário').length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Criar usuário' })).toBeTruthy(); + // The instance-admin flag and password reset live in the edit window, not the list. + expect(screen.queryByText('Super usuário')).toBeNull(); + expect(screen.queryByText('Redefinir senha')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Editar me@x.io' })); + expect(screen.getByRole('dialog', { name: 'Me' })).toBeTruthy(); + expect(screen.getByText('Super usuário')).toBeTruthy(); expect(screen.getByText('Redefinir senha')).toBeTruthy(); }); + it('hides the role for a superuser and labels the organization as initial', async () => { + render(); + await screen.findByText('Acme · Proprietário'); + fireEvent.click(screen.getByRole('button', { name: 'Criar usuário' })); + expect(screen.getByLabelText('Papel')).toBeTruthy(); + expect(screen.getByLabelText('Organização (opcional)')).toBeTruthy(); + fireEvent.click(screen.getByRole('checkbox', { name: /Super usuário/ })); + expect(screen.queryByLabelText('Papel')).toBeNull(); + expect(screen.getByLabelText('Organização inicial (opcional)')).toBeTruthy(); + }); + it('renders the sidebar in Portuguese', () => { inRouter( {}} onNavigate={() => {}} />); expect(screen.getByRole('complementary', { name: 'Navegação' })).toBeTruthy(); @@ -153,7 +170,7 @@ describe('shell, auth and organization screens in pt-BR', () => { const menu = screen.getByRole('menu', { name: 'Menu do usuário' }); expect(menu.textContent).toContain('Organização'); expect(menu.textContent).toContain('Seu papel: Proprietário'); - expect(menu.textContent).toContain('admin da instância'); + expect(menu.textContent).toContain('super usuário'); expect(screen.getByRole('menuitem', { name: /Organização e membros/ })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: /Sair/ })).toBeTruthy(); expect(await screen.findByText('Trocar de organização')).toBeTruthy(); diff --git a/packages/frontend/tests/pages/Organization.test.tsx b/packages/frontend/tests/pages/Organization.test.tsx index fc7f523a..3bbfb1a6 100644 --- a/packages/frontend/tests/pages/Organization.test.tsx +++ b/packages/frontend/tests/pages/Organization.test.tsx @@ -13,8 +13,8 @@ vi.mock('../../src/services/api/organizations.js', () => ({ addMember: (...a: unknown[]) => mockAddMember(...a), renameOrganization: vi.fn(), deleteOrganization: vi.fn(), - updateMemberRole: vi.fn(), removeMember: vi.fn(), + listAdminUsers: vi.fn(), })); import { setMe } from '../../src/services/session-store'; @@ -47,38 +47,53 @@ describe('Organization page', () => { mockListMembers.mockResolvedValue({ members }); }); - it('lets an admin manage non-owners only, without rename or delete', async () => { - asRole('admin'); + const renderAt = (tab?: string) => render( - + , ); + + it('splits the sections into tabs without a Triggers section', async () => { + asRole('owner'); + renderAt(); + const tabs = screen.getAllByRole('tab').map((el) => el.textContent); + expect(tabs).toEqual( + expect.arrayContaining(['General', 'Log retention', 'API keys', 'Danger zone']), + ); + expect(tabs.some((label) => label?.startsWith('Members'))).toBe(true); + expect(screen.queryByText('Open triggers')).toBeNull(); + // Only the General tab shows on arrival. + expect(screen.getByLabelText('Organization name')).toBeTruthy(); + expect(screen.queryByText('Delete organization')).toBeNull(); + fireEvent.click(screen.getByRole('tab', { name: 'Danger zone' })); + expect(screen.getByText('Delete organization')).toBeTruthy(); + }); + + it('lets an admin remove non-owners only, with no role editing and no delete tab', async () => { + asRole('admin'); + renderAt('members'); await screen.findByText('owner@x.io'); - expect(screen.queryByLabelText('Role of owner@x.io')).toBeNull(); - expect(screen.getByLabelText('Role of view@x.io')).toBeTruthy(); - const options = Array.from( - (screen.getByLabelText('Role of view@x.io') as HTMLSelectElement).options, - ).map((o) => o.value); + expect(screen.queryByRole('tab', { name: 'Danger zone' })).toBeNull(); + // Roles are read-only badges; members are only added or removed. + expect(screen.queryAllByRole('combobox').map((el) => el.id)).toEqual(['org-add-role']); + expect(screen.getAllByText('Remove')).toHaveLength(2); + const options = Array.from((screen.getByLabelText('Role') as HTMLSelectElement).options).map( + (o) => o.value, + ); expect(options).not.toContain('owner'); - expect(screen.queryByText('Delete organization')).toBeNull(); - expect(screen.getByLabelText('Organization name').hasAttribute('readonly')).toBe(true); expect(screen.getByText('Pending password change')).toBeTruthy(); }); it('is read-only for a viewer', async () => { asRole('viewer'); - render( - - - , - ); + renderAt('members'); await screen.findByText('owner@x.io'); expect(screen.queryByRole('form', { name: 'Add member' })).toBeNull(); expect(screen.queryByText('Remove')).toBeNull(); }); - it('shows the temporary password once after adding a member', async () => { + it('adds an existing account by email, with no name field', async () => { asRole('owner'); mockAddMember.mockResolvedValue({ member: { @@ -86,28 +101,24 @@ describe('Organization page', () => { email: 'new@x.io', name: '', role: 'member', - mustChangePassword: true, + mustChangePassword: false, }, - temporaryPassword: 'Temp-Pass-123', + temporaryPassword: null, }); - render( - - - , - ); + renderAt('members'); await screen.findByText('owner@x.io'); - expect(screen.getByText('Delete organization')).toBeTruthy(); const form = screen.getByRole('form', { name: 'Add member' }); + expect(form.querySelectorAll('input')).toHaveLength(1); const email = form.querySelector('input[type="email"]') as HTMLInputElement; fireEvent.change(email, { target: { value: 'new@x.io' } }); fireEvent.submit(form); await waitFor(() => - expect(mockAddMember).toHaveBeenCalledWith('org-1', { email: 'new@x.io', role: 'member' }), + expect(mockAddMember).toHaveBeenCalledWith( + 'org-1', + { email: 'new@x.io', role: 'member' }, + { silent: true }, + ), ); - const box = await screen.findByTestId('temporary-password'); - expect(box.textContent).toBe('Temp-Pass-123'); - expect(box.closest('.well')?.querySelector('.copy-btn')).toBeTruthy(); - fireEvent.click(screen.getByText('I saved it')); expect(screen.queryByTestId('temporary-password')).toBeNull(); }); }); diff --git a/packages/frontend/tests/pages/ProxyServers.test.tsx b/packages/frontend/tests/pages/ProxyServers.test.tsx index c7160aa9..e5ba496c 100644 --- a/packages/frontend/tests/pages/ProxyServers.test.tsx +++ b/packages/frontend/tests/pages/ProxyServers.test.tsx @@ -5,7 +5,11 @@ import { MemoryRouter } from 'react-router'; const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() })); vi.mock('../../src/services/toast-store.js', () => ({ toast: toastMock })); -const api = vi.hoisted(() => ({ listProxies: vi.fn(), updateProxy: vi.fn() })); +const api = vi.hoisted(() => ({ + listProxies: vi.fn(), + updateProxy: vi.fn(), + createProxy: vi.fn(), +})); vi.mock('../../src/services/api/proxies.js', async (orig) => ({ ...(await orig()), ...api, @@ -18,6 +22,9 @@ vi.mock('../../src/components/proxy/ProxyProfilesTab', () => ({ vi.mock('../../src/components/proxy/ProviderDefaultsTab', () => ({ default: () =>
, })); +// The new-proxy window lives in the detail page module; its chart tabs aren't needed here. +vi.mock('../../src/components/proxy/ProxyLogsTab', () => ({ default: () => null })); +vi.mock('../../src/components/proxy/ProxyStatsTab', () => ({ default: () => null })); import ProxyServers from '../../src/pages/ProxyServers'; import { setMe } from '../../src/services/session-store'; @@ -46,9 +53,24 @@ const alpha = proxyView({ host: 'proxy-a.example.com', binding_count: 2, debug_enabled: true, - stats_7d: { connections: 1500, successes: 1490, failures: 10, avg_tunnel_ms: 41.6, bytes_sent: 0, bytes_received: 0 }, + stats_7d: { + connections: 1500, + successes: 1490, + failures: 10, + avg_tunnel_ms: 41.6, + bytes_sent: 0, + bytes_received: 0, + }, +}); +const beta = proxyView({ + id: 'p2', + name: 'Beta', + host: '::1', + port: 8080, + scheme: 'https', + status: 'failing', + enabled: false, }); -const beta = proxyView({ id: 'p2', name: 'Beta', host: '::1', port: 8080, scheme: 'https', status: 'failing', enabled: false }); describe('ProxyServers page', () => { beforeEach(() => { @@ -64,7 +86,9 @@ describe('ProxyServers page', () => { it('is admin-only: a member sees the notice and nothing is fetched', () => { asRole('member'); renderPage(); - expect(screen.getByText('Only admins and owners can see and manage proxy servers.')).toBeTruthy(); + expect( + screen.getByText('Only admins and owners can see and manage proxy servers.'), + ).toBeTruthy(); expect(screen.queryByRole('tablist')).toBeNull(); expect(screen.queryByText('Add proxy server')).toBeNull(); expect(api.listProxies).not.toHaveBeenCalled(); @@ -76,21 +100,53 @@ describe('ProxyServers page', () => { const link = await screen.findByRole('link', { name: 'Alpha' }); expect(link.getAttribute('href')).toBe('/proxy-servers/p1'); const row = link.closest('tr')!; - const cells = within(row).getAllByRole('cell').map((c) => c.textContent); - expect(cells.slice(0, 7)).toEqual(['AlphaDebug', 'http://proxy-a.example.com:3128', 'Healthy', '1.5k', '10', '42 ms', '2']); - expect(within(row).getByRole('switch', { name: 'Enable Alpha' }).getAttribute('aria-checked')).toBe('true'); + const cells = within(row) + .getAllByRole('cell') + .map((c) => c.textContent); + expect(cells.slice(0, 7)).toEqual([ + 'AlphaDebug', + 'http://proxy-a.example.com:3128', + 'Healthy', + '1.5k', + '10', + '42 ms', + '2', + ]); + expect( + within(row).getByRole('switch', { name: 'Enable Alpha' }).getAttribute('aria-checked'), + ).toBe('true'); const second = screen.getByRole('link', { name: 'Beta' }).closest('tr')!; - const c2 = within(second).getAllByRole('cell').map((c) => c.textContent); + const c2 = within(second) + .getAllByRole('cell') + .map((c) => c.textContent); // No stats yet: zeros and a dash, IPv6 host bracketed. expect(c2.slice(0, 7)).toEqual(['Beta', 'https://[::1]:8080', 'Failing', '0', '0', '—', '0']); - expect(within(second).getByRole('switch', { name: 'Enable Beta' }).getAttribute('aria-checked')).toBe('false'); + expect( + within(second).getByRole('switch', { name: 'Enable Beta' }).getAttribute('aria-checked'), + ).toBe('false'); }); - it('has an Add proxy server link to the creation page', async () => { + it('adds a proxy server in a floating window without leaving the list', async () => { + api.createProxy.mockResolvedValue({ ...alpha, id: 'p-new', name: 'Gamma' }); renderPage(); await screen.findByText('Alpha'); - expect(screen.getByRole('link', { name: 'Add proxy server' }).getAttribute('href')).toBe('/proxy-servers/new'); + fireEvent.click(screen.getByRole('button', { name: 'Add proxy server' })); + const dialog = screen.getByRole('dialog', { name: t('proxies.form.createTitle') }); + expect(dialog.getAttribute('aria-modal')).toBe('false'); + // The list stays on screen behind the window. + expect(screen.getByText('Alpha')).toBeTruthy(); + fireEvent.change(within(dialog).getByLabelText(t('proxies.form.name')), { + target: { value: 'Gamma' }, + }); + fireEvent.change(within(dialog).getByLabelText(t('proxies.form.host')), { + target: { value: 'gamma.local' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: t('proxies.form.createSubmit') })); + await waitFor(() => expect(api.createProxy).toHaveBeenCalledTimes(1)); + expect(api.createProxy.mock.calls[0][0]).toMatchObject({ name: 'Gamma', host: 'gamma.local' }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(api.listProxies.mock.calls.length).toBeGreaterThan(1); }); it('shows the global proxy from the environment and its bypass list', async () => { @@ -126,7 +182,9 @@ describe('ProxyServers page', () => { await waitFor(() => expect(api.updateProxy).toHaveBeenCalledWith('p1', { enabled: false })); expect(toastMock.success).toHaveBeenCalledWith('Proxy server disabled'); await waitFor(() => - expect(screen.getByRole('switch', { name: 'Enable Alpha' }).getAttribute('aria-checked')).toBe('false'), + expect( + screen.getByRole('switch', { name: 'Enable Alpha' }).getAttribute('aria-checked'), + ).toBe('false'), ); // The other row is untouched. expect(api.updateProxy).toHaveBeenCalledTimes(1); @@ -151,21 +209,33 @@ describe('ProxyServers page', () => { it('switches between the three tabs by URL and marks the active one', async () => { const { unmount } = renderPage('/proxy-servers?tab=profiles'); expect(await screen.findByTestId('profiles-tab')).toBeTruthy(); - expect(screen.getByRole('tab', { name: 'Outbound profiles' }).getAttribute('aria-selected')).toBe('true'); - expect(screen.getByRole('tab', { name: 'Outbound profiles' }).getAttribute('href')).toBe('/proxy-servers?tab=profiles'); - expect(screen.getByRole('tab', { name: 'Provider defaults' }).getAttribute('href')).toBe('/proxy-servers?tab=providers'); - expect(screen.getByRole('tab', { name: 'Proxy servers' }).getAttribute('href')).toBe('/proxy-servers'); + expect( + screen.getByRole('tab', { name: 'Outbound profiles' }).getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getByRole('tab', { name: 'Outbound profiles' }).getAttribute('href')).toBe( + '/proxy-servers?tab=profiles', + ); + expect(screen.getByRole('tab', { name: 'Provider defaults' }).getAttribute('href')).toBe( + '/proxy-servers?tab=providers', + ); + expect(screen.getByRole('tab', { name: 'Proxy servers' }).getAttribute('href')).toBe( + '/proxy-servers', + ); expect(api.listProxies).not.toHaveBeenCalled(); unmount(); const providers = renderPage('/proxy-servers?tab=providers'); expect(await screen.findByTestId('providers-tab')).toBeTruthy(); - expect(screen.getByRole('tab', { name: 'Provider defaults' }).getAttribute('aria-selected')).toBe('true'); + expect( + screen.getByRole('tab', { name: 'Provider defaults' }).getAttribute('aria-selected'), + ).toBe('true'); providers.unmount(); renderPage('/proxy-servers?tab=nonsense'); expect(await screen.findByText('Alpha')).toBeTruthy(); - expect(screen.getByRole('tab', { name: 'Proxy servers' }).getAttribute('aria-selected')).toBe('true'); + expect(screen.getByRole('tab', { name: 'Proxy servers' }).getAttribute('aria-selected')).toBe( + 'true', + ); }); it('renders in pt-BR', async () => { @@ -174,8 +244,10 @@ describe('ProxyServers page', () => { await screen.findByText('Alpha'); expect(screen.getByRole('tab', { name: t('proxies.tabs.profiles') })).toBeTruthy(); expect(t('proxies.tabs.profiles')).not.toBe('Outbound profiles'); - expect(screen.getByRole('link', { name: t('proxies.create') })).toBeTruthy(); - expect(screen.getByRole('switch', { name: t('proxies.list.toggle', { name: 'Alpha' }) })).toBeTruthy(); + expect(screen.getByRole('button', { name: t('proxies.create') })).toBeTruthy(); + expect( + screen.getByRole('switch', { name: t('proxies.list.toggle', { name: 'Alpha' }) }), + ).toBeTruthy(); expect(screen.getByText(t('proxies.status.ok'))).toBeTruthy(); expect(screen.queryByText('Healthy')).toBeNull(); }); From e5ed03307679e34bde2c739fc1d5f61bff2384ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Brand=C3=A3o?= Date: Wed, 11 Nov 2026 01:49:50 -0300 Subject: [PATCH 2/2] chore(release): version 0.34.0 --- Dockerfile.heroku | 2 +- deploy/aws/TUTORIAL.md | 2 +- deploy/aws/deploy.sh | 2 +- deploy/aws/thinker.yaml | 2 +- deploy/coolify/TUTORIAL.md | 2 +- deploy/coolify/docker-compose.yml | 2 +- deploy/easypanel/TUTORIAL.md | 6 +++--- deploy/easypanel/template/README.md | 2 +- deploy/easypanel/template/meta.yaml | 2 +- deploy/fly/TUTORIAL.md | 4 ++-- deploy/fly/fly.toml | 2 +- deploy/gcp/deploystack.json | 2 +- deploy/gcp/variables.tf | 2 +- deploy/koyeb/TUTORIAL.md | 4 ++-- docs/releases/0.34.0.md | 9 +++++++++ package-lock.json | 2 +- packages/thinker/package.json | 2 +- 17 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 docs/releases/0.34.0.md diff --git a/Dockerfile.heroku b/Dockerfile.heroku index 917a4852..ecf9195f 100644 --- a/Dockerfile.heroku +++ b/Dockerfile.heroku @@ -1,3 +1,3 @@ # Community-maintained template, not tested in CI. # Thinker: https://github.com/patrickbrandao/thinker -FROM docker.io/tmsoftbrasil/thinker:0.33 +FROM docker.io/tmsoftbrasil/thinker:0.34 diff --git a/deploy/aws/TUTORIAL.md b/deploy/aws/TUTORIAL.md index c37e86d8..951939f3 100644 --- a/deploy/aws/TUTORIAL.md +++ b/deploy/aws/TUTORIAL.md @@ -39,7 +39,7 @@ The deploy script exposes the common settings as environment variables: STACK_NAME=thinker \ SERVICE_NAME=thinker \ AWS_REGION=us-east-1 \ -IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.33 \ +IMAGE_URL=docker.io/tmsoftbrasil/thinker:0.34 \ DATABASE_INSTANCE_CLASS=db.t4g.micro \ DESIRED_COUNT=1 \ ./deploy/aws/deploy.sh diff --git a/deploy/aws/deploy.sh b/deploy/aws/deploy.sh index 41b887ed..406f864b 100755 --- a/deploy/aws/deploy.sh +++ b/deploy/aws/deploy.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STACK_NAME="${STACK_NAME:-thinker}" SERVICE_NAME="${SERVICE_NAME:-thinker}" -IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.33}" +IMAGE_URL="${IMAGE_URL:-docker.io/tmsoftbrasil/thinker:0.34}" DESIRED_COUNT="${DESIRED_COUNT:-1}" DATABASE_INSTANCE_CLASS="${DATABASE_INSTANCE_CLASS:-db.t4g.micro}" DATABASE_DELETION_PROTECTION="${DATABASE_DELETION_PROTECTION:-false}" diff --git a/deploy/aws/thinker.yaml b/deploy/aws/thinker.yaml index 73663471..9ce4567e 100644 --- a/deploy/aws/thinker.yaml +++ b/deploy/aws/thinker.yaml @@ -14,7 +14,7 @@ Parameters: ConstraintDescription: Use up to 40 lowercase letters, numbers, and hyphens, starting with a letter. ImageUrl: Type: String - Default: docker.io/tmsoftbrasil/thinker:0.33 + Default: docker.io/tmsoftbrasil/thinker:0.34 Description: Thinker container image to deploy. ContainerPort: Type: Number diff --git a/deploy/coolify/TUTORIAL.md b/deploy/coolify/TUTORIAL.md index 9fd22654..9586017e 100644 --- a/deploy/coolify/TUTORIAL.md +++ b/deploy/coolify/TUTORIAL.md @@ -38,7 +38,7 @@ curl -sSf https:///api/v1/health ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.33`. +- Thinker Docker image `tmsoftbrasil/thinker:0.34`. - PostgreSQL 18 container. - Persistent Docker volume for PostgreSQL data. - Persistent Docker volume for compressed request recordings at `/data/request-recordings`. diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml index 348e7bfa..e8d2d99c 100644 --- a/deploy/coolify/docker-compose.yml +++ b/deploy/coolify/docker-compose.yml @@ -2,7 +2,7 @@ # Thinker: https://github.com/patrickbrandao/thinker services: thinker: - image: tmsoftbrasil/thinker:0.33 + image: tmsoftbrasil/thinker:0.34 restart: unless-stopped stop_grace_period: 30s environment: diff --git a/deploy/easypanel/TUTORIAL.md b/deploy/easypanel/TUTORIAL.md index 950bdc6b..d863f9fb 100644 --- a/deploy/easypanel/TUTORIAL.md +++ b/deploy/easypanel/TUTORIAL.md @@ -21,7 +21,7 @@ If Thinker is available in your Easypanel template catalog: 1. Open your Easypanel project. 2. Choose **Templates**. 3. Search for **Thinker**. -4. Keep the default image `tmsoftbrasil/thinker:0.33` unless you need to pin another version. +4. Keep the default image `tmsoftbrasil/thinker:0.34` unless you need to pin another version. 5. Deploy the template. The template provisions Thinker, PostgreSQL, a persistent request-recording volume, generated secrets, and a domain proxy on port `2099`. @@ -43,7 +43,7 @@ Create the Thinker app service: 1. Click **Create Service**. 2. Choose **App**. 3. Set source type to **Docker image**. -4. Use image `tmsoftbrasil/thinker:0.33`. +4. Use image `tmsoftbrasil/thinker:0.34`. 5. Set the proxy port to `2099`. 6. Add your domain and mark it as the primary domain. @@ -97,7 +97,7 @@ Before publishing a template, smoke test the full first-run flow: ## What Gets Provisioned -- Thinker Docker image `tmsoftbrasil/thinker:0.33`. +- Thinker Docker image `tmsoftbrasil/thinker:0.34`. - PostgreSQL service. - Generated or manually configured session and encryption secrets. - HTTPS domain proxy to Thinker port `2099`. diff --git a/deploy/easypanel/template/README.md b/deploy/easypanel/template/README.md index 232fcf24..290541fc 100644 --- a/deploy/easypanel/template/README.md +++ b/deploy/easypanel/template/README.md @@ -15,7 +15,7 @@ templates/thinker/meta.yaml The template provisions: -- Thinker app service from `tmsoftbrasil/thinker:0.33`. +- Thinker app service from `tmsoftbrasil/thinker:0.34`. - PostgreSQL 18 service. - Persistent app volume for compressed request recordings. - Generated database password, Better Auth secret, and Thinker encryption key. diff --git a/deploy/easypanel/template/meta.yaml b/deploy/easypanel/template/meta.yaml index afa2755b..1a46ccd9 100644 --- a/deploy/easypanel/template/meta.yaml +++ b/deploy/easypanel/template/meta.yaml @@ -37,7 +37,7 @@ schema: appServiceImage: type: string title: App Service Image - default: tmsoftbrasil/thinker:0.33 + default: tmsoftbrasil/thinker:0.34 databaseServiceName: type: string title: Database Service Name diff --git a/deploy/fly/TUTORIAL.md b/deploy/fly/TUTORIAL.md index 60b65e17..075ca64c 100644 --- a/deploy/fly/TUTORIAL.md +++ b/deploy/fly/TUTORIAL.md @@ -34,7 +34,7 @@ Environment variables: - `FLY_ORG`: Fly organization. Defaults to `personal`. - `FLY_RECORDING_BUCKET_NAME`: private Tigris bucket name. Defaults to `-recordings`. Keep the same value on reruns; changing an attached bucket requires a manual recording migration. -The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.33`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. +The script uses [`fly.toml`](fly.toml) as a template and deploys `docker.io/tmsoftbrasil/thinker:0.34`. It creates a private Tigris bucket and lets Fly stage the bucket's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` secrets on the app; Thinker uses those credentials with the bucket name, Tigris endpoint, and `auto` region from `fly.toml`. Re-running the script keeps that bucket and the existing `BETTER_AUTH_SECRET` and `THINKER_ENCRYPTION_KEY` values; rotate those secrets manually only if you intend to invalidate sessions and encrypted provider credentials. ## Manual Deploy @@ -112,7 +112,7 @@ fly logs --app ## What Gets Provisioned -- Fly app running `docker.io/tmsoftbrasil/thinker:0.33`. +- Fly app running `docker.io/tmsoftbrasil/thinker:0.34`. - Fly Postgres app. - Private Tigris S3-compatible bucket for compressed request recordings. - `DATABASE_URL` secret from `fly postgres attach`. diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml index 066a651e..da57436d 100644 --- a/deploy/fly/fly.toml +++ b/deploy/fly/fly.toml @@ -12,7 +12,7 @@ kill_signal = "SIGINT" kill_timeout = "30s" [build] - image = "docker.io/tmsoftbrasil/thinker:0.33" + image = "docker.io/tmsoftbrasil/thinker:0.34" [env] PORT = "2099" diff --git a/deploy/gcp/deploystack.json b/deploy/gcp/deploystack.json index b6c7d5ee..e82bb44a 100644 --- a/deploy/gcp/deploystack.json +++ b/deploy/gcp/deploystack.json @@ -26,7 +26,7 @@ "name": "image_url", "prompt": "Thinker image", "type": "string", - "default": "docker.io/tmsoftbrasil/thinker:0.33" + "default": "docker.io/tmsoftbrasil/thinker:0.34" }, { "name": "database_tier", diff --git a/deploy/gcp/variables.tf b/deploy/gcp/variables.tf index 97c2ddf1..c66b5657 100644 --- a/deploy/gcp/variables.tf +++ b/deploy/gcp/variables.tf @@ -23,7 +23,7 @@ variable "service_name" { variable "image_url" { description = "Thinker container image to deploy." type = string - default = "docker.io/tmsoftbrasil/thinker:0.33" + default = "docker.io/tmsoftbrasil/thinker:0.34" } variable "database_tier" { diff --git a/deploy/koyeb/TUTORIAL.md b/deploy/koyeb/TUTORIAL.md index 08b49730..2b312bed 100644 --- a/deploy/koyeb/TUTORIAL.md +++ b/deploy/koyeb/TUTORIAL.md @@ -33,7 +33,7 @@ openssl rand -hex 32 Open the Koyeb deploy link: ```text -https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.33&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false +https://app.koyeb.com/deploy?type=docker&image=docker.io%2Ftmsoftbrasil%2Fthinker%3A0.34&name=thinker&service_type=web&ports=2099%3Bhttp%3B%2F&env%5BPORT%5D=2099&env%5BDATABASE_URL%5D=postgres%3A%2F%2FUSER%3APASSWORD%40HOST%2FDB%3Fsslmode%3Drequire&env%5BBETTER_AUTH_SECRET%5D=replace-with-openssl-rand-hex-32&env%5BTHINKER_ENCRYPTION_KEY%5D=replace-with-different-openssl-rand-hex-32&env%5BBETTER_AUTH_URL%5D=https%3A%2F%2F%7B%7B+KOYEB_PUBLIC_DOMAIN+%7D%7D&env%5BBIND_ADDRESS%5D=0.0.0.0&env%5BDB_POOL_MAX%5D=8&env%5BAUTH_DB_POOL_MAX%5D=4&env%5BTHINKER_RECORDING_STORAGE%5D=s3&env%5BTHINKER_RECORDING_S3_BUCKET%5D=replace-with-private-bucket&env%5BTHINKER_RECORDING_S3_ENDPOINT%5D=https%3A%2F%2Fs3.example.com&env%5BTHINKER_RECORDING_S3_REGION%5D=us-east-1&env%5BTHINKER_RECORDING_S3_ACCESS_KEY_ID%5D=replace-with-limited-access-key&env%5BTHINKER_RECORDING_S3_SECRET_ACCESS_KEY%5D=replace-with-secret-key&env%5BTHINKER_RECORDING_S3_FORCE_PATH_STYLE%5D=false ``` In the deploy form: @@ -45,7 +45,7 @@ In the deploy form: - Leave `BETTER_AUTH_URL` as `https://{{ KOYEB_PUBLIC_DOMAIN }}`. - Leave `PORT`, `BIND_ADDRESS`, `DB_POOL_MAX`, `AUTH_DB_POOL_MAX`, and `THINKER_RECORDING_STORAGE=s3` unchanged for a single-instance deploy. Set `THINKER_RECORDING_S3_FORCE_PATH_STYLE=true` only if your object-storage provider requires path-style URLs. -The button deploys `docker.io/tmsoftbrasil/thinker:0.33` and exposes port `2099` over HTTP. +The button deploys `docker.io/tmsoftbrasil/thinker:0.34` and exposes port `2099` over HTTP. ## Open Thinker diff --git a/docs/releases/0.34.0.md b/docs/releases/0.34.0.md new file mode 100644 index 00000000..6a7f250d --- /dev/null +++ b/docs/releases/0.34.0.md @@ -0,0 +1,9 @@ +# Thinker 0.34.0 + +```bash +docker pull tmsoftbrasil/thinker:0.34.0 +``` + +## Mudanças + +- feat(dashboard): add windows with fixed title and footer, Users toolbar and editor window, Superuser rename, Organization tabs with members limited to existing accounts, new proxy in a floating window diff --git a/package-lock.json b/package-lock.json index ed39c940..c3e7d957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14768,7 +14768,7 @@ } }, "packages/thinker": { - "version": "0.33.0" + "version": "0.34.0" } } } diff --git a/packages/thinker/package.json b/packages/thinker/package.json index e6681553..eec6a1c8 100644 --- a/packages/thinker/package.json +++ b/packages/thinker/package.json @@ -1,6 +1,6 @@ { "name": "thinker", - "version": "0.33.0", + "version": "0.34.0", "private": true, "description": "Canonical release version for the Thinker Docker image. This package holds no code — the real backend and frontend live in ../backend and ../frontend. See README.md for details." }