mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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`);
|
||||||
152
ee/apps/den-controller/drizzle/0004_organization_plugin.sql
Normal file
152
ee/apps/den-controller/drizzle/0004_organization_plugin.sql
Normal 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;
|
||||||
@@ -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`)
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
875
ee/apps/den-controller/src/http/orgs.ts
Normal file
875
ee/apps/den-controller/src/http/orgs.ts
Normal 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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
17
ee/apps/den-controller/src/organization-access.ts
Normal file
17
ee/apps/den-controller/src/organization-access.ts
Normal 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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
320
ee/apps/den-web/app/(den)/_lib/den-org.ts
Normal file
320
ee/apps/den-web/app/(den)/_lib/den-org.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
17
ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx
Normal file
17
ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ManageMembersScreen } from "../_components/manage-members-screen";
|
||||||
|
|
||||||
|
export default function ManageMembersPage() {
|
||||||
|
return <ManageMembersScreen />;
|
||||||
|
}
|
||||||
7
ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/page.tsx
Normal file
7
ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/page.tsx
Normal 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 |
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user