mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../o/[orgSlug]/dashboard/org-settings/page";
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrgSettingsScreen } from "../_components/org-settings-screen";
|
||||
|
||||
export default function OrgSettingsPage() {
|
||||
return <OrgSettingsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `organization`
|
||||
ADD COLUMN `allowed_email_domains` json;
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1776440000000,
|
||||
"tag": "0011_marketplaces",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1776628833797,
|
||||
"tag": "0012_allowed_email_domains",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user