Add Den organizations, org permissions, and template sharing surfaces (#1172)

* Add Den org auth model and template APIs

Wire Better Auth organizations with TypeId-backed schema and migrations, enforce owner/admin org permissions, and add org-scoped template create/list/delete endpoints. Simplify the Den org dashboard UX and update Docker dev packaging paths for the ee apps/packages layout.

* Add manual-safe org migration SQL

Provide a Vitess-compatible version of the organization migration without statement-breakpoint markers or unsupported IF NOT EXISTS column syntax so operators can run it directly in SQL consoles.

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-03-25 18:30:50 -07:00
committed by GitHub
parent ea226be40a
commit 21d3b443a7
40 changed files with 4176 additions and 508 deletions

View File

@@ -12,6 +12,7 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
LOOPS_API_KEY= LOOPS_API_KEY=
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL= LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=
LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL=
PORT=8788 PORT=8788
WORKER_PROXY_PORT=8789 WORKER_PROXY_PORT=8789
CORS_ORIGINS=http://localhost:3005,http://localhost:5173 CORS_ORIGINS=http://localhost:3005,http://localhost:5173

View File

@@ -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`);

View File

@@ -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;

View File

@@ -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`)
);

View File

@@ -19,6 +19,7 @@
"@openwork-ee/den-db": "workspace:*", "@openwork-ee/den-db": "workspace:*",
"@openwork-ee/utils": "workspace:*", "@openwork-ee/utils": "workspace:*",
"@daytonaio/sdk": "^0.150.0", "@daytonaio/sdk": "^0.150.0",
"better-call": "^1.1.8",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

View File

@@ -1,13 +1,15 @@
import { betterAuth } from "better-auth" import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle" 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 { db } from "./db/index.js"
import * as schema from "./db/schema.js" import * as schema from "./db/schema.js"
import { createDenTypeId, normalizeDenTypeId } from "./db/typeid.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 { env } from "./env.js"
import { syncDenSignupContact } from "./loops.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 = { const socialProviders = {
...(env.github.clientId && env.github.clientSecret ...(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({ export const auth = betterAuth({
baseURL: env.betterAuthUrl, baseURL: env.betterAuthUrl,
secret: env.betterAuthSecret, secret: env.betterAuthSecret,
@@ -53,6 +71,20 @@ export const auth = betterAuth({
return createDenTypeId("account") return createDenTypeId("account")
case "verification": case "verification":
return createDenTypeId("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: default:
return false return false
} }
@@ -91,10 +123,13 @@ export const auth = betterAuth({
sendOnSignUp: true, sendOnSignUp: true,
sendOnSignIn: true, sendOnSignIn: true,
afterEmailVerification: async (user) => { afterEmailVerification: async (user) => {
const name = user.name ?? user.email ?? "Personal"
const userId = normalizeDenTypeId("user", user.id) const userId = normalizeDenTypeId("user", user.id)
await Promise.all([ await Promise.all([
ensureDefaultOrg(userId, name), ensureUserOrgAccess({
userId,
email: user.email,
name: user.name,
}),
syncDenSignupContact({ syncDenSignupContact({
email: user.email, email: user.email,
name: user.name, 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.",
})
}
},
},
}),
], ],
}) })

View File

@@ -2,26 +2,26 @@ import { env } from "./env.js"
const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional" const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional"
export async function sendDenVerificationEmail(input: { async function sendLoopsTransactionalEmail(input: {
email: string email: string
verificationCode: string transactionalId: string | undefined
dataVariables: Record<string, string>
logLabel: string
}) { }) {
const apiKey = env.loops.apiKey const apiKey = env.loops.apiKey
const transactionalId = env.loops.transactionalIdDenVerifyEmail
const email = input.email.trim() const email = input.email.trim()
const verificationCode = input.verificationCode.trim()
if (!email || !verificationCode) { if (!email) {
return return
} }
if (env.devMode) { 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 return
} }
if (!apiKey || !transactionalId) { if (!apiKey || !input.transactionalId) {
console.warn(`[auth] verification email skipped for ${email}: Loops is not configured`) console.warn(`[auth] ${input.logLabel} skipped for ${email}: Loops is not configured`)
return return
} }
@@ -33,11 +33,9 @@ export async function sendDenVerificationEmail(input: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
transactionalId, transactionalId: input.transactionalId,
email, email,
dataVariables: { dataVariables: input.dataVariables,
verificationCode,
},
}), }),
}) })
@@ -55,9 +53,51 @@ export async function sendDenVerificationEmail(input: {
// Ignore invalid upstream payloads. // 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) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown 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",
})
}

View File

@@ -16,6 +16,7 @@ const schema = z.object({
GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(),
LOOPS_API_KEY: z.string().optional(), LOOPS_API_KEY: z.string().optional(),
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL: 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(), PORT: z.string().optional(),
WORKER_PROXY_PORT: z.string().optional(), WORKER_PROXY_PORT: z.string().optional(),
OPENWORK_DEV_MODE: z.string().optional(), OPENWORK_DEV_MODE: z.string().optional(),
@@ -161,6 +162,7 @@ export const env = {
loops: { loops: {
apiKey: optionalString(parsed.LOOPS_API_KEY), apiKey: optionalString(parsed.LOOPS_API_KEY),
transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL), transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL),
transactionalIdDenOrgInviteEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL),
}, },
port: Number(parsed.PORT ?? "8788"), port: Number(parsed.PORT ?? "8788"),
workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"), workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"),

View File

@@ -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<ReturnType<typeof requireOrganizationContext>>, 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<ReturnType<typeof requireOrganizationContext>>, 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")
}

View File

@@ -30,6 +30,8 @@ async function getSessionFromBearerToken(token: string): Promise<AuthSessionLike
id: AuthSessionTable.id, id: AuthSessionTable.id,
token: AuthSessionTable.token, token: AuthSessionTable.token,
userId: AuthSessionTable.userId, userId: AuthSessionTable.userId,
activeOrganizationId: AuthSessionTable.activeOrganizationId,
activeTeamId: AuthSessionTable.activeTeamId,
expiresAt: AuthSessionTable.expiresAt, expiresAt: AuthSessionTable.expiresAt,
createdAt: AuthSessionTable.createdAt, createdAt: AuthSessionTable.createdAt,
updatedAt: AuthSessionTable.updatedAt, updatedAt: AuthSessionTable.updatedAt,

View File

@@ -8,7 +8,7 @@ import { AuditEventTable, AuthUserTable, DaytonaSandboxTable, OrgMembershipTable
import { env } from "../env.js" import { env } from "../env.js"
import { asyncRoute, isTransientDbConnectionError } from "./errors.js" import { asyncRoute, isTransientDbConnectionError } from "./errors.js"
import { getRequestSession } from "./session.js" import { getRequestSession } from "./session.js"
import { ensureDefaultOrg } from "../orgs.js" import { resolveUserOrganizationsForSession } from "../orgs.js"
import { deprovisionWorker, provisionWorker } from "../workers/provisioner.js" import { deprovisionWorker, provisionWorker } from "../workers/provisioner.js"
import { customDomainForWorker } from "../workers/vanity-domain.js" import { customDomainForWorker } from "../workers/vanity-domain.js"
import { createDenTypeId, normalizeDenTypeId } from "../db/typeid.js" import { createDenTypeId, normalizeDenTypeId } from "../db/typeid.js"
@@ -46,7 +46,7 @@ const token = () => randomBytes(32).toString("hex")
type WorkerRow = typeof WorkerTable.$inferSelect type WorkerRow = typeof WorkerTable.$inferSelect
type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect
type WorkerId = WorkerRow["id"] type WorkerId = WorkerRow["id"]
type OrgId = typeof OrgMembershipTable.$inferSelect.org_id type OrgId = typeof OrgMembershipTable.$inferSelect.organizationId
type UserId = typeof AuthUserTable.$inferSelect.id type UserId = typeof AuthUserTable.$inferSelect.id
function parseWorkerIdParam(value: string): WorkerId { function parseWorkerIdParam(value: string): WorkerId {
@@ -281,16 +281,24 @@ async function requireSession(req: express.Request, res: express.Response) {
} }
} }
async function getOrgId(userId: UserId): Promise<OrgId | null> { async function resolveActiveOrgId(session: Awaited<ReturnType<typeof requireSession>>): Promise<OrgId | null> {
const membership = await db if (!session) {
.select()
.from(OrgMembershipTable)
.where(eq(OrgMembershipTable.user_id, userId))
.limit(1)
if (membership.length === 0) {
return null 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) { async function countUserCloudWorkers(userId: UserId) {
@@ -494,7 +502,7 @@ workersRouter.get("/", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.json({ workers: [] }) res.json({ workers: [] })
return return
@@ -561,8 +569,11 @@ workersRouter.post("/", asyncRoute(async (req, res) => {
} }
} }
const orgId = const orgId = await resolveActiveOrgId(session)
(await getOrgId(session.user.id)) ?? (await ensureDefaultOrg(session.user.id, session.user.name ?? session.user.email ?? "Personal")) if (!orgId) {
res.status(400).json({ error: "organization_unavailable" })
return
}
const workerId = createDenTypeId("worker") const workerId = createDenTypeId("worker")
let workerStatus: WorkerRow["status"] = parsed.data.destination === "cloud" ? "provisioning" : "healthy" let workerStatus: WorkerRow["status"] = parsed.data.destination === "cloud" ? "provisioning" : "healthy"
@@ -634,6 +645,7 @@ workersRouter.post("/", asyncRoute(async (req, res) => {
session.user.id, session.user.id,
), ),
tokens: { tokens: {
owner: hostToken,
host: hostToken, host: hostToken,
client: clientToken, client: clientToken,
}, },
@@ -711,7 +723,7 @@ workersRouter.get("/:id", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return
@@ -748,7 +760,7 @@ workersRouter.patch("/:id", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return
@@ -800,7 +812,7 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return
@@ -847,6 +859,7 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => {
res.json({ res.json({
tokens: { tokens: {
owner: hostToken,
host: hostToken, host: hostToken,
client: clientToken, client: clientToken,
}, },
@@ -858,7 +871,7 @@ workersRouter.get("/:id/runtime", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return
@@ -895,7 +908,7 @@ workersRouter.post("/:id/runtime/upgrade", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return
@@ -934,7 +947,7 @@ workersRouter.delete("/:id", asyncRoute(async (req, res) => {
const session = await requireSession(req, res) const session = await requireSession(req, res)
if (!session) return if (!session) return
const orgId = await getOrgId(session.user.id) const orgId = await resolveActiveOrgId(session)
if (!orgId) { if (!orgId) {
res.status(404).json({ error: "worker_not_found" }) res.status(404).json({ error: "worker_not_found" })
return return

View File

@@ -9,10 +9,11 @@ import { env } from "./env.js"
import { adminRouter } from "./http/admin.js" import { adminRouter } from "./http/admin.js"
import { desktopAuthRouter } from "./http/desktop-auth.js" import { desktopAuthRouter } from "./http/desktop-auth.js"
import { asyncRoute, errorMiddleware } from "./http/errors.js" import { asyncRoute, errorMiddleware } from "./http/errors.js"
import { orgsRouter } from "./http/orgs.js"
import { getRequestSession } from "./http/session.js" import { getRequestSession } from "./http/session.js"
import { workersRouter } from "./http/workers.js" import { workersRouter } from "./http/workers.js"
import { normalizeDenTypeId } from "./db/typeid.js" import { normalizeDenTypeId } from "./db/typeid.js"
import { listUserOrgs } from "./orgs.js" import { resolveUserOrganizationsForSession } from "./orgs.js"
const app = express() const app = express()
const currentFile = fileURLToPath(import.meta.url) const currentFile = fileURLToPath(import.meta.url)
@@ -52,15 +53,27 @@ app.get("/v1/me/orgs", asyncRoute(async (req, res) => {
return 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({ res.json({
orgs, orgs: resolved.orgs.map((org) => ({
defaultOrgId: orgs[0]?.id ?? null, ...org,
isActive: org.id === resolved.activeOrgId,
})),
activeOrgId: resolved.activeOrgId,
activeOrgSlug: resolved.activeOrgSlug,
}) })
})) }))
app.use("/v1/admin", adminRouter) app.use("/v1/admin", adminRouter)
app.use("/v1/auth", desktopAuthRouter) app.use("/v1/auth", desktopAuthRouter)
app.use("/v1/orgs", orgsRouter)
app.use("/v1/workers", workersRouter) app.use("/v1/workers", workersRouter)
app.use(errorMiddleware) app.use(errorMiddleware)

View File

@@ -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

View File

@@ -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 { db } from "./db/index.js"
import { AuthUserTable, OrgMembershipTable, OrgTable } from "./db/schema.js" import {
import { createDenTypeId } from "./db/typeid.js" 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 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) { export type UserOrgSummary = {
return orgId id: OrgId
name: string
slug: string
logo: string | null
metadata: string | null
role: string
membershipId: string
createdAt: Date
updatedAt: Date
} }
function isDuplicateSlugError(error: unknown) { export type OrganizationContext = {
if (!(error instanceof Error)) { organization: {
return false 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<string, string[]>
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() try {
return message.includes("duplicate entry") && message.includes("org.org_slug") const parsed = JSON.parse(value) as Record<string, unknown>
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<OrgId> { export function serializePermissionRecord(value: Record<string, string[]>) {
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<string>) {
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 const existing = await db
.select() .select()
.from(OrgMembershipTable) .from(MemberTable)
.where(eq(OrgMembershipTable.user_id, userId)) .where(
and(
eq(MemberTable.organizationId, input.organizationId),
eq(MemberTable.userId, input.userId),
),
)
.limit(1) .limit(1)
if (existing.length > 0) { 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 created = await db
const candidateOrgId = createDenTypeId("org") .select()
const slug = personalSlugFromOrgId(candidateOrgId) .from(MemberTable)
.where(
and(
eq(MemberTable.organizationId, input.organizationId),
eq(MemberTable.userId, input.userId),
),
)
.limit(1)
try { if (!created[0]) {
await db.insert(OrgTable).values({ throw new Error("failed_to_create_member")
id: candidateOrgId, }
name,
slug, return created[0]
owner_user_id: userId, }
})
orgId = candidateOrgId async function acceptInvitation(invitation: InvitationRow, userId: UserId) {
break const availableRoles = await listAssignableRoles(invitation.organizationId)
} catch (error) { const role = normalizeAssignableRole(invitation.role, availableRoles)
if (isDuplicateSlugError(error) && attempt < 4) {
continue 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) { await db
throw new Error("failed to create default org") .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({ const member = await acceptInvitation(invitation, input.userId)
id: createDenTypeId("orgMembership"), return {
org_id: orgId, invitation,
user_id: userId, 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", 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) { export async function listUserOrgs(userId: UserId) {
const memberships = await db const memberships = await db
.select({ .select({
membershipId: OrgMembershipTable.id, membershipId: MemberTable.id,
role: OrgMembershipTable.role, role: MemberTable.role,
org: { organization: {
id: OrgTable.id, id: OrganizationTable.id,
name: OrgTable.name, name: OrganizationTable.name,
slug: OrgTable.slug, slug: OrganizationTable.slug,
ownerUserId: OrgTable.owner_user_id, logo: OrganizationTable.logo,
createdAt: OrgTable.created_at, metadata: OrganizationTable.metadata,
updatedAt: OrgTable.updated_at, createdAt: OrganizationTable.createdAt,
updatedAt: OrganizationTable.updatedAt,
}, },
}) })
.from(OrgMembershipTable) .from(MemberTable)
.innerJoin(OrgTable, eq(OrgMembershipTable.org_id, OrgTable.id)) .innerJoin(OrganizationTable, eq(MemberTable.organizationId, OrganizationTable.id))
.where(eq(OrgMembershipTable.user_id, userId)) .where(eq(MemberTable.userId, userId))
.orderBy(asc(MemberTable.createdAt))
return memberships.map((row) => ({ return memberships.map((row) => ({
id: row.org.id, id: row.organization.id,
name: row.org.name, name: row.organization.name,
slug: row.org.slug, slug: row.organization.slug,
ownerUserId: row.org.ownerUserId, logo: row.organization.logo,
metadata: row.organization.metadata,
role: row.role, role: row.role,
membershipId: row.membershipId, membershipId: row.membershipId,
createdAt: row.org.createdAt, createdAt: row.organization.createdAt,
updatedAt: row.org.updatedAt, 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
} }

View File

@@ -111,7 +111,10 @@ export function AuthScreen() {
onSubmit={async (event) => { onSubmit={async (event) => {
const next = verificationRequired ? await submitVerificationCode(event) : await submitAuth(event); const next = verificationRequired ? await submitVerificationCode(event) : await submitAuth(event);
if (next === "dashboard") { if (next === "dashboard") {
router.replace("/dashboard"); const target = await resolveUserLandingRoute();
if (target) {
router.replace(target);
}
} else if (next === "checkout") { } else if (next === "checkout") {
router.replace("/checkout"); router.replace("/checkout");
} }

View File

@@ -66,7 +66,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
handledReturnRef.current = true; handledReturnRef.current = true;
setResuming(true); setResuming(true);
void refreshCheckoutReturn(true).then((target) => { void refreshCheckoutReturn(true).then((target) => {
if (target === "/dashboard") { if (target !== "/checkout") {
router.replace(target); router.replace(target);
return; return;
} }
@@ -93,7 +93,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
if (!onboardingPending) { if (!onboardingPending) {
void resolveUserLandingRoute().then((target) => { void resolveUserLandingRoute().then((target) => {
if (target === "/dashboard" && !MOCK_BILLING) { if (target && target !== "/checkout" && !MOCK_BILLING) {
router.replace(target); router.replace(target);
} }
}); });

View File

@@ -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 (
<section className="mx-auto grid w-full max-w-[52rem] gap-4 rounded-[32px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-6">
<p className="text-sm text-[var(--dls-text-secondary)]">Loading your workspace...</p>
</section>
);
}

View File

@@ -158,7 +158,7 @@ function SectionBadge({
); );
} }
export function DashboardScreen() { export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean }) {
const router = useRouter(); const router = useRouter();
const { const {
user, user,
@@ -236,6 +236,358 @@ export function DashboardScreen() {
const desktopDisabled = !openworkDeepLink || !isReady; const desktopDisabled = !openworkDeepLink || !isReady;
const showConnectionHint = !openworkDeepLink || !hasWorkspaceScopedUrl; const showConnectionHint = !openworkDeepLink || !hasWorkspaceScopedUrl;
const mainContent = (
<main className="min-h-0 flex-1 overflow-y-auto bg-[var(--dls-sidebar)]">
<div className="mx-auto flex max-w-5xl flex-col gap-5 p-4 md:gap-6 md:p-12">
{selectedWorker ? (
<>
<div className="flex items-center justify-between gap-4">
<div>
<div className="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Overview</div>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)] md:text-3xl">{currentWorker?.workerName ?? selectedWorker.workerName}</h1>
</div>
</div>
<div className="relative overflow-hidden rounded-[28px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-6 md:rounded-[32px] md:p-10">
<div className="relative z-10 flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
<div className="flex max-w-xl flex-col gap-6">
<div className="flex items-center gap-3">
<div className="relative flex h-3 w-3">
<span className={`absolute inline-flex h-full w-full rounded-full ${isReady ? "bg-emerald-400/60" : "animate-ping bg-amber-400/70"}`} />
<span className={`relative inline-flex h-3 w-3 rounded-full ${isReady ? "bg-emerald-500" : "bg-amber-500"}`} />
</div>
<h2 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)] md:text-2xl">
{isReady ? "Your worker is ready." : isStarting ? "Provisioning in the background" : currentWorker ? getWorkerStatusCopy(currentWorker.status) : "Preparing worker"}
</h2>
</div>
<p className="text-[16px] leading-relaxed text-[var(--dls-text-secondary)] md:text-[17px]">
{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."}
</p>
<ProvisioningGraphic ready={isReady} />
</div>
<div className="flex w-full flex-col gap-3 md:w-[200px] md:shrink-0 md:items-end">
{openworkAppConnectUrl ? (
<a
href={openworkAppConnectUrl}
target="_blank"
rel="noreferrer"
className={`flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3.5 text-base font-medium transition-all md:min-h-[52px] md:text-sm ${
webDisabled ? "pointer-events-none border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)]" : "bg-[#011627] text-white hover:bg-black"
}`}
aria-disabled={webDisabled}
>
<GlobeIcon className="h-[18px] w-[18px]" />
{webDisabled ? "Preparing web access" : "Open in Web"}
</a>
) : (
<button
type="button"
disabled
className="flex w-full items-center justify-center gap-2 rounded-xl border border-[var(--dls-border)] bg-[var(--dls-surface)] px-6 py-3.5 font-medium text-[var(--dls-text-secondary)]"
>
<GlobeIcon className="h-[18px] w-[18px]" />
Preparing web access
</button>
)}
<div className="hidden w-full flex-col items-center md:flex">
<button
type="button"
className={`flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
desktopDisabled ? "border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)]" : "border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)] hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
}`}
onClick={() => {
if (!desktopDisabled && openworkDeepLink) {
window.location.href = openworkDeepLink;
}
}}
disabled={desktopDisabled}
>
<MonitorIcon className="h-4 w-4" />
{desktopDisabled ? "Preparing desktop launch" : "Open in Desktop"}
</button>
<span className="mt-2 text-[11px] font-medium text-[var(--dls-text-secondary)]">requires the OpenWork desktop app</span>
</div>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group" open>
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<LockIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Connection details</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Connect now or copy manual credentials for another client.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-[16px] bg-[#011627] px-5 py-3 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
if (openworkDeepLink) {
window.location.href = openworkDeepLink;
}
}}
disabled={!openworkDeepLink || !isReady}
>
{openworkDeepLink ? "Open in Desktop" : "Preparing connection..."}
</button>
<button
type="button"
className="rounded-[16px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-4 py-3 text-sm font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void generateWorkerToken()}
disabled={actionBusy !== null}
>
{actionBusy === "token" ? "Refreshing token..." : "Refresh token"}
</button>
</div>
<div className="mt-6 space-y-4">
<CredentialRow
label="Connection URL"
value={activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null}
placeholder="Connection URL is still preparing..."
hint={showConnectionHint ? (!openworkDeepLink ? "Getting connection details ready..." : "Finishing your workspace URL...") : undefined}
canCopy={Boolean(activeWorker?.openworkUrl ?? activeWorker?.instanceUrl)}
copied={copiedField === "openwork-url"}
onCopy={() => void copyToClipboard("openwork-url", activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null)}
muted={!isReady}
/>
<CredentialRow
label="Owner token"
value={activeWorker?.ownerToken ?? null}
placeholder="Use refresh token"
hint="Use this token when the remote client must answer permission prompts."
canCopy={Boolean(activeWorker?.ownerToken)}
copied={copiedField === "owner-token"}
onCopy={() => void copyToClipboard("owner-token", activeWorker?.ownerToken ?? null)}
muted={!isReady}
/>
<CredentialRow
label="Collaborator token"
value={activeWorker?.clientToken ?? null}
placeholder="Use refresh token"
hint="Routine remote access without owner-only actions."
canCopy={Boolean(activeWorker?.clientToken)}
copied={copiedField === "client-token"}
onCopy={() => void copyToClipboard("client-token", activeWorker?.clientToken ?? null)}
muted={!isReady}
/>
</div>
</div>
</details>
</div>
<div className="flex flex-col gap-6">
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<ActivityIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Worker actions</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Refresh state, recover tokens, or replace the worker. Controls unlock as the worker becomes reachable.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void refreshWorkers({ keepSelection: true })}
disabled={workersBusy || actionBusy !== null}
>
{workersBusy ? "Refreshing..." : "Refresh list"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void checkWorkerStatus({ workerId: selectedWorker.workerId })}
disabled={actionBusy !== null}
>
{actionBusy === "status" ? "Checking..." : "Check status"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void generateWorkerToken()}
disabled={actionBusy !== null}
>
{actionBusy === "token" ? "Fetching..." : "Refresh token"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-hover)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-primary)] transition hover:bg-[var(--dls-active)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void redeployWorker(selectedWorker.workerId)}
disabled={!isSelectedWorkerFailed || redeployBusyWorkerId !== null || deleteBusyWorkerId !== null || actionBusy !== null || launchBusy}
>
{redeployBusyWorkerId === selectedWorker.workerId ? "Redeploying..." : "Redeploy"}
</button>
<button
type="button"
className="rounded-[12px] border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void deleteWorker(selectedWorker.workerId)}
disabled={deleteBusyWorkerId !== null || redeployBusyWorkerId !== null || actionBusy !== null || launchBusy}
>
{deleteBusyWorkerId === selectedWorker.workerId ? "Deleting..." : "Delete worker"}
</button>
</div>
</div>
</details>
</div>
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<TerminalIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Worker runtime</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Compare installed runtime versions with the versions this worker should be running.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="mb-4 flex flex-wrap gap-2">
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void refreshRuntime(selectedWorker.workerId)}
disabled={runtimeBusy || runtimeUpgradeBusy}
>
{runtimeBusy ? "Checking..." : "Refresh runtime"}
</button>
<button
type="button"
className="rounded-[12px] bg-[#011627] px-3 py-2 text-xs font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void upgradeRuntime()}
disabled={runtimeUpgradeBusy || runtimeBusy || !isReady}
>
{runtimeUpgradeBusy || runtimeSnapshot?.upgrade.status === "running" ? "Upgrading..." : "Upgrade runtime"}
</button>
</div>
{runtimeError ? <div className="mb-4 rounded-[14px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{runtimeError}</div> : null}
<div className="space-y-3">
{(runtimeSnapshot?.services ?? []).map((service) => (
<div key={service.name} className="rounded-[18px] border border-[var(--dls-border)] bg-[var(--dls-sidebar)] px-4 py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-[var(--dls-text-primary)]">{getRuntimeServiceLabel(service.name)}</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
Installed {service.actualVersion ?? "unknown"} · Target {service.targetVersion ?? "unknown"}
</p>
</div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
<span className={`rounded-full px-2.5 py-1 ${service.running ? "bg-emerald-100 text-emerald-700" : "bg-slate-200 text-slate-600"}`}>
{service.running ? "Running" : service.enabled ? "Stopped" : "Disabled"}
</span>
<span className={`rounded-full px-2.5 py-1 ${service.upgradeAvailable ? "bg-amber-100 text-amber-700" : "bg-slate-200 text-slate-600"}`}>
{service.upgradeAvailable ? "Upgrade available" : "Current"}
</span>
</div>
</div>
</div>
))}
{!runtimeSnapshot && !runtimeBusy ? (
<div className="space-y-3">
<SkeletonBar widthClass="w-full" />
<SkeletonBar widthClass="w-4/5" />
<p className="text-sm text-[var(--dls-text-secondary)]">Runtime details appear after the worker is reachable.</p>
</div>
) : null}
</div>
</div>
</details>
</div>
<SectionBadge
icon={<ActivityIcon className="h-[18px] w-[18px]" />}
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}
/>
<div className="rounded-[28px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-8">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<GlobeIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Billing snapshot</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
{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."}
</p>
<Link
href="/checkout"
className="mt-4 inline-flex rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
>
Open billing
</Link>
</div>
</div>
</div>
</>
) : (
<div className="rounded-[24px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-8">
<div className="mx-auto max-w-[30rem] text-center">
<h2 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">No workers yet</h2>
<p className="mt-3 text-sm leading-6 text-[var(--dls-text-secondary)]">Create your first worker to unlock connection details and runtime controls.</p>
</div>
</div>
)}
</div>
</main>
);
if (!showSidebar) {
return mainContent;
}
return ( return (
<section className="flex flex-1 w-full flex-col overflow-hidden bg-[var(--dls-surface)] md:flex-row"> <section className="flex flex-1 w-full flex-col overflow-hidden bg-[var(--dls-surface)] md:flex-row">
<aside className="order-2 w-full shrink-0 border-t border-[var(--dls-border)] bg-[var(--dls-sidebar)] md:order-1 md:w-[296px] md:border-r md:border-t-0"> <aside className="order-2 w-full shrink-0 border-t border-[var(--dls-border)] bg-[var(--dls-sidebar)] md:order-1 md:w-[296px] md:border-r md:border-t-0">
@@ -316,351 +668,7 @@ export function DashboardScreen() {
</div> </div>
</aside> </aside>
<main className="order-1 min-h-0 flex-1 overflow-y-auto bg-[var(--dls-sidebar)] md:order-2"> {mainContent}
<div className="mx-auto flex max-w-5xl flex-col gap-5 p-4 md:gap-6 md:p-12">
{selectedWorker ? (
<>
<div className="flex items-center justify-between gap-4">
<div>
<div className="mb-2 text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Overview</div>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)] md:text-3xl">{currentWorker?.workerName ?? selectedWorker.workerName}</h1>
</div>
</div>
<div className="relative overflow-hidden rounded-[28px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-6 md:rounded-[32px] md:p-10">
<div className="relative z-10 flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
<div className="flex max-w-xl flex-col gap-6">
<div className="flex items-center gap-3">
<div className="relative flex h-3 w-3">
<span className={`absolute inline-flex h-full w-full rounded-full ${isReady ? "bg-emerald-400/60" : "animate-ping bg-amber-400/70"}`} />
<span className={`relative inline-flex h-3 w-3 rounded-full ${isReady ? "bg-emerald-500" : "bg-amber-500"}`} />
</div>
<h2 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)] md:text-2xl">
{isReady ? "Your worker is ready." : isStarting ? "Provisioning in the background" : currentWorker ? getWorkerStatusCopy(currentWorker.status) : "Preparing worker"}
</h2>
</div>
<p className="text-[16px] leading-relaxed text-[var(--dls-text-secondary)] md:text-[17px]">
{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."}
</p>
<ProvisioningGraphic ready={isReady} />
</div>
<div className="flex w-full flex-col gap-3 md:w-[200px] md:shrink-0 md:items-end">
{openworkAppConnectUrl ? (
<a
href={openworkAppConnectUrl}
target="_blank"
rel="noreferrer"
className={`flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3.5 text-base font-medium transition-all md:min-h-[52px] md:text-sm ${
webDisabled ? "pointer-events-none border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)]" : "bg-[#011627] text-white hover:bg-black"
}`}
aria-disabled={webDisabled}
>
<GlobeIcon className="h-[18px] w-[18px]" />
{webDisabled ? "Preparing web access" : "Open in Web"}
</a>
) : (
<button
type="button"
disabled
className="flex w-full items-center justify-center gap-2 rounded-xl border border-[var(--dls-border)] bg-[var(--dls-surface)] px-6 py-3.5 font-medium text-[var(--dls-text-secondary)]"
>
<GlobeIcon className="h-[18px] w-[18px]" />
Preparing web access
</button>
)}
<div className="hidden w-full flex-col items-center md:flex">
<button
type="button"
className={`flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
desktopDisabled ? "border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)]" : "border border-[var(--dls-border)] bg-[var(--dls-surface)] text-[var(--dls-text-secondary)] hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
}`}
onClick={() => {
if (!desktopDisabled && openworkDeepLink) {
window.location.href = openworkDeepLink;
}
}}
disabled={desktopDisabled}
>
<MonitorIcon className="h-4 w-4" />
{desktopDisabled ? "Preparing desktop launch" : "Open in Desktop"}
</button>
<span className="mt-2 text-[11px] font-medium text-[var(--dls-text-secondary)]">requires the OpenWork desktop app</span>
</div>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group" open>
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<LockIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Connection details</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Connect now or copy manual credentials for another client.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-[16px] bg-[#011627] px-5 py-3 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
if (openworkDeepLink) {
window.location.href = openworkDeepLink;
}
}}
disabled={!openworkDeepLink || !isReady}
>
{openworkDeepLink ? "Open in Desktop" : "Preparing connection..."}
</button>
<button
type="button"
className="rounded-[16px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-4 py-3 text-sm font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void generateWorkerToken()}
disabled={actionBusy !== null}
>
{actionBusy === "token" ? "Refreshing token..." : "Refresh token"}
</button>
</div>
<div className="mt-6 space-y-4">
<CredentialRow
label="Connection URL"
value={activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null}
placeholder="Connection URL is still preparing..."
hint={showConnectionHint ? (!openworkDeepLink ? "Getting connection details ready..." : "Finishing your workspace URL...") : undefined}
canCopy={Boolean(activeWorker?.openworkUrl ?? activeWorker?.instanceUrl)}
copied={copiedField === "openwork-url"}
onCopy={() => void copyToClipboard("openwork-url", activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null)}
muted={!isReady}
/>
<CredentialRow
label="Owner token"
value={activeWorker?.ownerToken ?? null}
placeholder="Use refresh token"
hint="Use this token when the remote client must answer permission prompts."
canCopy={Boolean(activeWorker?.ownerToken)}
copied={copiedField === "owner-token"}
onCopy={() => void copyToClipboard("owner-token", activeWorker?.ownerToken ?? null)}
muted={!isReady}
/>
<CredentialRow
label="Collaborator token"
value={activeWorker?.clientToken ?? null}
placeholder="Use refresh token"
hint="Routine remote access without owner-only actions."
canCopy={Boolean(activeWorker?.clientToken)}
copied={copiedField === "client-token"}
onCopy={() => void copyToClipboard("client-token", activeWorker?.clientToken ?? null)}
muted={!isReady}
/>
</div>
</div>
</details>
</div>
<div className="flex flex-col gap-6">
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<ActivityIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Worker actions</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Refresh state, recover tokens, or replace the worker. Controls unlock as the worker becomes reachable.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void refreshWorkers({ keepSelection: true })}
disabled={workersBusy || actionBusy !== null}
>
{workersBusy ? "Refreshing..." : "Refresh list"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void checkWorkerStatus({ workerId: selectedWorker.workerId })}
disabled={actionBusy !== null}
>
{actionBusy === "status" ? "Checking..." : "Check status"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void generateWorkerToken()}
disabled={actionBusy !== null}
>
{actionBusy === "token" ? "Fetching..." : "Refresh token"}
</button>
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-hover)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-primary)] transition hover:bg-[var(--dls-active)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void redeployWorker(selectedWorker.workerId)}
disabled={!isSelectedWorkerFailed || redeployBusyWorkerId !== null || deleteBusyWorkerId !== null || actionBusy !== null || launchBusy}
>
{redeployBusyWorkerId === selectedWorker.workerId ? "Redeploying..." : "Redeploy"}
</button>
<button
type="button"
className="rounded-[12px] border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void deleteWorker(selectedWorker.workerId)}
disabled={deleteBusyWorkerId !== null || redeployBusyWorkerId !== null || actionBusy !== null || launchBusy}
>
{deleteBusyWorkerId === selectedWorker.workerId ? "Deleting..." : "Delete worker"}
</button>
</div>
</div>
</details>
</div>
<div className={`rounded-[28px] border border-[var(--dls-border)] transition-all ${isReady ? "bg-[var(--dls-surface)]" : "bg-[var(--dls-sidebar)] opacity-80"}`}>
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between p-8 outline-none [&::-webkit-details-marker]:hidden">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<TerminalIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Worker runtime</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
Compare installed runtime versions with the versions this worker should be running.
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--dls-hover)] text-[var(--dls-text-secondary)] transition-transform group-open:rotate-180">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m2 4 4 4 4-4"/></svg>
</div>
</summary>
<div className="px-8 pb-8 pt-0">
<div className="mb-4 flex flex-wrap gap-2">
<button
type="button"
className="rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)] disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void refreshRuntime(selectedWorker.workerId)}
disabled={runtimeBusy || runtimeUpgradeBusy}
>
{runtimeBusy ? "Checking..." : "Refresh runtime"}
</button>
<button
type="button"
className="rounded-[12px] bg-[#011627] px-3 py-2 text-xs font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void upgradeRuntime()}
disabled={runtimeUpgradeBusy || runtimeBusy || !isReady}
>
{runtimeUpgradeBusy || runtimeSnapshot?.upgrade.status === "running" ? "Upgrading..." : "Upgrade runtime"}
</button>
</div>
{runtimeError ? <div className="mb-4 rounded-[14px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{runtimeError}</div> : null}
<div className="space-y-3">
{(runtimeSnapshot?.services ?? []).map((service) => (
<div key={service.name} className="rounded-[18px] border border-[var(--dls-border)] bg-[var(--dls-sidebar)] px-4 py-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-[var(--dls-text-primary)]">{getRuntimeServiceLabel(service.name)}</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
Installed {service.actualVersion ?? "unknown"} · Target {service.targetVersion ?? "unknown"}
</p>
</div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
<span className={`rounded-full px-2.5 py-1 ${service.running ? "bg-emerald-100 text-emerald-700" : "bg-slate-200 text-slate-600"}`}>
{service.running ? "Running" : service.enabled ? "Stopped" : "Disabled"}
</span>
<span className={`rounded-full px-2.5 py-1 ${service.upgradeAvailable ? "bg-amber-100 text-amber-700" : "bg-slate-200 text-slate-600"}`}>
{service.upgradeAvailable ? "Upgrade available" : "Current"}
</span>
</div>
</div>
</div>
))}
{!runtimeSnapshot && !runtimeBusy ? (
<div className="space-y-3">
<SkeletonBar widthClass="w-full" />
<SkeletonBar widthClass="w-4/5" />
<p className="text-sm text-[var(--dls-text-secondary)]">Runtime details appear after the worker is reachable.</p>
</div>
) : null}
</div>
</div>
</details>
</div>
<SectionBadge
icon={<ActivityIcon className="h-[18px] w-[18px]" />}
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}
/>
<div className="rounded-[28px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-8">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-2.5 text-[var(--dls-text-secondary)]">
<GlobeIcon className="h-[18px] w-[18px]" />
</div>
<h3 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">Billing snapshot</h3>
</div>
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
{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."}
</p>
<Link
href="/checkout"
className="mt-4 inline-flex rounded-[12px] border border-[var(--dls-border)] bg-[var(--dls-surface)] px-3 py-2 text-xs font-semibold text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
>
Open billing
</Link>
</div>
</div>
</div>
</>
) : (
<div className="rounded-[24px] border border-[var(--dls-border)] bg-[var(--dls-surface)] p-8">
<div className="mx-auto max-w-[30rem] text-center">
<h2 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">No workers yet</h2>
<p className="mt-3 text-sm leading-6 text-[var(--dls-text-secondary)]">Create your first worker to unlock connection details and runtime controls.</p>
</div>
</div>
)}
</div>
</main>
</section> </section>
); );
} }

View File

@@ -243,7 +243,7 @@ export function getSocialCallbackUrl(): string {
const callbackUrl = new URL("/", origin); const callbackUrl = new URL("/", origin);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search); 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() ?? ""; const value = params.get(key)?.trim() ?? "";
if (value) { if (value) {
callbackUrl.searchParams.set(key, 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, openworkUrl: instance && typeof instance.url === "string" ? instance.url : null,
workspaceId: null, workspaceId: null,
clientToken: tokens && typeof tokens.client === "string" ? tokens.client : 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 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 tokens = payload.tokens;
const connect = isRecord(payload.connect) ? payload.connect : null; const connect = isRecord(payload.connect) ? payload.connect : null;
const clientToken = typeof tokens.client === "string" ? tokens.client : 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 hostToken = typeof tokens.host === "string" ? tokens.host : null;
const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null; const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null;
const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null; const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null;

View File

@@ -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<string, string[]>;
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<string, unknown> {
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<string, string[]> {
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,
};
}

View File

@@ -52,6 +52,11 @@ import {
resolveOpenworkWorkspaceUrl, resolveOpenworkWorkspaceUrl,
trackPosthogEvent trackPosthogEvent
} from "../_lib/den-flow"; } from "../_lib/den-flow";
import {
PENDING_ORG_INVITATION_STORAGE_KEY,
getOrgDashboardRoute,
parseOrgListPayload,
} from "../_lib/den-org";
type LaunchWorkerResult = "success" | "checkout" | "error"; type LaunchWorkerResult = "success" | "checkout" | "error";
@@ -81,7 +86,7 @@ type DenFlowContextValue = {
cancelVerification: () => void; cancelVerification: () => void;
beginSocialAuth: (provider: SocialAuthProvider) => Promise<void>; beginSocialAuth: (provider: SocialAuthProvider) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
resolveUserLandingRoute: () => Promise<"/dashboard" | "/checkout" | null>; resolveUserLandingRoute: () => Promise<string | null>;
billingSummary: BillingSummary | null; billingSummary: BillingSummary | null;
billingBusy: boolean; billingBusy: boolean;
billingCheckoutBusy: boolean; billingCheckoutBusy: boolean;
@@ -90,7 +95,7 @@ type DenFlowContextValue = {
effectiveCheckoutUrl: string | null; effectiveCheckoutUrl: string | null;
refreshBilling: (options?: { includeCheckout?: boolean; quiet?: boolean }) => Promise<BillingSummary | null>; refreshBilling: (options?: { includeCheckout?: boolean; quiet?: boolean }) => Promise<BillingSummary | null>;
handleSubscriptionCancellation: (cancelAtPeriodEnd: boolean) => Promise<void>; handleSubscriptionCancellation: (cancelAtPeriodEnd: boolean) => Promise<void>;
refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise<"/dashboard" | "/checkout">; refreshCheckoutReturn: (sessionTokenPresent: boolean) => Promise<string>;
onboardingPending: boolean; onboardingPending: boolean;
onboardingDecisionBusy: boolean; onboardingDecisionBusy: boolean;
workers: WorkerListItem[]; workers: WorkerListItem[];
@@ -348,7 +353,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
): Promise<"dashboard" | "checkout" | null> { ): Promise<"dashboard" | "checkout" | null> {
let payload = payloadOverride; let payload = payloadOverride;
if (payload === undefined) { if (payload === undefined || (!getToken(payload) && nextMode === "sign-up" && Boolean(password))) {
const signInBody = { const signInBody = {
email: trimmedEmail, email: trimmedEmail,
password, password,
@@ -914,6 +919,67 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
return sessionUser; 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() { async function completeDesktopAuthHandoff() {
if (!desktopAuthRequested || desktopRedirectBusy) { if (!desktopAuthRequested || desktopRedirectBusy) {
return; return;
@@ -984,8 +1050,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
return null; return null;
} }
const dashboardRoute = await resolveDashboardRoute();
if (!onboardingPending) { if (!onboardingPending) {
return "/dashboard"; return dashboardRoute;
} }
const summary = const summary =
@@ -996,7 +1064,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
return "/checkout"; return "/checkout";
} }
return !summary.featureGateEnabled || summary.hasActivePlan ? "/dashboard" : "/checkout"; return !summary.featureGateEnabled || summary.hasActivePlan ? (dashboardRoute ?? "/") : "/checkout";
} }
async function submitAuth(event: FormEvent<HTMLFormElement>) { async function submitAuth(event: FormEvent<HTMLFormElement>) {
@@ -1194,6 +1262,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.localStorage.removeItem(LAST_WORKER_STORAGE_KEY); window.localStorage.removeItem(LAST_WORKER_STORAGE_KEY);
window.sessionStorage.removeItem(PENDING_SOCIAL_SIGNUP_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) { if (!summary.featureGateEnabled || summary.hasActivePlan) {
return "/dashboard" as const; return (await resolveDashboardRoute()) ?? "/";
} }
return "/checkout" as const; return "/checkout" as const;
@@ -1635,6 +1704,11 @@ export function DenFlowProvider({ children }: { children: ReactNode }) {
if (/^[a-z][a-z0-9+.-]*$/i.test(requestedScheme)) { if (/^[a-z][a-z0-9+.-]*$/i.test(requestedScheme)) {
setDesktopAuthScheme(requestedScheme); setDesktopAuthScheme(requestedScheme);
} }
const invitationId = params.get("invite")?.trim() ?? "";
if (invitationId) {
window.sessionStorage.setItem(PENDING_ORG_INVITATION_STORAGE_KEY, invitationId);
}
}, []); }, []);
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { DashboardScreen } from "../_components/dashboard-screen"; import { DashboardRedirectScreen } from "../_components/dashboard-redirect-screen";
export default function DashboardPage() { export default function DashboardPage() {
return <DashboardScreen />; return <DashboardRedirectScreen />;
} }

View File

@@ -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<string, string[]>) {
return Object.fromEntries(Object.entries(value).map(([resource, actions]) => [resource, [...actions]]));
}
function toggleAction(
value: Record<string, string[]>,
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 (
<article className="rounded-[28px] border border-[var(--dls-border)] bg-white p-5 shadow-[var(--dls-card-shadow)] md:p-6">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-tight text-[var(--dls-text-primary)]">{title}</h2>
<p className="mt-1 text-sm text-[var(--dls-text-secondary)]">{description}</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</article>
);
}
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-2xl border px-4 py-2.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
>
{children}
</button>
);
}
function InlinePanel({ children }: { children: ReactNode }) {
return <div className="mb-4 rounded-[24px] border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-4 md:p-5">{children}</div>;
}
export function ManageMembersScreen() {
const {
activeOrg,
orgContext,
orgBusy,
orgError,
mutationBusy,
inviteMember,
cancelInvitation,
updateMemberRole,
removeMember,
createRole,
updateRole,
deleteRole,
} = useOrgDashboard();
const [pageError, setPageError] = useState<string | null>(null);
const [showInviteForm, setShowInviteForm] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("member");
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [memberRoleDraft, setMemberRoleDraft] = useState("member");
const [showRoleForm, setShowRoleForm] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
const [roleNameDraft, setRoleNameDraft] = useState("");
const [rolePermissionDraft, setRolePermissionDraft] = useState<Record<string, string[]>>({});
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 (
<section className="mx-auto flex max-w-6xl flex-col gap-4 p-4 md:p-12">
<div className="rounded-[28px] border border-[var(--dls-border)] bg-white p-6 shadow-[var(--dls-card-shadow)]">
<p className="text-sm text-[var(--dls-text-secondary)]">Loading organization details...</p>
</div>
</section>
);
}
if (!orgContext || !activeOrg) {
return (
<section className="mx-auto flex max-w-6xl flex-col gap-4 p-4 md:p-12">
<div className="rounded-[28px] border border-[var(--dls-border)] bg-white p-6 shadow-[var(--dls-card-shadow)]">
<p className="text-sm font-medium text-rose-600">{orgError ?? "Organization details are unavailable."}</p>
</div>
</section>
);
}
return (
<section className="mx-auto flex max-w-6xl flex-col gap-6 p-4 md:p-12">
<div className="rounded-[32px] border border-[var(--dls-border)] bg-white p-6 shadow-[var(--dls-card-shadow)] md:p-8">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div>
<p className="text-[11px] font-bold uppercase tracking-[0.18em] text-[var(--dls-text-secondary)]">Manage Members</p>
<h1 className="mt-2 text-[2.4rem] font-semibold leading-[0.95] tracking-[-0.06em] text-[var(--dls-text-primary)]">
{activeOrg.name}
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-[var(--dls-text-secondary)]">
See everyone in the organization, invite new people, and keep roles tidy without the permission matrix taking over the page.
</p>
</div>
<div className="rounded-2xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] px-4 py-3 text-sm text-[var(--dls-text-secondary)]">
Your role: <span className="font-semibold text-[var(--dls-text-primary)]">{formatRoleLabel(orgContext.currentMember.role)}</span>
</div>
</div>
</div>
{pageError ? <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{pageError}</div> : null}
<SectionCard
title="Members"
description={access.canInviteMembers ? "Invite people, update their role, or remove them from the organization." : "View who is in the organization and what role they currently hold."}
action={access.canInviteMembers ? <SectionButton onClick={() => { resetMemberEditor(); setShowInviteForm((current) => !current); }}>{showInviteForm ? "Close invite form" : "Add member"}</SectionButton> : undefined}
>
{showInviteForm && access.canInviteMembers ? (
<InlinePanel>
<form
className="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(180px,0.7fr)_auto] md:items-end"
onSubmit={async (event) => {
event.preventDefault();
setPageError(null);
try {
await inviteMember({ email: inviteEmail, role: inviteRole });
resetInviteForm();
} catch (error) {
setPageError(error instanceof Error ? error.message : "Could not invite member.");
}
}}
>
<label className="grid gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Email</span>
<input
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder="teammate@example.com"
required
className="rounded-2xl border border-[var(--dls-border)] bg-white px-4 py-3 text-sm text-[var(--dls-text-primary)] outline-none transition focus:border-slate-400 focus:ring-4 focus:ring-slate-900/5"
/>
</label>
<label className="grid gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Role</span>
<select
value={inviteRole}
onChange={(event) => setInviteRole(event.target.value)}
className="rounded-2xl border border-[var(--dls-border)] bg-white px-4 py-3 text-sm text-[var(--dls-text-primary)] outline-none transition focus:border-slate-400 focus:ring-4 focus:ring-slate-900/5"
>
{assignableRoles.map((role) => (
<option key={role.id} value={role.role}>
{formatRoleLabel(role.role)}
</option>
))}
</select>
</label>
<div className="flex gap-2 md:justify-end">
<SectionButton onClick={resetInviteForm}>Cancel</SectionButton>
<button
type="submit"
className="rounded-2xl bg-[#011627] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={mutationBusy === "invite-member"}
>
{mutationBusy === "invite-member" ? "Sending..." : "Send invite"}
</button>
</div>
</form>
</InlinePanel>
) : null}
{editingMemberId && access.canManageMembers ? (
<InlinePanel>
<form
className="grid gap-3 md:grid-cols-[minmax(220px,0.9fr)_auto] md:items-end"
onSubmit={async (event) => {
event.preventDefault();
setPageError(null);
try {
await updateMemberRole(editingMemberId, memberRoleDraft);
resetMemberEditor();
} catch (error) {
setPageError(error instanceof Error ? error.message : "Could not update member role.");
}
}}
>
<label className="grid gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Role</span>
<select
value={memberRoleDraft}
onChange={(event) => setMemberRoleDraft(event.target.value)}
className="rounded-2xl border border-[var(--dls-border)] bg-white px-4 py-3 text-sm text-[var(--dls-text-primary)] outline-none transition focus:border-slate-400 focus:ring-4 focus:ring-slate-900/5"
>
{assignableRoles.map((role) => (
<option key={role.id} value={role.role}>
{formatRoleLabel(role.role)}
</option>
))}
</select>
</label>
<div className="flex gap-2 md:justify-end">
<SectionButton onClick={resetMemberEditor}>Cancel</SectionButton>
<button
type="submit"
className="rounded-2xl bg-[#011627] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={mutationBusy === "update-member-role"}
>
{mutationBusy === "update-member-role" ? "Saving..." : "Save member"}
</button>
</div>
</form>
</InlinePanel>
) : null}
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-[var(--dls-border)] text-left text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">
<th className="px-3 py-3">Member</th>
<th className="px-3 py-3">Role</th>
<th className="px-3 py-3">Joined</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody>
{orgContext.members.map((member) => (
<tr key={member.id} className="border-b border-[var(--dls-border)] last:border-b-0">
<td className="px-3 py-4">
<div className="grid gap-1">
<span className="font-semibold text-[var(--dls-text-primary)]">{member.user.name}</span>
<span className="text-xs text-[var(--dls-text-secondary)]">{member.user.email}</span>
</div>
</td>
<td className="px-3 py-4 text-[var(--dls-text-secondary)]">{splitRoleString(member.role).map(formatRoleLabel).join(", ")}</td>
<td className="px-3 py-4 text-[var(--dls-text-secondary)]">{member.createdAt ? new Date(member.createdAt).toLocaleDateString() : "-"}</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
{member.isOwner ? (
<span className="text-xs font-medium text-[var(--dls-text-secondary)]">Locked</span>
) : access.canManageMembers ? (
<>
<SectionButton
onClick={() => {
setEditingMemberId(member.id);
setMemberRoleDraft(member.role);
setShowInviteForm(false);
}}
>
Edit
</SectionButton>
<SectionButton
tone="danger"
disabled={mutationBusy === "remove-member"}
onClick={async () => {
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"}
</SectionButton>
</>
) : (
<span className="text-xs font-medium text-[var(--dls-text-secondary)]">Read only</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</SectionCard>
<SectionCard
title="Pending invitations"
description={access.canCancelInvitations ? "Admins and owners can revoke pending invites before they are accepted." : "Pending invites are visible here once they have been sent."}
>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-[var(--dls-border)] text-left text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">
<th className="px-3 py-3">Email</th>
<th className="px-3 py-3">Role</th>
<th className="px-3 py-3">Expires</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody>
{pendingInvitations.length === 0 ? (
<tr>
<td colSpan={4} className="px-3 py-6 text-sm text-[var(--dls-text-secondary)]">No pending invitations.</td>
</tr>
) : pendingInvitations.map((invitation) => (
<tr key={invitation.id} className="border-b border-[var(--dls-border)] last:border-b-0">
<td className="px-3 py-4 font-medium text-[var(--dls-text-primary)]">{invitation.email}</td>
<td className="px-3 py-4 text-[var(--dls-text-secondary)]">{formatRoleLabel(invitation.role)}</td>
<td className="px-3 py-4 text-[var(--dls-text-secondary)]">{invitation.expiresAt ? new Date(invitation.expiresAt).toLocaleDateString() : "-"}</td>
<td className="px-3 py-4">
{access.canCancelInvitations ? (
<SectionButton
disabled={mutationBusy === "cancel-invitation"}
onClick={async () => {
setPageError(null);
try {
await cancelInvitation(invitation.id);
} catch (error) {
setPageError(error instanceof Error ? error.message : "Could not cancel invitation.");
}
}}
>
{mutationBusy === "cancel-invitation" ? "Cancelling..." : "Cancel"}
</SectionButton>
) : <span className="text-xs font-medium text-[var(--dls-text-secondary)]">Read only</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</SectionCard>
<SectionCard
title="Roles"
description={access.canManageRoles ? "Default roles stay available, and owners can add, edit, or remove custom roles here." : "Role definitions are visible here, but only owners can change them."}
action={access.canManageRoles ? <SectionButton onClick={() => { setShowRoleForm((current) => !current); setEditingRoleId(null); setRoleNameDraft(""); setRolePermissionDraft({}); }}>{showRoleForm ? "Close role form" : "Add role"}</SectionButton> : undefined}
>
{(showRoleForm || editingRoleId) && access.canManageRoles ? (
<InlinePanel>
<form
className="grid gap-4"
onSubmit={async (event) => {
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.");
}
}}
>
<label className="grid gap-2 md:max-w-sm">
<span className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Role name</span>
<input
type="text"
value={roleNameDraft}
onChange={(event) => setRoleNameDraft(event.target.value)}
placeholder="qa-reviewer"
required
className="rounded-2xl border border-[var(--dls-border)] bg-white px-4 py-3 text-sm text-[var(--dls-text-primary)] outline-none transition focus:border-slate-400 focus:ring-4 focus:ring-slate-900/5"
/>
</label>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(DEN_ROLE_PERMISSION_OPTIONS).map(([resource, actions]) => (
<div key={resource} className="rounded-2xl border border-[var(--dls-border)] bg-white p-4">
<p className="mb-3 text-sm font-semibold text-[var(--dls-text-primary)]">{formatRoleLabel(resource)}</p>
<div className="grid gap-2">
{actions.map((action) => {
const checked = (rolePermissionDraft[resource] ?? []).includes(action);
return (
<label key={`${resource}-${action}`} className="inline-flex items-center gap-2 text-sm text-[var(--dls-text-secondary)]">
<input
type="checkbox"
checked={checked}
onChange={(event) => setRolePermissionDraft((current) => toggleAction(current, resource, action, event.target.checked))}
/>
<span>{formatRoleLabel(action)}</span>
</label>
);
})}
</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<SectionButton onClick={resetRoleEditor}>Cancel</SectionButton>
<button
type="submit"
className="rounded-2xl bg-[#011627] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={mutationBusy === "create-role" || mutationBusy === "update-role"}
>
{mutationBusy === "create-role" || mutationBusy === "update-role"
? "Saving..."
: editingRoleId
? "Save role"
: "Create role"}
</button>
</div>
</form>
</InlinePanel>
) : null}
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-[var(--dls-border)] text-left text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">
<th className="px-3 py-3">Role</th>
<th className="px-3 py-3">Type</th>
<th className="px-3 py-3">Actions</th>
</tr>
</thead>
<tbody>
{orgContext.roles.map((role) => (
<tr key={role.id} className="border-b border-[var(--dls-border)] last:border-b-0">
<td className="px-3 py-4 font-medium text-[var(--dls-text-primary)]">{formatRoleLabel(role.role)}</td>
<td className="px-3 py-4 text-[var(--dls-text-secondary)]">{role.protected ? "System" : role.builtIn ? "Default" : "Custom"}</td>
<td className="px-3 py-4">
{access.canManageRoles && !role.protected ? (
<div className="flex flex-wrap gap-2">
<SectionButton
onClick={() => {
setShowRoleForm(false);
setEditingRoleId(role.id);
setRoleNameDraft(role.role);
setRolePermissionDraft(clonePermissionRecord(role.permission));
}}
>
Edit
</SectionButton>
<SectionButton
tone="danger"
disabled={mutationBusy === "delete-role"}
onClick={async () => {
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"}
</SectionButton>
</div>
) : <span className="text-xs font-medium text-[var(--dls-text-secondary)]">Read only</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</SectionCard>
</section>
);
}

View File

@@ -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 (
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5" aria-hidden="true">
<path d="m2 4 4 4 4-4" />
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className="h-4 w-4" aria-hidden="true">
<path d="M8 3v10" />
<path d="M3 8h10" />
</svg>
);
}
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 (
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#011627,#334155)] text-sm font-semibold uppercase tracking-[0.08em] text-white shadow-[0_18px_40px_-18px_rgba(1,22,39,0.45)]">
{initials}
</div>
);
}
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<string | null>(null);
const navItems = [
{ href: activeOrg ? getOrgDashboardRoute(activeOrg.slug) : "#", label: "Dashboard" },
{ href: activeOrg ? getManageMembersRoute(activeOrg.slug) : "#", label: "Manage Members" },
{ href: "/checkout", label: "Billing" },
];
return (
<section className="flex min-h-screen min-h-dvh w-full overflow-hidden bg-[var(--dls-surface)] md:flex-row">
<aside className="w-full shrink-0 border-b border-[var(--dls-border)] bg-[var(--dls-sidebar)] md:w-[320px] md:border-b-0 md:border-r">
<div className="flex h-full flex-col gap-6 p-4 md:p-6">
<div className="relative">
<button
type="button"
className="flex w-full items-center justify-between gap-3 rounded-[28px] border border-[var(--dls-border)] bg-white px-4 py-4 text-left shadow-[var(--dls-card-shadow)] transition hover:border-slate-300"
onClick={() => setSwitcherOpen((current) => !current)}
>
<div className="flex min-w-0 items-center gap-3">
<OrgMark name={activeOrg?.name ?? "OpenWork"} />
<div className="min-w-0">
<p className="truncate text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Organization</p>
<p className="truncate text-lg font-semibold tracking-tight text-[var(--dls-text-primary)]">{activeOrg?.name ?? "Loading..."}</p>
<p className="truncate text-xs text-[var(--dls-text-secondary)]">{activeOrg ? formatRoleLabel(activeOrg.role) : "Preparing workspace"}</p>
</div>
</div>
<span className="rounded-full border border-[var(--dls-border)] bg-[var(--dls-surface)] p-2 text-[var(--dls-text-secondary)]">
<ChevronDownIcon />
</span>
</button>
{switcherOpen ? (
<div className="absolute left-0 right-0 top-[calc(100%+0.75rem)] z-30 rounded-[28px] border border-[var(--dls-border)] bg-white p-4 shadow-[0_30px_80px_-30px_rgba(15,23,42,0.28)]">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Switch organization</p>
{orgBusy ? <span className="text-xs text-[var(--dls-text-secondary)]">Refreshing...</span> : null}
</div>
<div className="grid gap-2">
{orgDirectory.map((org) => (
<button
key={org.id}
type="button"
onClick={() => {
setSwitcherOpen(false);
switchOrganization(org.slug);
}}
className={`flex items-center justify-between rounded-2xl border px-3 py-3 text-left transition ${
org.isActive ? "border-slate-300 bg-slate-50" : "border-transparent hover:border-slate-200 hover:bg-slate-50"
}`}
>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--dls-text-primary)]">{org.name}</span>
<span className="block truncate text-xs text-[var(--dls-text-secondary)]">{formatRoleLabel(org.role)}</span>
</span>
{org.isActive ? <span className="rounded-full bg-slate-900 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-white">Current</span> : null}
</button>
))}
</div>
<form
className="mt-4 grid gap-2 rounded-2xl border border-[var(--dls-border)] bg-[var(--dls-sidebar)] p-3"
onSubmit={async (event) => {
event.preventDefault();
setCreateError(null);
try {
await createOrganization(orgNameDraft);
setOrgNameDraft("");
setSwitcherOpen(false);
} catch (error) {
setCreateError(error instanceof Error ? error.message : "Could not create organization.");
}
}}
>
<label className="grid gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Create new organization</span>
<input
type="text"
value={orgNameDraft}
onChange={(event) => setOrgNameDraft(event.target.value)}
placeholder="Acme Labs"
className="rounded-2xl border border-[var(--dls-border)] bg-white px-4 py-3 text-sm text-[var(--dls-text-primary)] outline-none transition focus:border-slate-400 focus:ring-4 focus:ring-slate-900/5"
/>
</label>
<button
type="submit"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#011627] px-4 py-3 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={mutationBusy === "create-organization"}
>
<PlusIcon />
{mutationBusy === "create-organization" ? "Creating..." : "Create organization"}
</button>
{createError ? <p className="text-xs font-medium text-rose-600">{createError}</p> : null}
</form>
</div>
) : null}
</div>
<div className="rounded-[28px] border border-[var(--dls-border)] bg-white p-4 shadow-[var(--dls-card-shadow)]">
<div className="mb-4 flex items-center gap-3">
<OrgMark name={activeOrg?.name ?? "OpenWork"} />
<div>
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Den workspace</p>
<p className="text-sm font-medium text-[var(--dls-text-secondary)]">Branding and membership controls live here.</p>
</div>
</div>
<nav className="grid gap-1.5">
{navItems.map((item) => {
const selected = item.href !== "#" && pathname === item.href;
return (
<Link
key={item.label}
href={item.href}
className={`rounded-2xl px-4 py-3 text-sm font-medium transition ${
selected
? "bg-[var(--dls-active)] text-[var(--dls-text-primary)]"
: "text-[var(--dls-text-secondary)] hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
}`}
>
{item.label}
</Link>
);
})}
</nav>
</div>
<div className="mt-auto rounded-[28px] border border-[var(--dls-border)] bg-white p-4 shadow-[var(--dls-card-shadow)]">
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-[var(--dls-text-secondary)]">Signed in as</p>
<p className="mt-2 truncate text-sm font-medium text-[var(--dls-text-primary)]">{user?.email ?? "Unknown user"}</p>
{orgError ? <p className="mt-3 text-xs font-medium text-rose-600">{orgError}</p> : null}
<button
type="button"
className="mt-4 inline-flex w-full items-center justify-center rounded-2xl border border-[var(--dls-border)] bg-[var(--dls-surface)] px-4 py-3 text-sm font-medium text-[var(--dls-text-secondary)] transition hover:bg-[var(--dls-hover)] hover:text-[var(--dls-text-primary)]"
onClick={() => void signOut()}
>
Log out
</button>
</div>
</div>
</aside>
<main className="min-h-screen min-h-dvh flex-1">{children}</main>
</section>
);
}

View File

@@ -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<string, unknown>;
const creator = entry.creator && typeof entry.creator === "object"
? (entry.creator as Record<string, unknown>)
: 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<TemplateCard[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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 (
<section className="mx-auto flex max-w-6xl flex-col gap-6 p-4 md:p-12">
<div className="rounded-[32px] border border-[var(--dls-border)] bg-white p-6 shadow-[var(--dls-card-shadow)] md:p-8">
<p className="text-[11px] font-bold uppercase tracking-[0.18em] text-[var(--dls-text-secondary)]">Workspace Templates</p>
<h1 className="mt-2 text-[2.4rem] font-semibold leading-[0.95] tracking-[-0.06em] text-[var(--dls-text-primary)]">Shared setup templates</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-[var(--dls-text-secondary)]">
Templates created for this organization appear here. Use this as the quick place to browse and remove stale links.
</p>
<p className="mt-3 text-sm font-medium text-[var(--dls-text-secondary)]">
Create new templates from workspaces inside the OpenWork desktop app.
</p>
</div>
{error ? <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div> : null}
{busy ? (
<div className="rounded-[24px] border border-[var(--dls-border)] bg-white p-6 text-sm text-[var(--dls-text-secondary)]">Loading templates...</div>
) : templates.length === 0 ? (
<div className="rounded-[24px] border border-[var(--dls-border)] bg-white p-6 text-sm text-[var(--dls-text-secondary)]">No templates yet for this organization.</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{templates.map((template) => (
<article key={template.id} className="rounded-[24px] border border-[var(--dls-border)] bg-white p-5 shadow-[var(--dls-card-shadow)]">
<h2 className="text-lg font-semibold text-[var(--dls-text-primary)]">{template.name}</h2>
<p className="mt-2 text-xs text-[var(--dls-text-secondary)]">Created by {template.creator.name} ({template.creator.email})</p>
<p className="mt-1 text-xs text-[var(--dls-text-secondary)]">
{template.createdAt ? `Created ${new Date(template.createdAt).toLocaleString()}` : "Created recently"}
</p>
{canDelete ? (
<button
type="button"
className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => void deleteTemplate(template.id)}
disabled={deletingId === template.id}
>
{deletingId === template.id ? "Deleting..." : "Delete"}
</button>
) : null}
</article>
))}
</div>
)}
</section>
);
}

View File

@@ -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<void>;
createOrganization: (name: string) => Promise<void>;
switchOrganization: (slug: string) => void;
inviteMember: (input: { email: string; role: string }) => Promise<void>;
cancelInvitation: (invitationId: string) => Promise<void>;
updateMemberRole: (memberId: string, role: string) => Promise<void>;
removeMember: (memberId: string) => Promise<void>;
createRole: (input: { roleName: string; permission: Record<string, string[]> }) => Promise<void>;
updateRole: (roleId: string, input: { roleName?: string; permission?: Record<string, string[]> }) => Promise<void>;
deleteRole: (roleId: string) => Promise<void>;
};
const OrgDashboardContext = createContext<OrgDashboardContextValue | null>(null);
export function OrgDashboardProvider({
orgSlug,
children,
}: {
orgSlug: string;
children: ReactNode;
}) {
const router = useRouter();
const { user, sessionHydrated, signOut, refreshWorkers } = useDenFlow();
const [orgDirectory, setOrgDirectory] = useState<DenOrgSummary[]>([]);
const [orgContext, setOrgContext] = useState<DenOrgContext | null>(null);
const [orgBusy, setOrgBusy] = useState(false);
const [orgError, setOrgError] = useState<string | null>(null);
const [mutationBusy, setMutationBusy] = useState<string | null>(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<void>) {
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<string, string[]> }) {
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<string, string[]> }) {
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 <OrgDashboardContext.Provider value={value}>{children}</OrgDashboardContext.Provider>;
}
export function useOrgDashboard() {
const value = useContext(OrgDashboardContext);
if (!value) {
throw new Error("useOrgDashboard must be used within OrgDashboardProvider.");
}
return value;
}

View File

@@ -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 (
<OrgDashboardProvider orgSlug={params.orgSlug}>
<OrgDashboardShell>{children}</OrgDashboardShell>
</OrgDashboardProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { ManageMembersScreen } from "../_components/manage-members-screen";
export default function ManageMembersPage() {
return <ManageMembersScreen />;
}

View File

@@ -0,0 +1,7 @@
// import { DashboardScreen } from "../../../_components/dashboard-screen";
import { TemplatesDashboardScreen } from "./_components/templates-dashboard-screen";
export default function OrgDashboardPage() {
// return <DashboardScreen showSidebar={false} />;
return <TemplatesDashboardScreen />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -1,10 +1,9 @@
import "./load-env.js" import "./load-env.js"
import { Daytona } from "@daytonaio/sdk" import { Daytona } from "@daytonaio/sdk"
import { Hono } from "hono" import { Hono } from "hono"
import { createHash } from "node:crypto"
import { and, eq, isNull } from "@openwork-ee/den-db/drizzle" import { and, eq, isNull } from "@openwork-ee/den-db/drizzle"
import { createDenDb, DaytonaSandboxTable, RateLimitTable, WorkerTokenTable } from "@openwork-ee/den-db" 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" import { env } from "./env.js"
const { db } = createDenDb({ const { db } = createDenDb({
@@ -86,10 +85,6 @@ function stripProxyHeaders(input: Headers) {
return headers return headers
} }
function hashRateLimitId(key: string) {
return createHash("sha256").update(key).digest("hex")
}
function readClientIp(request: Request) { function readClientIp(request: Request) {
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
const realIp = request.headers.get("x-real-ip")?.trim() const realIp = request.headers.get("x-real-ip")?.trim()
@@ -120,7 +115,7 @@ async function consumeRateLimit(input: {
const current = rows[0] ?? null const current = rows[0] ?? null
if (!current) { if (!current) {
await db.insert(RateLimitTable).values({ await db.insert(RateLimitTable).values({
id: hashRateLimitId(input.key), id: createDenTypeId("rateLimit"),
key: input.key, key: input.key,
count: 1, count: 1,
lastRequest: now, lastRequest: now,

View File

@@ -21,7 +21,6 @@ const timestamps = {
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), .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 WorkerDestination = ["local", "cloud"] as const
export const WorkerStatus = ["provisioning", "healthy", "failed", "stopped"] as const export const WorkerStatus = ["provisioning", "healthy", "failed", "stopped"] as const
export const TokenScope = ["client", "host", "activity"] as const export const TokenScope = ["client", "host", "activity"] as const
@@ -47,6 +46,8 @@ export const AuthSessionTable = mysqlTable(
{ {
id: denTypeIdColumn("session", "id").notNull().primaryKey(), id: denTypeIdColumn("session", "id").notNull().primaryKey(),
userId: denTypeIdColumn("user", "user_id").notNull(), userId: denTypeIdColumn("user", "user_id").notNull(),
activeOrganizationId: denTypeIdColumn("organization", "active_organization_id"),
activeTeamId: denTypeIdColumn("team", "active_team_id"),
token: varchar("token", { length: 255 }).notNull(), token: varchar("token", { length: 255 }).notNull(),
expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(), expiresAt: timestamp("expires_at", { fsp: 3 }).notNull(),
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
@@ -102,7 +103,7 @@ export const AuthVerificationTable = mysqlTable(
export const RateLimitTable = mysqlTable( export const RateLimitTable = mysqlTable(
"rate_limit", "rate_limit",
{ {
id: varchar("id", { length: 255 }).notNull().primaryKey(), id: denTypeIdColumn("rateLimit", "id").notNull().primaryKey(),
key: varchar("key", { length: 512 }).notNull(), key: varchar("key", { length: 512 }).notNull(),
count: int("count").notNull().default(0), count: int("count").notNull().default(0),
lastRequest: bigint("last_request", { mode: "number" }).notNull(), lastRequest: bigint("last_request", { mode: "number" }).notNull(),
@@ -132,30 +133,141 @@ export const DesktopHandoffGrantTable = mysqlTable(
], ],
) )
export const OrgTable = mysqlTable( export const OrganizationTable = mysqlTable(
"org", "organization",
{ {
id: denTypeIdColumn("org", "id").notNull().primaryKey(), id: denTypeIdColumn("organization", "id").notNull().primaryKey(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull(),
owner_user_id: denTypeIdColumn("user", "owner_user_id").notNull(), logo: varchar("logo", { length: 2048 }),
...timestamps, 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( export const MemberTable = mysqlTable(
"org_membership", "member",
{ {
id: denTypeIdColumn("orgMembership", "id").notNull().primaryKey(), id: denTypeIdColumn("member", "id").notNull().primaryKey(),
org_id: denTypeIdColumn("org", "org_id").notNull(), organizationId: denTypeIdColumn("organization", "organization_id").notNull(),
user_id: denTypeIdColumn("user", "user_id").notNull(), userId: denTypeIdColumn("user", "user_id").notNull(),
role: mysqlEnum("role", OrgRole).notNull(), role: varchar("role", { length: 255 }).notNull().default("member"),
created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), 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( export const AdminAllowlistTable = mysqlTable(
"admin_allowlist", "admin_allowlist",
{ {

View File

@@ -5,8 +5,16 @@ export const denTypeIdPrefixes = {
session: "ses", session: "ses",
account: "acc", account: "acc",
verification: "ver", verification: "ver",
rateLimit: "rli",
org: "org", org: "org",
organization: "org",
orgMembership: "om", orgMembership: "om",
member: "om",
invitation: "inv",
team: "tem",
teamMember: "tmb",
organizationRole: "orl",
tempTemplateSharing: "tts",
adminAllowlist: "aal", adminAllowlist: "aal",
worker: "wrk", worker: "wrk",
workerInstance: "wki", workerInstance: "wki",

View File

@@ -7,20 +7,20 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY .npmrc /app/.npmrc COPY .npmrc /app/.npmrc
COPY patches /app/patches COPY patches /app/patches
COPY packages/utils/package.json /app/packages/utils/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY packages/den-db/package.json /app/packages/den-db/package.json COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json
COPY services/den/package.json /app/services/den/package.json COPY ee/apps/den-controller/package.json /app/ee/apps/den-controller/package.json
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY packages/utils /app/packages/utils COPY ee/packages/utils /app/ee/packages/utils
COPY packages/den-db /app/packages/den-db COPY ee/packages/den-db /app/ee/packages/den-db
COPY services/den /app/services/den COPY ee/apps/den-controller /app/ee/apps/den-controller
RUN pnpm --dir /app/packages/utils run build RUN pnpm --dir /app/ee/packages/utils run build
RUN pnpm --dir /app/packages/den-db run build RUN pnpm --dir /app/ee/packages/den-db run build
RUN pnpm --dir /app/services/den run build RUN pnpm --dir /app/ee/apps/den-controller run build
EXPOSE 8788 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"]

View File

@@ -1,13 +1,20 @@
FROM node:22-bookworm-slim 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 EXPOSE 3005
CMD ["npm", "run", "dev"] CMD ["sh", "-lc", "pnpm run build && pnpm run start"]

View File

@@ -7,20 +7,20 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY .npmrc /app/.npmrc COPY .npmrc /app/.npmrc
COPY patches /app/patches COPY patches /app/patches
COPY packages/utils/package.json /app/packages/utils/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY packages/den-db/package.json /app/packages/den-db/package.json COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json
COPY services/den-worker-proxy/package.json /app/services/den-worker-proxy/package.json COPY ee/apps/den-worker-proxy/package.json /app/ee/apps/den-worker-proxy/package.json
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY packages/utils /app/packages/utils COPY ee/packages/utils /app/ee/packages/utils
COPY packages/den-db /app/packages/den-db COPY ee/packages/den-db /app/ee/packages/den-db
COPY services/den-worker-proxy /app/services/den-worker-proxy COPY ee/apps/den-worker-proxy /app/ee/apps/den-worker-proxy
RUN pnpm --dir /app/packages/utils run build RUN pnpm --dir /app/ee/packages/utils run build
RUN pnpm --dir /app/packages/den-db run build RUN pnpm --dir /app/ee/packages/den-db run build
RUN pnpm --dir /app/services/den-worker-proxy run build RUN pnpm --dir /app/ee/apps/den-worker-proxy run build
EXPOSE 8789 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"]

3
pnpm-lock.yaml generated
View File

@@ -363,6 +363,9 @@ importers:
better-auth: better-auth:
specifier: ^1.4.18 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) 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: cors:
specifier: ^2.8.5 specifier: ^2.8.5
version: 2.8.6 version: 2.8.6