diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index b4de442ae..e77bac3a3 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -1,4 +1,4 @@ -import { and, asc, eq } from "@openwork-ee/den-db/drizzle" +import { and, asc, eq, inArray } from "@openwork-ee/den-db/drizzle" import { AuthSessionTable, AuthUserTable, @@ -98,6 +98,13 @@ export type OrganizationContext = { createdAt: Date | null updatedAt: Date | null }> + teams: Array<{ + id: typeof TeamTable.$inferSelect.id + name: string + createdAt: Date + updatedAt: Date + memberIds: MemberId[] + }> } export type MemberTeamSummary = { @@ -611,6 +618,8 @@ export async function getOrganizationContextForUser(input: { .where(eq(OrganizationRoleTable.organizationId, organization.id)) .orderBy(asc(OrganizationRoleTable.createdAt)) + const teams = await listOrganizationTeams(organization.id) + const builtInDynamicRoleNames = new Set(Object.keys(denDefaultDynamicOrganizationRoles)) return { @@ -655,9 +664,47 @@ export async function getOrganizationContextForUser(input: { updatedAt: role.updatedAt, })), ], + teams, } satisfies OrganizationContext } +async function listOrganizationTeams(organizationId: OrgId) { + const teams = await db + .select({ + id: TeamTable.id, + name: TeamTable.name, + createdAt: TeamTable.createdAt, + updatedAt: TeamTable.updatedAt, + }) + .from(TeamTable) + .where(eq(TeamTable.organizationId, organizationId)) + .orderBy(asc(TeamTable.createdAt)) + + if (teams.length === 0) { + return [] + } + + const memberships = await db + .select({ + teamId: TeamMemberTable.teamId, + orgMembershipId: TeamMemberTable.orgMembershipId, + }) + .from(TeamMemberTable) + .where(inArray(TeamMemberTable.teamId, teams.map((team) => team.id))) + + const memberIdsByTeamId = new Map() + for (const membership of memberships) { + const existing = memberIdsByTeamId.get(membership.teamId) ?? [] + existing.push(membership.orgMembershipId) + memberIdsByTeamId.set(membership.teamId, existing) + } + + return teams.map((team) => ({ + ...team, + memberIds: memberIdsByTeamId.get(team.id) ?? [], + })) +} + export async function listTeamsForMember(input: { organizationId: OrgId memberId: MemberRow["id"] diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 08a20ed09..71cb000f1 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -5,6 +5,7 @@ import { registerOrgInvitationRoutes } from "./invitations.js" import { registerOrgMemberRoutes } from "./members.js" import { registerOrgRoleRoutes } from "./roles.js" import { registerOrgSkillRoutes } from "./skills.js" +import { registerOrgTeamRoutes } from "./teams.js" import { registerOrgTemplateRoutes } from "./templates.js" export function registerOrgRoutes(app: Hono) { @@ -13,5 +14,6 @@ export function registerOrgRoutes(ap registerOrgMemberRoutes(app) registerOrgRoleRoutes(app) registerOrgSkillRoutes(app) + registerOrgTeamRoutes(app) registerOrgTemplateRoutes(app) } diff --git a/ee/apps/den-api/src/routes/org/shared.ts b/ee/apps/den-api/src/routes/org/shared.ts index 5e0c906e3..3cbbbacc9 100644 --- a/ee/apps/den-api/src/routes/org/shared.ts +++ b/ee/apps/den-api/src/routes/org/shared.ts @@ -104,6 +104,30 @@ export function ensureInviteManager(c: { get: (key: "organizationContext") => Or } } +export function ensureTeamManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) { + const payload = c.get("organizationContext") + if (!payload) { + return { + ok: false as const, + response: { + error: "organization_not_found", + }, + } + } + + if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) { + return { ok: true as const } + } + + return { + ok: false as const, + response: { + error: "forbidden", + message: "Only organization owners and admins can manage teams.", + }, + } +} + export function createInvitationId() { return createDenTypeId("invitation") } diff --git a/ee/apps/den-api/src/routes/org/skills.ts b/ee/apps/den-api/src/routes/org/skills.ts index eebf2afc3..7dec5fe9a 100644 --- a/ee/apps/den-api/src/routes/org/skills.ts +++ b/ee/apps/den-api/src/routes/org/skills.ts @@ -28,6 +28,19 @@ const createSkillSchema = z.object({ shared: z.enum(["org", "public"]).nullable().optional(), }) +const updateSkillSchema = z.object({ + skillText: z.string().trim().min(1).optional(), + shared: z.enum(["org", "public"]).nullable().optional(), +}).superRefine((value, ctx) => { + if (value.skillText === undefined && value.shared === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["skillText"], + message: "Provide at least one field to update.", + }) + } +}) + const createSkillHubSchema = z.object({ name: z.string().trim().min(1).max(255), description: z.string().trim().max(65535).nullish().transform((value) => value || null), @@ -310,6 +323,68 @@ export function registerOrgSkillRoutes { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + const input = c.req.valid("json") + + let skillId: SkillId + try { + skillId = parseSkillId(params.skillId) + } catch { + return c.json({ error: "skill_not_found" }, 404) + } + + const skillRows = await db + .select() + .from(SkillTable) + .where(and(eq(SkillTable.id, skillId), eq(SkillTable.organizationId, payload.organization.id))) + .limit(1) + + const skill = skillRows[0] + if (!skill) { + return c.json({ error: "skill_not_found" }, 404) + } + + if (!canManageSkill(payload, skill)) { + return c.json({ error: "forbidden", message: "Only the skill creator or an org admin can update skills." }, 403) + } + + const nextSkillText = input.skillText ?? skill.skillText + const metadata = parseSkillMetadata(nextSkillText) + const updatedAt = new Date() + const nextShared = input.shared === undefined ? skill.shared : input.shared + + await db + .update(SkillTable) + .set({ + title: metadata.title, + description: metadata.description, + skillText: nextSkillText, + shared: nextShared, + updatedAt, + }) + .where(eq(SkillTable.id, skill.id)) + + return c.json({ + skill: { + ...skill, + title: metadata.title, + description: metadata.description, + skillText: nextSkillText, + shared: nextShared, + updatedAt, + }, + }) + }, + ) + app.post( "/v1/orgs/:orgId/skill-hubs", requireUserMiddleware, diff --git a/ee/apps/den-api/src/routes/org/teams.ts b/ee/apps/den-api/src/routes/org/teams.ts new file mode 100644 index 000000000..1c113c64c --- /dev/null +++ b/ee/apps/den-api/src/routes/org/teams.ts @@ -0,0 +1,284 @@ +import { and, eq } from "@openwork-ee/den-db/drizzle" +import { + MemberTable, + SkillHubMemberTable, + TeamMemberTable, + TeamTable, +} from "@openwork-ee/den-db/schema" +import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import type { Hono } from "hono" +import { z } from "zod" +import { db } from "../../db.js" +import { + jsonValidator, + paramValidator, + requireUserMiddleware, + resolveOrganizationContextMiddleware, +} from "../../middleware/index.js" +import type { OrgRouteVariables } from "./shared.js" +import { + ensureTeamManager, + idParamSchema, + orgIdParamSchema, +} from "./shared.js" + +const createTeamSchema = z.object({ + name: z.string().trim().min(1).max(255), + memberIds: z.array(z.string().trim().min(1)).optional().default([]), +}) + +const updateTeamSchema = z.object({ + name: z.string().trim().min(1).max(255).optional(), + memberIds: z.array(z.string().trim().min(1)).optional(), +}).superRefine((value, ctx) => { + if (value.name === undefined && value.memberIds === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: "Provide at least one field to update.", + }) + } +}) + +type TeamId = typeof TeamTable.$inferSelect.id +type MemberId = typeof MemberTable.$inferSelect.id + +const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId").shape) + +function parseTeamId(value: string) { + return normalizeDenTypeId("team", value) +} + +function parseMemberIds(memberIds: string[]) { + return [...new Set(memberIds.map((value) => normalizeDenTypeId("member", value)))] +} + +async function ensureMembersBelongToOrganization(input: { + organizationId: typeof TeamTable.$inferSelect.organizationId + memberIds: MemberId[] +}) { + if (input.memberIds.length === 0) { + return true + } + + const rows = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .where(eq(MemberTable.organizationId, input.organizationId)) + + const memberIds = new Set(rows.map((row) => row.id)) + return input.memberIds.every((memberId) => memberIds.has(memberId)) +} + +export function registerOrgTeamRoutes(app: Hono) { + app.post( + "/v1/orgs/:orgId/teams", + requireUserMiddleware, + paramValidator(orgIdParamSchema), + resolveOrganizationContextMiddleware, + jsonValidator(createTeamSchema), + async (c) => { + const permission = ensureTeamManager(c) + if (!permission.ok) { + return c.json(permission.response, 403) + } + + const payload = c.get("organizationContext") + const input = c.req.valid("json") + + let memberIds: MemberId[] + try { + memberIds = parseMemberIds(input.memberIds) + } catch { + return c.json({ error: "member_not_found" }, 404) + } + + const membersBelongToOrg = await ensureMembersBelongToOrganization({ + organizationId: payload.organization.id, + memberIds, + }) + if (!membersBelongToOrg) { + return c.json({ error: "member_not_found" }, 404) + } + + const existingTeam = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(and(eq(TeamTable.organizationId, payload.organization.id), eq(TeamTable.name, input.name))) + .limit(1) + + if (existingTeam[0]) { + return c.json({ error: "team_exists", message: "That team already exists in this organization." }, 409) + } + + const teamId = createDenTypeId("team") + const now = new Date() + + await db.transaction(async (tx) => { + await tx.insert(TeamTable).values({ + id: teamId, + name: input.name, + organizationId: payload.organization.id, + createdAt: now, + updatedAt: now, + }) + + if (memberIds.length > 0) { + await tx.insert(TeamMemberTable).values( + memberIds.map((memberId) => ({ + id: createDenTypeId("teamMember"), + teamId, + orgMembershipId: memberId, + createdAt: now, + })), + ) + } + }) + + return c.json({ + team: { + id: teamId, + organizationId: payload.organization.id, + name: input.name, + createdAt: now, + updatedAt: now, + memberIds, + }, + }, 201) + }, + ) + + app.patch( + "/v1/orgs/:orgId/teams/:teamId", + requireUserMiddleware, + paramValidator(orgTeamParamsSchema), + resolveOrganizationContextMiddleware, + jsonValidator(updateTeamSchema), + async (c) => { + const permission = ensureTeamManager(c) + if (!permission.ok) { + return c.json(permission.response, 403) + } + + const payload = c.get("organizationContext") + const params = c.req.valid("param") + const input = c.req.valid("json") + + let teamId: TeamId + try { + teamId = parseTeamId(params.teamId) + } catch { + return c.json({ error: "team_not_found" }, 404) + } + + const teamRows = await db + .select() + .from(TeamTable) + .where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, payload.organization.id))) + .limit(1) + + const team = teamRows[0] + if (!team) { + return c.json({ error: "team_not_found" }, 404) + } + + let memberIds: MemberId[] | undefined + if (input.memberIds) { + try { + memberIds = parseMemberIds(input.memberIds) + } catch { + return c.json({ error: "member_not_found" }, 404) + } + + const membersBelongToOrg = await ensureMembersBelongToOrganization({ + organizationId: payload.organization.id, + memberIds, + }) + if (!membersBelongToOrg) { + return c.json({ error: "member_not_found" }, 404) + } + } + + const nextName = input.name ?? team.name + const duplicate = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(and(eq(TeamTable.organizationId, payload.organization.id), eq(TeamTable.name, nextName))) + .limit(1) + + if (duplicate[0] && duplicate[0].id !== team.id) { + return c.json({ error: "team_exists", message: "That team already exists in this organization." }, 409) + } + + const updatedAt = new Date() + await db.transaction(async (tx) => { + await tx.update(TeamTable).set({ name: nextName, updatedAt }).where(eq(TeamTable.id, team.id)) + + if (memberIds) { + await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id)) + if (memberIds.length > 0) { + await tx.insert(TeamMemberTable).values( + memberIds.map((memberId) => ({ + id: createDenTypeId("teamMember"), + teamId: team.id, + orgMembershipId: memberId, + createdAt: updatedAt, + })), + ) + } + } + }) + + return c.json({ + team: { + ...team, + name: nextName, + updatedAt, + memberIds: memberIds ?? [], + }, + }) + }, + ) + + app.delete( + "/v1/orgs/:orgId/teams/:teamId", + requireUserMiddleware, + paramValidator(orgTeamParamsSchema), + resolveOrganizationContextMiddleware, + async (c) => { + const permission = ensureTeamManager(c) + if (!permission.ok) { + return c.json(permission.response, 403) + } + + const payload = c.get("organizationContext") + const params = c.req.valid("param") + + let teamId: TeamId + try { + teamId = parseTeamId(params.teamId) + } catch { + return c.json({ error: "team_not_found" }, 404) + } + + const teamRows = await db + .select() + .from(TeamTable) + .where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, payload.organization.id))) + .limit(1) + + const team = teamRows[0] + if (!team) { + return c.json({ error: "team_not_found" }, 404) + } + + await db.transaction(async (tx) => { + await tx.delete(SkillHubMemberTable).where(eq(SkillHubMemberTable.teamId, team.id)) + await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id)) + await tx.delete(TeamTable).where(eq(TeamTable.id, team.id)) + }) + + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/button.tsx b/ee/apps/den-web/app/(den)/_components/ui/button.tsx new file mode 100644 index 000000000..fe55b0dc0 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/button.tsx @@ -0,0 +1,151 @@ +"use client"; + +import type { ButtonHTMLAttributes, ElementType } from "react"; + +// ─── Variant / size tokens ──────────────────────────────────────────────────── + +export type ButtonVariant = "primary" | "secondary" | "destructive"; +export type ButtonSize = "md" | "sm"; + +const variantClasses: Record = { + primary: + "bg-[#0f172a] text-white hover:bg-[#111c33]", + secondary: + "border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-900", + destructive: + "border border-red-200 text-red-600 hover:bg-red-50 hover:border-red-300", +}; + +// md is sized to match the Shared Workspaces reference buttons (px-5 py-2.5 ≈ h-10) +const sizeClasses: Record = { + md: "h-10 px-5 text-[13px] gap-2", + sm: "h-8 px-3.5 text-[12px] gap-1.5", +}; + +// ─── buttonVariants helper (for / elements) ─────────────────────── + +/** + * Returns the className string for button styles. + * Use this on and elements that should look like buttons. + */ +export function buttonVariants({ + variant = "primary", + size = "md", + className = "", +}: { + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} = {}): string { + return [ + "inline-flex items-center justify-center rounded-full font-medium transition-colors", + variantClasses[variant], + sizeClasses[size], + className, + ] + .filter(Boolean) + .join(" "); +} + +// ─── Spinner ────────────────────────────────────────────────────────────────── + +function Spinner({ px }: { px: number }) { + return ( + + ); +} + +// ─── DenButton ──────────────────────────────────────────────────────────────── + +export type DenButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant; + size?: ButtonSize; + /** + * Lucide icon component rendered on the left. + * In loading state the icon is replaced by a spinner. + */ + icon?: ElementType<{ size?: number; className?: string; strokeWidth?: number }>; + /** + * Shows a spinner and forces the button into a disabled state. + * - With icon: spinner replaces the icon; text stays visible. + * - Without icon: text becomes invisible (preserving button width) and a + * spinner appears centered over it. + */ + loading?: boolean; +}; + +export function DenButton({ + variant = "primary", + size = "md", + icon: Icon, + loading = false, + disabled = false, + children, + className, + ...rest +}: DenButtonProps) { + const isDisabled = disabled || loading; + const iconPx = size === "sm" ? 13 : 15; + const hasText = children !== null && children !== undefined; + // No-icon loading: hide text but keep its width, overlay centered spinner + const noIconLoading = loading && !Icon; + + return ( + + ); +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/dashboard-page-template.tsx b/ee/apps/den-web/app/(den)/_components/ui/dashboard-page-template.tsx new file mode 100644 index 000000000..2fe09dc5c --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/dashboard-page-template.tsx @@ -0,0 +1,109 @@ +"use client"; + +import type { ElementType } from "react"; +import { PaperMeshGradient } from "@openwork/ui/react"; +import { Dithering } from "@paper-design/shaders-react"; + +/** + * DashboardPageTemplate + * + * A consistent page shell for all org dashboard pages. + * Provides: + * - A gradient hero card (icon + badge + title) + * - A description line below the card + * - A children slot for page-specific content + * + * Caller controls only the gradient `colors` tuple — everything else + * (distortion, swirl, grain, speed, frame, dithering overlay) is fixed + * so every page looks coherent. + */ + +export type DashboardPageTemplateProps = { + /** Lucide (or any) icon component rendered inside the frosted glass icon box */ + icon: ElementType<{ + size?: number; + className?: string; + strokeWidth?: number; + }>; + /** Short label rendered as a frosted pill badge above the title. Omit to hide. */ + badgeLabel?: string; + /** Page heading rendered large inside the card */ + title: string; + /** One-liner rendered in gray below the card, above children */ + description: string; + /** + * Exactly 4 CSS hex colors for the mesh gradient. + * Tip: vary hue across pages so each section feels distinct at a glance. + */ + colors: [string, string, string, string]; + children?: React.ReactNode; +}; + +export function DashboardPageTemplate({ + icon: Icon, + badgeLabel, + title, + description, + colors, + children, +}: DashboardPageTemplateProps) { + return ( +
+ {/* ── Gradient hero card ── */} +
+ {/* Background layers: mesh gradient wrapped in a dithering texture */} +
+ + + +
+ + {/* Icon — top right */} +
+ +
+ + {/* Badge (optional) + Title — bottom left */} +
+ {badgeLabel ? ( + + {badgeLabel} + + ) : null} +

+ {title} +

+
+
+ + {/* ── Description ── */} +

{description}

+ + {/* ── Page content ── */} + {children} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/input.tsx b/ee/apps/den-web/app/(den)/_components/ui/input.tsx new file mode 100644 index 000000000..c744b0fbd --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/input.tsx @@ -0,0 +1,90 @@ +"use client"; + +import type { ElementType, InputHTMLAttributes } from "react"; + +export type DenInputProps = Omit, "disabled"> & { + /** + * Optional Lucide icon component rendered on the left. + * When omitted, no icon is shown and no extra left padding is added. + */ + icon?: ElementType<{ size?: number; className?: string }>; + /** + * Pixel size of the icon. Defaults to 16. + * Use 20 for larger search fields so the icon stays proportional. + * Left position and left-padding are derived automatically. + */ + iconSize?: number; + /** + * Disables the input and dims it to 60 % opacity. + * Forwarded as the native `disabled` attribute. + */ + disabled?: boolean; +}; + +/** + * DenInput + * + * Consistent text input for all dashboard pages, based on the + * Shared Workspaces compact search field. + * + * Defaults: rounded-lg · py-2.5 · px-4 · text-[14px] + * Icon: auto-positions and adjusts left padding. + * No className needed at the call site — override only when necessary. + */ +export function DenInput({ + icon: Icon, + iconSize = 16, + disabled = false, + className, + ...rest +}: DenInputProps) { + const isLargeIcon = iconSize > 16; + const iconLeft = isLargeIcon ? "left-5" : "left-3"; + // inject icon left-padding only if the caller hasn't specified one + const iconPl = Icon + ? className?.includes("pl-") + ? "" + : isLargeIcon + ? "pl-14" + : "pl-9" + : ""; + + const input = ( + + ); + + if (!Icon) return input; + + return ( +
+
+
+ {input} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/tabs.tsx b/ee/apps/den-web/app/(den)/_components/ui/tabs.tsx new file mode 100644 index 000000000..28184e080 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/tabs.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ElementType } from "react"; + +export type TabItem = { + value: T; + label: string; + icon?: ElementType<{ className?: string }>; + count?: number; +}; + +type UnderlineTabsProps = { + tabs: readonly TabItem[]; + activeTab: T; + onChange: (value: T) => void; + className?: string; +}; + +export function UnderlineTabs({ + tabs, + activeTab, + onChange, + className = "", +}: UnderlineTabsProps) { + return ( +
+ +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/ui/textarea.tsx b/ee/apps/den-web/app/(den)/_components/ui/textarea.tsx new file mode 100644 index 000000000..af4195ec8 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/ui/textarea.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { TextareaHTMLAttributes } from "react"; + +export type DenTextareaProps = Omit< + TextareaHTMLAttributes, + "disabled" +> & { + /** + * Number of visible text lines — sets the initial height. + * Defaults to 4. + */ + rows?: number; + /** + * Disables the textarea and dims it to 60 % opacity. + * Forwarded as the native `disabled` attribute. + */ + disabled?: boolean; +}; + +/** + * DenTextarea + * + * Matches DenInput styling exactly: same border, bg, focus ring, + * placeholder, and disabled state. Height is controlled by `rows`. + */ +export function DenTextarea({ + rows = 4, + disabled = false, + className, + ...rest +}: DenTextareaProps) { + return ( +