mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +08:00
feat: add Better Auth organization + admin plugins (#241)
Enable org-aware architecture: auto-create personal org on signup, first user promoted to admin (race-safe atomic SQL), requireAdmin middleware, org context resolution in auth middleware. - Add organization, member, invitation tables to auth schema - Add admin plugin fields (role, banned, banReason, banExpires) to user - Add session fields (impersonatedBy, activeOrganizationId) - Rename storage_quotas → org_quotas, matters.uid → matters.org_id - Add findPersonalOrg service with SQL-level metadata filtering - Widen Database type to include both app and auth schemas - Include auth schema in Cloudflare platform adapter - Update shared types for org-aware API (OrgQuota, Organization, Member) - Add DB migration 0001_org_and_admin_plugins - Add tests for admin role, org creation, requireAdmin, findPersonalOrg - Fix pre-existing shared package typecheck (add typescript devDep) Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
-- Admin plugin: add fields to user table
|
||||
ALTER TABLE `user` ADD `role` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `banned` integer DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `ban_reason` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `ban_expires` integer;
|
||||
--> statement-breakpoint
|
||||
-- Admin plugin: add impersonation tracking to session
|
||||
ALTER TABLE `session` ADD `impersonated_by` text;
|
||||
--> statement-breakpoint
|
||||
-- Organization plugin: add active org to session
|
||||
ALTER TABLE `session` ADD `active_organization_id` text;
|
||||
--> statement-breakpoint
|
||||
-- Organization plugin: create organization table
|
||||
CREATE TABLE `organization` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`logo` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);
|
||||
--> statement-breakpoint
|
||||
-- Organization plugin: create member table
|
||||
CREATE TABLE `member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);
|
||||
--> statement-breakpoint
|
||||
-- Organization plugin: create invitation table
|
||||
CREATE TABLE `invitation` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||
`inviter_id` text NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`inviter_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);
|
||||
--> statement-breakpoint
|
||||
-- Schema change: rename matters.uid to matters.org_id
|
||||
ALTER TABLE `matters` RENAME COLUMN `uid` TO `org_id`;
|
||||
--> statement-breakpoint
|
||||
-- Schema change: rename storage_quotas to org_quotas with new structure
|
||||
ALTER TABLE `storage_quotas` RENAME TO `org_quotas`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `org_quotas` RENAME COLUMN `uid` TO `org_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `org_quotas` DROP COLUMN `storage_id`;
|
||||
@@ -0,0 +1,675 @@
|
||||
{
|
||||
"id": "81789e49-005c-41c1-b35d-fdffbae9f767",
|
||||
"prevId": "adf1fb30-c3d2-4034-8e79-56f9ac4eee97",
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"tables": {
|
||||
"matters": {
|
||||
"name": "matters",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uid": {
|
||||
"name": "uid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"alias": {
|
||||
"name": "alias",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"dirtype": {
|
||||
"name": "dirtype",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"parent": {
|
||||
"name": "parent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"object": {
|
||||
"name": "object",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"storage_id": {
|
||||
"name": "storage_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"matters_alias_unique": {
|
||||
"name": "matters_alias_unique",
|
||||
"columns": [
|
||||
"alias"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"storage_quotas": {
|
||||
"name": "storage_quotas",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uid": {
|
||||
"name": "uid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"storage_id": {
|
||||
"name": "storage_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"quota": {
|
||||
"name": "quota",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"used": {
|
||||
"name": "used",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"storages": {
|
||||
"name": "storages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uid": {
|
||||
"name": "uid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"bucket": {
|
||||
"name": "bucket",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"region": {
|
||||
"name": "region",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"access_key": {
|
||||
"name": "access_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_key": {
|
||||
"name": "secret_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'$UID/$RAW_NAME'"
|
||||
},
|
||||
"custom_host": {
|
||||
"name": "custom_host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"system_options": {
|
||||
"name": "system_options",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"public": {
|
||||
"name": "public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_userId_idx": {
|
||||
"name": "account_userId_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": [
|
||||
"token"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"verification": {
|
||||
"name": "verification",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": [
|
||||
"identifier"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1775051640321,
|
||||
"tag": "0000_perfect_cyclops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1775073310565,
|
||||
"tag": "0001_org_and_admin_plugins",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { admin, organization } from 'better-auth/plugins'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import * as authSchema from './db/auth-schema'
|
||||
import type { Database } from './platform/interface'
|
||||
|
||||
@@ -18,7 +21,48 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust
|
||||
maxAge: 60 * 5,
|
||||
},
|
||||
},
|
||||
plugins: [admin(), organization()],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
await setupNewUser(db, user)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type Auth = ReturnType<typeof createAuth>
|
||||
|
||||
async function setupNewUser(db: Database, user: { id: string; name: string }) {
|
||||
await promoteFirstUserToAdmin(db, user.id)
|
||||
await createPersonalOrg(db, user)
|
||||
}
|
||||
|
||||
async function promoteFirstUserToAdmin(db: Database, userId: string): Promise<void> {
|
||||
await db.run(sql`UPDATE user SET role = 'admin' WHERE id = ${userId} AND (SELECT COUNT(*) FROM user) = 1`)
|
||||
}
|
||||
|
||||
async function createPersonalOrg(db: Database, user: { id: string; name: string }): Promise<void> {
|
||||
const orgId = nanoid()
|
||||
const now = new Date()
|
||||
const orgName = user.name ? `${user.name}'s Space` : 'Personal Space'
|
||||
|
||||
await db.insert(authSchema.organization).values({
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
slug: `personal-${user.id}`,
|
||||
metadata: JSON.stringify({ type: 'personal' }),
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
await db.insert(authSchema.member).values({
|
||||
id: nanoid(),
|
||||
organizationId: orgId,
|
||||
userId: user.id,
|
||||
role: 'owner',
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export const user = sqliteTable('user', {
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
|
||||
image: text('image'),
|
||||
role: text('role'),
|
||||
banned: integer('banned', { mode: 'boolean' }).default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
@@ -33,6 +37,8 @@ export const session = sqliteTable(
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
impersonatedBy: text('impersonated_by'),
|
||||
activeOrganizationId: text('active_organization_id'),
|
||||
},
|
||||
(table) => [index('session_userId_idx').on(table.userId)],
|
||||
)
|
||||
@@ -85,9 +91,66 @@ export const verification = sqliteTable(
|
||||
(table) => [index('verification_identifier_idx').on(table.identifier)],
|
||||
)
|
||||
|
||||
export const organization = sqliteTable('organization', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
logo: text('logo'),
|
||||
metadata: text('metadata'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
})
|
||||
|
||||
export const member = sqliteTable(
|
||||
'member',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull().default('member'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index('member_organizationId_idx').on(table.organizationId), index('member_userId_idx').on(table.userId)],
|
||||
)
|
||||
|
||||
export const invitation = sqliteTable(
|
||||
'invitation',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id')
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: 'cascade' }),
|
||||
email: text('email').notNull(),
|
||||
role: text('role').notNull(),
|
||||
status: text('status').notNull().default('pending'),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
inviterId: text('inviter_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [
|
||||
index('invitation_organizationId_idx').on(table.organizationId),
|
||||
index('invitation_email_idx').on(table.email),
|
||||
],
|
||||
)
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
members: many(member),
|
||||
}))
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
@@ -103,3 +166,30 @@ export const accountRelations = relations(account, ({ one }) => ({
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}))
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
inviter: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const matters = sqliteTable('matters', {
|
||||
id: text('id').primaryKey(),
|
||||
uid: text('uid').notNull(),
|
||||
orgId: text('org_id').notNull(),
|
||||
alias: text('alias').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(),
|
||||
@@ -33,12 +33,11 @@ export const storages = sqliteTable('storages', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
|
||||
export const storageQuotas = sqliteTable('storage_quotas', {
|
||||
export const orgQuotas = sqliteTable('org_quotas', {
|
||||
id: text('id').primaryKey(),
|
||||
uid: text('uid').notNull(),
|
||||
storageId: text('storage_id').notNull(),
|
||||
quota: integer('quota').notNull(),
|
||||
used: integer('used').default(0),
|
||||
orgId: text('org_id').notNull(),
|
||||
quota: integer('quota').notNull().default(0),
|
||||
used: integer('used').notNull().default(0),
|
||||
})
|
||||
|
||||
export const systemOptions = sqliteTable('system_options', {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { requireAdmin } from './auth.js'
|
||||
|
||||
function createAdminTestApp() {
|
||||
const { app, db, auth } = createTestApp()
|
||||
// Add a test-only route protected by requireAdmin
|
||||
app.get('/api/admin-only', requireAdmin, (c) => c.json({ ok: true }))
|
||||
return { app, db, auth }
|
||||
}
|
||||
|
||||
// Signs up then signs in to get a session cookie that reflects the post-hook role update
|
||||
async function authedHeadersWithFreshSession(
|
||||
app: ReturnType<typeof createTestApp>['app'],
|
||||
email: string,
|
||||
password = 'password123456',
|
||||
name = 'Test User',
|
||||
) {
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
const signInRes = await app.request('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const cookies = signInRes.headers.getSetCookie()
|
||||
return { Cookie: cookies.join('; ') }
|
||||
}
|
||||
|
||||
describe('requireAdmin middleware', () => {
|
||||
it('returns 403 when user is not authenticated', async () => {
|
||||
const { app } = createAdminTestApp()
|
||||
const res = await app.request('/api/admin-only')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when authenticated user does not have admin role', async () => {
|
||||
const { app } = createAdminTestApp()
|
||||
// First user becomes admin; second does not
|
||||
await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin')
|
||||
const headers = await authedHeaders(app, 'regular@example.com', 'password123456')
|
||||
const res = await app.request('/api/admin-only', { headers })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns Forbidden error body when user lacks admin role', async () => {
|
||||
const { app } = createAdminTestApp()
|
||||
await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin')
|
||||
const headers = await authedHeaders(app, 'regular@example.com', 'password123456')
|
||||
const res = await app.request('/api/admin-only', { headers })
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('Forbidden')
|
||||
})
|
||||
|
||||
it('allows request when user has admin role', async () => {
|
||||
const { app } = createAdminTestApp()
|
||||
// First signup → role updated to admin by hook; sign in to get fresh session
|
||||
const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin')
|
||||
const res = await app.request('/api/admin-only', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns expected body when admin accesses protected route', async () => {
|
||||
const { app } = createAdminTestApp()
|
||||
const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin')
|
||||
const res = await app.request('/api/admin-only', { headers })
|
||||
const body = (await res.json()) as { ok: boolean }
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,26 @@
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { findPersonalOrg } from '../services/org'
|
||||
import type { Env } from './platform'
|
||||
|
||||
type SessionWithPlugins = {
|
||||
user: { id: string; role?: string }
|
||||
session: { activeOrganizationId?: string }
|
||||
}
|
||||
|
||||
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
const auth = c.get('auth')
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
c.set('userId', session?.user?.id ?? null)
|
||||
const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null
|
||||
|
||||
c.set('userId', result?.user?.id ?? null)
|
||||
c.set('userRole', result?.user?.role ?? null)
|
||||
|
||||
if (result?.user?.id) {
|
||||
const orgId = result.session?.activeOrganizationId ?? (await findPersonalOrg(c.get('platform').db, result.user.id))
|
||||
c.set('orgId', orgId)
|
||||
} else {
|
||||
c.set('orgId', null)
|
||||
}
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -15,3 +31,15 @@ export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
export const requireAdmin = createMiddleware<Env>(async (c, next) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
const userRole = c.get('userRole')
|
||||
if (userRole !== 'admin') {
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ export type Env = {
|
||||
platform: Platform
|
||||
auth: Auth
|
||||
userId: string | null
|
||||
userRole: string | null
|
||||
orgId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { drizzle } from 'drizzle-orm/d1'
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as schema from '../db/schema'
|
||||
import type { Platform } from './interface'
|
||||
|
||||
@@ -8,7 +9,7 @@ interface CloudflareEnv {
|
||||
}
|
||||
|
||||
export function createCloudflarePlatform(env: CloudflareEnv): Platform {
|
||||
const db = drizzle(env.DB, { schema })
|
||||
const db = drizzle(env.DB, { schema: { ...schema, ...authSchema } })
|
||||
|
||||
return {
|
||||
db,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import type { DrizzleD1Database } from 'drizzle-orm/d1'
|
||||
import type * as schema from '../db/schema'
|
||||
import type * as authSchema from '../db/auth-schema'
|
||||
import type * as appSchema from '../db/schema'
|
||||
|
||||
export type Database = BetterSQLite3Database<typeof schema> | DrizzleD1Database<typeof schema>
|
||||
type FullSchema = typeof appSchema & typeof authSchema
|
||||
|
||||
export type Database = BetterSQLite3Database<FullSchema> | DrizzleD1Database<FullSchema>
|
||||
|
||||
export interface Platform {
|
||||
db: Database
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
describe('Auth API', () => {
|
||||
@@ -46,4 +48,105 @@ describe('Auth API', () => {
|
||||
})
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it('first user gets admin role after signup', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Admin User', email: 'admin@example.com', password: 'password123456' }),
|
||||
})
|
||||
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'admin@example.com'))
|
||||
expect(users[0].role).toBe('admin')
|
||||
})
|
||||
|
||||
it('first user gets a personal organization created after signup', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const signUpRes = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Admin User', email: 'admin@example.com', password: 'password123456' }),
|
||||
})
|
||||
const body = (await signUpRes.json()) as { user: { id: string } }
|
||||
const userId = body.user.id
|
||||
|
||||
const orgs = await db
|
||||
.select()
|
||||
.from(authSchema.organization)
|
||||
.where(eq(authSchema.organization.slug, `personal-${userId}`))
|
||||
expect(orgs).toHaveLength(1)
|
||||
expect(JSON.parse(orgs[0].metadata!)).toEqual({ type: 'personal' })
|
||||
})
|
||||
|
||||
it('second user does NOT get admin role', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
// First user
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }),
|
||||
})
|
||||
// Second user
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }),
|
||||
})
|
||||
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'second@example.com'))
|
||||
expect(users[0].role).not.toBe('admin')
|
||||
})
|
||||
|
||||
it('second user also gets a personal organization created', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
// First user
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }),
|
||||
})
|
||||
// Second user
|
||||
const signUpRes = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }),
|
||||
})
|
||||
const body = (await signUpRes.json()) as { user: { id: string } }
|
||||
const userId = body.user.id
|
||||
|
||||
const orgs = await db
|
||||
.select()
|
||||
.from(authSchema.organization)
|
||||
.where(eq(authSchema.organization.slug, `personal-${userId}`))
|
||||
expect(orgs).toHaveLength(1)
|
||||
expect(JSON.parse(orgs[0].metadata!)).toEqual({ type: 'personal' })
|
||||
})
|
||||
|
||||
it('second user gets a member record with owner role in their personal org', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
// First user
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'First', email: 'first@example.com', password: 'password123456' }),
|
||||
})
|
||||
// Second user
|
||||
const signUpRes = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Second', email: 'second@example.com', password: 'password123456' }),
|
||||
})
|
||||
const body = (await signUpRes.json()) as { user: { id: string } }
|
||||
const userId = body.user.id
|
||||
|
||||
const orgs = await db
|
||||
.select()
|
||||
.from(authSchema.organization)
|
||||
.where(eq(authSchema.organization.slug, `personal-${userId}`))
|
||||
const orgId = orgs[0].id
|
||||
|
||||
const members = await db.select().from(authSchema.member).where(eq(authSchema.member.organizationId, orgId))
|
||||
expect(members).toHaveLength(1)
|
||||
expect(members[0].userId).toBe(userId)
|
||||
expect(members[0].role).toBe('owner')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { findPersonalOrg } from './org.js'
|
||||
|
||||
type TestDb = ReturnType<typeof createTestApp>['db']
|
||||
|
||||
async function insertUser(db: TestDb, overrides: Partial<{ id: string; name: string; email: string }> = {}) {
|
||||
const id = overrides.id ?? nanoid()
|
||||
await db.insert(authSchema.user).values({
|
||||
id,
|
||||
name: overrides.name ?? 'Test User',
|
||||
email: overrides.email ?? `${id}@example.com`,
|
||||
emailVerified: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertOrg(db: TestDb, overrides: Partial<{ id: string; slug: string; metadata: string | null }> = {}) {
|
||||
const id = overrides.id ?? nanoid()
|
||||
await db.insert(authSchema.organization).values({
|
||||
id,
|
||||
name: 'Test Org',
|
||||
slug: overrides.slug ?? nanoid(),
|
||||
metadata: overrides.metadata !== undefined ? overrides.metadata : JSON.stringify({ type: 'personal' }),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertMember(db: TestDb, organizationId: string, userId: string) {
|
||||
await db.insert(authSchema.member).values({
|
||||
id: nanoid(),
|
||||
organizationId,
|
||||
userId,
|
||||
role: 'owner',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
describe('findPersonalOrg', () => {
|
||||
it('returns the org id when a personal org exists for the user', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'personal' }) })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBe(orgId)
|
||||
})
|
||||
|
||||
it('returns null when user has no memberships', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when user only belongs to a non-personal org', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'team' }) })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when org metadata is null', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: null })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when org metadata is invalid JSON', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: 'not-valid-json' })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when org metadata is valid JSON but missing type field', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db, { metadata: JSON.stringify({ other: 'value' }) })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the personal org among multiple memberships', async () => {
|
||||
const { db } = createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const teamOrgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'team' }) })
|
||||
const personalOrgId = await insertOrg(db, { metadata: JSON.stringify({ type: 'personal' }) })
|
||||
await insertMember(db, teamOrgId, userId)
|
||||
await insertMember(db, personalOrgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
expect(result).toBe(personalOrgId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export async function findPersonalOrg(db: Database, userId: string): Promise<string | null> {
|
||||
const rows = await db.all<{ org_id: string }>(sql`
|
||||
SELECT m.organization_id AS org_id
|
||||
FROM member m
|
||||
INNER JOIN organization o ON o.id = m.organization_id
|
||||
WHERE m.user_id = ${userId}
|
||||
AND o.metadata LIKE '%"type":"personal"%'
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
return rows[0]?.org_id ?? null
|
||||
}
|
||||
@@ -13,6 +13,10 @@ const AUTH_SCHEMA_SQL = `
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
image TEXT,
|
||||
role TEXT,
|
||||
banned INTEGER DEFAULT 0,
|
||||
ban_reason TEXT,
|
||||
ban_expires INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
@@ -24,7 +28,9 @@ const AUTH_SCHEMA_SQL = `
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE
|
||||
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||
impersonated_by TEXT,
|
||||
active_organization_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id);
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
@@ -52,12 +58,42 @@ const AUTH_SCHEMA_SQL = `
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification(identifier);
|
||||
CREATE TABLE IF NOT EXISTS organization (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
logo TEXT,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
updated_at INTEGER DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS member (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS member_organizationId_idx ON member(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS member_userId_idx ON member(user_id);
|
||||
CREATE TABLE IF NOT EXISTS invitation (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
inviter_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation(email);
|
||||
`
|
||||
|
||||
const APP_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS matters (
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL,
|
||||
org_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
@@ -86,12 +122,11 @@ const APP_SCHEMA_SQL = `
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS storage_quotas (
|
||||
CREATE TABLE IF NOT EXISTS org_quotas (
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL,
|
||||
storage_id TEXT NOT NULL,
|
||||
quota INTEGER NOT NULL,
|
||||
used INTEGER DEFAULT 0
|
||||
org_id TEXT NOT NULL,
|
||||
quota INTEGER NOT NULL DEFAULT 0,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS system_options (
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DirType, ObjectStatus, StorageMode } from '../constants'
|
||||
|
||||
export interface StorageObject {
|
||||
id: string
|
||||
uid: string
|
||||
orgId: string
|
||||
alias: string
|
||||
name: string
|
||||
type: string
|
||||
@@ -33,14 +33,31 @@ export interface Storage {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface StorageQuota {
|
||||
export interface OrgQuota {
|
||||
id: string
|
||||
uid: string
|
||||
storageId: string
|
||||
orgId: string
|
||||
quota: number
|
||||
used: number
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo: string | null
|
||||
metadata: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
organizationId: string
|
||||
userId: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SystemOption {
|
||||
key: string
|
||||
value: string
|
||||
|
||||
Generated
+3
@@ -85,6 +85,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)))
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||
|
||||
Reference in New Issue
Block a user