diff --git a/ee/apps/den-controller/.env.example b/ee/apps/den-controller/.env.example index 783c5ea7..e201e7fa 100644 --- a/ee/apps/den-controller/.env.example +++ b/ee/apps/den-controller/.env.example @@ -12,6 +12,7 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= LOOPS_API_KEY= LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL= +LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL= PORT=8788 WORKER_PROXY_PORT=8789 CORS_ORIGINS=http://localhost:3005,http://localhost:5173 diff --git a/ee/apps/den-controller/drizzle/0004_organization_plugin.manual.sql b/ee/apps/den-controller/drizzle/0004_organization_plugin.manual.sql new file mode 100644 index 00000000..63fe1e78 --- /dev/null +++ b/ee/apps/den-controller/drizzle/0004_organization_plugin.manual.sql @@ -0,0 +1,105 @@ +-- Manual SQL variant for MySQL/Vitess consoles. +-- - No `--> statement-breakpoint` markers +-- - No PREPARE/EXECUTE dynamic SQL +-- - Avoids `ADD COLUMN IF NOT EXISTS` (not supported in some Vitess setups) + +-- Run these only if the columns are missing. +-- Check first: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'session' +-- AND column_name IN ('active_organization_id', 'active_team_id'); + +ALTER TABLE `session` + ADD COLUMN `active_organization_id` varchar(64) NULL; + +ALTER TABLE `session` + ADD COLUMN `active_team_id` varchar(64) NULL; + +CREATE TABLE IF NOT EXISTS `organization` ( + `id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `logo` varchar(2048), + `metadata` text, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `organization_id` PRIMARY KEY(`id`), + CONSTRAINT `organization_slug` UNIQUE(`slug`) +); + +CREATE TABLE IF NOT EXISTS `member` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `user_id` varchar(64) NOT NULL, + `role` varchar(255) NOT NULL DEFAULT 'member', + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `member_id` PRIMARY KEY(`id`), + CONSTRAINT `member_organization_user` UNIQUE(`organization_id`, `user_id`), + KEY `member_organization_id` (`organization_id`), + KEY `member_user_id` (`user_id`) +); + +CREATE TABLE IF NOT EXISTS `invitation` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `email` varchar(255) NOT NULL, + `role` varchar(255) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `team_id` varchar(64) DEFAULT NULL, + `inviter_id` varchar(64) NOT NULL, + `expires_at` timestamp(3) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `invitation_id` PRIMARY KEY(`id`), + KEY `invitation_organization_id` (`organization_id`), + KEY `invitation_email` (`email`), + KEY `invitation_status` (`status`), + KEY `invitation_team_id` (`team_id`) +); + +CREATE TABLE IF NOT EXISTS `team` ( + `id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `team_id` PRIMARY KEY(`id`), + CONSTRAINT `team_organization_name` UNIQUE(`organization_id`, `name`), + KEY `team_organization_id` (`organization_id`) +); + +CREATE TABLE IF NOT EXISTS `team_member` ( + `id` varchar(64) NOT NULL, + `team_id` varchar(64) NOT NULL, + `user_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `team_member_id` PRIMARY KEY(`id`), + CONSTRAINT `team_member_team_user` UNIQUE(`team_id`, `user_id`), + KEY `team_member_team_id` (`team_id`), + KEY `team_member_user_id` (`user_id`) +); + +CREATE TABLE IF NOT EXISTS `organization_role` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `role` varchar(255) NOT NULL, + `permission` text NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `organization_role_id` PRIMARY KEY(`id`), + CONSTRAINT `organization_role_name` UNIQUE(`organization_id`, `role`), + KEY `organization_role_organization_id` (`organization_id`) +); + +-- Optional legacy backfill. Run only if these legacy tables exist: +-- org +-- org_membership +-- +-- INSERT INTO `organization` (`id`, `name`, `slug`, `logo`, `metadata`, `created_at`, `updated_at`) +-- SELECT `id`, `name`, `slug`, NULL, NULL, `created_at`, `updated_at` +-- FROM `org` +-- WHERE `id` NOT IN (SELECT `id` FROM `organization`); +-- +-- INSERT INTO `member` (`id`, `organization_id`, `user_id`, `role`, `created_at`) +-- SELECT `id`, `org_id`, `user_id`, `role`, `created_at` +-- FROM `org_membership` +-- WHERE `id` NOT IN (SELECT `id` FROM `member`); diff --git a/ee/apps/den-controller/drizzle/0004_organization_plugin.sql b/ee/apps/den-controller/drizzle/0004_organization_plugin.sql new file mode 100644 index 00000000..646d9ef9 --- /dev/null +++ b/ee/apps/den-controller/drizzle/0004_organization_plugin.sql @@ -0,0 +1,152 @@ +SET @has_active_organization_id := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'session' + AND column_name = 'active_organization_id' +); +--> statement-breakpoint +SET @add_active_organization_id_sql := IF( + @has_active_organization_id = 0, + 'ALTER TABLE `session` ADD COLUMN `active_organization_id` varchar(64) NULL', + 'SELECT 1' +); +--> statement-breakpoint +PREPARE add_active_organization_id_stmt FROM @add_active_organization_id_sql; +--> statement-breakpoint +EXECUTE add_active_organization_id_stmt; +--> statement-breakpoint +DEALLOCATE PREPARE add_active_organization_id_stmt; +--> statement-breakpoint +SET @has_active_team_id := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'session' + AND column_name = 'active_team_id' +); +--> statement-breakpoint +SET @add_active_team_id_sql := IF( + @has_active_team_id = 0, + 'ALTER TABLE `session` ADD COLUMN `active_team_id` varchar(64) NULL', + 'SELECT 1' +); +--> statement-breakpoint +PREPARE add_active_team_id_stmt FROM @add_active_team_id_sql; +--> statement-breakpoint +EXECUTE add_active_team_id_stmt; +--> statement-breakpoint +DEALLOCATE PREPARE add_active_team_id_stmt; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `organization` ( + `id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `logo` varchar(2048), + `metadata` text, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `organization_id` PRIMARY KEY(`id`), + CONSTRAINT `organization_slug` UNIQUE(`slug`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `member` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `user_id` varchar(64) NOT NULL, + `role` varchar(255) NOT NULL DEFAULT 'member', + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `member_id` PRIMARY KEY(`id`), + CONSTRAINT `member_organization_user` UNIQUE(`organization_id`, `user_id`), + KEY `member_organization_id` (`organization_id`), + KEY `member_user_id` (`user_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `invitation` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `email` varchar(255) NOT NULL, + `role` varchar(255) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `team_id` varchar(64) DEFAULT NULL, + `inviter_id` varchar(64) NOT NULL, + `expires_at` timestamp(3) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `invitation_id` PRIMARY KEY(`id`), + KEY `invitation_organization_id` (`organization_id`), + KEY `invitation_email` (`email`), + KEY `invitation_status` (`status`), + KEY `invitation_team_id` (`team_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `team` ( + `id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `team_id` PRIMARY KEY(`id`), + CONSTRAINT `team_organization_name` UNIQUE(`organization_id`, `name`), + KEY `team_organization_id` (`organization_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `team_member` ( + `id` varchar(64) NOT NULL, + `team_id` varchar(64) NOT NULL, + `user_id` varchar(64) NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `team_member_id` PRIMARY KEY(`id`), + CONSTRAINT `team_member_team_user` UNIQUE(`team_id`, `user_id`), + KEY `team_member_team_id` (`team_id`), + KEY `team_member_user_id` (`user_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `organization_role` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `role` varchar(255) NOT NULL, + `permission` text NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `organization_role_id` PRIMARY KEY(`id`), + CONSTRAINT `organization_role_name` UNIQUE(`organization_id`, `role`), + KEY `organization_role_organization_id` (`organization_id`) +); +--> statement-breakpoint +SET @has_legacy_org_table := ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'org' +); +--> statement-breakpoint +SET @copy_legacy_org_sql := IF( + @has_legacy_org_table > 0, + 'INSERT INTO `organization` (`id`, `name`, `slug`, `logo`, `metadata`, `created_at`, `updated_at`) SELECT `id`, `name`, `slug`, NULL, NULL, `created_at`, `updated_at` FROM `org` WHERE `id` NOT IN (SELECT `id` FROM `organization`)', + 'SELECT 1' +); +--> statement-breakpoint +PREPARE copy_legacy_org_stmt FROM @copy_legacy_org_sql; +--> statement-breakpoint +EXECUTE copy_legacy_org_stmt; +--> statement-breakpoint +DEALLOCATE PREPARE copy_legacy_org_stmt; +--> statement-breakpoint +SET @has_legacy_org_membership_table := ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'org_membership' +); +--> statement-breakpoint +SET @copy_legacy_org_membership_sql := IF( + @has_legacy_org_membership_table > 0, + 'INSERT INTO `member` (`id`, `organization_id`, `user_id`, `role`, `created_at`) SELECT `id`, `org_id`, `user_id`, `role`, `created_at` FROM `org_membership` WHERE `id` NOT IN (SELECT `id` FROM `member`)', + 'SELECT 1' +); +--> statement-breakpoint +PREPARE copy_legacy_org_membership_stmt FROM @copy_legacy_org_membership_sql; +--> statement-breakpoint +EXECUTE copy_legacy_org_membership_stmt; +--> statement-breakpoint +DEALLOCATE PREPARE copy_legacy_org_membership_stmt; diff --git a/ee/apps/den-controller/drizzle/0005_temp_template_sharing.sql b/ee/apps/den-controller/drizzle/0005_temp_template_sharing.sql new file mode 100644 index 00000000..21348a06 --- /dev/null +++ b/ee/apps/den-controller/drizzle/0005_temp_template_sharing.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS `temp_template_sharing` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `creator_member_id` varchar(64) NOT NULL, + `creator_user_id` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `template_json` text NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `temp_template_sharing_id` PRIMARY KEY(`id`), + KEY `temp_template_sharing_org_id` (`organization_id`), + KEY `temp_template_sharing_creator_member_id` (`creator_member_id`), + KEY `temp_template_sharing_creator_user_id` (`creator_user_id`) +); diff --git a/ee/apps/den-controller/package.json b/ee/apps/den-controller/package.json index c2935a35..842b3511 100644 --- a/ee/apps/den-controller/package.json +++ b/ee/apps/den-controller/package.json @@ -19,6 +19,7 @@ "@openwork-ee/den-db": "workspace:*", "@openwork-ee/utils": "workspace:*", "@daytonaio/sdk": "^0.150.0", + "better-call": "^1.1.8", "better-auth": "^1.4.18", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/ee/apps/den-controller/src/auth.ts b/ee/apps/den-controller/src/auth.ts index d3a650a7..d0b6bdfa 100644 --- a/ee/apps/den-controller/src/auth.ts +++ b/ee/apps/den-controller/src/auth.ts @@ -1,13 +1,15 @@ import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" -import { emailOTP } from "better-auth/plugins" +import { emailOTP, organization } from "better-auth/plugins" +import { APIError } from "better-call" import { db } from "./db/index.js" import * as schema from "./db/schema.js" import { createDenTypeId, normalizeDenTypeId } from "./db/typeid.js" -import { sendDenVerificationEmail } from "./email.js" +import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js" import { env } from "./env.js" import { syncDenSignupContact } from "./loops.js" -import { ensureDefaultOrg } from "./orgs.js" +import { ensureUserOrgAccess, seedDefaultOrganizationRoles } from "./orgs.js" +import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js" const socialProviders = { ...(env.github.clientId && env.github.clientSecret @@ -28,6 +30,22 @@ const socialProviders = { : {}), } +function hasRole(roleValue: string, roleName: string) { + return roleValue + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .includes(roleName) +} + +function getInvitationOrigin() { + return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl +} + +function buildInvitationLink(invitationId: string) { + return new URL(`/?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() +} + export const auth = betterAuth({ baseURL: env.betterAuthUrl, secret: env.betterAuthSecret, @@ -53,6 +71,20 @@ export const auth = betterAuth({ return createDenTypeId("account") case "verification": return createDenTypeId("verification") + case "rateLimit": + return createDenTypeId("rateLimit") + case "organization": + return createDenTypeId("organization") + case "member": + return createDenTypeId("member") + case "invitation": + return createDenTypeId("invitation") + case "team": + return createDenTypeId("team") + case "teamMember": + return createDenTypeId("teamMember") + case "organizationRole": + return createDenTypeId("organizationRole") default: return false } @@ -91,10 +123,13 @@ export const auth = betterAuth({ sendOnSignUp: true, sendOnSignIn: true, afterEmailVerification: async (user) => { - const name = user.name ?? user.email ?? "Personal" const userId = normalizeDenTypeId("user", user.id) await Promise.all([ - ensureDefaultOrg(userId, name), + ensureUserOrgAccess({ + userId, + email: user.email, + name: user.name, + }), syncDenSignupContact({ email: user.email, name: user.name, @@ -124,5 +159,55 @@ export const auth = betterAuth({ }) }, }), + organization({ + ac: denOrganizationAccess, + roles: denOrganizationStaticRoles, + creatorRole: "owner", + requireEmailVerificationOnInvitation: true, + dynamicAccessControl: { + enabled: true, + }, + teams: { + enabled: true, + defaultTeam: { + enabled: false, + }, + }, + async sendInvitationEmail(data) { + await sendDenOrganizationInvitationEmail({ + email: data.email, + inviteLink: buildInvitationLink(data.id), + invitedByName: data.inviter.user.name ?? data.inviter.user.email, + invitedByEmail: data.inviter.user.email, + organizationName: data.organization.name, + role: data.role, + }) + }, + organizationHooks: { + afterCreateOrganization: async ({ organization }) => { + await seedDefaultOrganizationRoles(normalizeDenTypeId("organization", organization.id)) + }, + beforeRemoveMember: async ({ member }) => { + if (hasRole(member.role, "owner")) { + throw new APIError("BAD_REQUEST", { + message: "The organization owner cannot be removed.", + }) + } + }, + beforeUpdateMemberRole: async ({ member, newRole }) => { + if (hasRole(member.role, "owner")) { + throw new APIError("BAD_REQUEST", { + message: "The organization owner role cannot be changed.", + }) + } + + if (hasRole(newRole, "owner")) { + throw new APIError("BAD_REQUEST", { + message: "Owner can only be assigned during organization creation.", + }) + } + }, + }, + }), ], }) diff --git a/ee/apps/den-controller/src/email.ts b/ee/apps/den-controller/src/email.ts index d81436a8..369c4539 100644 --- a/ee/apps/den-controller/src/email.ts +++ b/ee/apps/den-controller/src/email.ts @@ -2,26 +2,26 @@ import { env } from "./env.js" const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional" -export async function sendDenVerificationEmail(input: { +async function sendLoopsTransactionalEmail(input: { email: string - verificationCode: string + transactionalId: string | undefined + dataVariables: Record + logLabel: string }) { const apiKey = env.loops.apiKey - const transactionalId = env.loops.transactionalIdDenVerifyEmail const email = input.email.trim() - const verificationCode = input.verificationCode.trim() - if (!email || !verificationCode) { + if (!email) { return } if (env.devMode) { - console.info(`[auth] dev verification code for ${email}: ${verificationCode}`) + console.info(`[auth] dev ${input.logLabel} payload for ${email}: ${JSON.stringify(input.dataVariables)}`) return } - if (!apiKey || !transactionalId) { - console.warn(`[auth] verification email skipped for ${email}: Loops is not configured`) + if (!apiKey || !input.transactionalId) { + console.warn(`[auth] ${input.logLabel} skipped for ${email}: Loops is not configured`) return } @@ -33,11 +33,9 @@ export async function sendDenVerificationEmail(input: { "Content-Type": "application/json", }, body: JSON.stringify({ - transactionalId, + transactionalId: input.transactionalId, email, - dataVariables: { - verificationCode, - }, + dataVariables: input.dataVariables, }), }) @@ -55,9 +53,51 @@ export async function sendDenVerificationEmail(input: { // Ignore invalid upstream payloads. } - console.warn(`[auth] failed to send verification email for ${email}: ${detail}`) + console.warn(`[auth] failed to send ${input.logLabel} for ${email}: ${detail}`) } catch (error) { const message = error instanceof Error ? error.message : "Unknown error" - console.warn(`[auth] failed to send verification email for ${email}: ${message}`) + console.warn(`[auth] failed to send ${input.logLabel} for ${email}: ${message}`) } } + +export async function sendDenVerificationEmail(input: { + email: string + verificationCode: string +}) { + const verificationCode = input.verificationCode.trim() + + if (!input.email.trim() || !verificationCode) { + return + } + + await sendLoopsTransactionalEmail({ + email: input.email, + transactionalId: env.loops.transactionalIdDenVerifyEmail, + dataVariables: { + verificationCode, + }, + logLabel: "verification email", + }) +} + +export async function sendDenOrganizationInvitationEmail(input: { + email: string + inviteLink: string + invitedByName: string + invitedByEmail: string + organizationName: string + role: string +}) { + await sendLoopsTransactionalEmail({ + email: input.email, + transactionalId: env.loops.transactionalIdDenOrgInviteEmail, + dataVariables: { + inviteLink: input.inviteLink, + invitedByName: input.invitedByName, + invitedByEmail: input.invitedByEmail, + organizationName: input.organizationName, + role: input.role, + }, + logLabel: "organization invite email", + }) +} diff --git a/ee/apps/den-controller/src/env.ts b/ee/apps/den-controller/src/env.ts index 0ec7986f..966ab777 100644 --- a/ee/apps/den-controller/src/env.ts +++ b/ee/apps/den-controller/src/env.ts @@ -16,6 +16,7 @@ const schema = z.object({ GOOGLE_CLIENT_SECRET: z.string().optional(), LOOPS_API_KEY: z.string().optional(), LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL: z.string().optional(), + LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL: z.string().optional(), PORT: z.string().optional(), WORKER_PROXY_PORT: z.string().optional(), OPENWORK_DEV_MODE: z.string().optional(), @@ -161,6 +162,7 @@ export const env = { loops: { apiKey: optionalString(parsed.LOOPS_API_KEY), transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL), + transactionalIdDenOrgInviteEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL), }, port: Number(parsed.PORT ?? "8788"), workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"), diff --git a/ee/apps/den-controller/src/http/orgs.ts b/ee/apps/den-controller/src/http/orgs.ts new file mode 100644 index 00000000..e0feb992 --- /dev/null +++ b/ee/apps/den-controller/src/http/orgs.ts @@ -0,0 +1,875 @@ +import express from "express" +import { z } from "zod" +import { and, desc, eq, gt } from "../db/drizzle.js" +import { db } from "../db/index.js" +import { + AuthUserTable, + InvitationTable, + MemberTable, + OrganizationRoleTable, + OrganizationTable, + TempTemplateSharingTable, +} from "../db/schema.js" +import { createDenTypeId, normalizeDenTypeId } from "../db/typeid.js" +import { sendDenOrganizationInvitationEmail } from "../email.js" +import { env } from "../env.js" +import { + acceptInvitationForUser, + createOrganizationForUser, + getOrganizationContextForUser, + listAssignableRoles, + removeOrganizationMember, + roleIncludesOwner, + serializePermissionRecord, + setSessionActiveOrganization, +} from "../orgs.js" +import { asyncRoute } from "./errors.js" +import { getRequestSession } from "./session.js" + +const createOrganizationSchema = z.object({ + name: z.string().trim().min(2).max(120), +}) + +const inviteMemberSchema = z.object({ + email: z.string().email(), + role: z.string().trim().min(1).max(64), +}) + +const updateMemberRoleSchema = z.object({ + role: z.string().trim().min(1).max(64), +}) + +const permissionSchema = z.record(z.string(), z.array(z.string())) + +type InvitationId = typeof InvitationTable.$inferSelect.id +type MemberId = typeof MemberTable.$inferSelect.id +type OrganizationRoleId = typeof OrganizationRoleTable.$inferSelect.id +type TemplateSharingId = typeof TempTemplateSharingTable.$inferSelect.id + +const createRoleSchema = z.object({ + roleName: z.string().trim().min(2).max(64), + permission: permissionSchema, +}) + +const updateRoleSchema = z.object({ + roleName: z.string().trim().min(2).max(64).optional(), + permission: permissionSchema.optional(), +}) + +const createTemplateSchema = z.object({ + name: z.string().trim().min(1).max(255), + templateData: z.unknown(), +}) + +function splitRoles(value: string) { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function memberHasRole(value: string, role: string) { + return splitRoles(value).includes(role) +} + +function normalizeRoleName(value: string) { + return value + .trim() + .toLowerCase() + .replace(/\s+/g, "-") +} + +function replaceRoleValue(value: string, previousRole: string, nextRole: string | null) { + const existing = splitRoles(value) + const remaining = existing.filter((role) => role !== previousRole) + + if (nextRole && !remaining.includes(nextRole)) { + remaining.push(nextRole) + } + + return remaining[0] ? remaining.join(",") : "member" +} + +function getInvitationOrigin() { + return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl +} + +function buildInvitationLink(invitationId: string) { + return new URL(`/?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() +} + +function parseTemplateJson(value: string) { + try { + return JSON.parse(value) + } catch { + return null + } +} + +async function requireSession(req: express.Request, res: express.Response) { + const session = await getRequestSession(req) + if (!session?.user?.id) { + res.status(401).json({ error: "unauthorized" }) + return null + } + + const sessionId = typeof session.session?.id === "string" + ? normalizeDenTypeId("session", session.session.id) + : null + + return { + ...session, + sessionId, + user: { + ...session.user, + id: normalizeDenTypeId("user", session.user.id), + }, + } +} + +async function requireOrganizationContext(req: express.Request, res: express.Response) { + const session = await requireSession(req, res) + if (!session) { + return null + } + + const organizationSlug = req.params.orgSlug?.trim() + if (!organizationSlug) { + res.status(400).json({ error: "organization_slug_required" }) + return null + } + + const context = await getOrganizationContextForUser({ + userId: session.user.id, + organizationSlug, + }) + + if (!context) { + res.status(404).json({ error: "organization_not_found" }) + return null + } + + if (session.sessionId) { + await setSessionActiveOrganization(session.sessionId, context.organization.id) + } + + return { + session, + context, + } +} + +function ensureOwner(context: Awaited>, res: express.Response) { + if (!context) { + return false + } + + if (!context.context.currentMember.isOwner) { + res.status(403).json({ + error: "forbidden", + message: "Only organization owners can manage members and roles.", + }) + return false + } + + return true +} + +function ensureInviteManager(context: Awaited>, res: express.Response) { + if (!context) { + return false + } + + if (context.context.currentMember.isOwner || memberHasRole(context.context.currentMember.role, "admin")) { + return true + } + + res.status(403).json({ + error: "forbidden", + message: "Only organization owners and admins can invite members.", + }) + return false +} + +export const orgsRouter = express.Router() + +orgsRouter.post("/", asyncRoute(async (req, res) => { + const session = await requireSession(req, res) + if (!session) { + return + } + + const parsed = createOrganizationSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + const organizationId = await createOrganizationForUser({ + userId: session.user.id, + name: parsed.data.name, + }) + + if (session.sessionId) { + await setSessionActiveOrganization(session.sessionId, organizationId) + } + + const context = await getOrganizationContextForUser({ + userId: session.user.id, + organizationSlug: organizationId, + }) + + res.status(201).json({ organization: context?.organization ?? null }) +})) + +orgsRouter.get("/invitations/accept", asyncRoute(async (req, res) => { + const session = await requireSession(req, res) + if (!session) { + return + } + + const invitationIdRaw = typeof req.query.id === "string" ? req.query.id.trim() : "" + const accepted = await acceptInvitationForUser({ + userId: session.user.id, + email: session.user.email ?? `${session.user.id}@placeholder.local`, + invitationId: invitationIdRaw || null, + }) + + if (!accepted) { + res.status(404).json({ error: "invitation_not_found" }) + return + } + + if (session.sessionId) { + await setSessionActiveOrganization(session.sessionId, accepted.member.organizationId) + } + + const orgRows = await db + .select({ slug: OrganizationTable.slug }) + .from(OrganizationTable) + .where(eq(OrganizationTable.id, accepted.member.organizationId)) + .limit(1) + + res.json({ + accepted: true, + organizationId: accepted.member.organizationId, + organizationSlug: orgRows[0]?.slug ?? null, + invitationId: accepted.invitation.id, + }) +})) + +orgsRouter.get("/:orgSlug/context", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload) { + return + } + + res.json(payload.context) +})) + +orgsRouter.post("/:orgSlug/invitations", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureInviteManager(payload, res)) { + return + } + + const parsed = inviteMemberSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + const email = parsed.data.email.trim().toLowerCase() + const availableRoles = await listAssignableRoles(payload.context.organization.id) + const role = normalizeRoleName(parsed.data.role) + if (!availableRoles.has(role)) { + res.status(400).json({ + error: "invalid_role", + message: "Choose one of the existing organization roles.", + }) + return + } + + const existingMembers = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .where( + and( + eq(MemberTable.organizationId, payload.context.organization.id), + eq(AuthUserTable.email, email), + ), + ) + .limit(1) + + if (existingMembers[0]) { + res.status(409).json({ + error: "member_exists", + message: "That email address is already a member of this organization.", + }) + return + } + + const existingInvitation = await db + .select() + .from(InvitationTable) + .where( + and( + eq(InvitationTable.organizationId, payload.context.organization.id), + eq(InvitationTable.email, email), + eq(InvitationTable.status, "pending"), + gt(InvitationTable.expiresAt, new Date()), + ), + ) + .limit(1) + + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) + const invitationId = existingInvitation[0]?.id ?? createInvitationId() + + if (existingInvitation[0]) { + await db + .update(InvitationTable) + .set({ + role, + inviterId: payload.session.user.id, + expiresAt, + }) + .where(eq(InvitationTable.id, existingInvitation[0].id)) + } else { + await db.insert(InvitationTable).values({ + id: invitationId, + organizationId: payload.context.organization.id, + email, + role, + status: "pending", + inviterId: payload.session.user.id, + expiresAt, + }) + } + + await sendDenOrganizationInvitationEmail({ + email, + inviteLink: buildInvitationLink(invitationId), + invitedByName: payload.session.user.name ?? payload.session.user.email ?? "OpenWork", + invitedByEmail: payload.session.user.email ?? "", + organizationName: payload.context.organization.name, + role, + }) + + res.status(existingInvitation[0] ? 200 : 201).json({ + invitationId, + email, + role, + expiresAt, + }) +})) + +orgsRouter.post("/:orgSlug/invitations/:invitationId/cancel", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureInviteManager(payload, res)) { + return + } + + let invitationId: InvitationId + try { + invitationId = normalizeDenTypeId("invitation", req.params.invitationId) + } catch { + res.status(404).json({ error: "invitation_not_found" }) + return + } + + const invitationRows = await db + .select({ id: InvitationTable.id, status: InvitationTable.status }) + .from(InvitationTable) + .where( + and( + eq(InvitationTable.id, invitationId), + eq(InvitationTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + if (!invitationRows[0]) { + res.status(404).json({ error: "invitation_not_found" }) + return + } + + await db + .update(InvitationTable) + .set({ status: "canceled" }) + .where(eq(InvitationTable.id, invitationId)) + + res.json({ success: true }) +})) + +orgsRouter.post("/:orgSlug/members/:memberId/role", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureOwner(payload, res)) { + return + } + + const parsed = updateMemberRoleSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + let memberId: MemberId + try { + memberId = normalizeDenTypeId("member", req.params.memberId) + } catch { + res.status(404).json({ error: "member_not_found" }) + return + } + + const memberRows = await db + .select() + .from(MemberTable) + .where( + and( + eq(MemberTable.id, memberId), + eq(MemberTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + const member = memberRows[0] + if (!member) { + res.status(404).json({ error: "member_not_found" }) + return + } + + if (roleIncludesOwner(member.role)) { + res.status(400).json({ + error: "owner_role_locked", + message: "The organization owner role cannot be changed.", + }) + return + } + + const role = normalizeRoleName(parsed.data.role) + const availableRoles = await listAssignableRoles(payload.context.organization.id) + if (!availableRoles.has(role)) { + res.status(400).json({ error: "invalid_role", message: "Choose one of the existing organization roles." }) + return + } + + await db + .update(MemberTable) + .set({ role }) + .where(eq(MemberTable.id, member.id)) + + res.json({ success: true }) +})) + +orgsRouter.delete("/:orgSlug/members/:memberId", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureOwner(payload, res)) { + return + } + + let memberId: MemberId + try { + memberId = normalizeDenTypeId("member", req.params.memberId) + } catch { + res.status(404).json({ error: "member_not_found" }) + return + } + + const memberRows = await db + .select() + .from(MemberTable) + .where( + and( + eq(MemberTable.id, memberId), + eq(MemberTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + const member = memberRows[0] + if (!member) { + res.status(404).json({ error: "member_not_found" }) + return + } + + if (roleIncludesOwner(member.role)) { + res.status(400).json({ + error: "owner_role_locked", + message: "The organization owner cannot be removed.", + }) + return + } + + await removeOrganizationMember({ + organizationId: payload.context.organization.id, + memberId: member.id, + }) + res.status(204).end() +})) + +orgsRouter.post("/:orgSlug/roles", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureOwner(payload, res)) { + return + } + + const parsed = createRoleSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + const roleName = normalizeRoleName(parsed.data.roleName) + if (roleName === "owner") { + res.status(400).json({ error: "invalid_role", message: "Owner is managed by the system." }) + return + } + + const existing = await db + .select({ id: OrganizationRoleTable.id }) + .from(OrganizationRoleTable) + .where( + and( + eq(OrganizationRoleTable.organizationId, payload.context.organization.id), + eq(OrganizationRoleTable.role, roleName), + ), + ) + .limit(1) + + if (existing[0]) { + res.status(409).json({ error: "role_exists", message: "That role already exists in this organization." }) + return + } + + await db.insert(OrganizationRoleTable).values({ + id: createRoleId(), + organizationId: payload.context.organization.id, + role: roleName, + permission: serializePermissionRecord(parsed.data.permission), + }) + + res.status(201).json({ success: true }) +})) + +orgsRouter.patch("/:orgSlug/roles/:roleId", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureOwner(payload, res)) { + return + } + + const parsed = updateRoleSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + let roleId: OrganizationRoleId + try { + roleId = normalizeDenTypeId("organizationRole", req.params.roleId) + } catch { + res.status(404).json({ error: "role_not_found" }) + return + } + + const roleRows = await db + .select() + .from(OrganizationRoleTable) + .where( + and( + eq(OrganizationRoleTable.id, roleId), + eq(OrganizationRoleTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + const roleRow = roleRows[0] + if (!roleRow) { + res.status(404).json({ error: "role_not_found" }) + return + } + + const nextRoleName = parsed.data.roleName ? normalizeRoleName(parsed.data.roleName) : roleRow.role + if (nextRoleName === "owner") { + res.status(400).json({ error: "invalid_role", message: "Owner is managed by the system." }) + return + } + + if (nextRoleName !== roleRow.role) { + const duplicate = await db + .select({ id: OrganizationRoleTable.id }) + .from(OrganizationRoleTable) + .where( + and( + eq(OrganizationRoleTable.organizationId, payload.context.organization.id), + eq(OrganizationRoleTable.role, nextRoleName), + ), + ) + .limit(1) + + if (duplicate[0]) { + res.status(409).json({ error: "role_exists", message: "That role name is already in use." }) + return + } + } + + const nextPermission = parsed.data.permission + ? serializePermissionRecord(parsed.data.permission) + : roleRow.permission + + await db + .update(OrganizationRoleTable) + .set({ + role: nextRoleName, + permission: nextPermission, + }) + .where(eq(OrganizationRoleTable.id, roleRow.id)) + + if (nextRoleName !== roleRow.role) { + const members = await db + .select() + .from(MemberTable) + .where(eq(MemberTable.organizationId, payload.context.organization.id)) + + for (const member of members) { + if (!splitRoles(member.role).includes(roleRow.role)) { + continue + } + + await db + .update(MemberTable) + .set({ role: replaceRoleValue(member.role, roleRow.role, nextRoleName) }) + .where(eq(MemberTable.id, member.id)) + } + + const invitations = await db + .select() + .from(InvitationTable) + .where(eq(InvitationTable.organizationId, payload.context.organization.id)) + + for (const invitation of invitations) { + if (!splitRoles(invitation.role).includes(roleRow.role)) { + continue + } + + await db + .update(InvitationTable) + .set({ role: replaceRoleValue(invitation.role, roleRow.role, nextRoleName) }) + .where(eq(InvitationTable.id, invitation.id)) + } + } + + res.json({ success: true }) +})) + +orgsRouter.delete("/:orgSlug/roles/:roleId", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload || !ensureOwner(payload, res)) { + return + } + + let roleId: OrganizationRoleId + try { + roleId = normalizeDenTypeId("organizationRole", req.params.roleId) + } catch { + res.status(404).json({ error: "role_not_found" }) + return + } + + const roleRows = await db + .select() + .from(OrganizationRoleTable) + .where( + and( + eq(OrganizationRoleTable.id, roleId), + eq(OrganizationRoleTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + const roleRow = roleRows[0] + if (!roleRow) { + res.status(404).json({ error: "role_not_found" }) + return + } + + const membersUsingRole = await db + .select({ id: MemberTable.id, role: MemberTable.role }) + .from(MemberTable) + .where(eq(MemberTable.organizationId, payload.context.organization.id)) + + if (membersUsingRole.some((member) => splitRoles(member.role).includes(roleRow.role))) { + res.status(400).json({ + error: "role_in_use", + message: "Update members using this role before deleting it.", + }) + return + } + + const invitationsUsingRole = await db + .select({ id: InvitationTable.id, role: InvitationTable.role }) + .from(InvitationTable) + .where(eq(InvitationTable.organizationId, payload.context.organization.id)) + + if (invitationsUsingRole.some((invitation) => splitRoles(invitation.role).includes(roleRow.role))) { + res.status(400).json({ + error: "role_in_use", + message: "Cancel or update pending invitations using this role before deleting it.", + }) + return + } + + await db.delete(OrganizationRoleTable).where(eq(OrganizationRoleTable.id, roleRow.id)) + res.status(204).end() +})) + +orgsRouter.post("/:orgSlug/templates", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload) { + return + } + + const parsed = createTemplateSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + const templateId = createDenTypeId("tempTemplateSharing") + const now = new Date() + + await db.insert(TempTemplateSharingTable).values({ + id: templateId, + organizationId: payload.context.organization.id, + creatorMemberId: payload.context.currentMember.id, + creatorUserId: payload.session.user.id, + name: parsed.data.name, + templateJson: JSON.stringify(parsed.data.templateData), + createdAt: now, + updatedAt: now, + }) + + res.status(201).json({ + template: { + id: templateId, + name: parsed.data.name, + templateData: parsed.data.templateData, + createdAt: now, + updatedAt: now, + organizationId: payload.context.organization.id, + creator: { + memberId: payload.context.currentMember.id, + userId: payload.session.user.id, + role: payload.context.currentMember.role, + name: payload.session.user.name, + email: payload.session.user.email, + }, + }, + }) +})) + +orgsRouter.get("/:orgSlug/templates", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload) { + return + } + + const templates = await db + .select({ + template: { + id: TempTemplateSharingTable.id, + organizationId: TempTemplateSharingTable.organizationId, + name: TempTemplateSharingTable.name, + templateJson: TempTemplateSharingTable.templateJson, + createdAt: TempTemplateSharingTable.createdAt, + updatedAt: TempTemplateSharingTable.updatedAt, + }, + creatorMember: { + id: MemberTable.id, + role: MemberTable.role, + }, + creatorUser: { + id: AuthUserTable.id, + name: AuthUserTable.name, + email: AuthUserTable.email, + image: AuthUserTable.image, + }, + }) + .from(TempTemplateSharingTable) + .innerJoin(MemberTable, eq(TempTemplateSharingTable.creatorMemberId, MemberTable.id)) + .innerJoin(AuthUserTable, eq(TempTemplateSharingTable.creatorUserId, AuthUserTable.id)) + .where(eq(TempTemplateSharingTable.organizationId, payload.context.organization.id)) + .orderBy(desc(TempTemplateSharingTable.createdAt)) + + res.json({ + templates: templates.map((row) => ({ + id: row.template.id, + organizationId: row.template.organizationId, + name: row.template.name, + templateData: parseTemplateJson(row.template.templateJson), + createdAt: row.template.createdAt, + updatedAt: row.template.updatedAt, + creator: { + memberId: row.creatorMember.id, + role: row.creatorMember.role, + userId: row.creatorUser.id, + name: row.creatorUser.name, + email: row.creatorUser.email, + image: row.creatorUser.image, + }, + })), + }) +})) + +orgsRouter.delete("/:orgSlug/templates/:templateId", asyncRoute(async (req, res) => { + const payload = await requireOrganizationContext(req, res) + if (!payload) { + return + } + + let templateId: TemplateSharingId + try { + templateId = normalizeDenTypeId("tempTemplateSharing", req.params.templateId) + } catch { + res.status(404).json({ error: "template_not_found" }) + return + } + + const templateRows = await db + .select() + .from(TempTemplateSharingTable) + .where( + and( + eq(TempTemplateSharingTable.id, templateId), + eq(TempTemplateSharingTable.organizationId, payload.context.organization.id), + ), + ) + .limit(1) + + const template = templateRows[0] + if (!template) { + res.status(404).json({ error: "template_not_found" }) + return + } + + const isOwner = payload.context.currentMember.isOwner + const isCreator = template.creatorMemberId === payload.context.currentMember.id + if (!isOwner && !isCreator) { + res.status(403).json({ + error: "forbidden", + message: "Only the template creator or organization owner can delete templates.", + }) + return + } + + await db.delete(TempTemplateSharingTable).where(eq(TempTemplateSharingTable.id, template.id)) + res.status(204).end() +})) + +function createInvitationId() { + return createDenTypeId("invitation") +} + +function createRoleId() { + return createDenTypeId("organizationRole") +} diff --git a/ee/apps/den-controller/src/http/session.ts b/ee/apps/den-controller/src/http/session.ts index 587b83bc..4c17e279 100644 --- a/ee/apps/den-controller/src/http/session.ts +++ b/ee/apps/den-controller/src/http/session.ts @@ -30,6 +30,8 @@ async function getSessionFromBearerToken(token: string): Promise randomBytes(32).toString("hex") type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect type WorkerId = WorkerRow["id"] -type OrgId = typeof OrgMembershipTable.$inferSelect.org_id +type OrgId = typeof OrgMembershipTable.$inferSelect.organizationId type UserId = typeof AuthUserTable.$inferSelect.id function parseWorkerIdParam(value: string): WorkerId { @@ -281,16 +281,24 @@ async function requireSession(req: express.Request, res: express.Response) { } } -async function getOrgId(userId: UserId): Promise { - const membership = await db - .select() - .from(OrgMembershipTable) - .where(eq(OrgMembershipTable.user_id, userId)) - .limit(1) - if (membership.length === 0) { +async function resolveActiveOrgId(session: Awaited>): Promise { + if (!session) { return null } - return membership[0].org_id + + const sessionId = typeof session.session?.id === "string" + ? normalizeDenTypeId("session", session.session.id) + : null + + const resolved = await resolveUserOrganizationsForSession({ + sessionId, + activeOrganizationId: session.session?.activeOrganizationId ?? null, + userId: session.user.id, + email: session.user.email ?? `${session.user.id}@placeholder.local`, + name: session.user.name, + }) + + return resolved.activeOrgId } async function countUserCloudWorkers(userId: UserId) { @@ -494,7 +502,7 @@ workersRouter.get("/", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.json({ workers: [] }) return @@ -561,8 +569,11 @@ workersRouter.post("/", asyncRoute(async (req, res) => { } } - const orgId = - (await getOrgId(session.user.id)) ?? (await ensureDefaultOrg(session.user.id, session.user.name ?? session.user.email ?? "Personal")) + const orgId = await resolveActiveOrgId(session) + if (!orgId) { + res.status(400).json({ error: "organization_unavailable" }) + return + } const workerId = createDenTypeId("worker") let workerStatus: WorkerRow["status"] = parsed.data.destination === "cloud" ? "provisioning" : "healthy" @@ -634,6 +645,7 @@ workersRouter.post("/", asyncRoute(async (req, res) => { session.user.id, ), tokens: { + owner: hostToken, host: hostToken, client: clientToken, }, @@ -711,7 +723,7 @@ workersRouter.get("/:id", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return @@ -748,7 +760,7 @@ workersRouter.patch("/:id", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return @@ -800,7 +812,7 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return @@ -847,6 +859,7 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => { res.json({ tokens: { + owner: hostToken, host: hostToken, client: clientToken, }, @@ -858,7 +871,7 @@ workersRouter.get("/:id/runtime", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return @@ -895,7 +908,7 @@ workersRouter.post("/:id/runtime/upgrade", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return @@ -934,7 +947,7 @@ workersRouter.delete("/:id", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) return - const orgId = await getOrgId(session.user.id) + const orgId = await resolveActiveOrgId(session) if (!orgId) { res.status(404).json({ error: "worker_not_found" }) return diff --git a/ee/apps/den-controller/src/index.ts b/ee/apps/den-controller/src/index.ts index d6298885..1af4b9df 100644 --- a/ee/apps/den-controller/src/index.ts +++ b/ee/apps/den-controller/src/index.ts @@ -9,10 +9,11 @@ import { env } from "./env.js" import { adminRouter } from "./http/admin.js" import { desktopAuthRouter } from "./http/desktop-auth.js" import { asyncRoute, errorMiddleware } from "./http/errors.js" +import { orgsRouter } from "./http/orgs.js" import { getRequestSession } from "./http/session.js" import { workersRouter } from "./http/workers.js" import { normalizeDenTypeId } from "./db/typeid.js" -import { listUserOrgs } from "./orgs.js" +import { resolveUserOrganizationsForSession } from "./orgs.js" const app = express() const currentFile = fileURLToPath(import.meta.url) @@ -52,15 +53,27 @@ app.get("/v1/me/orgs", asyncRoute(async (req, res) => { return } - const orgs = await listUserOrgs(normalizeDenTypeId("user", session.user.id)) + const resolved = await resolveUserOrganizationsForSession({ + sessionId: session.session?.id ? normalizeDenTypeId("session", session.session.id) : null, + activeOrganizationId: session.session?.activeOrganizationId ?? null, + userId: normalizeDenTypeId("user", session.user.id), + email: session.user.email ?? `${session.user.id}@placeholder.local`, + name: session.user.name, + }) + res.json({ - orgs, - defaultOrgId: orgs[0]?.id ?? null, + orgs: resolved.orgs.map((org) => ({ + ...org, + isActive: org.id === resolved.activeOrgId, + })), + activeOrgId: resolved.activeOrgId, + activeOrgSlug: resolved.activeOrgSlug, }) })) app.use("/v1/admin", adminRouter) app.use("/v1/auth", desktopAuthRouter) +app.use("/v1/orgs", orgsRouter) app.use("/v1/workers", workersRouter) app.use(errorMiddleware) diff --git a/ee/apps/den-controller/src/organization-access.ts b/ee/apps/den-controller/src/organization-access.ts new file mode 100644 index 00000000..b22e8b58 --- /dev/null +++ b/ee/apps/den-controller/src/organization-access.ts @@ -0,0 +1,17 @@ +import { createAccessControl } from "better-auth/plugins/access" +import { defaultRoles, defaultStatements } from "better-auth/plugins/organization/access" + +export const denOrganizationAccess = createAccessControl(defaultStatements) + +export const denOrganizationStaticRoles = { + owner: defaultRoles.owner, + admin: defaultRoles.admin, + member: defaultRoles.member, +} as const + +export const denDefaultDynamicOrganizationRoles = { + admin: defaultRoles.admin.statements, + member: defaultRoles.member.statements, +} as const + +export const denOrganizationPermissionStatements = defaultStatements diff --git a/ee/apps/den-controller/src/orgs.ts b/ee/apps/den-controller/src/orgs.ts index 75f895ff..db2f14b3 100644 --- a/ee/apps/den-controller/src/orgs.ts +++ b/ee/apps/den-controller/src/orgs.ts @@ -1,97 +1,620 @@ -import { eq } from "./db/drizzle.js" +import { and, asc, eq, gt } from "./db/drizzle.js" import { db } from "./db/index.js" -import { AuthUserTable, OrgMembershipTable, OrgTable } from "./db/schema.js" -import { createDenTypeId } from "./db/typeid.js" +import { + AuthSessionTable, + AuthUserTable, + InvitationTable, + MemberTable, + OrganizationRoleTable, + OrganizationTable, + TeamMemberTable, + TeamTable, +} from "./db/schema.js" +import { createDenTypeId, normalizeDenTypeId } from "./db/typeid.js" +import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js" type UserId = typeof AuthUserTable.$inferSelect.id -type OrgId = typeof OrgTable.$inferSelect.id +type SessionId = typeof AuthSessionTable.$inferSelect.id +type OrgId = typeof OrganizationTable.$inferSelect.id +type MemberRow = typeof MemberTable.$inferSelect +type InvitationRow = typeof InvitationTable.$inferSelect -function personalSlugFromOrgId(orgId: OrgId) { - return orgId +export type UserOrgSummary = { + id: OrgId + name: string + slug: string + logo: string | null + metadata: string | null + role: string + membershipId: string + createdAt: Date + updatedAt: Date } -function isDuplicateSlugError(error: unknown) { - if (!(error instanceof Error)) { - return false +export type OrganizationContext = { + organization: { + id: OrgId + name: string + slug: string + logo: string | null + metadata: string | null + createdAt: Date + updatedAt: Date + } + currentMember: { + id: string + userId: UserId + role: string + createdAt: Date + isOwner: boolean + } + members: Array<{ + id: string + userId: UserId + role: string + createdAt: Date + isOwner: boolean + user: { + id: UserId + email: string + name: string + image: string | null + } + }> + invitations: Array<{ + id: string + email: string + role: string + status: string + expiresAt: Date + createdAt: Date + }> + roles: Array<{ + id: string + role: string + permission: Record + builtIn: boolean + protected: boolean + createdAt: Date | null + updatedAt: Date | null + }> +} + +function splitRoles(value: string) { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function hasRole(roleValue: string, roleName: string) { + return splitRoles(roleValue).includes(roleName) +} + +export function roleIncludesOwner(roleValue: string) { + return hasRole(roleValue, "owner") +} + +function titleCase(value: string) { + return value + .split(/\s+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" ") +} + +function buildPersonalOrgName(email: string) { + const localPart = email.split("@")[0] ?? "Personal" + const normalized = titleCase(localPart.replace(/[._-]+/g, " ").trim()) || "Personal" + const suffix = normalized.endsWith("s") ? "' Org" : "'s Org" + return `${normalized}${suffix}` +} + +export function parsePermissionRecord(value: string | null) { + if (!value) { + return {} } - const message = error.message.toLowerCase() - return message.includes("duplicate entry") && message.includes("org.org_slug") + try { + const parsed = JSON.parse(value) as Record + return Object.fromEntries( + Object.entries(parsed) + .filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1])) + .map(([resource, actions]) => [ + resource, + actions.filter((entry: unknown): entry is string => typeof entry === "string"), + ]), + ) + } catch { + return {} + } } -export async function ensureDefaultOrg(userId: UserId, name: string): Promise { +export function serializePermissionRecord(value: Record) { + return JSON.stringify(value) +} + +async function listMembershipRows(userId: UserId) { + return db + .select() + .from(MemberTable) + .where(eq(MemberTable.userId, userId)) + .orderBy(asc(MemberTable.createdAt)) +} + +async function listPendingInvitations(email: string) { + return db + .select() + .from(InvitationTable) + .where( + and( + eq(InvitationTable.email, email.trim().toLowerCase()), + eq(InvitationTable.status, "pending"), + gt(InvitationTable.expiresAt, new Date()), + ), + ) + .orderBy(asc(InvitationTable.createdAt)) +} + +async function ensureDefaultDynamicRoles(orgId: OrgId) { + const existingRows = await db + .select({ role: OrganizationRoleTable.role }) + .from(OrganizationRoleTable) + .where(eq(OrganizationRoleTable.organizationId, orgId)) + + const existing = new Set(existingRows.map((row) => row.role)) + + for (const [role, permission] of Object.entries(denDefaultDynamicOrganizationRoles)) { + if (existing.has(role)) { + continue + } + + await db.insert(OrganizationRoleTable).values({ + id: createDenTypeId("organizationRole"), + organizationId: orgId, + role, + permission: serializePermissionRecord(permission), + }) + } +} + +function normalizeAssignableRole(input: string, availableRoles: Set) { + const roles = splitRoles(input).filter((role) => availableRoles.has(role)) + if (roles.length === 0) { + return "member" + } + return roles.join(",") +} + +export async function listAssignableRoles(orgId: OrgId) { + await ensureDefaultDynamicRoles(orgId) + + const rows = await db + .select({ role: OrganizationRoleTable.role }) + .from(OrganizationRoleTable) + .where(eq(OrganizationRoleTable.organizationId, orgId)) + + return new Set(rows.map((row) => row.role)) +} + +async function insertMemberIfMissing(input: { + organizationId: OrgId + userId: UserId + role: string +}) { const existing = await db .select() - .from(OrgMembershipTable) - .where(eq(OrgMembershipTable.user_id, userId)) + .from(MemberTable) + .where( + and( + eq(MemberTable.organizationId, input.organizationId), + eq(MemberTable.userId, input.userId), + ), + ) .limit(1) if (existing.length > 0) { - return existing[0].org_id + return existing[0] } - let orgId: OrgId | null = null + await db.insert(MemberTable).values({ + id: createDenTypeId("member"), + organizationId: input.organizationId, + userId: input.userId, + role: input.role, + }) - for (let attempt = 0; attempt < 5; attempt += 1) { - const candidateOrgId = createDenTypeId("org") - const slug = personalSlugFromOrgId(candidateOrgId) + const created = await db + .select() + .from(MemberTable) + .where( + and( + eq(MemberTable.organizationId, input.organizationId), + eq(MemberTable.userId, input.userId), + ), + ) + .limit(1) - try { - await db.insert(OrgTable).values({ - id: candidateOrgId, - name, - slug, - owner_user_id: userId, - }) - orgId = candidateOrgId - break - } catch (error) { - if (isDuplicateSlugError(error) && attempt < 4) { - continue + if (!created[0]) { + throw new Error("failed_to_create_member") + } + + return created[0] +} + +async function acceptInvitation(invitation: InvitationRow, userId: UserId) { + const availableRoles = await listAssignableRoles(invitation.organizationId) + const role = normalizeAssignableRole(invitation.role, availableRoles) + + const member = await insertMemberIfMissing({ + organizationId: invitation.organizationId, + userId, + role, + }) + + if (invitation.teamId) { + const teams = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(eq(TeamTable.id, invitation.teamId)) + .limit(1) + + if (teams[0]) { + const existingTeamMember = await db + .select({ id: TeamMemberTable.id }) + .from(TeamMemberTable) + .where( + and( + eq(TeamMemberTable.teamId, invitation.teamId), + eq(TeamMemberTable.userId, userId), + ), + ) + .limit(1) + + if (!existingTeamMember[0]) { + await db.insert(TeamMemberTable).values({ + id: createDenTypeId("teamMember"), + teamId: invitation.teamId, + userId, + }) } - throw error } } - if (!orgId) { - throw new Error("failed to create default org") + await db + .update(InvitationTable) + .set({ status: "accepted" }) + .where(eq(InvitationTable.id, invitation.id)) + + return member +} + +export async function acceptInvitationForUser(input: { + userId: UserId + email: string + invitationId?: string | null +}) { + const invitations = await listPendingInvitations(input.email) + const invitation = input.invitationId + ? invitations.find((candidate) => candidate.id === input.invitationId) ?? null + : (invitations[0] ?? null) + + if (!invitation) { + return null } - await db.insert(OrgMembershipTable).values({ - id: createDenTypeId("orgMembership"), - org_id: orgId, - user_id: userId, + const member = await acceptInvitation(invitation, input.userId) + return { + invitation, + member, + } +} + +async function createOrganizationRecord(input: { + userId: UserId + name: string + logo?: string | null + metadata?: string | null +}) { + const organizationId = createDenTypeId("organization") + + await db.insert(OrganizationTable).values({ + id: organizationId, + name: input.name, + slug: organizationId, + logo: input.logo ?? null, + metadata: input.metadata ?? null, + }) + + await db.insert(MemberTable).values({ + id: createDenTypeId("member"), + organizationId, + userId: input.userId, role: "owner", }) - return orgId + + await ensureDefaultDynamicRoles(organizationId) + + return organizationId +} + +export async function ensureUserOrgAccess(input: { + userId: UserId + email: string + name?: string | null +}) { + const memberships = await listMembershipRows(input.userId) + if (memberships.length > 0) { + const organizationIds = [...new Set(memberships.map((membership) => membership.organizationId))] + await Promise.all(organizationIds.map((organizationId) => ensureDefaultDynamicRoles(organizationId))) + return memberships[0].organizationId + } + + const pendingInvitation = (await listPendingInvitations(input.email))[0] + if (pendingInvitation) { + const acceptedMember = await acceptInvitation(pendingInvitation, input.userId) + return acceptedMember.organizationId + } + + return createOrganizationRecord({ + userId: input.userId, + name: buildPersonalOrgName(input.email), + }) +} + +export async function createOrganizationForUser(input: { + userId: UserId + name: string +}) { + return createOrganizationRecord({ + userId: input.userId, + name: input.name.trim(), + }) +} + +export async function seedDefaultOrganizationRoles(orgId: OrgId) { + await ensureDefaultDynamicRoles(orgId) +} + +export async function setSessionActiveOrganization(sessionId: SessionId, organizationId: OrgId | null) { + await db + .update(AuthSessionTable) + .set({ activeOrganizationId: organizationId }) + .where(eq(AuthSessionTable.id, sessionId)) } export async function listUserOrgs(userId: UserId) { const memberships = await db .select({ - membershipId: OrgMembershipTable.id, - role: OrgMembershipTable.role, - org: { - id: OrgTable.id, - name: OrgTable.name, - slug: OrgTable.slug, - ownerUserId: OrgTable.owner_user_id, - createdAt: OrgTable.created_at, - updatedAt: OrgTable.updated_at, + membershipId: MemberTable.id, + role: MemberTable.role, + organization: { + id: OrganizationTable.id, + name: OrganizationTable.name, + slug: OrganizationTable.slug, + logo: OrganizationTable.logo, + metadata: OrganizationTable.metadata, + createdAt: OrganizationTable.createdAt, + updatedAt: OrganizationTable.updatedAt, }, }) - .from(OrgMembershipTable) - .innerJoin(OrgTable, eq(OrgMembershipTable.org_id, OrgTable.id)) - .where(eq(OrgMembershipTable.user_id, userId)) + .from(MemberTable) + .innerJoin(OrganizationTable, eq(MemberTable.organizationId, OrganizationTable.id)) + .where(eq(MemberTable.userId, userId)) + .orderBy(asc(MemberTable.createdAt)) return memberships.map((row) => ({ - id: row.org.id, - name: row.org.name, - slug: row.org.slug, - ownerUserId: row.org.ownerUserId, + id: row.organization.id, + name: row.organization.name, + slug: row.organization.slug, + logo: row.organization.logo, + metadata: row.organization.metadata, role: row.role, membershipId: row.membershipId, - createdAt: row.org.createdAt, - updatedAt: row.org.updatedAt, - })) + createdAt: row.organization.createdAt, + updatedAt: row.organization.updatedAt, + })) satisfies UserOrgSummary[] +} + +export async function resolveUserOrganizationsForSession(input: { + sessionId: SessionId | null + activeOrganizationId?: string | null + userId: UserId + email: string + name?: string | null +}) { + await ensureUserOrgAccess({ + userId: input.userId, + email: input.email, + name: input.name, + }) + + const orgs = await listUserOrgs(input.userId) + const availableOrgIds = new Set(orgs.map((org) => org.id)) + + let activeOrgId: OrgId | null = null + if (input.activeOrganizationId) { + try { + const normalized = normalizeDenTypeId("organization", input.activeOrganizationId) + if (availableOrgIds.has(normalized)) { + activeOrgId = normalized + } + } catch { + activeOrgId = null + } + } + + activeOrgId ??= orgs[0]?.id ?? null + + if (input.sessionId && activeOrgId && activeOrgId !== input.activeOrganizationId) { + await setSessionActiveOrganization(input.sessionId, activeOrgId) + } + + const activeOrg = orgs.find((org) => org.id === activeOrgId) ?? null + + return { + orgs, + activeOrgId, + activeOrgSlug: activeOrg?.slug ?? null, + } +} + +export async function getOrganizationContextForUser(input: { + userId: UserId + organizationSlug: string +}) { + const organizationRows = await db + .select() + .from(OrganizationTable) + .where(eq(OrganizationTable.slug, input.organizationSlug)) + .limit(1) + + const organization = organizationRows[0] + if (!organization) { + return null + } + + const currentMemberRows = await db + .select() + .from(MemberTable) + .where( + and( + eq(MemberTable.organizationId, organization.id), + eq(MemberTable.userId, input.userId), + ), + ) + .limit(1) + + const currentMember = currentMemberRows[0] + if (!currentMember) { + return null + } + + await ensureDefaultDynamicRoles(organization.id) + + const members = await db + .select({ + id: MemberTable.id, + userId: MemberTable.userId, + role: MemberTable.role, + createdAt: MemberTable.createdAt, + user: { + id: AuthUserTable.id, + email: AuthUserTable.email, + name: AuthUserTable.name, + image: AuthUserTable.image, + }, + }) + .from(MemberTable) + .innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .where(eq(MemberTable.organizationId, organization.id)) + .orderBy(asc(MemberTable.createdAt)) + + const invitations = await db + .select({ + id: InvitationTable.id, + email: InvitationTable.email, + role: InvitationTable.role, + status: InvitationTable.status, + expiresAt: InvitationTable.expiresAt, + createdAt: InvitationTable.createdAt, + }) + .from(InvitationTable) + .where(eq(InvitationTable.organizationId, organization.id)) + .orderBy(asc(InvitationTable.createdAt)) + + const dynamicRoles = await db + .select() + .from(OrganizationRoleTable) + .where(eq(OrganizationRoleTable.organizationId, organization.id)) + .orderBy(asc(OrganizationRoleTable.createdAt)) + + const builtInDynamicRoleNames = new Set(Object.keys(denDefaultDynamicOrganizationRoles)) + + return { + organization: { + id: organization.id, + name: organization.name, + slug: organization.slug, + logo: organization.logo, + metadata: organization.metadata, + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + }, + currentMember: { + id: currentMember.id, + userId: currentMember.userId, + role: currentMember.role, + createdAt: currentMember.createdAt, + isOwner: roleIncludesOwner(currentMember.role), + }, + members: members.map((member) => ({ + ...member, + isOwner: roleIncludesOwner(member.role), + })), + invitations, + roles: [ + { + id: "builtin-owner", + role: "owner", + permission: denOrganizationStaticRoles.owner.statements, + builtIn: true, + protected: true, + createdAt: null, + updatedAt: null, + }, + ...dynamicRoles.map((role) => ({ + id: role.id, + role: role.role, + permission: parsePermissionRecord(role.permission), + builtIn: builtInDynamicRoleNames.has(role.role), + protected: false, + createdAt: role.createdAt, + updatedAt: role.updatedAt, + })), + ], + } satisfies OrganizationContext +} + +export async function removeOrganizationMember(input: { + organizationId: OrgId + memberId: MemberRow["id"] +}) { + const memberRows = await db + .select() + .from(MemberTable) + .where( + and( + eq(MemberTable.id, input.memberId), + eq(MemberTable.organizationId, input.organizationId), + ), + ) + .limit(1) + + const member = memberRows[0] ?? null + if (!member) { + return null + } + + const teams = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(eq(TeamTable.organizationId, input.organizationId)) + + await db.transaction(async (tx) => { + for (const team of teams) { + await tx + .delete(TeamMemberTable) + .where( + and( + eq(TeamMemberTable.teamId, team.id), + eq(TeamMemberTable.userId, member.userId), + ), + ) + } + + await tx.delete(MemberTable).where(eq(MemberTable.id, member.id)) + }) + + return member } diff --git a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx index afbad3f0..6773d289 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx @@ -111,7 +111,10 @@ export function AuthScreen() { onSubmit={async (event) => { const next = verificationRequired ? await submitVerificationCode(event) : await submitAuth(event); if (next === "dashboard") { - router.replace("/dashboard"); + const target = await resolveUserLandingRoute(); + if (target) { + router.replace(target); + } } else if (next === "checkout") { router.replace("/checkout"); } diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index b441f99b..8407d299 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -66,7 +66,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: handledReturnRef.current = true; setResuming(true); void refreshCheckoutReturn(true).then((target) => { - if (target === "/dashboard") { + if (target !== "/checkout") { router.replace(target); return; } @@ -93,7 +93,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken: if (!onboardingPending) { void resolveUserLandingRoute().then((target) => { - if (target === "/dashboard" && !MOCK_BILLING) { + if (target && target !== "/checkout" && !MOCK_BILLING) { router.replace(target); } }); diff --git a/ee/apps/den-web/app/(den)/_components/dashboard-redirect-screen.tsx b/ee/apps/den-web/app/(den)/_components/dashboard-redirect-screen.tsx new file mode 100644 index 00000000..74724018 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/dashboard-redirect-screen.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useDenFlow } from "../_providers/den-flow-provider"; + +export function DashboardRedirectScreen() { + const router = useRouter(); + const { resolveUserLandingRoute, sessionHydrated } = useDenFlow(); + + useEffect(() => { + if (!sessionHydrated) { + return; + } + + void resolveUserLandingRoute().then((target) => { + router.replace(target ?? "/"); + }); + }, [resolveUserLandingRoute, router, sessionHydrated]); + + return ( +
+

Loading your workspace...

+
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx index f043d588..9a2e034d 100644 --- a/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/dashboard-screen.tsx @@ -158,7 +158,7 @@ function SectionBadge({ ); } -export function DashboardScreen() { +export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean }) { const router = useRouter(); const { user, @@ -236,6 +236,358 @@ export function DashboardScreen() { const desktopDisabled = !openworkDeepLink || !isReady; const showConnectionHint = !openworkDeepLink || !hasWorkspaceScopedUrl; + const mainContent = ( +
+
+ {selectedWorker ? ( + <> +
+
+
Overview
+

{currentWorker?.workerName ?? selectedWorker.workerName}

+
+
+ +
+
+
+
+
+ + +
+

+ {isReady ? "Your worker is ready." : isStarting ? "Provisioning in the background" : currentWorker ? getWorkerStatusCopy(currentWorker.status) : "Preparing worker"} +

+
+ +

+ {isReady + ? "Open the worker in web or desktop, or copy the live credentials below." + : "We are allocating resources and preparing the OpenWork connection before unlocking the rest of the controls."} +

+ + +
+ +
+ {openworkAppConnectUrl ? ( + + + {webDisabled ? "Preparing web access" : "Open in Web"} + + ) : ( + + )} + +
+ + requires the OpenWork desktop app +
+
+
+
+ +
+
+
+ +
+
+
+ +
+

Connection details

+
+

+ Connect now or copy manual credentials for another client. +

+
+
+ +
+
+ +
+
+ + +
+ +
+ void copyToClipboard("openwork-url", activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null)} + muted={!isReady} + /> + + void copyToClipboard("owner-token", activeWorker?.ownerToken ?? null)} + muted={!isReady} + /> + + void copyToClipboard("client-token", activeWorker?.clientToken ?? null)} + muted={!isReady} + /> +
+
+
+
+ +
+
+
+ +
+
+
+ +
+

Worker actions

+
+

+ Refresh state, recover tokens, or replace the worker. Controls unlock as the worker becomes reachable. +

+
+
+ +
+
+ +
+
+ + + + + +
+
+
+
+ +
+
+ +
+
+
+ +
+

Worker runtime

+
+

+ Compare installed runtime versions with the versions this worker should be running. +

+
+
+ +
+
+ +
+
+ + +
+ + {runtimeError ?
{runtimeError}
: null} + +
+ {(runtimeSnapshot?.services ?? []).map((service) => ( +
+
+
+

{getRuntimeServiceLabel(service.name)}

+

+ Installed {service.actualVersion ?? "unknown"} · Target {service.targetVersion ?? "unknown"} +

+
+
+ + {service.running ? "Running" : service.enabled ? "Stopped" : "Disabled"} + + + {service.upgradeAvailable ? "Upgrade available" : "Current"} + +
+
+
+ ))} + + {!runtimeSnapshot && !runtimeBusy ? ( +
+ + +

Runtime details appear after the worker is reachable.

+
+ ) : null} +
+
+
+
+ + } + title="Recent activity" + body={events.length > 0 ? `${events[0]?.label ?? "Activity"}: ${events[0]?.detail ?? "Waiting for updates."}` : "Actions and provisioning updates appear here as they happen."} + dimmed={false} + /> + +
+
+
+ +
+

Billing snapshot

+
+

+ {billingSummary?.featureGateEnabled + ? billingSummary.hasActivePlan + ? "Your account has an active Den Cloud plan." + : "Your account needs billing before the next launch." + : "Billing gates are disabled in this environment."} +

+ + Open billing + +
+
+
+ + ) : ( +
+
+

No workers yet

+

Create your first worker to unlock connection details and runtime controls.

+
+
+ )} +
+
+ ); + + if (!showSidebar) { + return mainContent; + } + return (
-
-
- {selectedWorker ? ( - <> -
-
-
Overview
-

{currentWorker?.workerName ?? selectedWorker.workerName}

-
-
- -
-
-
-
-
- - -
-

- {isReady ? "Your worker is ready." : isStarting ? "Provisioning in the background" : currentWorker ? getWorkerStatusCopy(currentWorker.status) : "Preparing worker"} -

-
- -

- {isReady - ? "Open the worker in web or desktop, or copy the live credentials below." - : "We are allocating resources and preparing the OpenWork connection before unlocking the rest of the controls."} -

- - -
- -
- {openworkAppConnectUrl ? ( - - - {webDisabled ? "Preparing web access" : "Open in Web"} - - ) : ( - - )} - -
- - requires the OpenWork desktop app -
-
-
-
- -
-
-
- -
-
-
- -
-

Connection details

-
-

- Connect now or copy manual credentials for another client. -

-
-
- -
-
- -
-
- - -
- -
- void copyToClipboard("openwork-url", activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null)} - muted={!isReady} - /> - - void copyToClipboard("owner-token", activeWorker?.ownerToken ?? null)} - muted={!isReady} - /> - - void copyToClipboard("client-token", activeWorker?.clientToken ?? null)} - muted={!isReady} - /> -
-
-
-
- -
-
-
- -
-
-
- -
-

Worker actions

-
-

- Refresh state, recover tokens, or replace the worker. Controls unlock as the worker becomes reachable. -

-
-
- -
-
- -
-
- - - - - -
-
-
-
- -
-
- -
-
-
- -
-

Worker runtime

-
-

- Compare installed runtime versions with the versions this worker should be running. -

-
-
- -
-
- -
-
- - -
- - {runtimeError ?
{runtimeError}
: null} - -
- {(runtimeSnapshot?.services ?? []).map((service) => ( -
-
-
-

{getRuntimeServiceLabel(service.name)}

-

- Installed {service.actualVersion ?? "unknown"} · Target {service.targetVersion ?? "unknown"} -

-
-
- - {service.running ? "Running" : service.enabled ? "Stopped" : "Disabled"} - - - {service.upgradeAvailable ? "Upgrade available" : "Current"} - -
-
-
- ))} - - {!runtimeSnapshot && !runtimeBusy ? ( -
- - -

Runtime details appear after the worker is reachable.

-
- ) : null} -
-
-
-
- - } - title="Recent activity" - body={events.length > 0 ? `${events[0]?.label ?? "Activity"}: ${events[0]?.detail ?? "Waiting for updates."}` : "Actions and provisioning updates appear here as they happen."} - dimmed={false} - /> - -
-
-
- -
-

Billing snapshot

-
-

- {billingSummary?.featureGateEnabled - ? billingSummary.hasActivePlan - ? "Your account has an active Den Cloud plan." - : "Your account needs billing before the next launch." - : "Billing gates are disabled in this environment."} -

- - Open billing - -
-
-
- - ) : ( -
-
-

No workers yet

-

Create your first worker to unlock connection details and runtime controls.

-
-
- )} -
-
+ {mainContent}
); } diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index eabde5aa..021b2b01 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -243,7 +243,7 @@ export function getSocialCallbackUrl(): string { const callbackUrl = new URL("/", origin); if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); - for (const key of ["mode", "desktopAuth", "desktopScheme"]) { + for (const key of ["mode", "desktopAuth", "desktopScheme", "invite"]) { const value = params.get(key)?.trim() ?? ""; if (value) { callbackUrl.searchParams.set(key, value); @@ -417,7 +417,11 @@ export function getWorker(payload: unknown): WorkerLaunch | null { openworkUrl: instance && typeof instance.url === "string" ? instance.url : null, workspaceId: null, clientToken: tokens && typeof tokens.client === "string" ? tokens.client : null, - ownerToken: tokens && typeof tokens.owner === "string" ? tokens.owner : null, + ownerToken: tokens && typeof tokens.owner === "string" + ? tokens.owner + : tokens && typeof tokens.host === "string" + ? tokens.host + : null, hostToken: tokens && typeof tokens.host === "string" ? tokens.host : null }; } @@ -452,7 +456,11 @@ export function getWorkerTokens(payload: unknown): WorkerTokens | null { const tokens = payload.tokens; const connect = isRecord(payload.connect) ? payload.connect : null; const clientToken = typeof tokens.client === "string" ? tokens.client : null; - const ownerToken = typeof tokens.owner === "string" ? tokens.owner : null; + const ownerToken = typeof tokens.owner === "string" + ? tokens.owner + : typeof tokens.host === "string" + ? tokens.host + : null; const hostToken = typeof tokens.host === "string" ? tokens.host : null; const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null; const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null; diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts new file mode 100644 index 00000000..87aae6a0 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -0,0 +1,320 @@ +export type DenOrgSummary = { + id: string; + name: string; + slug: string; + logo: string | null; + metadata: string | null; + role: string; + membershipId: string; + createdAt: string | null; + updatedAt: string | null; + isActive: boolean; +}; + +export type DenOrgMember = { + id: string; + userId: string; + role: string; + createdAt: string | null; + isOwner: boolean; + user: { + id: string; + email: string; + name: string; + image: string | null; + }; +}; + +export type DenOrgInvitation = { + id: string; + email: string; + role: string; + status: string; + expiresAt: string | null; + createdAt: string | null; +}; + +export type DenOrgRole = { + id: string; + role: string; + permission: Record; + builtIn: boolean; + protected: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DenOrgContext = { + organization: { + id: string; + name: string; + slug: string; + logo: string | null; + metadata: string | null; + createdAt: string | null; + updatedAt: string | null; + }; + currentMember: { + id: string; + userId: string; + role: string; + createdAt: string | null; + isOwner: boolean; + }; + members: DenOrgMember[]; + invitations: DenOrgInvitation[]; + roles: DenOrgRole[]; +}; + +export const DEN_ROLE_PERMISSION_OPTIONS = { + organization: ["update", "delete"], + member: ["create", "update", "delete"], + invitation: ["create", "cancel"], + team: ["create", "update", "delete"], + ac: ["create", "read", "update", "delete"], +} as const; + +export const PENDING_ORG_INVITATION_STORAGE_KEY = "openwork:web:pending-org-invitation"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asIsoString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asBoolean(value: unknown): boolean { + return value === true; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function parsePermissionRecord(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value) + .filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1])) + .map(([resource, actions]) => [ + resource, + actions.filter((entry: unknown): entry is string => typeof entry === "string"), + ]) + ); +} + +export function splitRoleString(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function getOrgAccessFlags(roleValue: string, isOwner: boolean) { + const roles = new Set(splitRoleString(roleValue)); + const isAdmin = isOwner || roles.has("admin"); + + return { + isOwner, + isAdmin, + canInviteMembers: isAdmin, + canCancelInvitations: isAdmin, + canManageMembers: isOwner, + canManageRoles: isOwner, + }; +} + +export function formatRoleLabel(role: string): string { + return role + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +export function getOrgDashboardRoute(orgSlug: string): string { + return `/o/${encodeURIComponent(orgSlug)}/dashboard`; +} + +export function getManageMembersRoute(orgSlug: string): string { + return `${getOrgDashboardRoute(orgSlug)}/manage-members`; +} + +export function parseOrgListPayload(payload: unknown): { + orgs: DenOrgSummary[]; + activeOrgId: string | null; + activeOrgSlug: string | null; +} { + if (!isRecord(payload) || !Array.isArray(payload.orgs)) { + return { orgs: [], activeOrgId: null, activeOrgSlug: null }; + } + + const orgs = payload.orgs + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + + const id = asString(entry.id); + const name = asString(entry.name); + const slug = asString(entry.slug); + const role = asString(entry.role); + const membershipId = asString(entry.membershipId); + if (!id || !name || !slug || !role || !membershipId) { + return null; + } + + return { + id, + name, + slug, + logo: asString(entry.logo), + metadata: asString(entry.metadata), + role, + membershipId, + createdAt: asIsoString(entry.createdAt), + updatedAt: asIsoString(entry.updatedAt), + isActive: asBoolean(entry.isActive), + } satisfies DenOrgSummary; + }) + .filter((entry): entry is DenOrgSummary => entry !== null); + + return { + orgs, + activeOrgId: asString(payload.activeOrgId), + activeOrgSlug: asString(payload.activeOrgSlug), + }; +} + +export function parseOrgContextPayload(payload: unknown): DenOrgContext | null { + if (!isRecord(payload) || !isRecord(payload.organization) || !isRecord(payload.currentMember)) { + return null; + } + + const organization = payload.organization; + const currentMember = payload.currentMember; + const organizationId = asString(organization.id); + const organizationName = asString(organization.name); + const organizationSlug = asString(organization.slug); + const currentMemberId = asString(currentMember.id); + const currentMemberUserId = asString(currentMember.userId); + const currentMemberRole = asString(currentMember.role); + + if (!organizationId || !organizationName || !organizationSlug || !currentMemberId || !currentMemberUserId || !currentMemberRole) { + return null; + } + + const members = Array.isArray(payload.members) + ? payload.members + .map((entry) => { + if (!isRecord(entry) || !isRecord(entry.user)) { + return null; + } + + const id = asString(entry.id); + const userId = asString(entry.userId); + const role = asString(entry.role); + const user = entry.user; + const userEmail = asString(user.email); + const userName = asString(user.name); + const userIdentity = asString(user.id); + if (!id || !userId || !role || !userEmail || !userName || !userIdentity) { + return null; + } + + return { + id, + userId, + role, + createdAt: asIsoString(entry.createdAt), + isOwner: asBoolean(entry.isOwner), + user: { + id: userIdentity, + email: userEmail, + name: userName, + image: asString(user.image), + }, + } satisfies DenOrgMember; + }) + .filter((entry): entry is DenOrgMember => entry !== null) + : []; + + const invitations = Array.isArray(payload.invitations) + ? payload.invitations + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + + const id = asString(entry.id); + const email = asString(entry.email); + const role = asString(entry.role); + const status = asString(entry.status); + if (!id || !email || !role || !status) { + return null; + } + + return { + id, + email, + role, + status, + expiresAt: asIsoString(entry.expiresAt), + createdAt: asIsoString(entry.createdAt), + } satisfies DenOrgInvitation; + }) + .filter((entry): entry is DenOrgInvitation => entry !== null) + : []; + + const roles = Array.isArray(payload.roles) + ? payload.roles + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + + const id = asString(entry.id); + const role = asString(entry.role); + if (!id || !role) { + return null; + } + + return { + id, + role, + permission: parsePermissionRecord(entry.permission), + builtIn: asBoolean(entry.builtIn), + protected: asBoolean(entry.protected), + createdAt: asIsoString(entry.createdAt), + updatedAt: asIsoString(entry.updatedAt), + } satisfies DenOrgRole; + }) + .filter((entry): entry is DenOrgRole => entry !== null) + : []; + + return { + organization: { + id: organizationId, + name: organizationName, + slug: organizationSlug, + logo: asString(organization.logo), + metadata: asString(organization.metadata), + createdAt: asIsoString(organization.createdAt), + updatedAt: asIsoString(organization.updatedAt), + }, + currentMember: { + id: currentMemberId, + userId: currentMemberUserId, + role: currentMemberRole, + createdAt: asIsoString(currentMember.createdAt), + isOwner: asBoolean(currentMember.isOwner), + }, + members, + invitations, + roles, + }; +} diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 2bde5919..bac68140 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -52,6 +52,11 @@ import { resolveOpenworkWorkspaceUrl, trackPosthogEvent } from "../_lib/den-flow"; +import { + PENDING_ORG_INVITATION_STORAGE_KEY, + getOrgDashboardRoute, + parseOrgListPayload, +} from "../_lib/den-org"; type LaunchWorkerResult = "success" | "checkout" | "error"; @@ -81,7 +86,7 @@ type DenFlowContextValue = { cancelVerification: () => void; beginSocialAuth: (provider: SocialAuthProvider) => Promise; signOut: () => Promise; - resolveUserLandingRoute: () => Promise<"/dashboard" | "/checkout" | null>; + resolveUserLandingRoute: () => Promise; billingSummary: BillingSummary | null; billingBusy: boolean; billingCheckoutBusy: boolean; @@ -90,7 +95,7 @@ type DenFlowContextValue = { effectiveCheckoutUrl: string | null; refreshBilling: (options?: { includeCheckout?: boolean; quiet?: boolean }) => Promise; handleSubscriptionCancellation: (cancelAtPeriodEnd: boolean) => Promise; - refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise<"/dashboard" | "/checkout">; + refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise; onboardingPending: boolean; onboardingDecisionBusy: boolean; workers: WorkerListItem[]; @@ -348,7 +353,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { ): Promise<"dashboard" | "checkout" | null> { let payload = payloadOverride; - if (payload === undefined) { + if (payload === undefined || (!getToken(payload) && nextMode === "sign-up" && Boolean(password))) { const signInBody = { email: trimmedEmail, password, @@ -914,6 +919,67 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return sessionUser; } + async function loadOrgDirectory() { + const headers = new Headers(); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } + + const { response, payload } = await requestJson("/v1/me/orgs", { method: "GET", headers }, 12000); + if (!response.ok) { + return { + orgs: [], + activeOrgId: null, + activeOrgSlug: null, + }; + } + + return parseOrgListPayload(payload); + } + + async function acceptPendingInvitationIfNeeded() { + if (typeof window === "undefined") { + return null; + } + + const invitationId = window.sessionStorage.getItem(PENDING_ORG_INVITATION_STORAGE_KEY)?.trim() ?? ""; + if (!invitationId) { + return null; + } + + const headers = new Headers(); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } + + const { response, payload } = await requestJson( + `/v1/orgs/invitations/accept?id=${encodeURIComponent(invitationId)}`, + { method: "GET", headers }, + 12000, + ); + + if (!response.ok) { + if (response.status === 404) { + window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); + } + return null; + } + + window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); + if (typeof payload === "object" && payload && "organizationSlug" in payload && typeof payload.organizationSlug === "string") { + return payload.organizationSlug; + } + + return null; + } + + async function resolveDashboardRoute() { + const acceptedOrgSlug = await acceptPendingInvitationIfNeeded(); + const orgDirectory = await loadOrgDirectory(); + const activeOrgSlug = acceptedOrgSlug ?? orgDirectory.activeOrgSlug ?? orgDirectory.orgs[0]?.slug ?? null; + return activeOrgSlug ? getOrgDashboardRoute(activeOrgSlug) : null; + } + async function completeDesktopAuthHandoff() { if (!desktopAuthRequested || desktopRedirectBusy) { return; @@ -984,8 +1050,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return null; } + const dashboardRoute = await resolveDashboardRoute(); + if (!onboardingPending) { - return "/dashboard"; + return dashboardRoute; } const summary = @@ -996,7 +1064,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return "/checkout"; } - return !summary.featureGateEnabled || summary.hasActivePlan ? "/dashboard" : "/checkout"; + return !summary.featureGateEnabled || summary.hasActivePlan ? (dashboardRoute ?? "/") : "/checkout"; } async function submitAuth(event: FormEvent) { @@ -1194,6 +1262,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { if (typeof window !== "undefined") { window.localStorage.removeItem(LAST_WORKER_STORAGE_KEY); window.sessionStorage.removeItem(PENDING_SOCIAL_SIGNUP_STORAGE_KEY); + window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); } } @@ -1608,7 +1677,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { } if (!summary.featureGateEnabled || summary.hasActivePlan) { - return "/dashboard" as const; + return (await resolveDashboardRoute()) ?? "/"; } return "/checkout" as const; @@ -1635,6 +1704,11 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { if (/^[a-z][a-z0-9+.-]*$/i.test(requestedScheme)) { setDesktopAuthScheme(requestedScheme); } + + const invitationId = params.get("invite")?.trim() ?? ""; + if (invitationId) { + window.sessionStorage.setItem(PENDING_ORG_INVITATION_STORAGE_KEY, invitationId); + } }, []); useEffect(() => { diff --git a/ee/apps/den-web/app/(den)/dashboard/page.tsx b/ee/apps/den-web/app/(den)/dashboard/page.tsx index aefb191b..aca39326 100644 --- a/ee/apps/den-web/app/(den)/dashboard/page.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { DashboardScreen } from "../_components/dashboard-screen"; +import { DashboardRedirectScreen } from "../_components/dashboard-redirect-screen"; export default function DashboardPage() { - return ; + return ; } diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/manage-members-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/manage-members-screen.tsx new file mode 100644 index 00000000..278db534 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/manage-members-screen.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + DEN_ROLE_PERMISSION_OPTIONS, + formatRoleLabel, + getOrgAccessFlags, + splitRoleString, + type DenOrgRole, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function clonePermissionRecord(value: Record) { + return Object.fromEntries(Object.entries(value).map(([resource, actions]) => [resource, [...actions]])); +} + +function toggleAction( + value: Record, + resource: string, + action: string, + enabled: boolean, +) { + const next = clonePermissionRecord(value); + const current = new Set(next[resource] ?? []); + + if (enabled) { + current.add(action); + } else { + current.delete(action); + } + + next[resource] = [...current]; + return next; +} + +function SectionCard({ + title, + description, + action, + children, +}: { + title: string; + description: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ {action ?
{action}
: null} +
+ {children} +
+ ); +} + +function SectionButton({ + children, + onClick, + tone = "default", + disabled = false, +}: { + children: ReactNode; + onClick?: () => void; + tone?: "default" | "danger"; + disabled?: boolean; +}) { + const className = tone === "danger" + ? "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100" + : "border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)] hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"; + + return ( + + ); +} + +function InlinePanel({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function ManageMembersScreen() { + const { + activeOrg, + orgContext, + orgBusy, + orgError, + mutationBusy, + inviteMember, + cancelInvitation, + updateMemberRole, + removeMember, + createRole, + updateRole, + deleteRole, + } = useOrgDashboard(); + const [pageError, setPageError] = useState(null); + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [editingMemberId, setEditingMemberId] = useState(null); + const [memberRoleDraft, setMemberRoleDraft] = useState("member"); + const [showRoleForm, setShowRoleForm] = useState(false); + const [editingRoleId, setEditingRoleId] = useState(null); + const [roleNameDraft, setRoleNameDraft] = useState(""); + const [rolePermissionDraft, setRolePermissionDraft] = useState>({}); + + const assignableRoles = useMemo( + () => (orgContext?.roles ?? []).filter((role) => !role.protected), + [orgContext?.roles], + ); + + const access = useMemo( + () => getOrgAccessFlags(orgContext?.currentMember.role ?? "member", orgContext?.currentMember.isOwner ?? false), + [orgContext?.currentMember.isOwner, orgContext?.currentMember.role], + ); + + const pendingInvitations = useMemo( + () => (orgContext?.invitations ?? []).filter((invitation) => invitation.status === "pending"), + [orgContext?.invitations], + ); + + function resetInviteForm() { + setInviteEmail(""); + setInviteRole(assignableRoles[0]?.role ?? "member"); + setShowInviteForm(false); + } + + function resetMemberEditor() { + setEditingMemberId(null); + setMemberRoleDraft(assignableRoles[0]?.role ?? "member"); + } + + function resetRoleEditor() { + setEditingRoleId(null); + setRoleNameDraft(""); + setRolePermissionDraft({}); + setShowRoleForm(false); + } + + useEffect(() => { + if (!assignableRoles[0]) { + return; + } + + setInviteRole((current) => (assignableRoles.some((role) => role.role === current) ? current : assignableRoles[0].role)); + setMemberRoleDraft((current) => (assignableRoles.some((role) => role.role === current) ? current : assignableRoles[0].role)); + }, [assignableRoles]); + + if (orgBusy && !orgContext) { + return ( +
+
+

Loading organization details...

+
+
+ ); + } + + if (!orgContext || !activeOrg) { + return ( +
+
+

{orgError ?? "Organization details are unavailable."}

+
+
+ ); + } + + return ( +
+
+
+
+

Manage Members

+

+ {activeOrg.name} +

+

+ See everyone in the organization, invite new people, and keep roles tidy without the permission matrix taking over the page. +

+
+
+ Your role: {formatRoleLabel(orgContext.currentMember.role)} +
+
+
+ + {pageError ?
{pageError}
: null} + + { resetMemberEditor(); setShowInviteForm((current) => !current); }}>{showInviteForm ? "Close invite form" : "Add member"} : undefined} + > + {showInviteForm && access.canInviteMembers ? ( + +
{ + event.preventDefault(); + setPageError(null); + try { + await inviteMember({ email: inviteEmail, role: inviteRole }); + resetInviteForm(); + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not invite member."); + } + }} + > + + +
+ Cancel + +
+
+
+ ) : null} + + {editingMemberId && access.canManageMembers ? ( + +
{ + event.preventDefault(); + setPageError(null); + try { + await updateMemberRole(editingMemberId, memberRoleDraft); + resetMemberEditor(); + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not update member role."); + } + }} + > + +
+ Cancel + +
+
+
+ ) : null} + +
+ + + + + + + + + + + {orgContext.members.map((member) => ( + + + + + + + ))} + +
MemberRoleJoinedActions
+
+ {member.user.name} + {member.user.email} +
+
{splitRoleString(member.role).map(formatRoleLabel).join(", ")}{member.createdAt ? new Date(member.createdAt).toLocaleDateString() : "-"} +
+ {member.isOwner ? ( + Locked + ) : access.canManageMembers ? ( + <> + { + setEditingMemberId(member.id); + setMemberRoleDraft(member.role); + setShowInviteForm(false); + }} + > + Edit + + { + setPageError(null); + try { + await removeMember(member.id); + if (editingMemberId === member.id) { + resetMemberEditor(); + } + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not remove member."); + } + }} + > + {mutationBusy === "remove-member" ? "Removing..." : "Remove"} + + + ) : ( + Read only + )} +
+
+
+
+ + +
+ + + + + + + + + + + {pendingInvitations.length === 0 ? ( + + + + ) : pendingInvitations.map((invitation) => ( + + + + + + + ))} + +
EmailRoleExpiresActions
No pending invitations.
{invitation.email}{formatRoleLabel(invitation.role)}{invitation.expiresAt ? new Date(invitation.expiresAt).toLocaleDateString() : "-"} + {access.canCancelInvitations ? ( + { + setPageError(null); + try { + await cancelInvitation(invitation.id); + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not cancel invitation."); + } + }} + > + {mutationBusy === "cancel-invitation" ? "Cancelling..." : "Cancel"} + + ) : Read only} +
+
+
+ + { setShowRoleForm((current) => !current); setEditingRoleId(null); setRoleNameDraft(""); setRolePermissionDraft({}); }}>{showRoleForm ? "Close role form" : "Add role"} : undefined} + > + {(showRoleForm || editingRoleId) && access.canManageRoles ? ( + +
{ + event.preventDefault(); + setPageError(null); + try { + if (editingRoleId) { + await updateRole(editingRoleId, { + roleName: roleNameDraft, + permission: rolePermissionDraft, + }); + } else { + await createRole({ + roleName: roleNameDraft, + permission: rolePermissionDraft, + }); + } + resetRoleEditor(); + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not save role."); + } + }} + > + + +
+ {Object.entries(DEN_ROLE_PERMISSION_OPTIONS).map(([resource, actions]) => ( +
+

{formatRoleLabel(resource)}

+
+ {actions.map((action) => { + const checked = (rolePermissionDraft[resource] ?? []).includes(action); + return ( + + ); + })} +
+
+ ))} +
+ +
+ Cancel + +
+
+
+ ) : null} + +
+ + + + + + + + + + {orgContext.roles.map((role) => ( + + + + + + ))} + +
RoleTypeActions
{formatRoleLabel(role.role)}{role.protected ? "System" : role.builtIn ? "Default" : "Custom"} + {access.canManageRoles && !role.protected ? ( +
+ { + setShowRoleForm(false); + setEditingRoleId(role.id); + setRoleNameDraft(role.role); + setRolePermissionDraft(clonePermissionRecord(role.permission)); + }} + > + Edit + + { + setPageError(null); + try { + await deleteRole(role.id); + if (editingRoleId === role.id) { + resetRoleEditor(); + } + } catch (error) { + setPageError(error instanceof Error ? error.message : "Could not delete role."); + } + }} + > + {mutationBusy === "delete-role" ? "Deleting..." : "Delete"} + +
+ ) : Read only} +
+
+
+
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx new file mode 100644 index 00000000..beb43312 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -0,0 +1,203 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState, type ReactNode } from "react"; +import { useDenFlow } from "../../../../_providers/den-flow-provider"; +import { + formatRoleLabel, + getManageMembersRoute, + getOrgDashboardRoute, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function ChevronDownIcon() { + return ( + + ); +} + +function PlusIcon() { + return ( + + ); +} + +function OrgMark({ name }: { name: string }) { + const initials = useMemo(() => { + const parts = name.trim().split(/\s+/).filter(Boolean); + return (parts[0]?.slice(0, 1) ?? "O") + (parts[1]?.slice(0, 1) ?? ""); + }, [name]); + + return ( +
+ {initials} +
+ ); +} + +export function OrgDashboardShell({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const { user, signOut } = useDenFlow(); + const { + activeOrg, + orgDirectory, + orgBusy, + orgError, + mutationBusy, + createOrganization, + switchOrganization, + } = useOrgDashboard(); + const [switcherOpen, setSwitcherOpen] = useState(false); + const [orgNameDraft, setOrgNameDraft] = useState(""); + const [createError, setCreateError] = useState(null); + + const navItems = [ + { href: activeOrg ? getOrgDashboardRoute(activeOrg.slug) : "#", label: "Dashboard" }, + { href: activeOrg ? getManageMembersRoute(activeOrg.slug) : "#", label: "Manage Members" }, + { href: "/checkout", label: "Billing" }, + ]; + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/templates-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/templates-dashboard-screen.tsx new file mode 100644 index 00000000..5590c733 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/templates-dashboard-screen.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +type TemplateCard = { + id: string; + name: string; + createdAt: string | null; + creator: { + name: string; + email: string; + }; +}; + +function asTemplateCard(value: unknown): TemplateCard | null { + if (!value || typeof value !== "object") { + return null; + } + + const entry = value as Record; + const creator = entry.creator && typeof entry.creator === "object" + ? (entry.creator as Record) + : null; + + if ( + typeof entry.id !== "string" || + typeof entry.name !== "string" || + !creator || + typeof creator.name !== "string" || + typeof creator.email !== "string" + ) { + return null; + } + + return { + id: entry.id, + name: entry.name, + createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null, + creator: { + name: creator.name, + email: creator.email, + }, + }; +} + +export function TemplatesDashboardScreen() { + const { orgSlug, orgContext } = useOrgDashboard(); + const [templates, setTemplates] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const canDelete = orgContext?.currentMember.isOwner ?? false; + + async function loadTemplates() { + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/templates`, + { method: "GET" }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load templates (${response.status}).`)); + } + + const list = + payload && typeof payload === "object" && Array.isArray((payload as { templates?: unknown[] }).templates) + ? (payload as { templates: unknown[] }).templates + : []; + + setTemplates(list.map(asTemplateCard).filter((entry): entry is TemplateCard => entry !== null)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load templates."); + } finally { + setBusy(false); + } + } + + async function deleteTemplate(templateId: string) { + setDeletingId(templateId); + setError(null); + try { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/templates/${encodeURIComponent(templateId)}`, + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to delete template (${response.status}).`)); + } + + await loadTemplates(); + } catch (deleteError) { + setError(deleteError instanceof Error ? deleteError.message : "Failed to delete template."); + } finally { + setDeletingId(null); + } + } + + useEffect(() => { + void loadTemplates(); + }, [orgSlug]); + + return ( +
+
+

Workspace Templates

+

Shared setup templates

+

+ Templates created for this organization appear here. Use this as the quick place to browse and remove stale links. +

+

+ Create new templates from workspaces inside the OpenWork desktop app. +

+
+ + {error ?
{error}
: null} + + {busy ? ( +
Loading templates...
+ ) : templates.length === 0 ? ( +
No templates yet for this organization.
+ ) : ( +
+ {templates.map((template) => ( +
+

{template.name}

+

Created by {template.creator.name} ({template.creator.email})

+

+ {template.createdAt ? `Created ${new Date(template.createdAt).toLocaleString()}` : "Created recently"} +

+ {canDelete ? ( + + ) : null} +
+ ))} +
+ )} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/org-dashboard-provider.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/org-dashboard-provider.tsx new file mode 100644 index 00000000..f9565ba0 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_providers/org-dashboard-provider.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useRouter } from "next/navigation"; +import { useDenFlow } from "../../../../_providers/den-flow-provider"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { + type DenOrgContext, + type DenOrgSummary, + getOrgDashboardRoute, + parseOrgContextPayload, + parseOrgListPayload, +} from "../../../../_lib/den-org"; + +type OrgDashboardContextValue = { + orgSlug: string; + orgDirectory: DenOrgSummary[]; + activeOrg: DenOrgSummary | null; + orgContext: DenOrgContext | null; + orgBusy: boolean; + orgError: string | null; + mutationBusy: string | null; + refreshOrgData: () => Promise; + createOrganization: (name: string) => Promise; + switchOrganization: (slug: string) => void; + inviteMember: (input: { email: string; role: string }) => Promise; + cancelInvitation: (invitationId: string) => Promise; + updateMemberRole: (memberId: string, role: string) => Promise; + removeMember: (memberId: string) => Promise; + createRole: (input: { roleName: string; permission: Record }) => Promise; + updateRole: (roleId: string, input: { roleName?: string; permission?: Record }) => Promise; + deleteRole: (roleId: string) => Promise; +}; + +const OrgDashboardContext = createContext(null); + +export function OrgDashboardProvider({ + orgSlug, + children, +}: { + orgSlug: string; + children: ReactNode; +}) { + const router = useRouter(); + const { user, sessionHydrated, signOut, refreshWorkers } = useDenFlow(); + const [orgDirectory, setOrgDirectory] = useState([]); + const [orgContext, setOrgContext] = useState(null); + const [orgBusy, setOrgBusy] = useState(false); + const [orgError, setOrgError] = useState(null); + const [mutationBusy, setMutationBusy] = useState(null); + + const activeOrg = useMemo( + () => orgDirectory.find((entry) => entry.slug === orgSlug) ?? orgDirectory.find((entry) => entry.isActive) ?? null, + [orgDirectory, orgSlug], + ); + + async function loadOrgDirectory() { + const { response, payload } = await requestJson("/v1/me/orgs", { method: "GET" }, 12000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load organizations (${response.status}).`)); + } + + return parseOrgListPayload(payload).orgs; + } + + async function loadOrgContext(targetOrgSlug: string) { + const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(targetOrgSlug)}/context`, { method: "GET" }, 12000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load organization (${response.status}).`)); + } + + const parsed = parseOrgContextPayload(payload); + if (!parsed) { + throw new Error("Organization context response was incomplete."); + } + + return parsed; + } + + async function refreshOrgData() { + if (!user) { + setOrgDirectory([]); + setOrgContext(null); + setOrgError(null); + return; + } + + setOrgBusy(true); + setOrgError(null); + + try { + const [directory, context] = await Promise.all([ + loadOrgDirectory(), + loadOrgContext(orgSlug), + ]); + + setOrgDirectory(directory.map((entry) => ({ ...entry, isActive: entry.slug === context.organization.slug }))); + setOrgContext(context); + await refreshWorkers({ keepSelection: false }); + } catch (error) { + setOrgError(error instanceof Error ? error.message : "Failed to load organization details."); + } finally { + setOrgBusy(false); + } + } + + async function runMutation(label: string, action: () => Promise) { + setMutationBusy(label); + setOrgError(null); + try { + await action(); + await refreshOrgData(); + } finally { + setMutationBusy(null); + } + } + + async function createOrganization(name: string) { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Enter an organization name."); + } + + setMutationBusy("create-organization"); + setOrgError(null); + try { + const { response, payload } = await requestJson( + "/v1/orgs", + { + method: "POST", + body: JSON.stringify({ name: trimmed }), + }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to create organization (${response.status}).`)); + } + + const organization = + typeof payload === "object" && payload && "organization" in payload && payload.organization && typeof payload.organization === "object" + ? payload.organization as { slug?: unknown } + : null; + const nextSlug = typeof organization?.slug === "string" ? organization.slug : null; + + if (!nextSlug) { + throw new Error("Organization was created, but no slug was returned."); + } + + router.push(getOrgDashboardRoute(nextSlug)); + } finally { + setMutationBusy(null); + } + } + + function switchOrganization(nextSlug: string) { + router.push(getOrgDashboardRoute(nextSlug)); + } + + async function inviteMember(input: { email: string; role: string }) { + await runMutation("invite-member", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/invitations`, + { + method: "POST", + body: JSON.stringify(input), + }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to invite member (${response.status}).`)); + } + }); + } + + async function cancelInvitation(invitationId: string) { + await runMutation("cancel-invitation", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/invitations/${encodeURIComponent(invitationId)}/cancel`, + { method: "POST", body: JSON.stringify({}) }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to cancel invitation (${response.status}).`)); + } + }); + } + + async function updateMemberRole(memberId: string, role: string) { + await runMutation("update-member-role", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/members/${encodeURIComponent(memberId)}/role`, + { + method: "POST", + body: JSON.stringify({ role }), + }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to update member (${response.status}).`)); + } + }); + } + + async function removeMember(memberId: string) { + await runMutation("remove-member", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/members/${encodeURIComponent(memberId)}`, + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to remove member (${response.status}).`)); + } + }); + } + + async function createRole(input: { roleName: string; permission: Record }) { + await runMutation("create-role", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/roles`, + { + method: "POST", + body: JSON.stringify(input), + }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to create role (${response.status}).`)); + } + }); + } + + async function updateRole(roleId: string, input: { roleName?: string; permission?: Record }) { + await runMutation("update-role", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/roles/${encodeURIComponent(roleId)}`, + { + method: "PATCH", + body: JSON.stringify(input), + }, + 12000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to update role (${response.status}).`)); + } + }); + } + + async function deleteRole(roleId: string) { + await runMutation("delete-role", async () => { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgSlug)}/roles/${encodeURIComponent(roleId)}`, + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to delete role (${response.status}).`)); + } + }); + } + + useEffect(() => { + if (!sessionHydrated) { + return; + } + + if (!user) { + void signOut(); + router.replace("/"); + return; + } + + void refreshOrgData(); + }, [orgSlug, router, sessionHydrated, user?.id]); + + const value: OrgDashboardContextValue = { + orgSlug, + orgDirectory, + activeOrg, + orgContext, + orgBusy, + orgError, + mutationBusy, + refreshOrgData, + createOrganization, + switchOrganization, + inviteMember, + cancelInvitation, + updateMemberRole, + removeMember, + createRole, + updateRole, + deleteRole, + }; + + return {children}; +} + +export function useOrgDashboard() { + const value = useContext(OrgDashboardContext); + if (!value) { + throw new Error("useOrgDashboard must be used within OrgDashboardProvider."); + } + return value; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx new file mode 100644 index 00000000..f39f605b --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { OrgDashboardShell } from "./_components/org-dashboard-shell"; +import { OrgDashboardProvider } from "./_providers/org-dashboard-provider"; + +export default function OrgDashboardLayout({ + children, + params, +}: { + children: ReactNode; + params: { orgSlug: string }; +}) { + return ( + + {children} + + ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/manage-members/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/manage-members/page.tsx new file mode 100644 index 00000000..7e435f15 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/manage-members/page.tsx @@ -0,0 +1,5 @@ +import { ManageMembersScreen } from "../_components/manage-members-screen"; + +export default function ManageMembersPage() { + return ; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/page.tsx new file mode 100644 index 00000000..708328da --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/page.tsx @@ -0,0 +1,7 @@ +// import { DashboardScreen } from "../../../_components/dashboard-screen"; +import { TemplatesDashboardScreen } from "./_components/templates-dashboard-screen"; + +export default function OrgDashboardPage() { + // return ; + return ; +} diff --git a/ee/apps/den-web/pr/screenshots/den-v2-mvp/dashboard-signup-redirect.png b/ee/apps/den-web/pr/screenshots/den-v2-mvp/dashboard-signup-redirect.png new file mode 100644 index 00000000..be62f5d7 Binary files /dev/null and b/ee/apps/den-web/pr/screenshots/den-v2-mvp/dashboard-signup-redirect.png differ diff --git a/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-switcher.png b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-switcher.png new file mode 100644 index 00000000..52c2d5ac Binary files /dev/null and b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-switcher.png differ diff --git a/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin-invite.png b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin-invite.png new file mode 100644 index 00000000..5d936445 Binary files /dev/null and b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin-invite.png differ diff --git a/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin.png b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin.png new file mode 100644 index 00000000..f9a7bde1 Binary files /dev/null and b/ee/apps/den-web/pr/screenshots/den-v2-mvp/manage-members-table-admin.png differ diff --git a/ee/apps/den-worker-proxy/src/app.ts b/ee/apps/den-worker-proxy/src/app.ts index 4853a38d..fc958fbe 100644 --- a/ee/apps/den-worker-proxy/src/app.ts +++ b/ee/apps/den-worker-proxy/src/app.ts @@ -1,10 +1,9 @@ import "./load-env.js" import { Daytona } from "@daytonaio/sdk" import { Hono } from "hono" -import { createHash } from "node:crypto" import { and, eq, isNull } from "@openwork-ee/den-db/drizzle" import { createDenDb, DaytonaSandboxTable, RateLimitTable, WorkerTokenTable } from "@openwork-ee/den-db" -import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { env } from "./env.js" const { db } = createDenDb({ @@ -86,10 +85,6 @@ function stripProxyHeaders(input: Headers) { return headers } -function hashRateLimitId(key: string) { - return createHash("sha256").update(key).digest("hex") -} - function readClientIp(request: Request) { const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() const realIp = request.headers.get("x-real-ip")?.trim() @@ -120,7 +115,7 @@ async function consumeRateLimit(input: { const current = rows[0] ?? null if (!current) { await db.insert(RateLimitTable).values({ - id: hashRateLimitId(input.key), + id: createDenTypeId("rateLimit"), key: input.key, count: 1, lastRequest: now, diff --git a/ee/packages/den-db/src/schema.ts b/ee/packages/den-db/src/schema.ts index e7df7355..5721269f 100644 --- a/ee/packages/den-db/src/schema.ts +++ b/ee/packages/den-db/src/schema.ts @@ -21,7 +21,6 @@ const timestamps = { .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), } -export const OrgRole = ["owner", "member"] as const export const WorkerDestination = ["local", "cloud"] as const export const WorkerStatus = ["provisioning", "healthy", "failed", "stopped"] as const export const TokenScope = ["client", "host", "activity"] as const @@ -47,6 +46,8 @@ export const AuthSessionTable = mysqlTable( { id: denTypeIdColumn("session", "id").notNull().primaryKey(), userId: denTypeIdColumn("user", "user_id").notNull(), + activeOrganizationId: denTypeIdColumn("organization", "active_organization_id"), + activeTeamId: denTypeIdColumn("team", "active_team_id"), token: varchar("token", { length: 255 }).notNull(), expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(), ipAddress: text("ip_address"), @@ -102,7 +103,7 @@ export const AuthVerificationTable = mysqlTable( export const RateLimitTable = mysqlTable( "rate_limit", { - id: varchar("id", { length: 255 }).notNull().primaryKey(), + id: denTypeIdColumn("rateLimit", "id").notNull().primaryKey(), key: varchar("key", { length: 512 }).notNull(), count: int("count").notNull().default(0), lastRequest: bigint("last_request", { mode: "number" }).notNull(), @@ -132,30 +133,141 @@ export const DesktopHandoffGrantTable = mysqlTable( ], ) -export const OrgTable = mysqlTable( - "org", +export const OrganizationTable = mysqlTable( + "organization", { - id: denTypeIdColumn("org", "id").notNull().primaryKey(), + id: denTypeIdColumn("organization", "id").notNull().primaryKey(), name: varchar("name", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull(), - owner_user_id: denTypeIdColumn("user", "owner_user_id").notNull(), - ...timestamps, + logo: varchar("logo", { length: 2048 }), + metadata: text("metadata"), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), }, - (table) => [uniqueIndex("org_slug").on(table.slug), index("org_owner_user_id").on(table.owner_user_id)], + (table) => [uniqueIndex("organization_slug").on(table.slug)], ) -export const OrgMembershipTable = mysqlTable( - "org_membership", +export const MemberTable = mysqlTable( + "member", { - id: denTypeIdColumn("orgMembership", "id").notNull().primaryKey(), - org_id: denTypeIdColumn("org", "org_id").notNull(), - user_id: denTypeIdColumn("user", "user_id").notNull(), - role: mysqlEnum("role", OrgRole).notNull(), - created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + id: denTypeIdColumn("member", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + userId: denTypeIdColumn("user", "user_id").notNull(), + role: varchar("role", { length: 255 }).notNull().default("member"), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), }, - (table) => [index("org_membership_org_id").on(table.org_id), index("org_membership_user_id").on(table.user_id)], + (table) => [ + index("member_organization_id").on(table.organizationId), + index("member_user_id").on(table.userId), + uniqueIndex("member_organization_user").on(table.organizationId, table.userId), + ], ) +export const InvitationTable = mysqlTable( + "invitation", + { + id: denTypeIdColumn("invitation", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + email: varchar("email", { length: 255 }).notNull(), + role: varchar("role", { length: 255 }).notNull(), + status: varchar("status", { length: 32 }).notNull().default("pending"), + teamId: denTypeIdColumn("team", "team_id"), + inviterId: denTypeIdColumn("user", "inviter_id").notNull(), + expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("invitation_organization_id").on(table.organizationId), + index("invitation_email").on(table.email), + index("invitation_status").on(table.status), + index("invitation_team_id").on(table.teamId), + ], +) + +export const TeamTable = mysqlTable( + "team", + { + id: denTypeIdColumn("team", "id").notNull().primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + }, + (table) => [ + index("team_organization_id").on(table.organizationId), + uniqueIndex("team_organization_name").on(table.organizationId, table.name), + ], +) + +export const TeamMemberTable = mysqlTable( + "team_member", + { + id: denTypeIdColumn("teamMember", "id").notNull().primaryKey(), + teamId: denTypeIdColumn("team", "team_id").notNull(), + userId: denTypeIdColumn("user", "user_id").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("team_member_team_id").on(table.teamId), + index("team_member_user_id").on(table.userId), + uniqueIndex("team_member_team_user").on(table.teamId, table.userId), + ], +) + +export const OrganizationRoleTable = mysqlTable( + "organization_role", + { + id: denTypeIdColumn("organizationRole", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + role: varchar("role", { length: 255 }).notNull(), + permission: text("permission").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + }, + (table) => [ + index("organization_role_organization_id").on(table.organizationId), + uniqueIndex("organization_role_name").on(table.organizationId, table.role), + ], +) + +export const TempTemplateSharingTable = mysqlTable( + "temp_template_sharing", + { + id: denTypeIdColumn("tempTemplateSharing", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn("organization", "organization_id").notNull(), + creatorMemberId: denTypeIdColumn("member", "creator_member_id").notNull(), + creatorUserId: denTypeIdColumn("user", "creator_user_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + templateJson: text("template_json").notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + }, + (table) => [ + index("temp_template_sharing_org_id").on(table.organizationId), + index("temp_template_sharing_creator_member_id").on(table.creatorMemberId), + index("temp_template_sharing_creator_user_id").on(table.creatorUserId), + ], +) + +export const organization = OrganizationTable +export const member = MemberTable +export const invitation = InvitationTable +export const team = TeamTable +export const teamMember = TeamMemberTable +export const organizationRole = OrganizationRoleTable +export const tempTemplateSharing = TempTemplateSharingTable + +export const OrgTable = OrganizationTable +export const OrgMembershipTable = MemberTable + export const AdminAllowlistTable = mysqlTable( "admin_allowlist", { diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index db788435..3319a8e9 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -5,8 +5,16 @@ export const denTypeIdPrefixes = { session: "ses", account: "acc", verification: "ver", + rateLimit: "rli", org: "org", + organization: "org", orgMembership: "om", + member: "om", + invitation: "inv", + team: "tem", + teamMember: "tmb", + organizationRole: "orl", + tempTemplateSharing: "tts", adminAllowlist: "aal", worker: "wrk", workerInstance: "wki", diff --git a/packaging/docker/Dockerfile.den b/packaging/docker/Dockerfile.den index 51ebe7c6..5fc3c4ba 100644 --- a/packaging/docker/Dockerfile.den +++ b/packaging/docker/Dockerfile.den @@ -7,20 +7,20 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches -COPY packages/utils/package.json /app/packages/utils/package.json -COPY packages/den-db/package.json /app/packages/den-db/package.json -COPY services/den/package.json /app/services/den/package.json +COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json +COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json +COPY ee/apps/den-controller/package.json /app/ee/apps/den-controller/package.json RUN pnpm install --frozen-lockfile -COPY packages/utils /app/packages/utils -COPY packages/den-db /app/packages/den-db -COPY services/den /app/services/den +COPY ee/packages/utils /app/ee/packages/utils +COPY ee/packages/den-db /app/ee/packages/den-db +COPY ee/apps/den-controller /app/ee/apps/den-controller -RUN pnpm --dir /app/packages/utils run build -RUN pnpm --dir /app/packages/den-db run build -RUN pnpm --dir /app/services/den run build +RUN pnpm --dir /app/ee/packages/utils run build +RUN pnpm --dir /app/ee/packages/den-db run build +RUN pnpm --dir /app/ee/apps/den-controller run build EXPOSE 8788 -CMD ["sh", "-lc", "node services/den/dist/index.js"] +CMD ["sh", "-lc", "yes | pnpm --dir /app/ee/packages/den-db run db:push && node ee/apps/den-controller/dist/index.js"] diff --git a/packaging/docker/Dockerfile.den-web b/packaging/docker/Dockerfile.den-web index b2fc3658..dd22909a 100644 --- a/packaging/docker/Dockerfile.den-web +++ b/packaging/docker/Dockerfile.den-web @@ -1,13 +1,20 @@ FROM node:22-bookworm-slim -WORKDIR /app/packages/web +RUN corepack enable -COPY packages/web/package.json /app/packages/web/package.json +WORKDIR /app -RUN npm install --no-package-lock --no-fund --no-audit +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ +COPY .npmrc /app/.npmrc +COPY patches /app/patches +COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json -COPY packages/web /app/packages/web +RUN pnpm install --frozen-lockfile --filter @openwork-ee/den-web... + +COPY ee/apps/den-web /app/ee/apps/den-web + +WORKDIR /app/ee/apps/den-web EXPOSE 3005 -CMD ["npm", "run", "dev"] +CMD ["sh", "-lc", "pnpm run build && pnpm run start"] diff --git a/packaging/docker/Dockerfile.den-worker-proxy b/packaging/docker/Dockerfile.den-worker-proxy index bf8dad3c..8400453e 100644 --- a/packaging/docker/Dockerfile.den-worker-proxy +++ b/packaging/docker/Dockerfile.den-worker-proxy @@ -7,20 +7,20 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches -COPY packages/utils/package.json /app/packages/utils/package.json -COPY packages/den-db/package.json /app/packages/den-db/package.json -COPY services/den-worker-proxy/package.json /app/services/den-worker-proxy/package.json +COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json +COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json +COPY ee/apps/den-worker-proxy/package.json /app/ee/apps/den-worker-proxy/package.json RUN pnpm install --frozen-lockfile -COPY packages/utils /app/packages/utils -COPY packages/den-db /app/packages/den-db -COPY services/den-worker-proxy /app/services/den-worker-proxy +COPY ee/packages/utils /app/ee/packages/utils +COPY ee/packages/den-db /app/ee/packages/den-db +COPY ee/apps/den-worker-proxy /app/ee/apps/den-worker-proxy -RUN pnpm --dir /app/packages/utils run build -RUN pnpm --dir /app/packages/den-db run build -RUN pnpm --dir /app/services/den-worker-proxy run build +RUN pnpm --dir /app/ee/packages/utils run build +RUN pnpm --dir /app/ee/packages/den-db run build +RUN pnpm --dir /app/ee/apps/den-worker-proxy run build EXPOSE 8789 -CMD ["sh", "-lc", "node services/den-worker-proxy/dist/server.js"] +CMD ["sh", "-lc", "node ee/apps/den-worker-proxy/dist/server.js"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f546b6f1..98ca9efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,9 @@ importers: better-auth: specifier: ^1.4.18 version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10) + better-call: + specifier: ^1.1.8 + version: 1.1.8(zod@4.3.6) cors: specifier: ^2.8.5 version: 2.8.6