diff --git a/@api/database/migrations/0000_complete_big_bertha.sql b/@api/database/migrations/0000_complete_big_bertha.sql deleted file mode 100644 index 6d766b4..0000000 --- a/@api/database/migrations/0000_complete_big_bertha.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "pg_uuidv7"; - -CREATE TABLE IF NOT EXISTS "user_login_otps" ( - "user_id" uuid PRIMARY KEY NOT NULL, - "code" varchar(6) NOT NULL, - "expired_at" timestamp NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "users" ( - "id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL, - "name" varchar(255) NOT NULL, - "email" varchar(255) NOT NULL, - "avatar_url" varchar(2550) NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "users_email_unique" UNIQUE("email") -); ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "user_login_otps" ADD CONSTRAINT "user_login_otps_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; diff --git a/@api/database/migrations/0000_moaning_toad_men.sql b/@api/database/migrations/0000_moaning_toad_men.sql new file mode 100644 index 0000000..1f9c089 --- /dev/null +++ b/@api/database/migrations/0000_moaning_toad_men.sql @@ -0,0 +1,64 @@ +CREATE EXTENSION IF NOT EXISTS "pg_uuidv7"; + +DO $$ BEGIN + CREATE TYPE "organization_member_roles" AS ENUM('admin', 'member'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "email_otps" ( + "email" varchar(255) PRIMARY KEY NOT NULL, + "code" varchar(6) NOT NULL, + "expired_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "oauth_accounts" ( + "provider" varchar(255) NOT NULL, + "provider_user_id" varchar(255) NOT NULL, + "user_id" uuid NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT oauth_accounts_provider_provider_user_id_pk PRIMARY KEY("provider","provider_user_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organization_members" ( + "organization_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" "organization_member_roles" DEFAULT 'member' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT organization_members_organization_id_user_id_pk PRIMARY KEY("organization_id","user_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL, + "name" varchar(255) NOT NULL, + "logo_url" varchar(2550) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL, + "name" varchar(255) NOT NULL, + "email" varchar(255) NOT NULL, + "avatar_url" varchar(2550) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_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 $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/@api/database/migrations/0001_safe_stepford_cuckoos.sql b/@api/database/migrations/0001_safe_stepford_cuckoos.sql deleted file mode 100644 index 4b818ce..0000000 --- a/@api/database/migrations/0001_safe_stepford_cuckoos.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS "oauth_accounts" ( - "provider" varchar(255) NOT NULL, - "provider_user_id" varchar(255) NOT NULL, - "user_id" uuid NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT oauth_accounts_provider_provider_user_id_pk PRIMARY KEY("provider","provider_user_id") -); ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; diff --git a/@api/database/migrations/meta/0000_snapshot.json b/@api/database/migrations/meta/0000_snapshot.json index 34d8419..c9b3c89 100644 --- a/@api/database/migrations/meta/0000_snapshot.json +++ b/@api/database/migrations/meta/0000_snapshot.json @@ -1,16 +1,16 @@ { - "id": "203b607f-e416-4a49-8909-d6f918e79161", + "id": "4a71e5fc-5636-4250-8fa6-b48c533e7d1c", "prevId": "00000000-0000-0000-0000-000000000000", "version": "5", "dialect": "pg", "tables": { - "user_login_otps": { - "name": "user_login_otps", + "email_otps": { + "name": "email_otps", "schema": "", "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", + "email": { + "name": "email", + "type": "varchar(255)", "primaryKey": true, "notNull": true }, @@ -35,10 +35,45 @@ } }, "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "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": { - "user_login_otps_user_id_users_id_fk": { - "name": "user_login_otps_user_id_users_id_fk", - "tableFrom": "user_login_otps", + "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"], @@ -46,6 +81,107 @@ "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": {} + }, + "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_organization_id_user_id_pk": { + "name": "organization_members_organization_id_user_id_pk", + "columns": ["organization_id", "user_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": {} }, @@ -98,7 +234,15 @@ } } }, - "enums": {}, + "enums": { + "organization_member_roles": { + "name": "organization_member_roles", + "values": { + "admin": "admin", + "member": "member" + } + } + }, "schemas": {}, "_meta": { "schemas": {}, diff --git a/@api/database/migrations/meta/0001_snapshot.json b/@api/database/migrations/meta/0001_snapshot.json deleted file mode 100644 index dfe473c..0000000 --- a/@api/database/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "id": "7c618899-b343-4c08-a1fd-335cbfe48b93", - "prevId": "203b607f-e416-4a49-8909-d6f918e79161", - "version": "5", - "dialect": "pg", - "tables": { - "oauth_accounts": { - "name": "oauth_accounts", - "schema": "", - "columns": { - "provider": { - "name": "provider", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "provider_user_id": { - "name": "provider_user_id", - "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": {} - }, - "user_login_otps": { - "name": "user_login_otps", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "uuid", - "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": { - "user_login_otps_user_id_users_id_fk": { - "name": "user_login_otps_user_id_users_id_fk", - "tableFrom": "user_login_otps", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["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": {}, - "schemas": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - } -} diff --git a/@api/database/migrations/meta/_journal.json b/@api/database/migrations/meta/_journal.json index 1953cfd..33aaf4d 100644 --- a/@api/database/migrations/meta/_journal.json +++ b/@api/database/migrations/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "5", - "when": 1701856254732, - "tag": "0000_complete_big_bertha", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1701920418755, - "tag": "0001_safe_stepford_cuckoos", + "when": 1702191989796, + "tag": "0000_moaning_toad_men", "breakpoints": true } ] diff --git a/@api/database/schema.ts b/@api/database/schema.ts index d2eb7e2..b49b53e 100644 --- a/@api/database/schema.ts +++ b/@api/database/schema.ts @@ -1,5 +1,5 @@ import { relations, sql } from 'drizzle-orm' -import { pgTable, varchar, timestamp, uuid, primaryKey } from 'drizzle-orm/pg-core' +import { pgTable, varchar, timestamp, uuid, primaryKey, pgEnum } from 'drizzle-orm/pg-core' export const Users = pgTable('users', { id: uuid('id') @@ -11,12 +11,9 @@ export const Users = pgTable('users', { createdAt: timestamp('created_at').defaultNow().notNull(), }) -export const UserRelations = relations(Users, ({ one, many }) => ({ - OauthAccountRelations: many(OauthAccounts), - loginOtp: one(UserLoginOtps, { - fields: [Users.id], - references: [UserLoginOtps.userId], - }), +export const UserRelations = relations(Users, ({ many }) => ({ + oauthAccounts: many(OauthAccounts), + organizationMembers: many(OrganizationMembers), })) export const OauthAccounts = pgTable( @@ -34,17 +31,16 @@ export const OauthAccounts = pgTable( }), ) -export const OauthAccountRelations = relations(OauthAccounts, ({ one }) => ({ +export const OauthAccountRelations = relations(OauthAccounts, ({ one, many }) => ({ user: one(Users, { fields: [OauthAccounts.userId], references: [Users.id], }), + organizationMembers: many(OrganizationMembers), })) -export const UserLoginOtps = pgTable('user_login_otps', { - userId: uuid('user_id') - .primaryKey() - .references(() => Users.id), +export const EmailOtps = pgTable('email_otps', { + email: varchar('email', { length: 255 }).notNull().primaryKey(), code: varchar('code', { length: 6 }).notNull(), expiresAt: timestamp('expired_at') .notNull() @@ -52,9 +48,49 @@ export const UserLoginOtps = pgTable('user_login_otps', { createdAt: timestamp('created_at').defaultNow().notNull(), }) -export const UserLoginOtpRelations = relations(UserLoginOtps, ({ one }) => ({ +export const Organizations = pgTable('organizations', { + id: uuid('id') + .primaryKey() + .default(sql`uuid_generate_v7()`), + name: varchar('name', { length: 255 }).notNull(), + logoUrl: varchar('logo_url', { length: 2550 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}) + +export const OrganizationRelations = relations(Organizations, ({ many }) => ({ + members: many(OrganizationMembers), +})) + +export const organizationMembersRoles = pgEnum('organization_member_roles', ['admin', 'member']) + +export const OrganizationMembers = pgTable( + 'organization_members', + { + organizationId: uuid('organization_id') + .notNull() + .references(() => Organizations.id), + userId: uuid('user_id') + .notNull() + .references(() => Users.id), + role: organizationMembersRoles('role').notNull().default('member'), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.organizationId, t.userId] }), + }), +) + +export const OrganizationMemberRelations = relations(OrganizationMembers, ({ one }) => ({ + organization: one(Organizations, { + fields: [OrganizationMembers.organizationId], + references: [Organizations.id], + }), user: one(Users, { - fields: [UserLoginOtps.userId], + fields: [OrganizationMembers.userId], references: [Users.id], }), + _oauthAccount: one(OauthAccounts, { + fields: [OrganizationMembers.userId], + references: [OauthAccounts.userId], + }), })) diff --git a/@api/lib/auth.ts b/@api/lib/auth.ts index bc8e57a..0803bfb 100644 --- a/@api/lib/auth.ts +++ b/@api/lib/auth.ts @@ -1,3 +1,4 @@ +import { organizationMembersRoles } from '@api/database/schema' import type { Env } from '@api/env' import { GitHub, Google } from 'arctic' import { TimeSpan } from 'oslo' @@ -10,13 +11,17 @@ export const authJwtPayloadSchema = z.object({ user: z.object({ id: z.string().uuid(), }), + organizationMember: z.object({ + role: z.enum(organizationMembersRoles.enumValues), + organizationId: z.string().uuid(), + }), }) export function createCreateAuthJwtFn({ env }: { env: Env }) { return async (payload: z.infer) => { const key = new TextEncoder().encode(env.AUTH_SECRET) - return createJWT(AUTH_JWT_ALGORITHM, key, payload, { + return createJWT(AUTH_JWT_ALGORITHM, key, authJwtPayloadSchema.parse(payload), { expiresIn: new TimeSpan(30, 'd'), includeIssuedTimestamp: true, }) diff --git a/@api/lib/db.ts b/@api/lib/db.ts index cf04aaa..9d26db0 100644 --- a/@api/lib/db.ts +++ b/@api/lib/db.ts @@ -1,4 +1,5 @@ import { neon, neonConfig } from '@neondatabase/serverless' +import { TRPCError } from '@trpc/server' import { drizzle as drizzleNeon } from 'drizzle-orm/neon-http' import { withReplicas } from 'drizzle-orm/pg-core' import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js' @@ -18,3 +19,86 @@ export function createDb({ env }: { env: Env }) { // @ts-expect-error TODO: fix type for withReplicas return withReplicas(write, [read]) } + +export type Db = ReturnType + +export async function createUser(ctx: { + db: Db + user: { + email: string + avatarUrl: string + name: string + } + oauth?: { + provider: (typeof schema.OauthAccounts.$inferInsert)['provider'] + providerUserId: string + } +}) { + const lowerCaseEmail = ctx.user.email.toLocaleLowerCase() + + return await ctx.db.transaction(async (trx) => { + const [user] = await trx + .insert(schema.Users) + .values({ + email: lowerCaseEmail, + name: ctx.user.name, + avatarUrl: ctx.user.avatarUrl, + }) + .returning() + + if (!user) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create user', + }) + } + + const [organization] = await ctx.db + .insert(schema.Organizations) + .values({ + name: `${user.name}'s Organization`, + logoUrl: ctx.user.avatarUrl, + }) + .returning() + + if (!organization) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create organization', + }) + } + + const [organizationMember] = await trx + .insert(schema.OrganizationMembers) + .values({ + organizationId: organization.id, + userId: user.id, + role: 'admin', + }) + .returning() + + if (!organizationMember) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create organization member', + }) + } + + if (ctx.oauth) { + await trx.insert(schema.OauthAccounts).values({ + provider: ctx.oauth.provider, + providerUserId: ctx.oauth.providerUserId, + userId: user.id, + }) + } + + return { + user, + organization: organization, + organizationMember: { + ...organizationMember, + organization, + }, + } + }) +} diff --git a/@api/lib/utils.ts b/@api/lib/utils.ts index fe2689e..67dbcea 100644 --- a/@api/lib/utils.ts +++ b/@api/lib/utils.ts @@ -6,3 +6,7 @@ export function generateFallbackAvatarUrl(user: { name: string; email: string }) return avatarUrl.toString() } + +export function generateFallbackLogoUrl(organization: { name: string }) { + return `https://ui-avatars.com/api/${organization.name}/96/f4f4f5/09090b/1` +} diff --git a/@api/router.ts b/@api/router.ts index fce3082..a9cda94 100644 --- a/@api/router.ts +++ b/@api/router.ts @@ -1,10 +1,12 @@ import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server' import { authRouter } from './routes/auth' +import { organizationRouter } from './routes/organization' import { procedure, router } from './trpc' export const appRouter = router({ ping: procedure.query(() => 'pong'), auth: authRouter, + organization: organizationRouter, }) export type AppRouter = typeof appRouter diff --git a/@api/routes/auth/_lib/output.ts b/@api/routes/auth/_lib/output.ts new file mode 100644 index 0000000..9af40ea --- /dev/null +++ b/@api/routes/auth/_lib/output.ts @@ -0,0 +1,25 @@ +import { organizationMembersRoles } from '@api/database/schema' +import { z } from 'zod' + +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string().email(), + avatarUrl: z.string().url(), +}) + +// TODO: move to shared @api and @web +export const authOutputSchema = z.object({ + auth: z.object({ + user: userSchema, + organizationMember: z.object({ + role: z.enum(organizationMembersRoles.enumValues), + organization: z.object({ + id: z.string().uuid(), + name: z.string(), + logoUrl: z.string().url(), + }), + }), + jwt: z.string(), + }), +}) diff --git a/@api/routes/auth/email.ts b/@api/routes/auth/email.ts index 2b47ae5..597a05c 100644 --- a/@api/routes/auth/email.ts +++ b/@api/routes/auth/email.ts @@ -1,11 +1,13 @@ -import { UserLoginOtps, Users } from '@api/database/schema' +import { EmailOtps } from '@api/database/schema' import { generateLoginEmail } from '@api/emails/login' +import { createUser } from '@api/lib/db' import { generateFallbackAvatarUrl } from '@api/lib/utils' import { procedure, router } from '@api/trpc' import { TRPCError } from '@trpc/server' import { eq } from 'drizzle-orm' import { alphabet, generateRandomString } from 'oslo/random' import { z } from 'zod' +import { authOutputSchema } from './_lib/output' export const authEmailRouter = router({ sendOtp: procedure @@ -17,45 +19,23 @@ export const authEmailRouter = router({ .mutation(async ({ ctx, input }) => { // TODO: rate limit 2 times per hour - const [user] = await ctx.db - .insert(Users) + const newOtp = generateRandomString(6, alphabet('a-z', '0-9')) + + await ctx.db + .insert(EmailOtps) .values({ + code: newOtp, email: input.email, - avatarUrl: generateFallbackAvatarUrl({ name: '', email: input.email }), - name: input.email.split('@')[0]!, }) .onConflictDoUpdate({ - target: Users.email, + target: EmailOtps.email, set: { - email: input.email, + code: newOtp, }, }) - .returning() - - if (!user) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create user', - }) - } ctx.ec.waitUntil( (async () => { - const newOtp = generateRandomString(6, alphabet('a-z', '0-9')) - await ctx.db - .insert(UserLoginOtps) - .values({ - userId: user.id, - code: newOtp, - }) - .onConflictDoUpdate({ - target: UserLoginOtps.userId, - set: { - code: newOtp, - expiresAt: new Date(Date.now() + 1000 * 60 * 5), - }, - }) - const { subject, html } = generateLoginEmail({ otp: newOtp.toUpperCase() }) await ctx.email.send({ to: [input.email], @@ -72,19 +52,17 @@ export const authEmailRouter = router({ otp: z.string().length(6).toLowerCase(), }), ) + .output(authOutputSchema) .mutation(async ({ ctx, input }) => { // TODO: rate limit 10 times per 5 minutes - const user = await ctx.db.query.Users.findFirst({ - with: { - loginOtp: true, - }, + const emailOtp = await ctx.db.query.EmailOtps.findFirst({ where(t, { eq }) { return eq(t.email, input.email) }, }) - if (!user || !user.loginOtp || user.loginOtp.code !== input.otp || user.loginOtp.expiresAt < new Date()) { + if (!emailOtp || emailOtp.code !== input.otp || emailOtp.expiresAt < new Date()) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid OTP', @@ -93,21 +71,67 @@ export const authEmailRouter = router({ ctx.ec.waitUntil( (async () => { - await ctx.db.delete(UserLoginOtps).where(eq(UserLoginOtps.userId, user.id)) + await ctx.db.delete(EmailOtps).where(eq(EmailOtps.email, input.email)) })(), ) - const jwt = await ctx.auth.createJwt({ user: { id: user.id } }) + const existingUser = await ctx.db.query.Users.findFirst({ + with: { + organizationMembers: { + with: { + organization: true, + }, + limit: 1, + }, + }, + where(t, { eq }) { + return eq(t.email, input.email) + }, + }) + + if (existingUser) { + const organizationMember = existingUser.organizationMembers[0] + + if (!organizationMember) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to find organization member', + }) + } + + return { + auth: { + user: existingUser, + organizationMember, + jwt: await ctx.auth.createJwt({ + user: existingUser, + organizationMember, + }), + }, + } + } + + const userName = input.email.split('@')[0] || 'Unknown' + const { user, organizationMember } = await createUser({ + db: ctx.db, + user: { + avatarUrl: generateFallbackAvatarUrl({ + name: userName, + email: input.email, + }), + email: input.email, + name: userName, + }, + }) return { auth: { - user: { - id: user.id, - name: user.name, - avatarUrl: user.avatarUrl, - email: user.email, - }, - jwt, + user: user, + organizationMember, + jwt: await ctx.auth.createJwt({ + user, + organizationMember, + }), }, } }), diff --git a/@api/routes/auth/github.ts b/@api/routes/auth/github.ts index e460bcc..c667ade 100644 --- a/@api/routes/auth/github.ts +++ b/@api/routes/auth/github.ts @@ -1,10 +1,12 @@ -import { OauthAccounts, Users } from '@api/database/schema' +import { Users } from '@api/database/schema' +import { createUser } from '@api/lib/db' import { procedure, router } from '@api/trpc' import { TRPCError } from '@trpc/server' import type { GitHubUser } from 'arctic' import { generateState } from 'arctic' import { eq } from 'drizzle-orm' import { z } from 'zod' +import { authOutputSchema } from './_lib/output' export const authGithubRouter = router({ loginUrl: procedure.mutation(async ({ ctx }) => { @@ -19,6 +21,7 @@ export const authGithubRouter = router({ code: z.string(), }), ) + .output(authOutputSchema) .mutation(async ({ ctx, input }) => { const tokens = await ctx.auth.github.validateAuthorizationCode(input.code) // TODO: use arctic @@ -40,6 +43,14 @@ export const authGithubRouter = router({ const githubAvatarUrl = userGithub.avatar_url const oauthAccount = await ctx.db.query.OauthAccounts.findFirst({ + with: { + organizationMembers: { + with: { + organization: true, + }, + limit: 1, + }, + }, where(t, { eq, and }) { return and(eq(t.provider, 'github'), eq(t.providerUserId, githubUserId)) }, @@ -58,6 +69,15 @@ export const authGithubRouter = router({ })(), ) + const organizationMember = oauthAccount.organizationMembers[0] + + if (!organizationMember) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to find organization member', + }) + } + return { auth: { user: { @@ -66,7 +86,13 @@ export const authGithubRouter = router({ email: githubEmail, avatarUrl: githubAvatarUrl, }, - jwt: await ctx.auth.createJwt({ user: { id: githubUserId } }), + organizationMember, + jwt: await ctx.auth.createJwt({ + user: { + id: oauthAccount.userId, + }, + organizationMember, + }), }, } } @@ -84,38 +110,27 @@ export const authGithubRouter = router({ }) } - const user = await ctx.db.transaction(async (trx) => { - const [user] = await trx - .insert(Users) - .values({ - email: githubEmail, - name: githubName, - avatarUrl: githubAvatarUrl, - }) - .returning() - - if (!user) { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create user' }) - } - - await trx.insert(OauthAccounts).values({ + const { user, organizationMember } = await createUser({ + db: ctx.db, + user: { + name: githubName, + avatarUrl: githubAvatarUrl, + email: githubEmail, + }, + oauth: { provider: 'github', providerUserId: githubUserId, - userId: user.id, - }) - - return user + }, }) return { auth: { - user: { - id: user.id, - name: user.name, - email: user.email, - avatarUrl: user.avatarUrl, - }, - jwt: await ctx.auth.createJwt({ user: { id: user.id } }), + user: user, + organizationMember, + jwt: await ctx.auth.createJwt({ + user, + organizationMember, + }), }, } }), diff --git a/@api/routes/auth/google.ts b/@api/routes/auth/google.ts index 9367413..c8ea06f 100644 --- a/@api/routes/auth/google.ts +++ b/@api/routes/auth/google.ts @@ -1,9 +1,11 @@ -import { OauthAccounts, Users } from '@api/database/schema' +import { Users } from '@api/database/schema' +import { createUser } from '@api/lib/db' import { procedure, router } from '@api/trpc' import { TRPCError } from '@trpc/server' import { generateState, generateCodeVerifier } from 'arctic' import { eq } from 'drizzle-orm' import { z } from 'zod' +import { authOutputSchema } from './_lib/output' export const authGoogleRouter = router({ loginUrl: procedure.mutation(async ({ ctx }) => { @@ -20,6 +22,7 @@ export const authGoogleRouter = router({ codeVerifier: z.string(), }), ) + .output(authOutputSchema) .mutation(async ({ ctx, input }) => { const tokens = await ctx.auth.google.validateAuthorizationCode(input.code, input.codeVerifier) const userGoogle = await ctx.auth.google.getUser(tokens.accessToken) @@ -34,6 +37,14 @@ export const authGoogleRouter = router({ const googleAvatarUrl = userGoogle.picture const oauthAccount = await ctx.db.query.OauthAccounts.findFirst({ + with: { + organizationMembers: { + with: { + organization: true, + }, + limit: 1, + }, + }, where(t, { eq, and }) { return and(eq(t.provider, 'google'), eq(t.providerUserId, googleUserId)) }, @@ -52,6 +63,11 @@ export const authGoogleRouter = router({ })(), ) + const organizationMember = oauthAccount.organizationMembers[0] + if (!organizationMember) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to find organization member' }) + } + return { auth: { user: { @@ -60,7 +76,13 @@ export const authGoogleRouter = router({ email: googleEmail, avatarUrl: googleAvatarUrl, }, - jwt: await ctx.auth.createJwt({ user: { id: oauthAccount.userId } }), + organizationMember, + jwt: await ctx.auth.createJwt({ + user: { + id: oauthAccount.userId, + }, + organizationMember, + }), }, } } @@ -78,38 +100,27 @@ export const authGoogleRouter = router({ }) } - const user = await ctx.db.transaction(async (trx) => { - const [user] = await trx - .insert(Users) - .values({ - email: googleEmail, - name: userGoogle.name, - avatarUrl: userGoogle.picture, - }) - .returning() - - if (!user) { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create user' }) - } - - await trx.insert(OauthAccounts).values({ + const { user, organizationMember } = await createUser({ + db: ctx.db, + user: { + name: googleName, + avatarUrl: googleAvatarUrl, + email: googleEmail, + }, + oauth: { provider: 'google', providerUserId: googleUserId, - userId: user.id, - }) - - return user + }, }) return { auth: { - user: { - id: user.id, - name: user.name, - email: user.email, - avatarUrl: user.avatarUrl, - }, - jwt: await ctx.auth.createJwt({ user: { id: user.id } }), + user, + organizationMember, + jwt: await ctx.auth.createJwt({ + user, + organizationMember, + }), }, } }), diff --git a/@api/routes/auth/index.ts b/@api/routes/auth/index.ts index 59a466e..d745363 100644 --- a/@api/routes/auth/index.ts +++ b/@api/routes/auth/index.ts @@ -2,9 +2,13 @@ import { router } from '@api/trpc' import { authEmailRouter } from './email' import { authGithubRouter } from './github' import { authGoogleRouter } from './google' +import { authOrganizationSwitchRoute } from './organization-switch' export const authRouter = router({ google: authGoogleRouter, email: authEmailRouter, github: authGithubRouter, + organization: router({ + switch: authOrganizationSwitchRoute, + }), }) diff --git a/@api/routes/auth/organization-switch.ts b/@api/routes/auth/organization-switch.ts new file mode 100644 index 0000000..33f8839 --- /dev/null +++ b/@api/routes/auth/organization-switch.ts @@ -0,0 +1,43 @@ +import { authedProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { authOutputSchema } from './_lib/output' + +export const authOrganizationSwitchRoute = authedProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + }), + ) + .output(authOutputSchema) + .mutation(async ({ ctx, input }) => { + // TODO: use session id for issue new jwt, does not use jwt to issue new jwt + + const organizationMember = await ctx.db.query.OrganizationMembers.findFirst({ + with: { + user: true, + organization: true, + }, + where(t, { and, eq }) { + return and(eq(t.organizationId, input.organizationId), eq(t.userId, ctx.auth.user.id)) + }, + }) + + if (!organizationMember) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this organization', + }) + } + + return { + auth: { + user: organizationMember.user, + organizationMember, + jwt: await ctx.auth.createJwt({ + user: organizationMember.user, + organizationMember, + }), + }, + } + }) diff --git a/@api/routes/organization/create.ts b/@api/routes/organization/create.ts new file mode 100644 index 0000000..586a6d6 --- /dev/null +++ b/@api/routes/organization/create.ts @@ -0,0 +1,55 @@ +import { OrganizationMembers, Organizations } from '@api/database/schema' +import { generateFallbackLogoUrl } from '@api/lib/utils' +import { authedProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const organizationCreateRoute = authedProcedure + .input( + z.object({ + organization: z.object({ + name: z.string(), + }), + }), + ) + .mutation(async ({ ctx, input }) => { + // TODO: limit + + const { organization, organizationMember } = await ctx.db.transaction(async (trx) => { + const [organization] = await trx + .insert(Organizations) + .values({ + name: input.organization.name, + logoUrl: generateFallbackLogoUrl({ name: input.organization.name }), + }) + .returning() + + if (!organization) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create organization', + }) + } + + const [organizationMember] = await trx + .insert(OrganizationMembers) + .values({ + organizationId: organization.id, + userId: ctx.auth.user.id, + role: 'admin', + }) + .returning() + + return { + organization, + organizationMember, + } + }) + + return { + organization: { + ...organization, + members: [organizationMember], + }, + } + }) diff --git a/@api/routes/organization/detail.ts b/@api/routes/organization/detail.ts new file mode 100644 index 0000000..74f212c --- /dev/null +++ b/@api/routes/organization/detail.ts @@ -0,0 +1,42 @@ +import { authedProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const organizationDetailRoute = authedProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + }), + ) + .query(async ({ ctx, input }) => { + const organization = await ctx.db.query.Organizations.findFirst({ + with: { + members: { + with: { + user: true, + }, + }, + }, + where(t, { eq }) { + return eq(t.id, input.organizationId) + }, + }) + + if (!organization) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Organization not found', + }) + } + + if (organization.members.find((member) => member.userId === ctx.auth.user.id) === undefined) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this organization', + }) + } + + return { + organization, + } + }) diff --git a/@api/routes/organization/index.ts b/@api/routes/organization/index.ts new file mode 100644 index 0000000..b83cb24 --- /dev/null +++ b/@api/routes/organization/index.ts @@ -0,0 +1,10 @@ +import { router } from '@api/trpc' +import { organizationCreateRoute } from './create' +import { organizationDetailRoute } from './detail' +import { organizationListRoute } from './list' + +export const organizationRouter = router({ + list: organizationListRoute, + detail: organizationDetailRoute, + create: organizationCreateRoute, +}) diff --git a/@api/routes/organization/list.ts b/@api/routes/organization/list.ts new file mode 100644 index 0000000..1ece78c --- /dev/null +++ b/@api/routes/organization/list.ts @@ -0,0 +1,38 @@ +import { OrganizationMembers } from '@api/database/schema' +import { authedProcedure } from '@api/trpc' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' + +export const organizationListRoute = authedProcedure + .input( + z.object({ + limit: z.number().int().positive().max(20).default(10), + cursor: z.number().int().nonnegative().default(0), + }), + ) + .query(async ({ ctx, input }) => { + const items = await ctx.db.query.Organizations.findMany({ + with: { + members: { + with: { + user: true, + }, + }, + }, + where(t, { exists }) { + return exists( + ctx.db + .select() + .from(OrganizationMembers) + .where(and(eq(t.id, OrganizationMembers.organizationId), eq(OrganizationMembers.userId, ctx.auth.user.id))), + ) + }, + offset: input.cursor, + limit: input.limit, + }) + + return { + items, + nextCursor: items.length < input.limit ? null : input.cursor + input.limit, + } + }) diff --git a/@api/trpc.ts b/@api/trpc.ts index 5fa3d10..0b63d67 100644 --- a/@api/trpc.ts +++ b/@api/trpc.ts @@ -11,22 +11,22 @@ export const router = t.router export const procedure = t.procedure -export const authedProcedure = procedure.use( - middleware(async ({ ctx, next }) => { - const bearer = ctx.request.headers.get('Authorization') - if (!bearer) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) - const token = bearer.replace(/^Bearer /, '') - const authData = await ctx.auth.validateJwt(token) - if (!authData) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) +const authedMiddleware = middleware(async ({ ctx, next }) => { + const bearer = ctx.request.headers.get('Authorization') + if (!bearer) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) + const token = bearer.replace(/^Bearer /, '') + const authData = await ctx.auth.validateJwt(token) + if (!authData) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) - return next({ - ctx: { - ...ctx, - auth: { - ...ctx.auth, - user: authData.user, - }, + return next({ + ctx: { + ...ctx, + auth: { + ...ctx.auth, + ...authData, }, - }) - }), -) + }, + }) +}) + +export const authedProcedure = procedure.use(authedMiddleware) diff --git a/@ui/hooks/use-local-storage.ts b/@ui/hooks/use-local-storage.ts new file mode 100644 index 0000000..8566e36 --- /dev/null +++ b/@ui/hooks/use-local-storage.ts @@ -0,0 +1,22 @@ +'use client' + +import { useState, useLayoutEffect } from 'react' +import SuperJSON from 'superjson' + +export function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { + const [storedValue, setStoredValue] = useState(initialValue) + + useLayoutEffect(() => { + const item = window.localStorage.getItem(key) + if (item) { + setStoredValue(SuperJSON.parse(item)) + } + }, [key]) + + function setValue(value: T) { + setStoredValue(value) + window.localStorage.setItem(key, SuperJSON.stringify(value)) + } + + return [storedValue, setValue] +} diff --git a/@ui/package.json b/@ui/package.json index 601624c..13fca5a 100644 --- a/@ui/package.json +++ b/@ui/package.json @@ -21,6 +21,10 @@ "import": "./build/lib/*.js", "types": "./build/lib/*.d.ts" }, + "./hooks/*": { + "import": "./build/hooks/*.js", + "types": "./build/hooks/*.d.ts" + }, "./configs/tailwind.config": "./configs/tailwind.config.ts", "./styles/globals.css": "./styles/globals.css" }, @@ -43,6 +47,8 @@ "@tailwindcss/container-queries": "^0.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "react-in-viewport": "1.0.0-alpha.30", + "superjson": "^2.2.1", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7" } diff --git a/@ui/ui/viewport-block.tsx b/@ui/ui/viewport-block.tsx new file mode 100644 index 0000000..3ef0e94 --- /dev/null +++ b/@ui/ui/viewport-block.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { ComponentPropsWithoutRef } from 'react' +import { type InjectedViewportProps, handleViewport } from 'react-in-viewport' + +const Block = ({ + inViewport, + forwardedRef, + enterCount, + leaveCount, + ...props +}: InjectedViewportProps) => { + return
+} + +const Viewport = handleViewport(Block) + +type Props = ComponentPropsWithoutRef<'div'> & { + onEnterViewport?: () => void + onLeaveViewport?: () => void + children?: React.ReactNode +} + +export const ViewportBlock = (props: Props) => { + return +} diff --git a/@web/app/(authed)/_components/navbar.tsx b/@web/app/(authed)/_components/navbar.tsx index aabf88e..1cd99c7 100644 --- a/@web/app/(authed)/_components/navbar.tsx +++ b/@web/app/(authed)/_components/navbar.tsx @@ -1,12 +1,17 @@ 'use client' import { Button } from '@dinstack/ui/button' +import { DropdownMenuTrigger } from '@dinstack/ui/dropdown-menu' import { ScrollArea } from '@dinstack/ui/scroll-area' import { Skeleton } from '@dinstack/ui/skeleton' -import { DashboardIcon } from '@radix-ui/react-icons' +import { CaretDownIcon, DashboardIcon } from '@radix-ui/react-icons' +import { ProfileDropdownMenu } from '@web/components/profile-dropdown-menu' import { ThemeToggle } from '@web/components/theme-toggle' +import { api } from '@web/lib/api' +import { useAuthedStore } from '@web/stores/auth' import Link from 'next/link' import { usePathname } from 'next/navigation' +import { match } from 'ts-pattern' type Props = { onNavigate?: () => void @@ -57,14 +62,55 @@ export function Navbar(props: Props) {
-
+
- + + +
) } + +function ProfileButton() { + const auth = useAuthedStore() + + const query = api.organization.detail.useQuery({ + organizationId: auth.organizationMember.organization.id, + }) + + const orgName = query.data?.organization.name ?? auth.organizationMember.organization.name + const orgLogoUrl = query.data?.organization.logoUrl ?? auth.organizationMember.organization.logoUrl + + return ( + + + + ) +} diff --git a/@web/app/(authed)/dash/page.tsx b/@web/app/(authed)/dash/page.tsx index 0638503..3ed6af6 100644 --- a/@web/app/(authed)/dash/page.tsx +++ b/@web/app/(authed)/dash/page.tsx @@ -5,7 +5,7 @@ import { ScrollArea } from '@dinstack/ui/scroll-area' export default function Page() { return ( -
Dash
+
Dash
) diff --git a/@web/app/(authed)/dash2/page.tsx b/@web/app/(authed)/dash2/page.tsx index 0c53f12..3534ab7 100644 --- a/@web/app/(authed)/dash2/page.tsx +++ b/@web/app/(authed)/dash2/page.tsx @@ -5,7 +5,7 @@ import { ScrollArea } from '@dinstack/ui/scroll-area' export default function Page() { return ( -
Dash2
+
Dash2
) diff --git a/@web/app/(authed)/layout.tsx b/@web/app/(authed)/layout.tsx index 77e7132..1ff88f3 100644 --- a/@web/app/(authed)/layout.tsx +++ b/@web/app/(authed)/layout.tsx @@ -1,8 +1,8 @@ 'use client' import { Button } from '@dinstack/ui/button' -import { cn } from '@dinstack/ui/lib/utils' -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@dinstack/ui/sheet' +import { useLocalStorage } from '@dinstack/ui/hooks/use-local-storage' +import { Sheet, SheetContent, SheetTrigger } from '@dinstack/ui/sheet' import { Skeleton } from '@dinstack/ui/skeleton' import { CaretLeftIcon, CaretRightIcon, HamburgerMenuIcon } from '@radix-ui/react-icons' import { motion } from 'framer-motion' @@ -27,9 +27,9 @@ function SmallScreenNavbar() { const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false) return ( -
+
- + @@ -46,10 +46,10 @@ function SmallScreenNavbar() { } function LargeScreenNavbar() { - const [sidebarSize, setSidebarSize] = useState<'default' | 'icon'>('default') + const [sidebarSize, setSidebarSize] = useLocalStorage<'default' | 'icon'>('sidebar-size', 'default') return ( -
+
new QueryClient({ queryCache: new QueryCache({ onError(err) { if (err instanceof TRPCClientError && err.data?.code === 'UNAUTHORIZED') { - auth.reset() + useAuthStore.getState().reset() } }, }), @@ -29,7 +28,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { const message = err.message if (code === 'UNAUTHORIZED') { - auth.reset() + useAuthStore.getState().reset() } if (message !== code && code !== 'INTERNAL_SERVER_ERROR') { @@ -47,29 +46,27 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { }, }), }), - [auth, toast], ) - const trpcClient = useMemo( - () => - api.createClient({ - transformer: SuperJSON, - links: [ - httpBatchLink({ - url: new URL('/trpc', env.NEXT_PUBLIC_API_URL).toString(), - async headers() { - const headers: Record = {} + const [trpcClient] = useState(() => + api.createClient({ + transformer: SuperJSON, + links: [ + httpBatchLink({ + url: new URL('/trpc', env.NEXT_PUBLIC_API_URL).toString(), + async headers() { + const auth = useAuthStore.getState() + const headers: Record = {} - if (auth.user) { - headers['Authorization'] = `Bearer ${auth.jwt}` - } + if (auth.user) { + headers['Authorization'] = `Bearer ${auth.jwt}` + } - return headers - }, - }), - ], - }), - [auth], + return headers + }, + }), + ], + }), ) return ( diff --git a/@web/app/_providers/stores.tsx b/@web/app/_providers/stores.tsx index 9620c35..7079d37 100644 --- a/@web/app/_providers/stores.tsx +++ b/@web/app/_providers/stores.tsx @@ -2,10 +2,10 @@ import { useAuthStore } from '@web/stores/auth' import { useHistoryStore } from '@web/stores/history' -import { useEffect } from 'react' +import { useMemo } from 'react' export function StoresProvider({ children }: { children: React.ReactNode }) { - useEffect(() => { + useMemo(() => { useAuthStore.persist.rehydrate() useHistoryStore.persist.rehydrate() }, []) diff --git a/@web/components/organization-create-sheet.tsx b/@web/components/organization-create-sheet.tsx new file mode 100644 index 0000000..fa4a2e8 --- /dev/null +++ b/@web/components/organization-create-sheet.tsx @@ -0,0 +1,68 @@ +import { Button } from '@dinstack/ui/button' +import { Input } from '@dinstack/ui/input' +import { Label } from '@dinstack/ui/label' +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@dinstack/ui/sheet' +import { ReloadIcon } from '@radix-ui/react-icons' +import type { ApiOutputs } from '@web/lib/api' +import { api } from '@web/lib/api' +import { useId, useRef } from 'react' + +type Props = React.ComponentPropsWithoutRef & { + onSuccess?: (result: ApiOutputs['organization']['create']) => void +} + +export function OrganizationCreateSheet({ children, onSuccess, ...props }: Props) { + const nameId = useId() + const closeElement = useRef(null) + + const { mutate, isLoading } = api.organization.create.useMutation({ + onSuccess(data) { + onSuccess?.(data) + closeElement.current?.click() + }, + }) + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + const name = (e.currentTarget.elements.namedItem('name') as HTMLInputElement).value + + mutate({ + organization: { + name, + }, + }) + } + + return ( + + {children} + + + Create organization + + Create a new organization to collaborate with others. You can invite others to your organization later. + + +
+
+ + +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx new file mode 100644 index 0000000..5554fe3 --- /dev/null +++ b/@web/components/profile-dropdown-menu.tsx @@ -0,0 +1,253 @@ +import { Button } from '@dinstack/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@dinstack/ui/dropdown-menu' +import { ScrollArea } from '@dinstack/ui/scroll-area' +import { SheetTrigger } from '@dinstack/ui/sheet' +import { Skeleton } from '@dinstack/ui/skeleton' +import { ViewportBlock } from '@dinstack/ui/viewport-block' +import { ReloadIcon } from '@radix-ui/react-icons' +import { api } from '@web/lib/api' +import { useAuthedStore } from '@web/stores/auth' +import { useEffect, useState } from 'react' +import { match } from 'ts-pattern' +import { OrganizationCreateSheet } from './organization-create-sheet' + +type Props = React.ComponentPropsWithoutRef + +export function ProfileDropdownMenu({ children, open = false, onOpenChange, ...props }: Props) { + const [_open, _setOpen] = useState(open) + + useEffect(() => { + _setOpen(open) + }, [open]) + + const _onOpenChange = (v: boolean) => { + _setOpen(v) + onOpenChange?.(v) + } + + return ( + + {children} + + My Account + + {/* TODO: implement */} + + + Profile + ⇧⌘P + + + Billing + ⌘B + + + Settings + ⌘S + + + Keyboard shortcuts + ⌘K + + + + + + + + + ) +} + +function WorkspaceList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) { + const auth = useAuthedStore() + const detailQuery = api.organization.detail.useQuery({ + organizationId: auth.organizationMember.organization.id, + }) + const currentOrgName = detailQuery.data?.organization.name ?? auth.organizationMember.organization.name + const currentOrgLogoUrl = detailQuery.data?.organization.logoUrl ?? auth.organizationMember.organization.logoUrl + + const listQuery = api.organization.list.useInfiniteQuery( + { + limit: 6, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ) + + return ( + + 4 ? '200px' : 'auto', + }} + > +
+ onOpenChange(false)} + /> + + {match(listQuery) + .with({ status: 'loading' }, () => ) + .with({ status: 'error' }, () => '') + .with({ status: 'success' }, (query) => { + return query.data.pages.map((page) => { + return ( + <> + {page.items + .filter((item) => item.id !== auth.organizationMember.organization.id) + .map((item) => { + return ( + onOpenChange(false)} + /> + ) + })} + {!query.isFetching && query.hasNextPage && ( + query.fetchNextPage()} /> + )} + {query.hasNextPage && } + + ) + }) + }) + .exhaustive()} +
+
+
+ ) +} + +function WorkspaceListItemSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +function WorkspaceListItem(props: { + organization: { + id: string + name: string + logoUrl: string + numberMembers: { + number?: number + status: 'loading' | 'error' | 'success' + } + } + onSuccess?: () => void + disabled?: boolean +}) { + const auth = useAuthedStore() + const utils = api.useUtils() + const mutation = api.auth.organization.switch.useMutation({ + onSuccess(data) { + auth.setAuth(data.auth) + utils.invalidate() + props.onSuccess?.() + }, + }) + + return ( +
+ + + {/* TODO: implement */} + + + + + Email + Message + + More... + + + +
+ ) +} + +function LogoutDropdownMenuItem() { + const auth = useAuthedStore() + return auth.reset()}>Log out +} + +function CreateOrganizationDropdownMenuItem() { + return ( + + + + + + ) +} diff --git a/@web/components/theme-toggle.tsx b/@web/components/theme-toggle.tsx index 2cbc471..7c1df2a 100644 --- a/@web/components/theme-toggle.tsx +++ b/@web/components/theme-toggle.tsx @@ -11,7 +11,7 @@ export function ThemeToggle() { return ( -