feat(den): rename organization from Members page (#1482)

* feat(den-api): add PATCH /v1/orgs/:orgId to rename organization

- Owners can update the display name (2-120 chars) via a new PATCH route.
- Slug stays immutable to keep dashboard URLs stable.
- Adds updateOrganizationName helper in orgs.ts.

* feat(den-web): rename organization from Members page

- Adds an 'Organization' settings card above the Members tabs with inline
  rename for workspace owners.
- Non-owners see a read-only summary explaining who can rename.
- Wires a new updateOrganizationName mutation through OrgDashboardProvider
  that calls PATCH /v1/orgs/:orgId.

* fix(den-api): tolerate missing apps/desktop/package.json in Docker build

PR #1476 introduced a build step that reads apps/desktop/package.json to
bake in a default latest-app-version, but packaging/docker/Dockerfile.den
does not ship the Tauri desktop sources. As a result, the den-dev Docker
stack fails to build after the PR landed. Gracefully fall back to 0.0.0
(matching the runtime default) when the file is absent, and allow a
DEN_API_LATEST_APP_VERSION env override so deployers can still pin a
real value.

* docs(den-web): add rename organization screenshots

Captured via Chrome MCP against packaging/docker/den-dev-up.sh:
- before: Members page shows Organization card with Rename button.
- editing: inline form with the current name pre-filled.
- after: new name shown with success state, sidebar also updated.

* refactor(den-web): extract DenCard primitive and drop drop shadows

The dashboard has been inlining the same card style (rounded-[30px] + p-6
+ shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]) on every form surface.
Per Den visual language we do not use drop shadows, so the repeated
shadow class is both incorrect and a copy-paste risk.

- Add ee/apps/den-web/app/(den)/_components/ui/card.tsx exporting DenCard
  with two size presets (comfortable + spacious). Shadows are explicitly
  omitted and the component is designed so they cannot be re-introduced
  via className (no tokens ever emit shadow utilities).
- Replace all five inline cards in manage-members-screen.tsx with DenCard.
- Replace the reused sibling card in api-keys-screen.tsx with DenCard.
- Re-capture members-rename screenshots against the refactored UI.
This commit is contained in:
ben
2026-04-17 16:44:39 -07:00
committed by GitHub
parent 2c4bd553cb
commit 0655ee5c76
10 changed files with 282 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process"
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
@@ -9,8 +9,19 @@ const repoRoot = path.resolve(serviceDir, "..", "..", "..")
const desktopPackagePath = path.join(repoRoot, "apps", "desktop", "package.json")
const generatedVersionPath = path.join(serviceDir, "src", "generated", "app-version.ts")
const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"
const fallbackAppVersion = "0.0.0"
function readDesktopVersion() {
if (!existsSync(desktopPackagePath)) {
// The Den API is built inside contexts (e.g. the Docker image used by
// `packaging/docker/den-dev-up.sh`) that intentionally do not ship the
// Tauri desktop sources. Falling back lets the container image build
// without copying unrelated packages; consumers that need the real
// version can override via DEN_API_LATEST_APP_VERSION.
console.warn(`Desktop package.json not found at ${desktopPackagePath}; using fallback version ${fallbackAppVersion}`)
return fallbackAppVersion
}
const packageJson = JSON.parse(readFileSync(desktopPackagePath, "utf8"))
const version = packageJson.version?.trim()
@@ -41,7 +52,7 @@ function run(command, args) {
}
}
process.env.DEN_API_LATEST_APP_VERSION = readDesktopVersion()
process.env.DEN_API_LATEST_APP_VERSION = process.env.DEN_API_LATEST_APP_VERSION || readDesktopVersion()
writeGeneratedVersionFile(process.env.DEN_API_LATEST_APP_VERSION)
run(pnpmCommand, ["run", "build:den-db"])

View File

@@ -490,6 +490,29 @@ export async function createOrganizationForUser(input: {
})
}
export async function updateOrganizationName(input: {
organizationId: OrgId
name: string
}) {
const trimmed = input.name.trim()
if (!trimmed) {
return null
}
await db
.update(OrganizationTable)
.set({ name: trimmed })
.where(eq(OrganizationTable.id, input.organizationId))
const rows = await db
.select()
.from(OrganizationTable)
.where(eq(OrganizationTable.id, input.organizationId))
.limit(1)
return rows[0] ?? null
}
export async function seedDefaultOrganizationRoles(orgId: OrgId) {
await ensureDefaultDynamicRoles(orgId)
}

View File

@@ -9,15 +9,19 @@ import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization } from "../../orgs.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization, updateOrganizationName } from "../../orgs.js"
import { getRequiredUserEmail } from "../../user.js"
import type { OrgRouteVariables } from "./shared.js"
import { orgIdParamSchema } from "./shared.js"
import { ensureOwner, orgIdParamSchema } from "./shared.js"
const createOrganizationSchema = z.object({
name: z.string().trim().min(2).max(120),
})
const updateOrganizationSchema = z.object({
name: z.string().trim().min(2).max(120),
})
const invitationPreviewQuerySchema = z.object({
id: denTypeIdSchema("invitation"),
})
@@ -230,6 +234,46 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
},
)
app.patch(
"/v1/orgs/:orgId",
describeRoute({
tags: ["Organizations"],
summary: "Update organization",
description: "Updates organization fields that workspace owners are allowed to change. Currently limited to the display name; the slug is immutable to avoid breaking dashboard URLs.",
responses: {
200: jsonResponse("Organization updated successfully.", organizationResponseSchema),
400: jsonResponse("The organization update request body was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to update an organization.", unauthorizedSchema),
403: jsonResponse("Only workspace owners can update the organization.", forbiddenSchema),
404: jsonResponse("The organization could not be found.", notFoundSchema),
},
}),
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
jsonValidator(updateOrganizationSchema),
async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const updated = await updateOrganizationName({
organizationId: payload.organization.id,
name: input.name,
})
if (!updated) {
return c.json({ error: "organization_not_found" }, 404)
}
return c.json({ organization: updated })
},
)
app.get(
"/v1/orgs/:orgId/context",
describeRoute({

View File

@@ -0,0 +1,59 @@
"use client";
import { forwardRef, type HTMLAttributes } from "react";
/**
* Visual density presets for cards.
*
* Den surfaces never use drop shadows — flat borders only. If you need to
* emphasise a card, use a darker border, a coloured background, or the
* gradient hero (see `DashboardPageTemplate`). Do NOT reintroduce shadows.
*/
export type DenCardSize = "comfortable" | "spacious";
const sizeClasses: Record<DenCardSize, string> = {
// Matches the original Members / API Keys inline card.
comfortable: "rounded-[30px] p-6",
// Matches the editor-screen section card (LLM providers, skill hubs).
spacious: "rounded-[36px] p-8",
};
export type DenCardProps = HTMLAttributes<HTMLDivElement> & {
size?: DenCardSize;
};
/**
* DenCard
*
* Reusable surface container for the Den dashboard.
*
* Intentional constraints:
* - No drop shadows. Ever.
* - No hover transforms. Cards are surfaces, not buttons.
* - Single, borderable background (`border-gray-200 bg-white`).
*
* Pick a size that matches the surrounding flow:
* - `comfortable` (default): forms, summary cards, inline editors.
* - `spacious`: detail/editor pages with multiple stacked sections.
*
* Anything more opinionated (headings, toolbars, descriptions) belongs in a
* higher-level composition, not in this primitive.
*/
export const DenCard = forwardRef<HTMLDivElement, DenCardProps>(function DenCard(
{ size = "comfortable", className, ...rest },
ref,
) {
return (
<div
ref={ref}
{...rest}
className={[
"border border-gray-200 bg-white",
sizeClasses[size],
className ?? "",
]
.filter(Boolean)
.join(" ")}
/>
);
});

View File

@@ -4,6 +4,7 @@ import { type FormEvent, useEffect, useMemo, useState } from "react";
import { Copy, KeyRound, Trash2 } from "lucide-react";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { DenButton } from "../../../../_components/ui/button";
import { DenCard } from "../../../../_components/ui/card";
import { DenInput } from "../../../../_components/ui/input";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import {
@@ -280,7 +281,7 @@ export function ApiKeysScreen() {
</div>
) : null}
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
<DenCard className="mb-6">
{createdKey ? (
<div className="rounded-[24px] bg-[#0f172a] p-6 text-white">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -364,12 +365,12 @@ export function ApiKeysScreen() {
Create a new API key
</p>
</div>
<DenButton onClick={openCreateForm}>
<DenButton onClick={openCreateForm}>
New key
</DenButton>
</div>
)}
</div>
</DenCard>
<div className="overflow-hidden rounded-[28px] border border-gray-100 bg-white">
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_180px_120px] gap-4 border-b border-gray-100 px-6 py-3 text-[11px] font-medium uppercase tracking-wide text-gray-400">

View File

@@ -28,6 +28,7 @@ import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import { UnderlineTabs } from "../../../../_components/ui/tabs";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { DenButton } from "../../../../_components/ui/button";
import { DenCard } from "../../../../_components/ui/card";
import { DenInput } from "../../../../_components/ui/input";
import { DenSelect } from "../../../../_components/ui/select";
@@ -131,6 +132,7 @@ export function ManageMembersScreen() {
createRole,
updateRole,
deleteRole,
updateOrganizationName,
} = useOrgDashboard();
const [activeTab, setActiveTab] = useState<MembersTab>("members");
const [pageError, setPageError] = useState<string | null>(null);
@@ -150,6 +152,9 @@ export function ManageMembersScreen() {
Record<string, string[]>
>({});
const [limitDialogError, setLimitDialogError] = useState<OrgLimitError | null>(null);
const [isRenamingOrg, setIsRenamingOrg] = useState(false);
const [orgNameDraft, setOrgNameDraft] = useState("");
const [orgRenameSuccess, setOrgRenameSuccess] = useState<string | null>(null);
const assignableRoles = useMemo(
() => (orgContext?.roles ?? []).filter((role) => !role.protected),
@@ -271,7 +276,7 @@ export function ManageMembersScreen() {
const inviteForm =
showInviteForm && access.canInviteMembers ? (
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
<DenCard className="mb-6">
<form
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_220px_auto] lg:items-end"
onSubmit={async (event) => {
@@ -322,12 +327,12 @@ export function ManageMembersScreen() {
</DenButton>
</div>
</form>
</div>
</DenCard>
) : null;
const editMemberForm =
editingMemberId && access.canManageMembers ? (
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
<DenCard className="mb-6">
<form
className="grid gap-4 lg:grid-cols-[240px_auto] lg:items-end"
onSubmit={async (event) => {
@@ -362,12 +367,12 @@ export function ManageMembersScreen() {
</DenButton>
</div>
</form>
</div>
</DenCard>
) : null;
const teamForm =
(showTeamForm || editingTeamId) && access.canManageTeams ? (
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
<DenCard className="mb-6">
<form
className="grid gap-6"
onSubmit={async (event) => {
@@ -467,12 +472,12 @@ export function ManageMembersScreen() {
</DenButton>
</div>
</form>
</div>
</DenCard>
) : null;
const roleForm =
(showRoleForm || editingRoleId) && access.canManageRoles ? (
<div className="mb-6 rounded-[30px] border border-gray-200 bg-white p-6 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.22)]">
<DenCard className="mb-6">
<form
className="grid gap-6"
onSubmit={async (event) => {
@@ -565,7 +570,7 @@ export function ManageMembersScreen() {
</DenButton>
</div>
</form>
</div>
</DenCard>
) : null;
const toolbarAction = (() => {
@@ -636,6 +641,106 @@ export function ManageMembersScreen() {
</div>
) : null}
<DenCard className="mb-6" data-testid="org-settings-card">
{isRenamingOrg && access.isOwner ? (
<form
className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end"
onSubmit={async (event) => {
event.preventDefault();
setPageError(null);
setOrgRenameSuccess(null);
try {
await updateOrganizationName(orgNameDraft);
setIsRenamingOrg(false);
setOrgRenameSuccess("Organization renamed.");
} catch (error) {
setPageError(
error instanceof Error
? error.message
: "Could not rename organization.",
);
}
}}
>
<label className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">
Organization name
</span>
<DenInput
type="text"
value={orgNameDraft}
onChange={(event) => setOrgNameDraft(event.target.value)}
placeholder={activeOrg.name}
minLength={2}
maxLength={120}
required
autoFocus
data-testid="org-name-input"
/>
</label>
<div className="flex gap-2 lg:justify-end">
<ActionButton
size="md"
onClick={() => {
setIsRenamingOrg(false);
setOrgNameDraft(activeOrg.name);
setPageError(null);
}}
>
Cancel
</ActionButton>
<DenButton
type="submit"
loading={mutationBusy === "update-organization-name"}
data-testid="org-rename-save"
>
Save name
</DenButton>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Organization
</p>
<p
className="mt-2 text-[22px] font-semibold tracking-[-0.04em] text-gray-900"
data-testid="org-name-display"
>
{activeOrg.name}
</p>
{orgRenameSuccess ? (
<p className="mt-2 text-[13px] text-emerald-600" data-testid="org-rename-success">
{orgRenameSuccess}
</p>
) : (
<p className="mt-2 text-[13px] text-gray-500">
{access.isOwner
? "Only workspace owners can rename the organization."
: "Contact a workspace owner to change the organization name."}
</p>
)}
</div>
{access.isOwner ? (
<DenButton
icon={Pencil}
variant="secondary"
onClick={() => {
setOrgNameDraft(activeOrg.name);
setOrgRenameSuccess(null);
setPageError(null);
setIsRenamingOrg(true);
}}
data-testid="org-rename-open"
>
Rename
</DenButton>
) : null}
</div>
)}
</DenCard>
<UnderlineTabs
className="mb-6"
activeTab={activeTab}

View File

@@ -29,6 +29,7 @@ type OrgDashboardContextValue = {
mutationBusy: string | null;
refreshOrgData: () => Promise<void>;
createOrganization: (name: string) => Promise<void>;
updateOrganizationName: (name: string) => Promise<void>;
switchOrganization: (slug: string) => void;
inviteMember: (input: { email: string; role: string }) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
@@ -185,6 +186,28 @@ export function OrgDashboardProvider({
router.push(getOrgDashboardRoute(nextSlug));
}
async function updateOrganizationName(name: string) {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Enter an organization name.");
}
await runMutation("update-organization-name", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}`,
{
method: "PATCH",
body: JSON.stringify({ name: trimmed }),
},
12000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to update organization (${response.status}).`));
}
});
}
async function inviteMember(input: { email: string; role: string }) {
await runMutation("invite-member", async () => {
const { response, payload } = await requestJson(
@@ -372,6 +395,7 @@ export function OrgDashboardProvider({
mutationBusy,
refreshOrgData,
createOrganization,
updateOrganizationName,
switchOrganization,
inviteMember,
cancelInvitation,

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB