diff --git a/@api/database/migrations/0001_grey_wiccan.sql b/@api/database/migrations/0001_grey_wiccan.sql new file mode 100644 index 0000000..9a191d3 --- /dev/null +++ b/@api/database/migrations/0001_grey_wiccan.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS "organizations_invitations" ( + "id" char(64) PRIMARY KEY NOT NULL, + "organization_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "role" "organization_member_roles" DEFAULT 'member' NOT NULL, + "expired_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organizations_invitations" ADD CONSTRAINT "organizations_invitations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/@api/database/migrations/0002_medical_lilith.sql b/@api/database/migrations/0002_medical_lilith.sql new file mode 100644 index 0000000..785778e --- /dev/null +++ b/@api/database/migrations/0002_medical_lilith.sql @@ -0,0 +1 @@ +ALTER TABLE "organizations_invitations" ADD CONSTRAINT "organizations_invitations_organization_id_email_unique" UNIQUE("organization_id","email"); \ No newline at end of file diff --git a/@api/database/migrations/meta/0001_snapshot.json b/@api/database/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..1ea84eb --- /dev/null +++ b/@api/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,380 @@ +{ + "id": "87cbe321-13e9-4e5f-88f6-a57d14cbd28c", + "prevId": "be57e4a8-c363-4a94-93e7-dba50163ab8b", + "version": "5", + "dialect": "pg", + "tables": { + "email_otps": { + "name": "email_otps", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "oauth_account_providers", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_provider_user_id_pk": { + "name": "oauth_accounts_provider_provider_user_id_pk", + "columns": ["provider", "provider_user_id"] + } + }, + "uniqueConstraints": { + "oauth_accounts_provider_user_id_unique": { + "name": "oauth_accounts_provider_user_id_unique", + "nullsNotDistinct": false, + "columns": ["provider", "user_id"] + } + } + }, + "organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_members_user_id_organization_id_pk": { + "name": "organization_members_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {} + }, + "organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "organizations_invitations": { + "name": "organizations_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organizations_invitations_organization_id_organizations_id_fk": { + "name": "organizations_invitations_organization_id_organizations_id_fk", + "tableFrom": "organizations_invitations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_organization_id_fkey": { + "name": "sessions_user_id_organization_id_fkey", + "tableFrom": "sessions", + "tableTo": "organization_members", + "columnsFrom": ["user_id", "organization_id"], + "columnsTo": ["user_id", "organization_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + } + }, + "enums": { + "oauth_account_providers": { + "name": "oauth_account_providers", + "values": { + "github": "github", + "google": "google" + } + }, + "organization_member_roles": { + "name": "organization_member_roles", + "values": { + "admin": "admin", + "member": "member" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/@api/database/migrations/meta/0002_snapshot.json b/@api/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..804888d --- /dev/null +++ b/@api/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,386 @@ +{ + "id": "4fc9b2bd-a02a-4557-9edb-189a297fc0df", + "prevId": "87cbe321-13e9-4e5f-88f6-a57d14cbd28c", + "version": "5", + "dialect": "pg", + "tables": { + "email_otps": { + "name": "email_otps", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "oauth_account_providers", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_provider_user_id_pk": { + "name": "oauth_accounts_provider_provider_user_id_pk", + "columns": ["provider", "provider_user_id"] + } + }, + "uniqueConstraints": { + "oauth_accounts_provider_user_id_unique": { + "name": "oauth_accounts_provider_user_id_unique", + "nullsNotDistinct": false, + "columns": ["provider", "user_id"] + } + } + }, + "organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_members_user_id_organization_id_pk": { + "name": "organization_members_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {} + }, + "organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "organizations_invitations": { + "name": "organizations_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "organization_member_roles", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organizations_invitations_organization_id_organizations_id_fk": { + "name": "organizations_invitations_organization_id_organizations_id_fk", + "tableFrom": "organizations_invitations", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_invitations_organization_id_email_unique": { + "name": "organizations_invitations_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": ["organization_id", "email"] + } + } + }, + "sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(64)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_organization_id_fkey": { + "name": "sessions_user_id_organization_id_fkey", + "tableFrom": "sessions", + "tableTo": "organization_members", + "columnsFrom": ["user_id", "organization_id"], + "columnsTo": ["user_id", "organization_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v7()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(2550)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + } + }, + "enums": { + "oauth_account_providers": { + "name": "oauth_account_providers", + "values": { + "github": "github", + "google": "google" + } + }, + "organization_member_roles": { + "name": "organization_member_roles", + "values": { + "admin": "admin", + "member": "member" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/@api/database/migrations/meta/_journal.json b/@api/database/migrations/meta/_journal.json index 5ad3aed..24c4f7d 100644 --- a/@api/database/migrations/meta/_journal.json +++ b/@api/database/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1702472647210, "tag": "0000_simple_nextwave", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1702714658330, + "tag": "0001_grey_wiccan", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1702870623178, + "tag": "0002_medical_lilith", + "breakpoints": true } ] } diff --git a/@api/database/schema.ts b/@api/database/schema.ts index 2c95528..dc1473e 100644 --- a/@api/database/schema.ts +++ b/@api/database/schema.ts @@ -58,9 +58,7 @@ export const OauthAccountRelations = relations(OauthAccounts, ({ one, many }) => export const EmailOtps = pgTable('email_otps', { email: varchar('email', { length: 255 }).notNull().primaryKey(), code: varchar('code', { length: 6 }).notNull(), - expiresAt: timestamp('expired_at') - .notNull() - .$default(() => new Date(Date.now() + 1000 * 60 * 5)), + expiresAt: timestamp('expired_at').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }) @@ -75,6 +73,7 @@ export const Organizations = pgTable('organizations', { export const OrganizationRelations = relations(Organizations, ({ many }) => ({ members: many(OrganizationMembers), + invitations: many(OrganizationsInvitations), })) export const organizationMembersRoles = pgEnum('organization_member_roles', ['admin', 'member']) @@ -139,3 +138,30 @@ export const SessionRelations = relations(Sessions, ({ one }) => ({ references: [OrganizationMembers.userId, OrganizationMembers.organizationId], }), })) + +export const OrganizationsInvitations = pgTable( + 'organizations_invitations', + { + id: char('id', { length: 64 }) + .notNull() + .primaryKey() + .$defaultFn(() => generateRandomString(64, alphabet('a-z', 'A-Z', '0-9'))), + organizationId: uuid('organization_id') + .notNull() + .references(() => Organizations.id), + email: varchar('email', { length: 255 }).notNull(), + role: organizationMembersRoles('role').notNull().default('member'), + expiresAt: timestamp('expired_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + pu: unique().on(t.organizationId, t.email), + }), +) + +export const OrganizationsInvitationRelations = relations(OrganizationsInvitations, ({ one }) => ({ + organization: one(Organizations, { + fields: [OrganizationsInvitations.organizationId], + references: [Organizations.id], + }), +})) diff --git a/@api/emails/organization-invitation/index.ts b/@api/emails/organization-invitation/index.ts new file mode 100644 index 0000000..aad8d37 --- /dev/null +++ b/@api/emails/organization-invitation/index.ts @@ -0,0 +1,18 @@ +import template from './template.html' + +export function generateOrganizationInvitationEmail(data: { + inviterName: string + organizationName: string + invitationAcceptUrl: string +}) { + const html = template + .replaceAll('{{INVITER_NAME}}', data.inviterName) + .replaceAll('{{ORGANIZATION_NAME}}', data.organizationName) + .replaceAll('{{INVITATION_ACCEPT_URL}}', data.invitationAcceptUrl) + const subject = 'You have been invited to join an organization' + + return { + html, + subject, + } +} diff --git a/@api/emails/organization-invitation/template.html b/@api/emails/organization-invitation/template.html new file mode 100644 index 0000000..dc0a323 --- /dev/null +++ b/@api/emails/organization-invitation/template.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + +
+

+ {{INVITER_NAME}} invited you to join + "{{ORGANIZATION_NAME}}" + on ResolveX.ai +

+
+ + Join now + +
+
+ + + + + + +
+ logo +

+ ResolveX.ai, forge vital customer connections +

+
+ + diff --git a/@api/routes/organization/create.ts b/@api/routes/organization/create.ts index b19fb72..9bf71a5 100644 --- a/@api/routes/organization/create.ts +++ b/@api/routes/organization/create.ts @@ -1,7 +1,8 @@ -import { OrganizationMembers, Organizations } from '@api/database/schema' +import { OrganizationMembers, Organizations, Sessions } from '@api/database/schema' import { generateFallbackLogoUrl } from '@api/lib/utils' import { authProcedure } from '@api/trpc' import { TRPCError } from '@trpc/server' +import { eq } from 'drizzle-orm' import { z } from 'zod' export const organizationCreateRoute = authProcedure @@ -46,6 +47,13 @@ export const organizationCreateRoute = authProcedure } }) + await ctx.db + .update(Sessions) + .set({ + organizationId: organization.id, + }) + .where(eq(Sessions.id, ctx.auth.session.id)) + return { organization: { ...organization, diff --git a/@api/routes/organization/index.ts b/@api/routes/organization/index.ts index 0e33368..4581c66 100644 --- a/@api/routes/organization/index.ts +++ b/@api/routes/organization/index.ts @@ -3,6 +3,7 @@ import { organizationChangeLogoRoute } from './change-logo' import { organizationCreateRoute } from './create' import { organizationDetailRoute } from './detail' import { organizationListRoute } from './list' +import { organizationMemberRouter } from './member' import { organizationUpdateRoute } from './update' export const organizationRouter = router({ @@ -11,4 +12,5 @@ export const organizationRouter = router({ create: organizationCreateRoute, update: organizationUpdateRoute, changeLogo: organizationChangeLogoRoute, + member: organizationMemberRouter, }) diff --git a/@api/routes/organization/member.ts b/@api/routes/organization/member.ts new file mode 100644 index 0000000..7d6735e --- /dev/null +++ b/@api/routes/organization/member.ts @@ -0,0 +1,202 @@ +import { OrganizationMembers, OrganizationsInvitations, Sessions, organizationMembersRoles } from '@api/database/schema' +import { generateOrganizationInvitationEmail } from '@api/emails/organization-invitation' +import { authProcedure, organizationAdminMiddleware, router } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' + +export const organizationMemberRouter = router({ + invite: authProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + email: z.string().email().toLowerCase(), + role: z.enum(organizationMembersRoles.enumValues), + }), + ) + .use(organizationAdminMiddleware) + .mutation(async ({ ctx, input }) => { + const createInvitation = ctx.db + .insert(OrganizationsInvitations) + .values({ + organizationId: input.organizationId, + email: input.email, + role: input.role, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }) + .onConflictDoUpdate({ + target: [OrganizationsInvitations.organizationId, OrganizationsInvitations.email], + set: { + role: input.role, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }, + }) + .returning() + + const findUser = ctx.db.query.Users.findFirst({ + where(t, { eq }) { + return eq(t.id, ctx.auth.session.userId) + }, + }) + + const findOrganization = ctx.db.query.Organizations.findFirst({ + where(t, { eq }) { + return eq(t.id, input.organizationId) + }, + }) + + const [[invitation], user, organization] = await Promise.all([createInvitation, findUser, findOrganization]) + + if (!invitation) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create invitation', + }) + } + + if (!user || !organization) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch user or organization', + }) + } + + ctx.ec.waitUntil( + (async () => { + const invitationAcceptUrl = new URL(`/invitation-accept?id=${invitation.id}`, ctx.env.WEB_URL) + const { subject, html } = generateOrganizationInvitationEmail({ + inviterName: user.name, + organizationName: organization.name, + invitationAcceptUrl: invitationAcceptUrl.toString(), + }) + await ctx.email.send({ + to: [input.email], + subject, + html, + }) + })(), + ) + }), + invitationInfo: authProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + const invitation = await ctx.db.query.OrganizationsInvitations.findFirst({ + where(t, { eq }) { + return eq(t.id, input.invitationId) + }, + with: { + organization: true, + }, + }) + + if (!invitation) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Invitation not found', + }) + } + + if (invitation.expiresAt < new Date()) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'This invitation has expired', + }) + } + + return { + invitation, + } + }), + acceptInvitation: authProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const invitation = await ctx.db.query.OrganizationsInvitations.findFirst({ + where(t, { eq }) { + return eq(t.id, input.invitationId) + }, + }) + + if (!invitation) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Invitation not found', + }) + } + + if (invitation.expiresAt < new Date()) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'This invitation has expired', + }) + } + + const existingMember = await ctx.db.query.OrganizationMembers.findFirst({ + where(t, { and, eq }) { + return and(eq(t.organizationId, invitation.organizationId), eq(t.userId, ctx.auth.session.userId)) + }, + }) + + if (existingMember) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'You are already a member of this organization', + }) + } + + await ctx.db.transaction(async (trx) => { + await trx.insert(OrganizationMembers).values({ + organizationId: invitation.organizationId, + userId: ctx.auth.session.userId, + role: invitation.role, + }) + + await trx.delete(OrganizationsInvitations).where(eq(OrganizationsInvitations.id, invitation.id)) + }) + + await ctx.db + .update(Sessions) + .set({ + organizationId: invitation.organizationId, + }) + .where(eq(Sessions.id, ctx.auth.session.id)) + }), + remove: authProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + userId: z.string().uuid(), + }), + ) + .use(organizationAdminMiddleware) + .mutation(async ({ ctx, input }) => { + if (input.userId === ctx.auth.session.userId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You cannot remove yourself from the organization', + }) + } + + await ctx.db.transaction(async (trx) => { + await trx + .delete(Sessions) + .where(and(eq(Sessions.userId, input.userId), eq(Sessions.organizationId, input.organizationId))) + + await trx + .delete(OrganizationMembers) + .where( + and( + eq(OrganizationMembers.userId, input.userId), + eq(OrganizationMembers.organizationId, input.organizationId), + ), + ) + }) + }), +}) diff --git a/@api/trpc.ts b/@api/trpc.ts index 73617da..af472d0 100644 --- a/@api/trpc.ts +++ b/@api/trpc.ts @@ -76,8 +76,8 @@ const authMiddleware = middleware(async ({ ctx, next }) => { export const authProcedure = procedure.use(authMiddleware) export const organizationMemberMiddleware = experimental_standaloneMiddleware<{ - ctx: { auth: { session: { userId: string } }; db: Db } // defaults to 'object' if not defined - input: { organizationId: string } | { organization: { id: string } } // defaults to 'unknown' if not defined + ctx: { auth: { session: { userId: string } }; db: Db } + input: { organizationId: string } | { organization: { id: string } } }>().create(async ({ ctx, next, input }) => { const organizationId = 'organizationId' in input ? input.organizationId : input.organization.id @@ -92,3 +92,21 @@ export const organizationMemberMiddleware = experimental_standaloneMiddleware<{ return next() }) + +export const organizationAdminMiddleware = experimental_standaloneMiddleware<{ + ctx: { auth: { session: { userId: string } }; db: Db } + input: { organizationId: string } | { organization: { id: string } } +}>().create(async ({ ctx, next, input }) => { + const organizationId = 'organizationId' in input ? input.organizationId : input.organization.id + + const organizationMember = await ctx.db.query.OrganizationMembers.findFirst({ + where(t, { and, eq }) { + return and(eq(t.organizationId, organizationId), eq(t.userId, ctx.auth.session.userId), eq(t.role, 'admin')) + }, + }) + + if (!organizationMember) + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not an admin of this organization.' }) + + return next() +}) diff --git a/@ui/package.json b/@ui/package.json index 2ce35d5..86cea83 100644 --- a/@ui/package.json +++ b/@ui/package.json @@ -12,6 +12,9 @@ "@types/react": "^18.2.43" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", diff --git a/@ui/ui/alert-dialog.tsx b/@ui/ui/alert-dialog.tsx new file mode 100644 index 0000000..a3eefee --- /dev/null +++ b/@ui/ui/alert-dialog.tsx @@ -0,0 +1,105 @@ +'use client' + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from 'react' +import { buttonVariants } from '@ui/_/../ui/button' +import { cn } from '@ui/lib/utils' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/@ui/ui/avatar.tsx b/@ui/ui/avatar.tsx new file mode 100644 index 0000000..1ae2176 --- /dev/null +++ b/@ui/ui/avatar.tsx @@ -0,0 +1,39 @@ +'use client' + +import * as AvatarPrimitive from '@radix-ui/react-avatar' +import * as React from 'react' +import { cn } from '@ui/lib/utils' + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/@ui/ui/card.tsx b/@ui/ui/card.tsx new file mode 100644 index 0000000..c3bd783 --- /dev/null +++ b/@ui/ui/card.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import { cn } from '@ui/lib/utils' + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>

, +) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/@ui/ui/checkbox.tsx b/@ui/ui/checkbox.tsx new file mode 100644 index 0000000..96b75ff --- /dev/null +++ b/@ui/ui/checkbox.tsx @@ -0,0 +1,27 @@ +'use client' + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { CheckIcon } from '@radix-ui/react-icons' +import * as React from 'react' +import { cn } from '@ui/lib/utils' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/@ui/ui/mutation-status-icon.tsx b/@ui/ui/mutation-status-icon.tsx index ed1c1c9..548f171 100644 --- a/@ui/ui/mutation-status-icon.tsx +++ b/@ui/ui/mutation-status-icon.tsx @@ -11,6 +11,7 @@ export function MutationStatusIcon(props: { children?: React.ReactNode }) { const [showSuccess, setShowSuccess] = useState(false) + const [showError, setShowError] = useState(false) useLayoutEffect(() => { let timeout: number | null = null @@ -21,6 +22,13 @@ export function MutationStatusIcon(props: { }, 2_000) } + if (props.status === 'error') { + setShowError(true) + timeout = setTimeout(() => { + setShowError(false) + }, 2_000) + } + return () => { if (timeout) { clearTimeout(timeout) @@ -32,6 +40,8 @@ export function MutationStatusIcon(props: { .with('idle', () => props.children) .with('loading', () => ) .with('success', () => (showSuccess ? : props.children)) - .with('error', () => ) + .with('error', () => + showError ? : props.children, + ) .exhaustive() } diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx index ff55f08..6222bb8 100644 --- a/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx +++ b/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx @@ -8,6 +8,7 @@ import { useSearchParams } from 'next/navigation' import { useId, useRef } from 'react' import { match } from 'ts-pattern' import { z } from 'zod' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' import { Button } from '@ui/ui/button' import { GeneralError } from '@ui/ui/general-error' import { GeneralSkeleton } from '@ui/ui/general-skeleton' @@ -46,17 +47,19 @@ export function OrganizationInfosForm() {
{match(query) - .with({ status: 'loading' }, () => ) + .with({ status: 'loading' }, () => ) .with({ status: 'error' }, () => ) .with({ status: 'success' }, (query) => (
- {query.data.organization.name} + + + {query.data.organization.name[0]} +

JPG, GIF or PNG. 1MB max.

diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-member-invite-sheet.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-member-invite-sheet.tsx new file mode 100644 index 0000000..fdf81c2 --- /dev/null +++ b/@web/app/(auth)/(settings)/organization/_components/organization-member-invite-sheet.tsx @@ -0,0 +1,90 @@ +import type { ApiOutputs } from '@web/lib/api' +import { api } from '@web/lib/api' +import { useId, useRef } from 'react' +import { z } from 'zod' +import { Button } from '@ui/ui/button' +import { Checkbox } from '@ui/ui/checkbox' +import { Input } from '@ui/ui/input' +import { Label } from '@ui/ui/label' +import { MutationStatusIcon } from '@ui/ui/mutation-status-icon' +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@ui/ui/sheet' + +type Props = React.ComponentPropsWithoutRef & { + organizationId: string + onSuccess?: (result: ApiOutputs['organization']['member']['invite']) => void +} + +export function OrganizationMemberInviteSheet({ organizationId, children, onSuccess, ...props }: Props) { + const emailId = useId() + const withAdminRoleId = useId() + const closeElement = useRef(null) + + const mutation = api.organization.member.invite.useMutation({ + onSuccess(data) { + onSuccess?.(data) + closeElement.current?.click() + }, + }) + + const action = (formData: FormData) => { + const data = z + .object({ + email: z.string().email(), + withAdminRole: z + .enum(['on']) + .optional() + .transform((value) => value === 'on'), + }) + .parse(Object.fromEntries(formData)) + + mutation.mutate({ + organizationId, + email: data.email, + role: data.withAdminRole ? 'admin' : 'member', + }) + } + + return ( + + {children} + + + Invite Member to Organization + + We will send an email containing a link to join the organization to the email address you provide. + + + +
+ + +
+ +
+ +
+ +

+ Admins can do everything members can, plus manage organization settings and members. +

+
+
+ +
+ + + + +
+ +
+
+ ) +} diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx new file mode 100644 index 0000000..598ada2 --- /dev/null +++ b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx @@ -0,0 +1,136 @@ +'use client' + +import { PlusIcon } from '@radix-ui/react-icons' +import { api } from '@web/lib/api' +import { constructPublicResourceUrl, uppercaseFirstLetter } from '@web/lib/utils' +import { useSearchParams } from 'next/navigation' +import { useRef } from 'react' +import { match } from 'ts-pattern' +import { z } from 'zod' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@ui/ui/alert-dialog' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' +import { Button } from '@ui/ui/button' +import { GeneralError } from '@ui/ui/general-error' +import { GeneralSkeleton } from '@ui/ui/general-skeleton' +import { MutationStatusIcon } from '@ui/ui/mutation-status-icon' +import { SheetTrigger } from '@ui/ui/sheet' +import { OrganizationMemberInviteSheet } from './organization-member-invite-sheet' + +export function OrganizationMembers() { + const searchParams = useSearchParams() + const organizationId = z.string().uuid().parse(searchParams.get('id')) + + const query = api.organization.detail.useQuery({ + organizationId, + }) + + return ( +
+
+
+

Organization Members

+

These are the members of your organization.

+
+ +
+ {match(query) + .with({ status: 'loading' }, () => ) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, (query) => ( +
    + {query.data.organization.members.map((member) => { + return ( +
  • +
    + + + {member.user.name[0]} + +
    + + {member.user.name} + + {uppercaseFirstLetter(member.role)} + + + {member.user.email} +
    +
    + {/* TODO: not show on yourself */} + +
  • + ) + })} + +
  • + +
  • +
+ )) + .exhaustive()} +
+
+
+ ) +} + +export function MemberRemoveButton(props: { organizationId: string; userId: string }) { + const closeRef = useRef(null) + const mutation = api.organization.member.remove.useMutation({ + onSettled() { + closeRef.current?.click() + }, + }) + + const action = () => { + mutation.mutate({ + organizationId: props.organizationId, + userId: props.userId, + }) + } + + return ( + + + + + + + Are you absolutely sure? + This action will remove the member from your organization. + + + Cancel + + + + + ) +} + +export function MemberInviteButton(props: { organizationId: string }) { + return ( + + + + + + ) +} diff --git a/@web/app/(auth)/(settings)/organization/page.tsx b/@web/app/(auth)/(settings)/organization/page.tsx index b9dce88..3d34c30 100644 --- a/@web/app/(auth)/(settings)/organization/page.tsx +++ b/@web/app/(auth)/(settings)/organization/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { OrganizationInfosForm } from './_components/organization-infos-form' +import { OrganizationMembers } from './_components/organization-members' export const metadata: Metadata = { title: 'Organization', @@ -10,6 +11,7 @@ export default function OrganizationPage() {
+
) diff --git a/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx b/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx index 13b6b87..8b43dbd 100644 --- a/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx +++ b/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx @@ -6,6 +6,7 @@ import imageCompression from 'browser-image-compression' import { Base64 } from 'js-base64' import { useId, useRef } from 'react' import { match } from 'ts-pattern' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' import { Button } from '@ui/ui/button' import { GeneralError } from '@ui/ui/general-error' import { GeneralSkeleton } from '@ui/ui/general-skeleton' @@ -43,11 +44,13 @@ export function PersonalInfosForm() {
- {query.data.session.organizationMember.user.name} + + + {query.data.session.organizationMember.user.name[0]} +

JPG, GIF or PNG. 1MB max.

diff --git a/@web/app/(auth)/_components/navbar.tsx b/@web/app/(auth)/_components/navbar.tsx index cc24c5e..e327bd9 100644 --- a/@web/app/(auth)/_components/navbar.tsx +++ b/@web/app/(auth)/_components/navbar.tsx @@ -9,6 +9,7 @@ import { constructPublicResourceUrl, isActivePathname } from '@web/lib/utils' import Link from 'next/link' import { usePathname } from 'next/navigation' import { match } from 'ts-pattern' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' import { Button } from '@ui/ui/button' import { DropdownMenuTrigger } from '@ui/ui/dropdown-menu' import { ScrollArea } from '@ui/ui/scroll-area' @@ -84,7 +85,7 @@ function ProfileButton() { {match(sessionInfosQuery) .with({ status: 'loading' }, () => (
- +
@@ -100,11 +101,13 @@ function ProfileButton() { variant={'secondary'} >
- {query.data.session.organizationMember.organization.name} + + + {query.data.session.organizationMember.organization.name[0]} +
{query.data.session.organizationMember.organization.name} {`${ diff --git a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx new file mode 100644 index 0000000..e8a71f1 --- /dev/null +++ b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx @@ -0,0 +1,85 @@ +'use client' + +import { api } from '@web/lib/api' +import { constructPublicResourceUrl } from '@web/lib/utils' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { match } from 'ts-pattern' +import { z } from 'zod' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' +import { Button } from '@ui/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@ui/ui/card' +import { GeneralError } from '@ui/ui/general-error' +import { GeneralSkeleton } from '@ui/ui/general-skeleton' +import { MutationStatusIcon } from '@ui/ui/mutation-status-icon' + +export function InvitationCard() { + const searchParams = useSearchParams() + const invitationId = z.string().parse(searchParams.get('id')) + const query = api.organization.member.invitationInfo.useQuery({ + invitationId, + }) + + return ( +
+ {match(query) + .with({ status: 'loading' }, () => ( + + + + + + )) + .with({ status: 'error' }, () => ) + .with({ status: 'success' }, (query) => ( + + + + + {query.data.invitation.organization.name[0]} + + Join Our Organization +

{query.data.invitation.organization.name}

+
+ +

+ You have been invited to join our organization. Click the button below to accept the invitation. +

+ + +
+
+ )) + .exhaustive()} +
+ ) +} + +export function InvitationAcceptButton(props: { invitationId: string }) { + const router = useRouter() + const mutation = api.organization.member.acceptInvitation.useMutation({ + onSuccess() { + router.push('/dash') + }, + }) + + return ( + + ) +} diff --git a/@web/app/(auth)/invitation-accept/page.tsx b/@web/app/(auth)/invitation-accept/page.tsx new file mode 100644 index 0000000..5ea9060 --- /dev/null +++ b/@web/app/(auth)/invitation-accept/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next' +import { InvitationCard } from './_components/invitation-card' + +export const metadata: Metadata = { + title: 'Invitation Accept', +} + +export default function InvitationAcceptPage() { + return ( +
+ +
+ ) +} diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index 14f383d..76f4836 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -7,6 +7,7 @@ import { RESET } from 'jotai/utils' import Link from 'next/link' import { useEffect, useState } from 'react' import { match } from 'ts-pattern' +import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' import { Button } from '@ui/ui/button' import { DropdownMenu, @@ -165,13 +166,12 @@ function OrganizationListItem(props: { }} disabled={mutation.isLoading || props.disabled} > -
+
- {props.organization.name} + + + {props.organization.name[0]} +
diff --git a/package.json b/package.json index 6edfee6..bd6531d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "turbo run typecheck", "deploy:preview": "turbo run deploy:preview", "deploy:production": "turbo run deploy:production", - "ui:add": "pnpm --filter @ui/ui run ui:add" + "ui:add": "pnpm --filter @dinstack/ui run ui:add" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30a5635..05df382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,15 @@ importers: '@ui': dependencies: + '@radix-ui/react-alert-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-avatar': + specifier: ^1.0.4 + version: 1.0.4(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.0.4 + version: 1.0.4(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) @@ -1650,6 +1659,31 @@ packages: '@babel/runtime': 7.23.5 dev: false + /@radix-ui/react-alert-dialog@1.0.5(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.5 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-dialog': 1.0.5(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.43)(react@18.2.0) + '@types/react': 18.2.43 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-arrow@1.0.3(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} peerDependencies: @@ -1670,6 +1704,56 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-avatar@1.0.4(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.5 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@types/react': 18.2.43 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-checkbox@1.0.4(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.5 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.43)(react@18.2.0) + '@types/react': 18.2.43 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react@18.2.43)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: @@ -2186,6 +2270,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.43)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.5 + '@types/react': 18.2.43 + react: 18.2.0 + dev: false + /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.43)(react@18.2.0): resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} peerDependencies: