feat(den): restrict org invites by email domain (#1494)

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-19 13:40:30 -07:00
committed by GitHub
parent 8d0414bd25
commit 3baaac6488
14 changed files with 725 additions and 137 deletions

View File

@@ -20,6 +20,7 @@ type OrgId = typeof OrganizationTable.$inferSelect.id
type MemberRow = typeof MemberTable.$inferSelect
type MemberId = MemberRow["id"]
type InvitationRow = typeof InvitationTable.$inferSelect
export type AllowedEmailDomains = string[] | null
export type InvitationStatus = "pending" | "accepted" | "canceled" | "expired"
@@ -36,6 +37,7 @@ export type InvitationPreview = {
id: OrgId
name: string
slug: string
allowedEmailDomains: AllowedEmailDomains
}
}
@@ -58,6 +60,7 @@ export type OrganizationContext = {
name: string
slug: string
logo: string | null
allowedEmailDomains: AllowedEmailDomains
metadata: string | null
createdAt: Date
updatedAt: Date
@@ -154,6 +157,75 @@ function buildPersonalOrgName(input: {
return `${normalized}${suffix}`
}
function normalizeEmailDomainValue(value: string) {
const normalized = value.trim().toLowerCase().replace(/^@+/, "")
if (!normalized) {
return null
}
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(normalized)) {
return null
}
return normalized
}
export function normalizeAllowedEmailDomains(input: readonly string[] | null | undefined): {
domains: AllowedEmailDomains
invalidDomains: string[]
} {
if (!input || input.length === 0) {
return {
domains: null,
invalidDomains: [],
}
}
const normalized = new Set<string>()
const invalidDomains: string[] = []
for (const value of input) {
const nextDomain = normalizeEmailDomainValue(value)
if (!nextDomain) {
invalidDomains.push(value)
continue
}
normalized.add(nextDomain)
}
return {
domains: normalized.size > 0 ? [...normalized].sort() : null,
invalidDomains,
}
}
function getEmailDomain(email: string) {
const normalized = email.trim().toLowerCase()
const atIndex = normalized.lastIndexOf("@")
if (atIndex === -1 || atIndex + 1 >= normalized.length) {
return null
}
return normalized.slice(atIndex + 1)
}
export function isEmailAllowedForOrganization(allowedEmailDomains: readonly string[] | null | undefined, email: string) {
if (!allowedEmailDomains || allowedEmailDomains.length === 0) {
return true
}
const emailDomain = getEmailDomain(email)
if (!emailDomain) {
return false
}
return allowedEmailDomains.includes(emailDomain)
}
function normalizeStoredAllowedEmailDomains(value: unknown): AllowedEmailDomains {
const values = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : null
return normalizeAllowedEmailDomains(values).domains
}
export function parsePermissionRecord(value: string | null) {
if (!value) {
return {}
@@ -178,6 +250,23 @@ export function serializePermissionRecord(value: Record<string, string[]>) {
return JSON.stringify(value)
}
export class OrganizationEmailDomainRestrictionError extends Error {
readonly emailDomain: string | null
readonly allowedEmailDomains: string[]
constructor(email: string, allowedEmailDomains: string[]) {
const emailDomain = getEmailDomain(email)
super(
allowedEmailDomains.length === 1
? `This workspace only allows ${allowedEmailDomains[0]} email addresses.`
: `This workspace only allows email addresses from these domains: ${allowedEmailDomains.join(", ")}.`,
)
this.name = "OrganizationEmailDomainRestrictionError"
this.emailDomain = emailDomain
this.allowedEmailDomains = allowedEmailDomains
}
}
function clonePermissionRecord(value: Record<string, readonly string[]>) {
return Object.fromEntries(
Object.entries(value).map(([resource, actions]) => [resource, [...actions]]),
@@ -355,6 +444,17 @@ export async function acceptInvitationForUser(input: {
return null
}
const organizationRows = await db
.select({ allowedEmailDomains: OrganizationTable.allowedEmailDomains })
.from(OrganizationTable)
.where(eq(OrganizationTable.id, invitation.organizationId))
.limit(1)
const allowedEmailDomains = normalizeStoredAllowedEmailDomains(organizationRows[0]?.allowedEmailDomains)
if (!isEmailAllowedForOrganization(allowedEmailDomains, input.email)) {
throw new OrganizationEmailDomainRestrictionError(input.email, allowedEmailDomains ?? [])
}
const member = await acceptInvitation(invitation, input.userId)
return {
invitation,
@@ -384,6 +484,7 @@ export async function getInvitationPreview(invitationIdRaw: string): Promise<Inv
id: OrganizationTable.id,
name: OrganizationTable.name,
slug: OrganizationTable.slug,
allowedEmailDomains: OrganizationTable.allowedEmailDomains,
},
})
.from(InvitationTable)
@@ -401,7 +502,10 @@ export async function getInvitationPreview(invitationIdRaw: string): Promise<Inv
...row.invitation,
status: getInvitationStatus(row.invitation),
},
organization: row.organization,
organization: {
...row.organization,
allowedEmailDomains: normalizeStoredAllowedEmailDomains(row.organization.allowedEmailDomains),
},
}
}
@@ -494,14 +598,37 @@ export async function updateOrganizationName(input: {
organizationId: OrgId
name: string
}) {
const trimmed = input.name.trim()
if (!trimmed) {
return updateOrganizationSettings({
organizationId: input.organizationId,
name: input.name,
})
}
export async function updateOrganizationSettings(input: {
organizationId: OrgId
name?: string
allowedEmailDomains?: readonly string[] | null
}) {
const nextName = typeof input.name === "string" ? input.name.trim() : null
if (typeof input.name === "string" && !nextName) {
return null
}
const updates: Partial<typeof OrganizationTable.$inferInsert> = {}
if (nextName) {
updates.name = nextName
}
if (input.allowedEmailDomains !== undefined) {
updates.allowedEmailDomains = normalizeAllowedEmailDomains(input.allowedEmailDomains).domains
}
if (Object.keys(updates).length === 0) {
return null
}
await db
.update(OrganizationTable)
.set({ name: trimmed })
.set(updates)
.where(eq(OrganizationTable.id, input.organizationId))
const rows = await db
@@ -534,6 +661,7 @@ export async function listUserOrgs(userId: UserId) {
name: OrganizationTable.name,
slug: OrganizationTable.slug,
logo: OrganizationTable.logo,
allowedEmailDomains: OrganizationTable.allowedEmailDomains,
metadata: OrganizationTable.metadata,
createdAt: OrganizationTable.createdAt,
updatedAt: OrganizationTable.updatedAt,
@@ -546,10 +674,11 @@ export async function listUserOrgs(userId: UserId) {
return memberships.map((row) => ({
id: row.organization.id,
name: row.organization.name,
slug: row.organization.slug,
logo: row.organization.logo,
metadata: serializeOrganizationMetadata(row.organization.metadata),
name: row.organization.name,
slug: row.organization.slug,
logo: row.organization.logo,
allowedEmailDomains: normalizeStoredAllowedEmailDomains(row.organization.allowedEmailDomains),
metadata: serializeOrganizationMetadata(row.organization.metadata),
role: row.role,
orgMemberId: row.membershipId,
membershipId: row.membershipId,
@@ -661,14 +790,15 @@ export async function getOrganizationContextForUser(input: {
const builtInDynamicRoleNames = new Set(Object.keys(denDefaultDynamicOrganizationRoles))
return {
organization: {
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
metadata: serializeOrganizationMetadata(organization.metadata),
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
organization: {
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
allowedEmailDomains: normalizeStoredAllowedEmailDomains(organization.allowedEmailDomains),
metadata: serializeOrganizationMetadata(organization.metadata),
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
},
currentMember: {
id: currentMember.id,

View File

@@ -10,7 +10,15 @@ import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization, updateOrganizationName } from "../../orgs.js"
import {
acceptInvitationForUser,
createOrganizationForUser,
getInvitationPreview,
normalizeAllowedEmailDomains,
OrganizationEmailDomainRestrictionError,
setSessionActiveOrganization,
updateOrganizationSettings,
} from "../../orgs.js"
import { getRequiredUserEmail } from "../../user.js"
import type { OrgRouteVariables } from "./shared.js"
import { ensureOwner } from "./shared.js"
@@ -20,7 +28,10 @@ const createOrganizationSchema = z.object({
})
const updateOrganizationSchema = z.object({
name: z.string().trim().min(2).max(120),
name: z.string().trim().min(2).max(120).optional(),
allowedEmailDomains: z.array(z.string().trim().min(1).max(255)).max(100).nullable().optional(),
}).refine((value) => value.name !== undefined || value.allowedEmailDomains !== undefined, {
message: "Provide at least one organization field to update.",
})
const invitationPreviewQuerySchema = z.object({
@@ -74,6 +85,19 @@ const userEmailRequiredSchema = z.object({
error: z.literal("user_email_required"),
}).meta({ ref: "UserEmailRequiredError" })
const invalidEmailDomainSchema = z.object({
error: z.literal("invalid_email_domain"),
message: z.string(),
invalidDomains: z.array(z.string()),
}).meta({ ref: "InvalidEmailDomainError" })
const accountEmailDomainNotAllowedSchema = z.object({
error: z.literal("account_email_domain_not_allowed"),
message: z.string(),
emailDomain: z.string().nullable(),
allowedEmailDomains: z.array(z.string()),
}).meta({ ref: "AccountEmailDomainNotAllowedError" })
function getStoredSessionId(session: { id?: string | null } | null) {
if (!session?.id) {
return null
@@ -212,6 +236,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
400: jsonResponse("The invitation acceptance request body was invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be signed in to accept an invitation.", unauthorizedSchema),
403: jsonResponse("API keys cannot accept organization invitations.", forbiddenSchema),
409: jsonResponse("The current account email is not allowed to join this organization.", accountEmailDomainNotAllowedSchema),
404: jsonResponse("The invitation could not be found.", notFoundSchema),
},
}),
@@ -233,11 +258,24 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
return c.json({ error: "user_email_required" }, 400)
}
const accepted = await acceptInvitationForUser({
userId: normalizeDenTypeId("user", user.id),
email,
invitationId: input.id,
})
let accepted
try {
accepted = await acceptInvitationForUser({
userId: normalizeDenTypeId("user", user.id),
email,
invitationId: input.id,
})
} catch (error) {
if (error instanceof OrganizationEmailDomainRestrictionError) {
return c.json({
error: "account_email_domain_not_allowed",
message: error.message,
emailDomain: error.emailDomain,
allowedEmailDomains: error.allowedEmailDomains,
}, 409)
}
throw error
}
if (!accepted) {
return c.json({ error: "invitation_not_found" }, 404)
@@ -265,10 +303,10 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
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.",
description: "Updates organization fields that workspace owners are allowed to change, including the display name and allowed invitation email domains. 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),
400: jsonResponse("The organization update request body was invalid or contained malformed email domains.", invalidEmailDomainSchema),
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),
@@ -286,9 +324,22 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const updated = await updateOrganizationName({
const normalizedDomains = input.allowedEmailDomains === undefined
? { domains: undefined, invalidDomains: [] as string[] }
: normalizeAllowedEmailDomains(input.allowedEmailDomains)
if (normalizedDomains.invalidDomains.length > 0) {
return c.json({
error: "invalid_email_domain",
message: "Enter valid email domains like company.com.",
invalidDomains: normalizedDomains.invalidDomains,
}, 400)
}
const updated = await updateOrganizationSettings({
organizationId: payload.organization.id,
name: input.name,
allowedEmailDomains: normalizedDomains.domains,
})
if (!updated) {

View File

@@ -9,7 +9,7 @@ import { DenEmailSendError, sendDenOrganizationInvitationEmail } from "../../ema
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
import { getOrganizationLimitStatus } from "../../organization-limits.js"
import { listAssignableRoles } from "../../orgs.js"
import { isEmailAllowedForOrganization, listAssignableRoles } from "../../orgs.js"
import type { OrgRouteVariables } from "./shared.js"
import { buildInvitationLink, createInvitationId, ensureInviteManager, idParamSchema, normalizeRoleName } from "./shared.js"
@@ -32,6 +32,13 @@ const invitationEmailFailedSchema = z.object({
invitationId: denTypeIdSchema("invitation"),
}).meta({ ref: "InvitationEmailFailedError" })
const inviteEmailDomainNotAllowedSchema = z.object({
error: z.literal("invite_email_domain_not_allowed"),
message: z.string(),
emailDomain: z.string().nullable(),
allowedEmailDomains: z.array(z.string()),
}).meta({ ref: "InviteEmailDomainNotAllowedError" })
type InvitationId = typeof InvitationTable.$inferSelect.id
const orgInvitationParamsSchema = idParamSchema("invitationId", "invitation")
@@ -49,6 +56,7 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
401: jsonResponse("The caller must be signed in to invite organization members.", unauthorizedSchema),
403: jsonResponse("Only workspace owners and admins can create or resend invitations.", forbiddenSchema),
404: jsonResponse("The organization could not be found.", notFoundSchema),
409: jsonResponse("The email address is outside this workspace's allowed domains.", inviteEmailDomainNotAllowedSchema),
502: jsonResponse("The invitation was saved but the email provider (Loops) rejected or failed to deliver it. Retry by submitting the same email again.", invitationEmailFailedSchema),
},
}),
@@ -66,6 +74,19 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
const input = c.req.valid("json")
const email = input.email.trim().toLowerCase()
if (!isEmailAllowedForOrganization(payload.organization.allowedEmailDomains, email)) {
const emailDomain = email.includes("@") ? email.slice(email.lastIndexOf("@") + 1) : null
return c.json({
error: "invite_email_domain_not_allowed",
message:
payload.organization.allowedEmailDomains && payload.organization.allowedEmailDomains.length === 1
? `This workspace only allows ${payload.organization.allowedEmailDomains[0]} email addresses.`
: `This workspace only allows email addresses from these domains: ${(payload.organization.allowedEmailDomains ?? []).join(", ")}.`,
emailDomain,
allowedEmailDomains: payload.organization.allowedEmailDomains ?? [],
}, 409)
}
const availableRoles = await listAssignableRoles(payload.organization.id)
const role = normalizeRoleName(input.role)
if (!availableRoles.has(role)) {

View File

@@ -9,6 +9,7 @@ import {
formatRoleLabel,
getJoinOrgRoute,
getOrgDashboardRoute,
isEmailAllowedForOrganization,
parseInvitationPreviewPayload,
type DenInvitationPreview,
} from "../_lib/den-org";
@@ -45,6 +46,16 @@ function statusMessage(preview: DenInvitationPreview | null) {
}
}
function formatAllowedDomains(allowedEmailDomains: readonly string[] | null | undefined) {
if (!allowedEmailDomains || allowedEmailDomains.length === 0) {
return "any invited email address";
}
return allowedEmailDomains.length === 1
? allowedEmailDomains[0]
: allowedEmailDomains.join(", ");
}
export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
const router = useRouter();
const { user, sessionHydrated, signOut } = useDenFlow();
@@ -57,7 +68,14 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
const invitedEmailMatches = preview && user
? preview.invitation.email.trim().toLowerCase() === user.email.trim().toLowerCase()
: false;
const invitedEmailAllowed = preview
? isEmailAllowedForOrganization(preview.organization.allowedEmailDomains, preview.invitation.email)
: true;
const signedInEmailAllowed = preview && user
? isEmailAllowedForOrganization(preview.organization.allowedEmailDomains, user.email)
: true;
const roleLabel = preview ? formatRoleLabel(preview.invitation.role) : "";
const allowedDomainsLabel = preview ? formatAllowedDomains(preview.organization.allowedEmailDomains) : "";
useEffect(() => {
let cancelled = false;
@@ -190,6 +208,27 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
);
}
if (preview.invitation.status === "pending" && !invitedEmailAllowed) {
return (
<section className="den-page py-4 lg:py-6">
<div className="den-frame grid max-w-[44rem] gap-6 p-6 md:p-8">
<div className="grid gap-2">
<p className="den-eyebrow">OpenWork Cloud</p>
<h1 className="den-title-lg">This invite needs a different email domain.</h1>
<p className="den-copy">
{preview.organization.name} now only accepts accounts from {allowedDomainsLabel}. Ask a workspace owner to update the allowlist or send a new invite.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href="/" className="den-button-primary w-full sm:w-auto">
Back to OpenWork Cloud
</Link>
</div>
</div>
</section>
);
}
if (preview.invitation.status === "pending" && !user) {
return (
<section className="den-page grid gap-6 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:py-6">
@@ -210,7 +249,12 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
<p className="m-0 text-base font-medium text-[var(--dls-text-primary)]">
Your team is already set up and waiting.
</p>
<p className="den-copy">Member access is ready as soon as you join.</p>
<p className="den-copy">
Member access is ready as soon as you join.
{preview.organization.allowedEmailDomains?.length
? ` This workspace only accepts ${allowedDomainsLabel} accounts.`
: ""}
</p>
</div>
</div>
@@ -283,6 +327,25 @@ export function JoinOrgScreen({ invitationId }: { invitationId: string }) {
</Link>
</div>
</div>
) : user && !signedInEmailAllowed ? (
<div className="grid gap-4">
<p className="den-copy">
{preview.organization.name} only accepts accounts from <span className="font-medium text-[var(--dls-text-primary)]">{allowedDomainsLabel}</span>. You are signed in as <span className="font-medium text-[var(--dls-text-primary)]">{user.email}</span>, so this account cannot join.
</p>
<p className="text-sm text-gray-500">
Log out, then create a new account or sign in with an allowed email address.
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="den-button-primary w-full sm:w-auto"
onClick={() => void handleSwitchAccount()}
disabled={joinBusy}
>
Log out
</button>
</div>
</div>
) : !invitedEmailMatches ? (
<div className="grid gap-4">
<p className="den-copy">

View File

@@ -64,6 +64,7 @@ export type DenInvitationPreview = {
id: string;
name: string;
slug: string;
allowedEmailDomains: string[] | null;
};
};
@@ -106,6 +107,7 @@ export type DenOrgContext = {
name: string;
slug: string;
logo: string | null;
allowedEmailDomains: string[] | null;
metadata: string | null;
createdAt: string | null;
updatedAt: string | null;
@@ -157,6 +159,14 @@ function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function asStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) {
return null;
}
return value.filter((entry): entry is string => typeof entry === "string");
}
function parsePermissionRecord(value: unknown): Record<string, string[]> {
if (!isRecord(value)) {
return {};
@@ -251,6 +261,10 @@ export function getBillingRoute(orgSlug?: string | null): string {
return `${getOrgDashboardRoute(orgSlug)}/billing`;
}
export function getOrgSettingsRoute(orgSlug?: string | null): string {
return `${getOrgDashboardRoute(orgSlug)}/org-settings`;
}
export function getApiKeysRoute(orgSlug?: string | null): string {
return `${getOrgDashboardRoute(orgSlug)}/api-keys`;
}
@@ -505,6 +519,7 @@ export function parseOrgContextPayload(payload: unknown): DenOrgContext | null {
name: organizationName,
slug: organizationSlug,
logo: asString(organization.logo),
allowedEmailDomains: asStringArray(organization.allowedEmailDomains),
metadata: asString(organization.metadata),
createdAt: asIsoString(organization.createdAt),
updatedAt: asIsoString(organization.updatedAt),
@@ -565,10 +580,25 @@ export function parseInvitationPreviewPayload(payload: unknown): DenInvitationPr
id: organizationId,
name: organizationName,
slug: organizationSlug,
allowedEmailDomains: asStringArray(organization.allowedEmailDomains),
},
};
}
export function isEmailAllowedForOrganization(allowedEmailDomains: readonly string[] | null | undefined, email: string): boolean {
if (!allowedEmailDomains || allowedEmailDomains.length === 0) {
return true;
}
const normalized = email.trim().toLowerCase();
const atIndex = normalized.lastIndexOf("@");
if (atIndex === -1 || atIndex + 1 >= normalized.length) {
return false;
}
return allowedEmailDomains.includes(normalized.slice(atIndex + 1));
}
export function parseOrgApiKeysPayload(payload: unknown): DenOrgApiKey[] {
if (!isRecord(payload) || !Array.isArray(payload.apiKeys)) {
return [];

View File

@@ -0,0 +1 @@
export { default } from "../../o/[orgSlug]/dashboard/org-settings/page";

View File

@@ -132,7 +132,6 @@ export function ManageMembersScreen() {
createRole,
updateRole,
deleteRole,
updateOrganizationName,
} = useOrgDashboard();
const [activeTab, setActiveTab] = useState<MembersTab>("members");
const [pageError, setPageError] = useState<string | null>(null);
@@ -152,9 +151,6 @@ 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),
@@ -641,106 +637,6 @@ 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

@@ -14,6 +14,7 @@ import {
LogOut,
MessageSquare,
Share2,
SlidersHorizontal,
Users,
} from "lucide-react";
import { useDenFlow } from "../../../../_providers/den-flow-provider";
@@ -27,6 +28,7 @@ import {
getIntegrationsRoute,
getMembersRoute,
getOrgDashboardRoute,
getOrgSettingsRoute,
getPluginsRoute,
getSharedSetupsRoute,
getSkillHubsRoute,
@@ -121,6 +123,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
if (pathname.startsWith(getBillingRoute(orgSlug)) || pathname === "/checkout") {
return "Billing";
}
if (pathname.startsWith(getOrgSettingsRoute(orgSlug))) {
return "Org Settings";
}
return "Home";
}
@@ -195,6 +200,11 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
label: "Billing",
icon: CreditCard,
},
{
href: activeOrg ? getOrgSettingsRoute(activeOrg.slug) : "#",
label: "Org Settings",
icon: SlidersHorizontal,
},
];
const orgSwitcher = (

View File

@@ -0,0 +1,353 @@
"use client";
import { Check, Copy, Pencil, SlidersHorizontal } from "lucide-react";
import { useEffect, useMemo, useState } from "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 { DenTextarea } from "../../../../_components/ui/textarea";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
function normalizeAllowedEmailDomainsInput(value: string): string[] | null {
const domains = [
...new Set(
value
.split(/[\s,]+/)
.map((entry) => entry.trim().toLowerCase().replace(/^@+/, ""))
.filter(Boolean),
),
];
return domains.length > 0 ? domains : null;
}
function SettingsToggle({
checked,
disabled,
onChange,
}: {
checked: boolean;
disabled?: boolean;
onChange: (nextValue: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label="Restrict allowed email domains"
disabled={disabled}
onClick={() => onChange(!checked)}
className={[
"relative inline-flex h-7 w-12 items-center rounded-full border transition-colors",
checked
? "border-[#0f172a] bg-[#0f172a]"
: "border-gray-200 bg-gray-200",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"inline-block h-5 w-5 rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1",
].join(" ")}
/>
</button>
);
}
export function OrgSettingsScreen() {
const {
activeOrg,
orgContext,
orgBusy,
orgError,
mutationBusy,
updateOrganizationSettings,
} = useOrgDashboard();
const [orgNameDraft, setOrgNameDraft] = useState("");
const [allowedDomainsDraft, setAllowedDomainsDraft] = useState("");
const [domainRestrictionsEnabled, setDomainRestrictionsEnabled] =
useState(false);
const [domainEditModeEnabled, setDomainEditModeEnabled] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [pageSuccess, setPageSuccess] = useState<string | null>(null);
const [copiedOrgId, setCopiedOrgId] = useState(false);
const currentAllowedDomains =
orgContext?.organization.allowedEmailDomains ?? null;
const isOwner = orgContext?.currentMember.isOwner ?? false;
const draftAllowedDomains = useMemo(
() => normalizeAllowedEmailDomainsInput(allowedDomainsDraft),
[allowedDomainsDraft],
);
const hasDraftDomains = (draftAllowedDomains?.length ?? 0) > 0;
useEffect(() => {
if (!orgContext) {
return;
}
setOrgNameDraft(orgContext.organization.name);
setAllowedDomainsDraft(
(orgContext.organization.allowedEmailDomains ?? []).join("\n"),
);
setDomainRestrictionsEnabled(
(orgContext.organization.allowedEmailDomains?.length ?? 0) > 0,
);
setDomainEditModeEnabled(false);
}, [orgContext]);
useEffect(() => {
if (!copiedOrgId) {
return;
}
const timeout = window.setTimeout(() => setCopiedOrgId(false), 1600);
return () => window.clearTimeout(timeout);
}, [copiedOrgId]);
const createdAtLabel = useMemo(() => {
if (!orgContext?.organization.createdAt) {
return "Not available";
}
return new Date(orgContext.organization.createdAt).toLocaleDateString();
}, [orgContext?.organization.createdAt]);
if (orgBusy && !orgContext) {
return (
<div className="mx-auto max-w-[860px] p-8">
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading workspace settings...
</div>
</div>
);
}
if (!activeOrg || !orgContext) {
return (
<div className="mx-auto max-w-[860px] p-8">
<div className="rounded-[28px] border border-red-200 bg-red-50 px-6 py-10 text-[15px] text-red-700">
{orgError ?? "Workspace settings are not available right now."}
</div>
</div>
);
}
const organizationId = orgContext.organization.id;
async function handleCopyOrgId() {
await navigator.clipboard.writeText(organizationId);
setCopiedOrgId(true);
}
function handleDomainRestrictionToggle(nextValue: boolean) {
if (!isOwner) {
return;
}
if (!nextValue && hasDraftDomains) {
return;
}
setPageError(null);
setPageSuccess(null);
setDomainRestrictionsEnabled(nextValue);
setDomainEditModeEnabled(nextValue && !currentAllowedDomains?.length);
}
async function handleSaveSettings(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setPageError(null);
setPageSuccess(null);
try {
await updateOrganizationSettings({
name: orgNameDraft,
allowedEmailDomains: domainRestrictionsEnabled
? draftAllowedDomains
: null,
});
setDomainEditModeEnabled(false);
setPageSuccess("Workspace settings updated.");
} catch (error) {
setPageError(
error instanceof Error
? error.message
: "Could not update workspace settings.",
);
}
}
return (
<DashboardPageTemplate
icon={SlidersHorizontal}
title="Org settings"
description="Control your organization's settings."
colors={["#D9F99D", "#0F172A", "#0F766E", "#FDE68A"]}
>
{pageError ? (
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
{pageError}
</div>
) : null}
{pageSuccess ? (
<div className="mb-6 rounded-[24px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-[14px] text-emerald-700">
{pageSuccess}
</div>
) : null}
<form className="grid gap-6" onSubmit={handleSaveSettings}>
<DenCard size="spacious" className="grid gap-6">
<div className="grid gap-2">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Core
</p>
<h2 className="text-[24px] font-semibold tracking-[-0.04em] text-gray-900">
Organization Identity
</h2>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<label className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">
Name
</span>
<DenInput
type="text"
value={orgNameDraft}
onChange={(event) => setOrgNameDraft(event.target.value)}
minLength={2}
maxLength={120}
disabled={!isOwner}
required
/>
</label>
<div className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">ID</span>
<div className="flex gap-2">
<DenInput
value={organizationId}
readOnly
aria-label="Organization ID"
className="font-mono text-[13px]"
/>
<DenButton
variant="secondary"
type="button"
icon={copiedOrgId ? Check : Copy}
onClick={() => void handleCopyOrgId()}
>
{copiedOrgId ? "Copied" : "Copy"}
</DenButton>
</div>
</div>
</div>
</DenCard>
<DenCard size="spacious" className="grid gap-6">
<div className="flex items-start justify-between gap-4">
<div className="grid gap-2">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">
Access rules
</p>
<h2 className="text-[24px] font-semibold tracking-[-0.04em] text-gray-900">
Allowed email domains
</h2>
<p className="text-[14px] text-gray-500">
Only allow people with specific email domains to join this
Organization.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<span className="text-[13px] font-medium text-gray-500">
{domainRestrictionsEnabled ? "On" : "Off"}
</span>
<SettingsToggle
checked={domainRestrictionsEnabled}
disabled={
!isOwner || (domainRestrictionsEnabled && hasDraftDomains)
}
onChange={handleDomainRestrictionToggle}
/>
</div>
</div>
{domainRestrictionsEnabled && domainEditModeEnabled ? (
<label className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">
Domain allowlist
</span>
<span className="text-[10px] text-gray-500">
Enter domains one per line or with comma as separator
</span>
<DenTextarea
value={allowedDomainsDraft}
onChange={(event) => setAllowedDomainsDraft(event.target.value)}
rows={6}
disabled={!isOwner}
placeholder={"company.com\npartner.org"}
/>
</label>
) : null}
{domainRestrictionsEnabled && !domainEditModeEnabled ? (
<div className="grid gap-3 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-4">
<div className="flex items-start justify-between gap-3">
{currentAllowedDomains && currentAllowedDomains.length > 0 ? (
<div className="flex flex-wrap w-full gap-2">
{currentAllowedDomains.map((domain) => (
<span
key={domain}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-[13px] text-gray-700"
>
{domain}
</span>
))}
</div>
) : (
<p className="text-[14px] text-gray-600">
No email domains are configured yet.
</p>
)}
{isOwner ? (
<DenButton
type="button"
size="sm"
variant="secondary"
icon={Pencil}
onClick={() => {
setPageError(null);
setPageSuccess(null);
setDomainEditModeEnabled(true);
}}
>
Edit
</DenButton>
) : null}
</div>
</div>
) : null}
</DenCard>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] text-gray-500">
{!isOwner && "Only workspace owners can change these settings."}
</p>
{isOwner ? (
<DenButton
type="submit"
loading={mutationBusy === "update-organization-name"}
>
Save settings
</DenButton>
) : null}
</div>
</form>
</DashboardPageTemplate>
);
}

View File

@@ -30,6 +30,7 @@ type OrgDashboardContextValue = {
refreshOrgData: () => Promise<void>;
createOrganization: (name: string) => Promise<void>;
updateOrganizationName: (name: string) => Promise<void>;
updateOrganizationSettings: (input: { name?: string; allowedEmailDomains?: string[] | null }) => Promise<void>;
switchOrganization: (slug: string) => void;
inviteMember: (input: { email: string; role: string }) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
@@ -246,13 +247,29 @@ export function OrgDashboardProvider({
throw new Error("Enter an organization name.");
}
await updateOrganizationSettings({ name: trimmed });
}
async function updateOrganizationSettings(input: { name?: string; allowedEmailDomains?: string[] | null }) {
const body: { name?: string; allowedEmailDomains?: string[] | null } = {};
if (typeof input.name === "string") {
const trimmed = input.name.trim();
if (!trimmed) {
throw new Error("Enter an organization name.");
}
body.name = trimmed;
}
if (input.allowedEmailDomains !== undefined) {
body.allowedEmailDomains = input.allowedEmailDomains;
}
await runMutation("update-organization-name", async () => {
ensureActiveOrganizationSelected();
const { response, payload } = await requestJson(
"/v1/org",
{
method: "PATCH",
body: JSON.stringify({ name: trimmed }),
body: JSON.stringify(body),
},
12000,
);
@@ -459,10 +476,11 @@ export function OrgDashboardProvider({
orgError,
mutationBusy,
refreshOrgData,
createOrganization,
updateOrganizationName,
switchOrganization,
inviteMember,
createOrganization,
updateOrganizationName,
updateOrganizationSettings,
switchOrganization,
inviteMember,
cancelInvitation,
updateMemberRole,
removeMember,

View File

@@ -0,0 +1,5 @@
import { OrgSettingsScreen } from "../_components/org-settings-screen";
export default function OrgSettingsPage() {
return <OrgSettingsScreen />;
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE `organization`
ADD COLUMN `allowed_email_domains` json;

View File

@@ -78,6 +78,13 @@
"when": 1776440000000,
"tag": "0011_marketplaces",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1776628833797,
"tag": "0012_allowed_email_domains",
"breakpoints": true
}
]
}

View File

@@ -25,6 +25,7 @@ export const OrganizationTable = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
logo: varchar("logo", { length: 2048 }),
allowedEmailDomains: json("allowed_email_domains").$type<string[] | null>(),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 })