mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
59
ee/apps/den-web/app/(den)/_components/ui/card.tsx
Normal file
59
ee/apps/den-web/app/(den)/_components/ui/card.tsx
Normal 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(" ")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
ee/apps/den-web/docs/screenshots/members-rename-after.png
Normal file
BIN
ee/apps/den-web/docs/screenshots/members-rename-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 KiB |
BIN
ee/apps/den-web/docs/screenshots/members-rename-before.png
Normal file
BIN
ee/apps/den-web/docs/screenshots/members-rename-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 561 KiB |
BIN
ee/apps/den-web/docs/screenshots/members-rename-editing.png
Normal file
BIN
ee/apps/den-web/docs/screenshots/members-rename-editing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
Reference in New Issue
Block a user