From 3fc0e137ee62aa3ea6d69de1c4657d2fc01c1ebe Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 09:08:59 +0700 Subject: [PATCH 01/12] Add drizzle-zod package and update dependencies --- .../migrations/0003_youthful_magneto.sql | 2 + .../migrations/meta/0003_snapshot.json | 389 ++++++++++++++++++ @api/database/migrations/meta/_journal.json | 7 + @api/database/schema.ts | 41 +- @api/package.json | 1 + @api/routes/auth/_create-session.ts | 32 ++ .../{_lib/utils.ts => _get-oauth-user.ts} | 69 +--- @api/routes/auth/_lib/output.ts | 10 - @api/routes/auth/email.send-otp.ts | 43 ++ @api/routes/auth/email.ts | 131 ------ @api/routes/auth/email.validate-otp.ts | 88 ++++ @api/routes/auth/index.ts | 20 +- @api/routes/auth/infos.ts | 2 +- @api/routes/auth/logout.ts | 2 +- @api/routes/auth/oauth.authorization-url.ts | 33 ++ @api/routes/auth/oauth.connect.ts | 59 +++ @api/routes/auth/oauth.disconnect.ts | 16 + @api/routes/auth/oauth.login.ts | 84 ++++ @api/routes/auth/oauth.ts | 163 -------- @api/routes/auth/organization-switch.ts | 10 +- @api/routes/organization/create.ts | 8 +- @api/routes/organization/detail.ts | 3 +- @api/routes/organization/index.ts | 12 +- .../organization/member.accept-invitation.ts | 68 +++ .../organization/member.invitation-info.ts | 38 ++ @api/routes/organization/member.invite.ts | 77 ++++ @api/routes/organization/member.remove.ts | 37 ++ @api/routes/organization/member.ts | 202 --------- @api/routes/organization/update.ts | 12 +- @api/trpc.ts | 9 +- @web/components/login-screen.tsx | 2 +- pnpm-lock.yaml | 13 + 32 files changed, 1076 insertions(+), 607 deletions(-) create mode 100644 @api/database/migrations/0003_youthful_magneto.sql create mode 100644 @api/database/migrations/meta/0003_snapshot.json create mode 100644 @api/routes/auth/_create-session.ts rename @api/routes/auth/{_lib/utils.ts => _get-oauth-user.ts} (55%) delete mode 100644 @api/routes/auth/_lib/output.ts create mode 100644 @api/routes/auth/email.send-otp.ts delete mode 100644 @api/routes/auth/email.ts create mode 100644 @api/routes/auth/email.validate-otp.ts create mode 100644 @api/routes/auth/oauth.authorization-url.ts create mode 100644 @api/routes/auth/oauth.connect.ts create mode 100644 @api/routes/auth/oauth.disconnect.ts create mode 100644 @api/routes/auth/oauth.login.ts delete mode 100644 @api/routes/auth/oauth.ts create mode 100644 @api/routes/organization/member.accept-invitation.ts create mode 100644 @api/routes/organization/member.invitation-info.ts create mode 100644 @api/routes/organization/member.invite.ts create mode 100644 @api/routes/organization/member.remove.ts delete mode 100644 @api/routes/organization/member.ts diff --git a/@api/database/migrations/0003_youthful_magneto.sql b/@api/database/migrations/0003_youthful_magneto.sql new file mode 100644 index 0000000..19ebcf0 --- /dev/null +++ b/@api/database/migrations/0003_youthful_magneto.sql @@ -0,0 +1,2 @@ +ALTER TABLE "organizations_invitations" RENAME COLUMN "id" TO "secret_key";--> statement-breakpoint +ALTER TABLE "sessions" RENAME COLUMN "id" TO "secret_key"; \ No newline at end of file diff --git a/@api/database/migrations/meta/0003_snapshot.json b/@api/database/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..7d9308e --- /dev/null +++ b/@api/database/migrations/meta/0003_snapshot.json @@ -0,0 +1,389 @@ +{ + "id": "2c848cd7-0170-4675-895b-0eab4f152346", + "prevId": "4fc9b2bd-a02a-4557-9edb-189a297fc0df", + "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": { + "secret_key": { + "name": "secret_key", + "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": { + "secret_key": { + "name": "secret_key", + "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": { + "\"organizations_invitations\".\"id\"": "\"organizations_invitations\".\"secret_key\"", + "\"sessions\".\"id\"": "\"sessions\".\"secret_key\"" + } + } +} diff --git a/@api/database/migrations/meta/_journal.json b/@api/database/migrations/meta/_journal.json index 24c4f7d..c8c86b9 100644 --- a/@api/database/migrations/meta/_journal.json +++ b/@api/database/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1702870623178, "tag": "0002_medical_lilith", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1703035567950, + "tag": "0003_youthful_magneto", + "breakpoints": true } ] } diff --git a/@api/database/schema.ts b/@api/database/schema.ts index dc1473e..e0a49be 100644 --- a/@api/database/schema.ts +++ b/@api/database/schema.ts @@ -11,7 +11,9 @@ import { foreignKey, unique, } from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' import { alphabet, generateRandomString } from 'oslo/random' +import { z } from 'zod' export const Users = pgTable('users', { id: uuid('id') @@ -28,6 +30,13 @@ export const UserRelations = relations(Users, ({ many }) => ({ organizationMembers: many(OrganizationMembers), })) +export const userSchema = createSelectSchema(Users, { + email: z.string().max(255).email().toLowerCase(), +}) +export const userInsertSchema = createInsertSchema(Users, { + email: z.string().max(255).email().toLowerCase(), +}) + export const oauthAccountProviders = pgEnum('oauth_account_providers', ['github', 'google']) export const OauthAccounts = pgTable( @@ -55,6 +64,9 @@ export const OauthAccountRelations = relations(OauthAccounts, ({ one, many }) => organizationMembers: many(OrganizationMembers), })) +export const oauthAccountSchema = createSelectSchema(OauthAccounts) +export const oauthAccountInsertSchema = createInsertSchema(OauthAccounts) + export const EmailOtps = pgTable('email_otps', { email: varchar('email', { length: 255 }).notNull().primaryKey(), code: varchar('code', { length: 6 }).notNull(), @@ -62,6 +74,15 @@ export const EmailOtps = pgTable('email_otps', { createdAt: timestamp('created_at').defaultNow().notNull(), }) +export const emailOtpSchema = createSelectSchema(EmailOtps, { + email: z.string().max(255).email().toLowerCase(), + code: z.string().max(6).toLowerCase(), +}) +export const emailOtpInsertSchema = createInsertSchema(EmailOtps, { + email: z.string().max(255).email().toLowerCase(), + code: z.string().max(6).toLowerCase(), +}) + export const Organizations = pgTable('organizations', { id: uuid('id') .primaryKey() @@ -76,6 +97,9 @@ export const OrganizationRelations = relations(Organizations, ({ many }) => ({ invitations: many(OrganizationsInvitations), })) +export const organizationSchema = createSelectSchema(Organizations) +export const organizationInsertSchema = createInsertSchema(Organizations) + export const organizationMembersRoles = pgEnum('organization_member_roles', ['admin', 'member']) export const OrganizationMembers = pgTable( @@ -111,10 +135,13 @@ export const OrganizationMemberRelations = relations(OrganizationMembers, ({ one sessions: many(Sessions), })) +export const organizationMemberSchema = createSelectSchema(OrganizationMembers) +export const organizationMemberInsertSchema = createInsertSchema(OrganizationMembers) + export const Sessions = pgTable( 'sessions', { - id: char('id', { length: 64 }) + secretKey: char('secret_key', { length: 64 }) .notNull() .primaryKey() .$defaultFn(() => generateRandomString(64, alphabet('a-z', 'A-Z', '0-9'))), @@ -139,10 +166,13 @@ export const SessionRelations = relations(Sessions, ({ one }) => ({ }), })) +export const sessionSchema = createSelectSchema(Sessions) +export const sessionInsertSchema = createInsertSchema(Sessions) + export const OrganizationsInvitations = pgTable( 'organizations_invitations', { - id: char('id', { length: 64 }) + secretKey: char('secret_key', { length: 64 }) .notNull() .primaryKey() .$defaultFn(() => generateRandomString(64, alphabet('a-z', 'A-Z', '0-9'))), @@ -165,3 +195,10 @@ export const OrganizationsInvitationRelations = relations(OrganizationsInvitatio references: [Organizations.id], }), })) + +export const organizationInvitationSchema = createSelectSchema(OrganizationsInvitations, { + email: z.string().max(255).email().toLowerCase(), +}) +export const organizationsInvitationInsertSchema = createInsertSchema(OrganizationsInvitations, { + email: z.string().max(255).email().toLowerCase(), +}) diff --git a/@api/package.json b/@api/package.json index 91d13b7..0414f68 100644 --- a/@api/package.json +++ b/@api/package.json @@ -18,6 +18,7 @@ "@trpc/server": "^10.44.1", "arctic": "^0.3.6", "drizzle-orm": "^0.29.1", + "drizzle-zod": "^0.5.1", "lodash-es": "^4.17.21", "oslo": "^0.23.5", "superjson": "^2.2.1", diff --git a/@api/routes/auth/_create-session.ts b/@api/routes/auth/_create-session.ts new file mode 100644 index 0000000..6c674ce --- /dev/null +++ b/@api/routes/auth/_create-session.ts @@ -0,0 +1,32 @@ +import type { Context } from '@api/context' +import { Sessions } from '@api/database/schema' +import { TRPCError } from '@trpc/server' + +export async function createSession({ + ctx, + organizationMember, +}: { + ctx: Context & { request: Request } + organizationMember: { + organizationId: string + userId: string + } +}) { + const [session] = await ctx.db + .insert(Sessions) + .values({ + headers: Object.fromEntries(ctx.request.headers.entries()), + userId: organizationMember.userId, + organizationId: organizationMember.organizationId, + }) + .returning() + + if (!session) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create session', + }) + } + + return session +} diff --git a/@api/routes/auth/_lib/utils.ts b/@api/routes/auth/_get-oauth-user.ts similarity index 55% rename from @api/routes/auth/_lib/utils.ts rename to @api/routes/auth/_get-oauth-user.ts index 97e1650..1feb399 100644 --- a/@api/routes/auth/_lib/utils.ts +++ b/@api/routes/auth/_get-oauth-user.ts @@ -1,72 +1,9 @@ -import type { Context } from '@api/context' -import type { OauthAccounts } from '@api/database/schema' -import { Sessions } from '@api/database/schema' +import { Context } from '@api/context' +import { OauthAccounts } from '@api/database/schema' import { TRPCError } from '@trpc/server' -import type { GitHubUser } from 'arctic' -import { generateCodeVerifier, generateState } from 'arctic' +import { GitHubUser } from 'arctic' import { match } from 'ts-pattern' -export async function createSession({ - ctx, - organizationMember, -}: { - ctx: Context & { request: Request } - organizationMember: { - organizationId: string - userId: string - } -}) { - const [session] = await ctx.db - .insert(Sessions) - .values({ - headers: Object.fromEntries(ctx.request.headers.entries()), - userId: organizationMember.userId, - organizationId: organizationMember.organizationId, - }) - .returning() - - if (!session) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create session', - }) - } - - return session -} - -export async function createOauthAuthorizationUrl({ - ctx, - provider, -}: { - ctx: Context - provider: (typeof OauthAccounts.$inferSelect)['provider'] -}): Promise<{ - url: URL - state: string - codeVerifier: string -}> { - const state = generateState() - const codeVerifier = generateCodeVerifier() - - return await match(provider) - .with('github', async () => { - return { - url: await ctx.auth.github.createAuthorizationURL(state), - state, - codeVerifier, - } - }) - .with('google', async () => { - return { - url: await ctx.auth.google.createAuthorizationURL(state, codeVerifier), - state, - codeVerifier, - } - }) - .exhaustive() -} - export async function getOauthUser({ ctx, provider, diff --git a/@api/routes/auth/_lib/output.ts b/@api/routes/auth/_lib/output.ts deleted file mode 100644 index 5bb6108..0000000 --- a/@api/routes/auth/_lib/output.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from 'zod' - -// TODO: move to shared @api and @web -export const authOutputSchema = z.object({ - auth: z.object({ - session: z.object({ - id: z.string(), - }), - }), -}) diff --git a/@api/routes/auth/email.send-otp.ts b/@api/routes/auth/email.send-otp.ts new file mode 100644 index 0000000..8104475 --- /dev/null +++ b/@api/routes/auth/email.send-otp.ts @@ -0,0 +1,43 @@ +import { EmailOtps, emailOtpSchema } from '@api/database/schema' +import { generateLoginEmail } from '@api/emails/login' +import { procedure } from '@api/trpc' +import { alphabet, generateRandomString } from 'oslo/random' +import { z } from 'zod' + +export const authEmailSendOtpRoute = procedure + .input( + z.object({ + email: emailOtpSchema.shape.email, + }), + ) + .mutation(async ({ ctx, input }) => { + // TODO: rate limit 2 times per hour + + const newOtp = generateRandomString(6, alphabet('a-z', '0-9')) + + await ctx.db + .insert(EmailOtps) + .values({ + code: newOtp, + email: input.email, + expiresAt: new Date(Date.now() + 1000 * 60 * 5), + }) + .onConflictDoUpdate({ + target: EmailOtps.email, + set: { + code: newOtp, + expiresAt: new Date(Date.now() + 1000 * 60 * 5), + }, + }) + + ctx.ec.waitUntil( + (async () => { + const { subject, html } = generateLoginEmail({ otp: newOtp.toUpperCase() }) + await ctx.email.send({ + to: [input.email], + subject, + html, + }) + })(), + ) + }) diff --git a/@api/routes/auth/email.ts b/@api/routes/auth/email.ts deleted file mode 100644 index a277d3f..0000000 --- a/@api/routes/auth/email.ts +++ /dev/null @@ -1,131 +0,0 @@ -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' -import { createSession } from './_lib/utils' - -export const authEmailRouter = router({ - sendOtp: procedure - .input( - z.object({ - email: z.string().email().toLowerCase(), - }), - ) - .mutation(async ({ ctx, input }) => { - // TODO: rate limit 2 times per hour - - const newOtp = generateRandomString(6, alphabet('a-z', '0-9')) - - await ctx.db - .insert(EmailOtps) - .values({ - code: newOtp, - email: input.email, - expiresAt: new Date(Date.now() + 1000 * 60 * 5), - }) - .onConflictDoUpdate({ - target: EmailOtps.email, - set: { - code: newOtp, - expiresAt: new Date(Date.now() + 1000 * 60 * 5), - }, - }) - - ctx.ec.waitUntil( - (async () => { - const { subject, html } = generateLoginEmail({ otp: newOtp.toUpperCase() }) - await ctx.email.send({ - to: [input.email], - subject, - html, - }) - })(), - ) - }), - validateOtp: procedure - .input( - z.object({ - email: z.string().email().toLowerCase(), - otp: z.string().length(6).toLowerCase(), - }), - ) - .output(authOutputSchema) - .mutation(async ({ ctx, input }) => { - // TODO: rate limit 10 times per 5 minutes - - const emailOtp = await ctx.db.query.EmailOtps.findFirst({ - where(t, { eq }) { - return eq(t.email, input.email) - }, - }) - - if (!emailOtp || emailOtp.code !== input.otp || emailOtp.expiresAt < new Date()) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Invalid OTP', - }) - } - - ctx.ec.waitUntil( - (async () => { - await ctx.db.delete(EmailOtps).where(eq(EmailOtps.email, input.email)) - })(), - ) - - 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: { - session: await createSession({ ctx, organizationMember }), - }, - } - } - - const userName = input.email.split('@')[0] || 'Unknown' - const { organizationMember } = await createUser({ - db: ctx.db, - user: { - avatarUrl: generateFallbackAvatarUrl({ - name: userName, - email: input.email, - }), - email: input.email, - name: userName, - }, - }) - - return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, - } - }), -}) diff --git a/@api/routes/auth/email.validate-otp.ts b/@api/routes/auth/email.validate-otp.ts new file mode 100644 index 0000000..1196e90 --- /dev/null +++ b/@api/routes/auth/email.validate-otp.ts @@ -0,0 +1,88 @@ +import { EmailOtps, emailOtpSchema } from '@api/database/schema' +import { createUser } from '@api/lib/db' +import { generateFallbackAvatarUrl } from '@api/lib/utils' +import { procedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { createSession } from './_create-session' + +export const authEmailValidateOtpRoute = procedure + .input( + z.object({ + email: emailOtpSchema.shape.email, + code: emailOtpSchema.shape.code, + }), + ) + .mutation(async ({ ctx, input }) => { + // TODO: rate limit 10 times per 5 minutes + + const emailOtp = await ctx.db.query.EmailOtps.findFirst({ + where(t, { eq }) { + return eq(t.email, input.email) + }, + }) + + if (!emailOtp || emailOtp.code !== input.code || emailOtp.expiresAt < new Date()) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid OTP', + }) + } + + ctx.ec.waitUntil( + (async () => { + await ctx.db.delete(EmailOtps).where(eq(EmailOtps.email, input.email)) + })(), + ) + + 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: { + session: await createSession({ ctx, organizationMember }), + }, + } + } + + const userName = input.email.split('@')[0] || 'Unknown' + const { organizationMember } = await createUser({ + db: ctx.db, + user: { + avatarUrl: generateFallbackAvatarUrl({ + name: userName, + email: input.email, + }), + email: input.email, + name: userName, + }, + }) + + return { + auth: { + session: await createSession({ ctx, organizationMember }), + }, + } + }) diff --git a/@api/routes/auth/index.ts b/@api/routes/auth/index.ts index 2e37c44..679ff7e 100644 --- a/@api/routes/auth/index.ts +++ b/@api/routes/auth/index.ts @@ -1,14 +1,26 @@ import { router } from '@api/trpc' -import { authEmailRouter } from './email' +import { authEmailSendOtpRoute } from './email.send-otp' +import { authEmailValidateOtpRoute } from './email.validate-otp' import { authInfosRoute } from './infos' import { authLogoutRoute } from './logout' -import { authOauthRouter } from './oauth' +import { authOauthAuthorizationUrlRoute } from './oauth.authorization-url' +import { authOauthConnectRoute } from './oauth.connect' +import { authOauthDisconnectRoute } from './oauth.disconnect' +import { authOauthLoginRoute } from './oauth.login' import { authOrganizationSwitchRoute } from './organization-switch' import { authProfileRouter } from './profile' export const authRouter = router({ - email: authEmailRouter, - oauth: authOauthRouter, + email: router({ + sendOtp: authEmailSendOtpRoute, + validateOtp: authEmailValidateOtpRoute, + }), + oauth: router({ + authorizationUrl: authOauthAuthorizationUrlRoute, + login: authOauthLoginRoute, + connect: authOauthConnectRoute, + disconnect: authOauthDisconnectRoute, + }), organization: router({ switch: authOrganizationSwitchRoute, }), diff --git a/@api/routes/auth/infos.ts b/@api/routes/auth/infos.ts index 152ed13..93b6383 100644 --- a/@api/routes/auth/infos.ts +++ b/@api/routes/auth/infos.ts @@ -4,7 +4,7 @@ import { TRPCError } from '@trpc/server' export const authInfosRoute = authProcedure.query(async ({ ctx }) => { const findSession = ctx.db.query.Sessions.findFirst({ where(t, { eq }) { - return eq(t.id, ctx.auth.session.id) + return eq(t.secretKey, ctx.auth.session.secretKey) }, with: { organizationMember: { diff --git a/@api/routes/auth/logout.ts b/@api/routes/auth/logout.ts index 75275d0..c606281 100644 --- a/@api/routes/auth/logout.ts +++ b/@api/routes/auth/logout.ts @@ -3,5 +3,5 @@ import { authProcedure } from '@api/trpc' import { eq } from 'drizzle-orm' export const authLogoutRoute = authProcedure.mutation(async ({ ctx }) => { - await ctx.db.delete(Sessions).where(eq(Sessions.id, ctx.auth.session.id)) + await ctx.db.delete(Sessions).where(eq(Sessions.secretKey, ctx.auth.session.secretKey)) }) diff --git a/@api/routes/auth/oauth.authorization-url.ts b/@api/routes/auth/oauth.authorization-url.ts new file mode 100644 index 0000000..3f8c304 --- /dev/null +++ b/@api/routes/auth/oauth.authorization-url.ts @@ -0,0 +1,33 @@ +import { oauthAccountSchema } from '@api/database/schema' +import { procedure } from '@api/trpc' +import { generateCodeVerifier, generateState } from 'arctic' +import { match } from 'ts-pattern' +import { z } from 'zod' + +export const authOauthAuthorizationUrlRoute = procedure + .input( + z.object({ + provider: oauthAccountSchema.shape.provider, + }), + ) + .mutation(async ({ ctx, input }) => { + const state = generateState() + const codeVerifier = generateCodeVerifier() + + return await match(input.provider) + .with('github', async () => { + return { + url: await ctx.auth.github.createAuthorizationURL(state), + state, + codeVerifier, + } + }) + .with('google', async () => { + return { + url: await ctx.auth.google.createAuthorizationURL(state, codeVerifier), + state, + codeVerifier, + } + }) + .exhaustive() + }) diff --git a/@api/routes/auth/oauth.connect.ts b/@api/routes/auth/oauth.connect.ts new file mode 100644 index 0000000..bebd67c --- /dev/null +++ b/@api/routes/auth/oauth.connect.ts @@ -0,0 +1,59 @@ +import { OauthAccounts, oauthAccountSchema } from '@api/database/schema' +import { uppercaseFirstLetter } from '@api/lib/utils' +import { authProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { getOauthUser } from './_get-oauth-user' + +export const authOauthConnectRoute = authProcedure + .input( + z.object({ + provider: oauthAccountSchema.shape.provider, + code: z.string(), + codeVerifier: z.string(), + state: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const oauthUser = await getOauthUser({ + ...input, + ctx, + }) + + const findOauthAccount = ctx.db.query.OauthAccounts.findFirst({ + where(t, { eq, and }) { + return and(eq(t.provider, input.provider), eq(t.providerUserId, oauthUser.id)) + }, + }) + + const findUserOauthAccount = ctx.db.query.OauthAccounts.findFirst({ + where(t, { eq, and }) { + return and(eq(t.provider, input.provider), eq(t.userId, ctx.auth.session.userId)) + }, + }) + + const [oauthAccount, userOauthAccount] = await Promise.all([findOauthAccount, findUserOauthAccount]) + + if (oauthAccount) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `This ${uppercaseFirstLetter(input.provider)} account is already linked to another user.`, + }) + } + + if (userOauthAccount) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `You have already established a connection to another ${uppercaseFirstLetter( + input.provider, + )} account.`, + }) + } + + await ctx.db.insert(OauthAccounts).values({ + provider: input.provider, + providerUserId: oauthUser.id, + userId: ctx.auth.session.userId, + identifier: oauthUser.identifier, + }) + }) diff --git a/@api/routes/auth/oauth.disconnect.ts b/@api/routes/auth/oauth.disconnect.ts new file mode 100644 index 0000000..09ab405 --- /dev/null +++ b/@api/routes/auth/oauth.disconnect.ts @@ -0,0 +1,16 @@ +import { OauthAccounts, oauthAccountSchema } from '@api/database/schema' +import { authProcedure } from '@api/trpc' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' + +export const authOauthDisconnectRoute = authProcedure + .input( + z.object({ + provider: oauthAccountSchema.shape.provider, + }), + ) + .mutation(async ({ ctx, input }) => { + await ctx.db + .delete(OauthAccounts) + .where(and(eq(OauthAccounts.provider, input.provider), eq(OauthAccounts.userId, ctx.auth.session.userId))) + }) diff --git a/@api/routes/auth/oauth.login.ts b/@api/routes/auth/oauth.login.ts new file mode 100644 index 0000000..bdf2920 --- /dev/null +++ b/@api/routes/auth/oauth.login.ts @@ -0,0 +1,84 @@ +import { oauthAccountSchema } from '@api/database/schema' +import { createUser } from '@api/lib/db' +import { uppercaseFirstLetter } from '@api/lib/utils' +import { procedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { createSession } from './_create-session' +import { getOauthUser } from './_get-oauth-user' + +export const authOauthLoginRoute = procedure + .input( + z.object({ + provider: oauthAccountSchema.shape.provider, + code: z.string(), + codeVerifier: z.string(), + state: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const oauthUser = await getOauthUser({ + ...input, + ctx, + }) + + const oauthAccount = await ctx.db.query.OauthAccounts.findFirst({ + with: { + organizationMembers: { + with: { + organization: true, + }, + limit: 1, + }, + }, + where(t, { eq, and }) { + return and(eq(t.provider, input.provider), eq(t.providerUserId, oauthUser.id)) + }, + }) + + if (oauthAccount) { + const organizationMember = oauthAccount.organizationMembers[0] + if (!organizationMember) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to find organization member' }) + } + + return { + auth: { + session: await createSession({ ctx, organizationMember }), + }, + } + } + + const userByEmail = await ctx.db.query.Users.findFirst({ + where(t, { eq }) { + return eq(t.email, oauthUser.email) + }, + }) + + if (userByEmail) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Please login with your email and link to your ${uppercaseFirstLetter(input.provider)} account first`, + }) + } + + const { organizationMember } = await createUser({ + db: ctx.db, + user: { + name: oauthUser.name, + avatarUrl: oauthUser.avatarUrl, + email: oauthUser.email, + }, + oauth: { + provider: input.provider, + providerUserId: oauthUser.id, + identifier: oauthUser.identifier, + }, + }) + + return { + auth: { + session: await createSession({ ctx, organizationMember }), + }, + } + }) diff --git a/@api/routes/auth/oauth.ts b/@api/routes/auth/oauth.ts deleted file mode 100644 index 3302892..0000000 --- a/@api/routes/auth/oauth.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { OauthAccounts, oauthAccountProviders } from '@api/database/schema' -import { createUser } from '@api/lib/db' -import { uppercaseFirstLetter } from '@api/lib/utils' -import { authProcedure, procedure, router } from '@api/trpc' -import { TRPCError } from '@trpc/server' -import { and, eq } from 'drizzle-orm' -import { z } from 'zod' -import { createOauthAuthorizationUrl, createSession, getOauthUser } from './_lib/utils' - -export const authOauthRouter = router({ - authorizationUrl: procedure - .input( - z.object({ - provider: z.enum(oauthAccountProviders.enumValues), - }), - ) - .mutation(async ({ ctx, input }) => { - return await createOauthAuthorizationUrl({ - ...input, - ctx, - }) - }), - login: procedure - .input( - z.object({ - provider: z.enum(oauthAccountProviders.enumValues), - code: z.string(), - codeVerifier: z.string(), - state: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const oauthUser = await getOauthUser({ - ...input, - ctx, - }) - - const oauthAccount = await ctx.db.query.OauthAccounts.findFirst({ - with: { - organizationMembers: { - with: { - organization: true, - }, - limit: 1, - }, - }, - where(t, { eq, and }) { - return and(eq(t.provider, input.provider), eq(t.providerUserId, oauthUser.id)) - }, - }) - - if (oauthAccount) { - const organizationMember = oauthAccount.organizationMembers[0] - if (!organizationMember) { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to find organization member' }) - } - - return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, - } - } - - const userByEmail = await ctx.db.query.Users.findFirst({ - where(t, { eq }) { - return eq(t.email, oauthUser.email) - }, - }) - - if (userByEmail) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Please login with your email and link to your ${uppercaseFirstLetter( - input.provider, - )} account first`, - }) - } - - const { organizationMember } = await createUser({ - db: ctx.db, - user: { - name: oauthUser.name, - avatarUrl: oauthUser.avatarUrl, - email: oauthUser.email, - }, - oauth: { - provider: input.provider, - providerUserId: oauthUser.id, - identifier: oauthUser.identifier, - }, - }) - - return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, - } - }), - connect: authProcedure - .input( - z.object({ - provider: z.enum(oauthAccountProviders.enumValues), - code: z.string(), - codeVerifier: z.string(), - state: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const oauthUser = await getOauthUser({ - ...input, - ctx, - }) - - const findOauthAccount = ctx.db.query.OauthAccounts.findFirst({ - where(t, { eq, and }) { - return and(eq(t.provider, input.provider), eq(t.providerUserId, oauthUser.id)) - }, - }) - - const findUserOauthAccount = ctx.db.query.OauthAccounts.findFirst({ - where(t, { eq, and }) { - return and(eq(t.provider, input.provider), eq(t.userId, ctx.auth.session.userId)) - }, - }) - - const [oauthAccount, userOauthAccount] = await Promise.all([findOauthAccount, findUserOauthAccount]) - - if (oauthAccount) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `This ${uppercaseFirstLetter(input.provider)} account is already linked to another user.`, - }) - } - - if (userOauthAccount) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `You have already established a connection to another ${uppercaseFirstLetter( - input.provider, - )} account.`, - }) - } - - await ctx.db.insert(OauthAccounts).values({ - provider: input.provider, - providerUserId: oauthUser.id, - userId: ctx.auth.session.userId, - identifier: oauthUser.identifier, - }) - }), - disconnect: authProcedure - .input( - z.object({ - provider: z.enum(oauthAccountProviders.enumValues), - }), - ) - .mutation(async ({ ctx, input }) => { - await ctx.db - .delete(OauthAccounts) - .where(and(eq(OauthAccounts.provider, input.provider), eq(OauthAccounts.userId, ctx.auth.session.userId))) - }), -}) diff --git a/@api/routes/auth/organization-switch.ts b/@api/routes/auth/organization-switch.ts index 8078cf3..31643aa 100644 --- a/@api/routes/auth/organization-switch.ts +++ b/@api/routes/auth/organization-switch.ts @@ -1,4 +1,4 @@ -import { Sessions } from '@api/database/schema' +import { Sessions, organizationSchema } from '@api/database/schema' import { authProcedure } from '@api/trpc' import { eq } from 'drizzle-orm' import { z } from 'zod' @@ -6,14 +6,12 @@ import { z } from 'zod' export const authOrganizationSwitchRoute = authProcedure .input( z.object({ - organization: z.object({ - id: z.string().uuid(), - }), + organizationId: organizationSchema.shape.id, }), ) .mutation(async ({ ctx, input }) => { await ctx.db .update(Sessions) - .set({ organizationId: input.organization.id }) - .where(eq(Sessions.id, ctx.auth.session.id)) + .set({ organizationId: input.organizationId }) + .where(eq(Sessions.secretKey, ctx.auth.session.secretKey)) }) diff --git a/@api/routes/organization/create.ts b/@api/routes/organization/create.ts index 9bf71a5..6213cfa 100644 --- a/@api/routes/organization/create.ts +++ b/@api/routes/organization/create.ts @@ -1,4 +1,4 @@ -import { OrganizationMembers, Organizations, Sessions } from '@api/database/schema' +import { OrganizationMembers, Organizations, Sessions, organizationSchema } from '@api/database/schema' import { generateFallbackLogoUrl } from '@api/lib/utils' import { authProcedure } from '@api/trpc' import { TRPCError } from '@trpc/server' @@ -8,8 +8,8 @@ import { z } from 'zod' export const organizationCreateRoute = authProcedure .input( z.object({ - organization: z.object({ - name: z.string(), + organization: organizationSchema.pick({ + name: true, }), }), ) @@ -52,7 +52,7 @@ export const organizationCreateRoute = authProcedure .set({ organizationId: organization.id, }) - .where(eq(Sessions.id, ctx.auth.session.id)) + .where(eq(Sessions.secretKey, ctx.auth.session.secretKey)) return { organization: { diff --git a/@api/routes/organization/detail.ts b/@api/routes/organization/detail.ts index 3ecb3c3..a5b3fcb 100644 --- a/@api/routes/organization/detail.ts +++ b/@api/routes/organization/detail.ts @@ -1,3 +1,4 @@ +import { organizationSchema } from '@api/database/schema' import { authProcedure } from '@api/trpc' import { TRPCError } from '@trpc/server' import { z } from 'zod' @@ -5,7 +6,7 @@ import { z } from 'zod' export const organizationDetailRoute = authProcedure .input( z.object({ - organizationId: z.string().uuid(), + organizationId: organizationSchema.shape.id, }), ) .query(async ({ ctx, input }) => { diff --git a/@api/routes/organization/index.ts b/@api/routes/organization/index.ts index 4581c66..8aef90d 100644 --- a/@api/routes/organization/index.ts +++ b/@api/routes/organization/index.ts @@ -3,7 +3,10 @@ import { organizationChangeLogoRoute } from './change-logo' import { organizationCreateRoute } from './create' import { organizationDetailRoute } from './detail' import { organizationListRoute } from './list' -import { organizationMemberRouter } from './member' +import { organizationMemberAcceptInvitationRoute } from './member.accept-invitation' +import { organizationMemberInvitationInfoRoute } from './member.invitation-info' +import { organizationMemberInviteRoute } from './member.invite' +import { organizationMemberRemoveRoute } from './member.remove' import { organizationUpdateRoute } from './update' export const organizationRouter = router({ @@ -12,5 +15,10 @@ export const organizationRouter = router({ create: organizationCreateRoute, update: organizationUpdateRoute, changeLogo: organizationChangeLogoRoute, - member: organizationMemberRouter, + member: router({ + invite: organizationMemberInviteRoute, + invitationInfo: organizationMemberInvitationInfoRoute, + acceptInvitation: organizationMemberAcceptInvitationRoute, + remove: organizationMemberRemoveRoute, + }), }) diff --git a/@api/routes/organization/member.accept-invitation.ts b/@api/routes/organization/member.accept-invitation.ts new file mode 100644 index 0000000..6bbe04d --- /dev/null +++ b/@api/routes/organization/member.accept-invitation.ts @@ -0,0 +1,68 @@ +import { + OrganizationMembers, + OrganizationsInvitations, + Sessions, + organizationInvitationSchema, +} from '@api/database/schema' +import { authProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' + +export const organizationMemberAcceptInvitationRoute = authProcedure + .input( + z.object({ + invitationSecretKey: organizationInvitationSchema.shape.secretKey, + }), + ) + .mutation(async ({ ctx, input }) => { + const invitation = await ctx.db.query.OrganizationsInvitations.findFirst({ + where(t, { eq }) { + return eq(t.secretKey, input.invitationSecretKey) + }, + }) + + 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.secretKey, invitation.secretKey)) + }) + + await ctx.db + .update(Sessions) + .set({ + organizationId: invitation.organizationId, + }) + .where(eq(Sessions.secretKey, ctx.auth.session.secretKey)) + }) diff --git a/@api/routes/organization/member.invitation-info.ts b/@api/routes/organization/member.invitation-info.ts new file mode 100644 index 0000000..91ee172 --- /dev/null +++ b/@api/routes/organization/member.invitation-info.ts @@ -0,0 +1,38 @@ +import { authProcedure } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const organizationMemberInvitationInfoRoute = 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.secretKey, 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, + } + }) diff --git a/@api/routes/organization/member.invite.ts b/@api/routes/organization/member.invite.ts new file mode 100644 index 0000000..b51bc4c --- /dev/null +++ b/@api/routes/organization/member.invite.ts @@ -0,0 +1,77 @@ +import { OrganizationsInvitations, organizationInvitationSchema } from '@api/database/schema' +import { generateOrganizationInvitationEmail } from '@api/emails/organization-invitation' +import { authProcedure, organizationAdminMiddleware } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +export const organizationMemberInviteRoute = authProcedure + .input( + z.object({ + organizationId: organizationInvitationSchema.shape.organizationId, + email: organizationInvitationSchema.shape.email, + role: organizationInvitationSchema.shape.role, + }), + ) + .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?secret-key=${invitation.secretKey}`, 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, + }) + })(), + ) + }) diff --git a/@api/routes/organization/member.remove.ts b/@api/routes/organization/member.remove.ts new file mode 100644 index 0000000..614bdbd --- /dev/null +++ b/@api/routes/organization/member.remove.ts @@ -0,0 +1,37 @@ +import { OrganizationMembers, Sessions, organizationMemberSchema, organizationSchema } from '@api/database/schema' +import { authProcedure, organizationAdminMiddleware } from '@api/trpc' +import { TRPCError } from '@trpc/server' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' + +export const organizationMemberRemoveRoute = authProcedure + .input( + z.object({ + organizationId: organizationMemberSchema.shape.organizationId, + userId: organizationMemberSchema.shape.userId, + }), + ) + .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/routes/organization/member.ts b/@api/routes/organization/member.ts deleted file mode 100644 index 7d6735e..0000000 --- a/@api/routes/organization/member.ts +++ /dev/null @@ -1,202 +0,0 @@ -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/routes/organization/update.ts b/@api/routes/organization/update.ts index 664fa36..806462e 100644 --- a/@api/routes/organization/update.ts +++ b/@api/routes/organization/update.ts @@ -1,18 +1,18 @@ -import { Organizations } from '@api/database/schema' -import { authProcedure, organizationMemberMiddleware } from '@api/trpc' +import { Organizations, organizationSchema } from '@api/database/schema' +import { authProcedure, organizationAdminMiddleware } from '@api/trpc' import { eq } from 'drizzle-orm' import { z } from 'zod' export const organizationUpdateRoute = authProcedure .input( z.object({ - organization: z.object({ - id: z.string().uuid(), - name: z.string(), + organization: organizationSchema.pick({ + id: true, + name: true, }), }), ) - .use(organizationMemberMiddleware) + .use(organizationAdminMiddleware) .mutation(async ({ ctx, input }) => { await ctx.db .update(Organizations) diff --git a/@api/trpc.ts b/@api/trpc.ts index af472d0..618b613 100644 --- a/@api/trpc.ts +++ b/@api/trpc.ts @@ -40,13 +40,8 @@ export const procedure = t.procedure.use(turnstileMiddleware) const authMiddleware = middleware(async ({ ctx, next }) => { const bearer = ctx.request.headers.get('Authorization') if (!bearer) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) - const sessionId = bearer.replace(/^Bearer /, '') + const sessionSecretKey = bearer.replace(/^Bearer /, '') const session = await ctx.db.query.Sessions.findFirst({ - columns: { - id: true, - createdAt: true, - userId: true, - }, with: { organizationMember: { columns: { @@ -57,7 +52,7 @@ const authMiddleware = middleware(async ({ ctx, next }) => { }, }, where(t, { eq }) { - return eq(t.id, sessionId) + return eq(t.secretKey, sessionSecretKey) }, }) if (!session) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }) diff --git a/@web/components/login-screen.tsx b/@web/components/login-screen.tsx index b2384d6..3632537 100644 --- a/@web/components/login-screen.tsx +++ b/@web/components/login-screen.tsx @@ -181,7 +181,7 @@ function ValidateOtpForm(props: { className="space-y-5" onSubmit={(e) => { e.preventDefault() - mutation.mutate({ otp, email: props.email }) + mutation.mutate({ code: otp, email: props.email }) }} >
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05df382..94dc056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: drizzle-orm: specifier: ^0.29.1 version: 0.29.1(@neondatabase/serverless@0.6.0)(postgres@3.4.3) + drizzle-zod: + specifier: ^0.5.1 + version: 0.5.1(drizzle-orm@0.29.1)(zod@3.22.4) lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -3243,6 +3246,16 @@ packages: postgres: 3.4.3 dev: false + /drizzle-zod@0.5.1(drizzle-orm@0.29.1)(zod@3.22.4): + resolution: {integrity: sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A==} + peerDependencies: + drizzle-orm: '>=0.23.13' + zod: '*' + dependencies: + drizzle-orm: 0.29.1(@neondatabase/serverless@0.6.0)(postgres@3.4.3) + zod: 3.22.4 + dev: false + /electron-to-chromium@1.4.609: resolution: {integrity: sha512-ihiCP7PJmjoGNuLpl7TjNA8pCQWu09vGyjlPYw1Rqww4gvNuCcmvl+44G+2QyJ6S2K4o+wbTS++Xz0YN8Q9ERw==} dev: true From 95f0cbd5a0939de7baeceebea51b044a1249ad4d Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 09:16:27 +0700 Subject: [PATCH 02/12] Add organizationInvitationSchema and sessionSchema imports Update invitationId to invitationSecretKey in InvitationCard component Update invitationId to invitationSecretKey in InvitationAcceptButton component Update auth.session.id to auth.session.secretKey in QueryProvider component Update session.id to session.secretKey in authAtom component Update organization to organizationId in OrganizationListItem component --- @api/routes/organization/member.invitation-info.ts | 5 +++-- @shared/main.ts | 0 .../invitation-accept/_components/invitation-card.tsx | 11 ++++++----- @web/app/_providers/query.tsx | 2 +- @web/atoms/auth.ts | 5 +++-- @web/components/profile-dropdown-menu.tsx | 2 +- 6 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 @shared/main.ts diff --git a/@api/routes/organization/member.invitation-info.ts b/@api/routes/organization/member.invitation-info.ts index 91ee172..52e79b8 100644 --- a/@api/routes/organization/member.invitation-info.ts +++ b/@api/routes/organization/member.invitation-info.ts @@ -1,3 +1,4 @@ +import { organizationInvitationSchema } from '@api/database/schema' import { authProcedure } from '@api/trpc' import { TRPCError } from '@trpc/server' import { z } from 'zod' @@ -5,13 +6,13 @@ import { z } from 'zod' export const organizationMemberInvitationInfoRoute = authProcedure .input( z.object({ - invitationId: z.string(), + invitationSecretKey: organizationInvitationSchema.shape.secretKey, }), ) .query(async ({ ctx, input }) => { const invitation = await ctx.db.query.OrganizationsInvitations.findFirst({ where(t, { eq }) { - return eq(t.secretKey, input.invitationId) + return eq(t.secretKey, input.invitationSecretKey) }, with: { organization: true, diff --git a/@shared/main.ts b/@shared/main.ts new file mode 100644 index 0000000..e69de29 diff --git a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx index e8a71f1..fe0e6a2 100644 --- a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx +++ b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx @@ -1,5 +1,6 @@ 'use client' +import { organizationInvitationSchema } from '@api/database/schema' import { api } from '@web/lib/api' import { constructPublicResourceUrl } from '@web/lib/utils' import Link from 'next/link' @@ -15,9 +16,9 @@ import { MutationStatusIcon } from '@ui/ui/mutation-status-icon' export function InvitationCard() { const searchParams = useSearchParams() - const invitationId = z.string().parse(searchParams.get('id')) + const invitationSecretKey = organizationInvitationSchema.shape.secretKey.parse(searchParams.get('secret-key')) const query = api.organization.member.invitationInfo.useQuery({ - invitationId, + invitationSecretKey, }) return ( @@ -48,7 +49,7 @@ export function InvitationCard() {

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

- + @@ -60,7 +61,7 @@ export function InvitationCard() { ) } -export function InvitationAcceptButton(props: { invitationId: string }) { +export function InvitationAcceptButton(props: { invitationSecretKey: string }) { const router = useRouter() const mutation = api.organization.member.acceptInvitation.useMutation({ onSuccess() { @@ -74,7 +75,7 @@ export function InvitationAcceptButton(props: { invitationId: string }) { className="w-full gap-2" onClick={() => mutation.mutate({ - invitationId: props.invitationId, + invitationSecretKey: props.invitationSecretKey, }) } > diff --git a/@web/app/_providers/query.tsx b/@web/app/_providers/query.tsx index d6f4300..6e98049 100644 --- a/@web/app/_providers/query.tsx +++ b/@web/app/_providers/query.tsx @@ -62,7 +62,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { const headers: Record = {} if (auth) { - headers['Authorization'] = `Bearer ${auth.session.id}` + headers['Authorization'] = `Bearer ${auth.session.secretKey}` } return headers diff --git a/@web/atoms/auth.ts b/@web/atoms/auth.ts index c15d884..fd9e7db 100644 --- a/@web/atoms/auth.ts +++ b/@web/atoms/auth.ts @@ -1,3 +1,4 @@ +import { sessionSchema } from '@api/database/schema' import { z } from 'zod' import { atomWithLocalStorage } from './_helpers' @@ -5,8 +6,8 @@ export const authAtom = atomWithLocalStorage( 'auth-atom', z .object({ - session: z.object({ - id: z.string(), + session: sessionSchema.pick({ + secretKey: true, }), }) .nullable(), diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index 76f4836..4f29e8c 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -161,7 +161,7 @@ function OrganizationListItem(props: { variant={'ghost'} onClick={() => { mutation.mutate({ - organization: props.organization, + organizationId: props.organization.id, }) }} disabled={mutation.isLoading || props.disabled} From 2e4b0ee038aec96b287709071a2f93e0efd7cc57 Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 09:58:29 +0700 Subject: [PATCH 03/12] Add new hooks for user and organization member Update authentication atom and components Refactor authentication routes and add session retrieval Update login screen component --- @api/routes/auth/_find-session-for-auth.ts | 35 +++++++++++++++++++ @api/routes/auth/email.validate-otp.ts | 17 +++++---- @api/routes/auth/infos.ts | 26 ++------------ @api/routes/auth/oauth.login.ts | 17 +++++---- @web/app/(auth)/_wrappers/require-auth.tsx | 6 ++-- @web/app/_providers/query.tsx | 12 +++---- .../callback/_components/callback-handler.tsx | 8 ++--- @web/atoms/auth.ts | 31 +++++++++++----- @web/components/login-screen.tsx | 6 ++-- @web/components/profile-dropdown-menu.tsx | 4 +-- @web/hooks/use-organization-member.ts | 18 ++++++++++ @web/hooks/use-user.ts | 18 ++++++++++ 12 files changed, 136 insertions(+), 62 deletions(-) create mode 100644 @api/routes/auth/_find-session-for-auth.ts create mode 100644 @web/hooks/use-organization-member.ts create mode 100644 @web/hooks/use-user.ts diff --git a/@api/routes/auth/_find-session-for-auth.ts b/@api/routes/auth/_find-session-for-auth.ts new file mode 100644 index 0000000..6498ffc --- /dev/null +++ b/@api/routes/auth/_find-session-for-auth.ts @@ -0,0 +1,35 @@ +import { Context } from '@api/context' +import { TRPCError } from '@trpc/server' + +export async function findSessionForAuth({ ctx, sessionSecretKey }: { ctx: Context; sessionSecretKey: string }) { + const session = await ctx.db.query.Sessions.findFirst({ + where(t, { eq }) { + return eq(t.secretKey, sessionSecretKey) + }, + with: { + organizationMember: { + with: { + organization: { + with: { + members: { + with: { + user: true, + }, + }, + }, + }, + user: true, + }, + }, + }, + }) + + if (!session) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to find session', + }) + } + + return session +} diff --git a/@api/routes/auth/email.validate-otp.ts b/@api/routes/auth/email.validate-otp.ts index 1196e90..111e904 100644 --- a/@api/routes/auth/email.validate-otp.ts +++ b/@api/routes/auth/email.validate-otp.ts @@ -6,6 +6,7 @@ import { TRPCError } from '@trpc/server' import { eq } from 'drizzle-orm' import { z } from 'zod' import { createSession } from './_create-session' +import { findSessionForAuth } from './_find-session-for-auth' export const authEmailValidateOtpRoute = procedure .input( @@ -60,10 +61,12 @@ export const authEmailValidateOtpRoute = procedure }) } + const sessionSecretKey = (await createSession({ ctx, organizationMember })).secretKey + + const session = await findSessionForAuth({ ctx, sessionSecretKey }) + return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, + session, } } @@ -80,9 +83,11 @@ export const authEmailValidateOtpRoute = procedure }, }) + const sessionSecretKey = (await createSession({ ctx, organizationMember })).secretKey + + const session = await findSessionForAuth({ ctx, sessionSecretKey }) + return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, + session, } }) diff --git a/@api/routes/auth/infos.ts b/@api/routes/auth/infos.ts index 93b6383..a580dc5 100644 --- a/@api/routes/auth/infos.ts +++ b/@api/routes/auth/infos.ts @@ -1,24 +1,9 @@ import { authProcedure } from '@api/trpc' import { TRPCError } from '@trpc/server' +import { findSessionForAuth } from './_find-session-for-auth' export const authInfosRoute = authProcedure.query(async ({ ctx }) => { - const findSession = ctx.db.query.Sessions.findFirst({ - where(t, { eq }) { - return eq(t.secretKey, ctx.auth.session.secretKey) - }, - with: { - organizationMember: { - with: { - organization: { - with: { - members: true, - }, - }, - user: true, - }, - }, - }, - }) + const findSession = findSessionForAuth({ ctx, sessionSecretKey: ctx.auth.session.secretKey }) const findOauthAccounts = ctx.db.query.OauthAccounts.findMany({ where(t, { eq }) { @@ -28,13 +13,6 @@ export const authInfosRoute = authProcedure.query(async ({ ctx }) => { const [session, oauthAccounts] = await Promise.all([findSession, findOauthAccounts]) - if (!session) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to find session', - }) - } - return { session, oauthAccounts, diff --git a/@api/routes/auth/oauth.login.ts b/@api/routes/auth/oauth.login.ts index bdf2920..b11528d 100644 --- a/@api/routes/auth/oauth.login.ts +++ b/@api/routes/auth/oauth.login.ts @@ -5,6 +5,7 @@ import { procedure } from '@api/trpc' import { TRPCError } from '@trpc/server' import { z } from 'zod' import { createSession } from './_create-session' +import { findSessionForAuth } from './_find-session-for-auth' import { getOauthUser } from './_get-oauth-user' export const authOauthLoginRoute = procedure @@ -42,10 +43,12 @@ export const authOauthLoginRoute = procedure throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to find organization member' }) } + const sessionSecretKey = (await createSession({ ctx, organizationMember })).secretKey + + const session = await findSessionForAuth({ ctx, sessionSecretKey }) + return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, + session, } } @@ -76,9 +79,11 @@ export const authOauthLoginRoute = procedure }, }) + const sessionSecretKey = (await createSession({ ctx, organizationMember })).secretKey + + const session = await findSessionForAuth({ ctx, sessionSecretKey }) + return { - auth: { - session: await createSession({ ctx, organizationMember }), - }, + session, } }) diff --git a/@web/app/(auth)/_wrappers/require-auth.tsx b/@web/app/(auth)/_wrappers/require-auth.tsx index 12ef579..d600760 100644 --- a/@web/app/(auth)/_wrappers/require-auth.tsx +++ b/@web/app/(auth)/_wrappers/require-auth.tsx @@ -1,19 +1,19 @@ 'use client' -import { authAtom } from '@web/atoms/auth' +import { sessionAtom } from '@web/atoms/auth' import { LoginScreen } from '@web/components/login-screen' import { useAtom } from 'jotai' import { useIsRendered } from '@ui/hooks/use-is-rendered' export function RequireAuthWrapper({ children }: { children: React.ReactNode }) { - const [auth] = useAtom(authAtom) + const [session] = useAtom(sessionAtom) const isRendered = useIsRendered() if (!isRendered) { return null } - if (!auth) { + if (!session) { return (
diff --git a/@web/app/_providers/query.tsx b/@web/app/_providers/query.tsx index 6e98049..96b3f2b 100644 --- a/@web/app/_providers/query.tsx +++ b/@web/app/_providers/query.tsx @@ -2,7 +2,7 @@ import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { TRPCClientError, httpBatchLink } from '@trpc/client' -import { authAtom } from '@web/atoms/auth' +import { sessionAtom } from '@web/atoms/auth' import { env } from '@web/env' import { api } from '@web/lib/api' import { RESET } from 'jotai/utils' @@ -20,7 +20,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { queryCache: new QueryCache({ onError(err) { if (err instanceof TRPCClientError && err.data?.code === 'UNAUTHORIZED') { - store.set(authAtom, RESET) + store.set(sessionAtom, RESET) } }, }), @@ -31,7 +31,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { const message = err.message if (code === 'UNAUTHORIZED') { - store.set(authAtom, RESET) + store.set(sessionAtom, RESET) } if (message !== code && code !== 'INTERNAL_SERVER_ERROR') { @@ -58,11 +58,11 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { httpBatchLink({ url: new URL('/trpc', env.NEXT_PUBLIC_API_URL).toString(), async headers() { - const auth = store.get(authAtom) + const session = store.get(sessionAtom) const headers: Record = {} - if (auth) { - headers['Authorization'] = `Bearer ${auth.session.secretKey}` + if (session) { + headers['Authorization'] = `Bearer ${session.secretKey}` } return headers diff --git a/@web/app/oauth/[provider]/callback/_components/callback-handler.tsx b/@web/app/oauth/[provider]/callback/_components/callback-handler.tsx index 31598f7..2c8e71e 100644 --- a/@web/app/oauth/[provider]/callback/_components/callback-handler.tsx +++ b/@web/app/oauth/[provider]/callback/_components/callback-handler.tsx @@ -1,7 +1,7 @@ 'use client' import { oauthAccountProviders } from '@api/database/schema' -import { codeVerifierAtom, authAtom, stateAtom, loginRequestFromAtom } from '@web/atoms/auth' +import { codeVerifierAtom, sessionAtom, stateAtom, loginRequestFromAtom } from '@web/atoms/auth' import { api } from '@web/lib/api' import { useAtom } from 'jotai' import { RESET } from 'jotai/utils' @@ -17,7 +17,7 @@ export function CallbackHandler() { provider: z.enum(oauthAccountProviders.enumValues), }) .parse(useParams()) - const [auth, setAuth] = useAtom(authAtom) + const [session, setSession] = useAtom(sessionAtom) const [oldState, setOldState] = useAtom(stateAtom) const [codeVerifier, setCodeVerifier] = useAtom(codeVerifierAtom) const [loginRequestFrom] = useAtom(loginRequestFromAtom) @@ -26,7 +26,7 @@ export function CallbackHandler() { const searchParams = useSearchParams() const loginMutation = api.auth.oauth.login.useMutation({ onSuccess(data) { - setAuth(data.auth) + setSession(data.session) }, onSettled() { setOldState(RESET) @@ -53,7 +53,7 @@ export function CallbackHandler() { throw new Error('This page should not be accessed directly') } - if (auth) { + if (session) { connectMutation.mutate({ provider: param.provider, state, diff --git a/@web/atoms/auth.ts b/@web/atoms/auth.ts index fd9e7db..4efc03e 100644 --- a/@web/atoms/auth.ts +++ b/@web/atoms/auth.ts @@ -1,15 +1,30 @@ -import { sessionSchema } from '@api/database/schema' +import { organizationMemberSchema, organizationSchema, sessionSchema, userSchema } from '@api/database/schema' import { z } from 'zod' import { atomWithLocalStorage } from './_helpers' -export const authAtom = atomWithLocalStorage( - 'auth-atom', - z - .object({ - session: sessionSchema.pick({ - secretKey: true, - }), +export const sessionAtom = atomWithLocalStorage( + 'session-atom', + sessionSchema + .pick({ + secretKey: true, }) + .and( + z.object({ + organizationMember: z.object({ + role: organizationMemberSchema.shape.role, + user: userSchema, + organization: organizationSchema.and( + z.object({ + members: z.array( + z.object({ + user: userSchema, + }), + ), + }), + ), + }), + }), + ) .nullable(), null, ) diff --git a/@web/components/login-screen.tsx b/@web/components/login-screen.tsx index 3632537..3766a08 100644 --- a/@web/components/login-screen.tsx +++ b/@web/components/login-screen.tsx @@ -1,7 +1,7 @@ 'use client' import { ArrowLeftIcon, ArrowRightIcon, GitHubLogoIcon } from '@radix-ui/react-icons' -import { codeVerifierAtom, authAtom, stateAtom, loginRequestFromAtom } from '@web/atoms/auth' +import { codeVerifierAtom, sessionAtom, stateAtom, loginRequestFromAtom } from '@web/atoms/auth' import { loginWithEmailHistoryAtom } from '@web/atoms/history' import type { ApiOutputs } from '@web/lib/api' import { api } from '@web/lib/api' @@ -26,7 +26,7 @@ type Props = { } export function LoginScreen(props: Props) { - const [, setAuth] = useAtom(authAtom) + const [, setAuth] = useAtom(sessionAtom) const [step, setStep] = useState<'send-otp' | 'validate-otp'>('send-otp') const [email, setEmail] = useState('') const [history, setHistory] = useAtom(loginWithEmailHistoryAtom) @@ -85,7 +85,7 @@ export function LoginScreen(props: Props) { { - setAuth(data.auth) + setAuth(data.session) setHistory(RESET) }} onBack={() => { diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index 4f29e8c..d6d7848 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -1,5 +1,5 @@ import { ExitIcon, PersonIcon, PlusIcon } from '@radix-ui/react-icons' -import { authAtom } from '@web/atoms/auth' +import { sessionAtom } from '@web/atoms/auth' import { api } from '@web/lib/api' import { constructPublicResourceUrl } from '@web/lib/utils' import { useAtom } from 'jotai' @@ -204,7 +204,7 @@ function OrganizationListItem(props: { } function LogoutDropdownMenuItem() { - const [, setAuth] = useAtom(authAtom) + const [, setAuth] = useAtom(sessionAtom) const mutation = api.auth.logout.useMutation({ onSuccess() { setAuth(RESET) diff --git a/@web/hooks/use-organization-member.ts b/@web/hooks/use-organization-member.ts new file mode 100644 index 0000000..8a604c5 --- /dev/null +++ b/@web/hooks/use-organization-member.ts @@ -0,0 +1,18 @@ +import { sessionAtom } from '@web/atoms/auth' +import { useAtom } from 'jotai' + +export function useOrganizationMember() { + const [session] = useAtom(sessionAtom) + + return session?.organizationMember +} + +export function useAuthenticatedOrganizationMember() { + const organizationMember = useOrganizationMember() + + if (!organizationMember) { + throw new Error('This page requires authentication') + } + + return organizationMember +} diff --git a/@web/hooks/use-user.ts b/@web/hooks/use-user.ts new file mode 100644 index 0000000..6ce6e95 --- /dev/null +++ b/@web/hooks/use-user.ts @@ -0,0 +1,18 @@ +import { sessionAtom } from '@web/atoms/auth' +import { useAtom } from 'jotai' + +export function useUser() { + const [session] = useAtom(sessionAtom) + + return session?.organizationMember.user +} + +export function useAuthenticatedUser() { + const user = useUser() + + if (!user) { + throw new Error('This page requires authentication') + } + + return user +} From 37bd8661abbbf34d86fa3da4725b53c3a2f51a1a Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 10:10:55 +0700 Subject: [PATCH 04/12] Refactor auth layout and components --- .../_components/organization-members.tsx | 17 +++-- .../{_components/navbar.tsx => _navbar.tsx} | 65 +++++++------------ .../require-auth.tsx => _require-auth.tsx} | 0 @web/app/(auth)/layout.tsx | 4 +- @web/components/profile-dropdown-menu.tsx | 48 +++++++++----- 5 files changed, 69 insertions(+), 65 deletions(-) rename @web/app/(auth)/{_components/navbar.tsx => _navbar.tsx} (56%) rename @web/app/(auth)/{_wrappers/require-auth.tsx => _require-auth.tsx} (100%) diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx index 598ada2..290ada5 100644 --- a/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx +++ b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx @@ -1,6 +1,7 @@ 'use client' import { PlusIcon } from '@radix-ui/react-icons' +import { useAuthenticatedUser } from '@web/hooks/use-user' import { api } from '@web/lib/api' import { constructPublicResourceUrl, uppercaseFirstLetter } from '@web/lib/utils' import { useSearchParams } from 'next/navigation' @@ -28,11 +29,14 @@ import { OrganizationMemberInviteSheet } from './organization-member-invite-shee export function OrganizationMembers() { const searchParams = useSearchParams() const organizationId = z.string().uuid().parse(searchParams.get('id')) + const user = useAuthenticatedUser() const query = api.organization.detail.useQuery({ organizationId, }) + const memberRole = query.data?.organization.members.find((member) => member.userId === user?.id)?.role + return (
@@ -65,15 +69,18 @@ export function OrganizationMembers() { {member.user.email}
- {/* TODO: not show on yourself */} - + {user.id !== member.userId && ( + + )} ) })} -
  • - -
  • + {memberRole === 'admin' && ( +
  • + +
  • + )} )) .exhaustive()} diff --git a/@web/app/(auth)/_components/navbar.tsx b/@web/app/(auth)/_navbar.tsx similarity index 56% rename from @web/app/(auth)/_components/navbar.tsx rename to @web/app/(auth)/_navbar.tsx index e327bd9..58f63d8 100644 --- a/@web/app/(auth)/_components/navbar.tsx +++ b/@web/app/(auth)/_navbar.tsx @@ -4,6 +4,8 @@ import { CaretDownIcon, DashboardIcon } from '@radix-ui/react-icons' import { LogoDropdownMenu } from '@web/components/logo-dropdown-menu' import { ProfileDropdownMenu } from '@web/components/profile-dropdown-menu' import { ThemeToggle } from '@web/components/theme-toggle' +import { useAuthenticatedOrganizationMember } from '@web/hooks/use-organization-member' +import { useAuthenticatedUser } from '@web/hooks/use-user' import { api } from '@web/lib/api' import { constructPublicResourceUrl, isActivePathname } from '@web/lib/utils' import Link from 'next/link' @@ -78,52 +80,33 @@ export function Navbar(props: Props) { } function ProfileButton() { - const sessionInfosQuery = api.auth.infos.useQuery() + const organization = useAuthenticatedOrganizationMember().organization return ( - {match(sessionInfosQuery) - .with({ status: 'loading' }, () => ( -
    - -
    - - -
    +
    -
    - -
    - - )) - .exhaustive()} +
    + +
    +
    ) } diff --git a/@web/app/(auth)/_wrappers/require-auth.tsx b/@web/app/(auth)/_require-auth.tsx similarity index 100% rename from @web/app/(auth)/_wrappers/require-auth.tsx rename to @web/app/(auth)/_require-auth.tsx diff --git a/@web/app/(auth)/layout.tsx b/@web/app/(auth)/layout.tsx index f65f656..04a9fdc 100644 --- a/@web/app/(auth)/layout.tsx +++ b/@web/app/(auth)/layout.tsx @@ -9,8 +9,8 @@ import { match } from 'ts-pattern' import { Button } from '@ui/ui/button' import { Sheet, SheetContent, SheetTrigger } from '@ui/ui/sheet' import { Skeleton } from '@ui/ui/skeleton' -import { Navbar } from './_components/navbar' -import { RequireAuthWrapper } from './_wrappers/require-auth' +import { Navbar } from './_navbar' +import { RequireAuthWrapper } from './_require-auth' export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index d6d7848..b708cd3 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -1,5 +1,6 @@ import { ExitIcon, PersonIcon, PlusIcon } from '@radix-ui/react-icons' import { sessionAtom } from '@web/atoms/auth' +import { useAuthenticatedOrganizationMember } from '@web/hooks/use-organization-member' import { api } from '@web/lib/api' import { constructPublicResourceUrl } from '@web/lib/utils' import { useAtom } from 'jotai' @@ -63,7 +64,7 @@ export function ProfileDropdownMenu({ children, open = false, onOpenChange, ...p } function OrganizationList({ onOpenChange }: { onOpenChange: (v: boolean) => void }) { - const sessionInfosQuery = api.auth.infos.useQuery() + const organization = useAuthenticatedOrganizationMember().organization const listQuery = api.organization.list.useInfiniteQuery( { limit: 6, @@ -81,6 +82,18 @@ function OrganizationList({ onOpenChange }: { onOpenChange: (v: boolean) => void }} >
    + onOpenChange(false)} + /> + {match(listQuery) .with({ status: 'loading' }, () => ) .with({ status: 'error' }, () => '') @@ -88,22 +101,23 @@ function OrganizationList({ onOpenChange }: { onOpenChange: (v: boolean) => void return query.data.pages.map((page, i) => { return (
    - {page.items.map((item) => { - return ( - onOpenChange(false)} - /> - ) - })} + {page.items + .filter((item) => item.id !== organization.id) + .map((item) => { + return ( + onOpenChange(false)} + /> + ) + })} {!query.isFetching && query.hasNextPage && ( query.fetchNextPage()} /> )} From 05bccbac013b51525a91d584fe4b4d8a61c98f7a Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 10:49:14 +0700 Subject: [PATCH 05/12] Add AuthProvider to RootLayout --- @web/app/_providers/auth.tsx | 30 ++++++++++++++++++++++++++++++ @web/app/layout.tsx | 15 +++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 @web/app/_providers/auth.tsx diff --git a/@web/app/_providers/auth.tsx b/@web/app/_providers/auth.tsx new file mode 100644 index 0000000..16e3d31 --- /dev/null +++ b/@web/app/_providers/auth.tsx @@ -0,0 +1,30 @@ +'use client' + +import { sessionAtom } from '@web/atoms/auth' +import { api } from '@web/lib/api' +import { useAtom } from 'jotai' +import { useEffect } from 'react' + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session] = useAtom(sessionAtom) + + return ( + <> + {session ? : null} + {children} + + ) +} + +export function SyncSession() { + const [, setSession] = useAtom(sessionAtom) + const query = api.auth.infos.useQuery() + + useEffect(() => { + if (query.status !== 'success') return + + setSession(query.data.session) + }, [query.data]) + + return null +} diff --git a/@web/app/layout.tsx b/@web/app/layout.tsx index 8754c17..1adee08 100644 --- a/@web/app/layout.tsx +++ b/@web/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from 'next/font/google' import '@ui/styles/globals.css' import { ScrollArea } from '@ui/ui/scroll-area' import { Toaster } from '@ui/ui/toaster' +import { AuthProvider } from './_providers/auth' import JotaiProvider from './_providers/jotai' import { QueryProvider } from './_providers/query' import { ThemeProvider } from './_providers/theme' @@ -25,12 +26,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - -
    {children}
    -
    -
    - + + + +
    {children}
    +
    +
    + +
    From 93b82fd66d888552ff6c82fe9516fcbcbf4401c7 Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 10:52:13 +0700 Subject: [PATCH 06/12] Refactor JotaiProvider and QueryProvider to use jotaiStore --- @web/app/_providers/jotai.tsx | 7 +++---- @web/app/_providers/query.tsx | 24 ++++++++++++------------ @web/atoms/auth.ts | 2 +- @web/atoms/history.ts | 2 +- @web/{atoms/_helpers.ts => lib/jotai.ts} | 4 +++- 5 files changed, 20 insertions(+), 19 deletions(-) rename @web/{atoms/_helpers.ts => lib/jotai.ts} (95%) diff --git a/@web/app/_providers/jotai.tsx b/@web/app/_providers/jotai.tsx index bfae111..565ecc7 100644 --- a/@web/app/_providers/jotai.tsx +++ b/@web/app/_providers/jotai.tsx @@ -1,9 +1,8 @@ 'use client' -import { Provider, createStore } from 'jotai' - -export const store = createStore() +import { jotaiStore } from '@web/lib/jotai' +import { Provider } from 'jotai' export default function JotaiProvider({ children }: { children: React.ReactNode }) { - return {children} + return {children} } diff --git a/@web/app/_providers/query.tsx b/@web/app/_providers/query.tsx index 96b3f2b..c137c07 100644 --- a/@web/app/_providers/query.tsx +++ b/@web/app/_providers/query.tsx @@ -5,11 +5,11 @@ import { TRPCClientError, httpBatchLink } from '@trpc/client' import { sessionAtom } from '@web/atoms/auth' import { env } from '@web/env' import { api } from '@web/lib/api' +import { jotaiStore } from '@web/lib/jotai' import { RESET } from 'jotai/utils' import { useState } from 'react' import SuperJSON from 'superjson' import { useToast } from '@ui/ui/use-toast' -import { store } from './jotai' import { showTurnstileAtom, turnstileRefAtom, turnstileTokenAtom } from './turnstile' export function QueryProvider({ children }: { children: React.ReactNode }) { @@ -20,7 +20,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { queryCache: new QueryCache({ onError(err) { if (err instanceof TRPCClientError && err.data?.code === 'UNAUTHORIZED') { - store.set(sessionAtom, RESET) + jotaiStore.set(sessionAtom, RESET) } }, }), @@ -31,7 +31,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { const message = err.message if (code === 'UNAUTHORIZED') { - store.set(sessionAtom, RESET) + jotaiStore.set(sessionAtom, RESET) } if (message !== code && code !== 'INTERNAL_SERVER_ERROR') { @@ -58,7 +58,7 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { httpBatchLink({ url: new URL('/trpc', env.NEXT_PUBLIC_API_URL).toString(), async headers() { - const session = store.get(sessionAtom) + const session = jotaiStore.get(sessionAtom) const headers: Record = {} if (session) { @@ -70,28 +70,28 @@ export function QueryProvider({ children }: { children: React.ReactNode }) { async fetch(input, init) { const method = init?.method?.toUpperCase() ?? 'GET' if (method === 'POST' && init) { - if (!store.get(turnstileTokenAtom)) { - store.set(showTurnstileAtom, true) + if (!jotaiStore.get(turnstileTokenAtom)) { + jotaiStore.set(showTurnstileAtom, true) await new Promise((resolve) => { - const unsub = store.sub(turnstileTokenAtom, () => { - const token = store.get(turnstileTokenAtom) + const unsub = jotaiStore.sub(turnstileTokenAtom, () => { + const token = jotaiStore.get(turnstileTokenAtom) if (token) { unsub() resolve(token) } }) }) - store.set(showTurnstileAtom, false) + jotaiStore.set(showTurnstileAtom, false) } - const token = store.get(turnstileTokenAtom) + const token = jotaiStore.get(turnstileTokenAtom) init.headers = { ...init.headers, 'X-Turnstile-Token': `${token}`, } - store.set(turnstileTokenAtom, null) - store.get(turnstileRefAtom)?.reset() + jotaiStore.set(turnstileTokenAtom, null) + jotaiStore.get(turnstileRefAtom)?.reset() } return await fetch(input, init) diff --git a/@web/atoms/auth.ts b/@web/atoms/auth.ts index 4efc03e..54fa546 100644 --- a/@web/atoms/auth.ts +++ b/@web/atoms/auth.ts @@ -1,6 +1,6 @@ import { organizationMemberSchema, organizationSchema, sessionSchema, userSchema } from '@api/database/schema' import { z } from 'zod' -import { atomWithLocalStorage } from './_helpers' +import { atomWithLocalStorage } from '../lib/jotai' export const sessionAtom = atomWithLocalStorage( 'session-atom', diff --git a/@web/atoms/history.ts b/@web/atoms/history.ts index c799442..78015da 100644 --- a/@web/atoms/history.ts +++ b/@web/atoms/history.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { atomWithLocalStorage } from './_helpers' +import { atomWithLocalStorage } from '../lib/jotai' export const loginWithEmailHistoryAtom = atomWithLocalStorage( 'login-with-email-history-atom', diff --git a/@web/atoms/_helpers.ts b/@web/lib/jotai.ts similarity index 95% rename from @web/atoms/_helpers.ts rename to @web/lib/jotai.ts index a2d6784..86c9db9 100644 --- a/@web/atoms/_helpers.ts +++ b/@web/lib/jotai.ts @@ -1,4 +1,4 @@ -import { atom } from 'jotai' +import { atom, createStore } from 'jotai' import { RESET } from 'jotai/utils' import SuperJSON from 'superjson' import type { z } from 'zod' @@ -6,6 +6,8 @@ import type { z } from 'zod' type SetStateActionClosureWithReset = (prev: V) => V | typeof RESET type SetStateActionWithReset = V | typeof RESET | SetStateActionClosureWithReset +export const jotaiStore = createStore() + export function atomWithLocalStorage(key: string, schema: T, _initialValue: z.infer) { type V = z.infer const parse = (v: unknown): V => schema.parse(v) From e8266145530e7b9547cdafcf4b19fb56bb3e44d8 Mon Sep 17 00:00:00 2001 From: Din Date: Wed, 20 Dec 2023 10:57:15 +0700 Subject: [PATCH 07/12] Update import statements to use type imports --- @api/routes/auth/_find-session-for-auth.ts | 2 +- @api/routes/auth/_get-oauth-user.ts | 6 +++--- @api/routes/auth/infos.ts | 1 - @api/routes/organization/member.remove.ts | 2 +- @web/app/(auth)/_navbar.tsx | 3 --- .../invitation-accept/_components/invitation-card.tsx | 1 - @web/app/_providers/auth.tsx | 2 +- 7 files changed, 6 insertions(+), 11 deletions(-) diff --git a/@api/routes/auth/_find-session-for-auth.ts b/@api/routes/auth/_find-session-for-auth.ts index 6498ffc..48882ce 100644 --- a/@api/routes/auth/_find-session-for-auth.ts +++ b/@api/routes/auth/_find-session-for-auth.ts @@ -1,4 +1,4 @@ -import { Context } from '@api/context' +import type { Context } from '@api/context' import { TRPCError } from '@trpc/server' export async function findSessionForAuth({ ctx, sessionSecretKey }: { ctx: Context; sessionSecretKey: string }) { diff --git a/@api/routes/auth/_get-oauth-user.ts b/@api/routes/auth/_get-oauth-user.ts index 1feb399..a04fcad 100644 --- a/@api/routes/auth/_get-oauth-user.ts +++ b/@api/routes/auth/_get-oauth-user.ts @@ -1,7 +1,7 @@ -import { Context } from '@api/context' -import { OauthAccounts } from '@api/database/schema' +import type { Context } from '@api/context' +import type { OauthAccounts } from '@api/database/schema' import { TRPCError } from '@trpc/server' -import { GitHubUser } from 'arctic' +import type { GitHubUser } from 'arctic' import { match } from 'ts-pattern' export async function getOauthUser({ diff --git a/@api/routes/auth/infos.ts b/@api/routes/auth/infos.ts index a580dc5..76f21f3 100644 --- a/@api/routes/auth/infos.ts +++ b/@api/routes/auth/infos.ts @@ -1,5 +1,4 @@ import { authProcedure } from '@api/trpc' -import { TRPCError } from '@trpc/server' import { findSessionForAuth } from './_find-session-for-auth' export const authInfosRoute = authProcedure.query(async ({ ctx }) => { diff --git a/@api/routes/organization/member.remove.ts b/@api/routes/organization/member.remove.ts index 614bdbd..f074d7d 100644 --- a/@api/routes/organization/member.remove.ts +++ b/@api/routes/organization/member.remove.ts @@ -1,4 +1,4 @@ -import { OrganizationMembers, Sessions, organizationMemberSchema, organizationSchema } from '@api/database/schema' +import { OrganizationMembers, Sessions, organizationMemberSchema } from '@api/database/schema' import { authProcedure, organizationAdminMiddleware } from '@api/trpc' import { TRPCError } from '@trpc/server' import { and, eq } from 'drizzle-orm' diff --git a/@web/app/(auth)/_navbar.tsx b/@web/app/(auth)/_navbar.tsx index 58f63d8..2b2304b 100644 --- a/@web/app/(auth)/_navbar.tsx +++ b/@web/app/(auth)/_navbar.tsx @@ -5,12 +5,9 @@ import { LogoDropdownMenu } from '@web/components/logo-dropdown-menu' import { ProfileDropdownMenu } from '@web/components/profile-dropdown-menu' import { ThemeToggle } from '@web/components/theme-toggle' import { useAuthenticatedOrganizationMember } from '@web/hooks/use-organization-member' -import { useAuthenticatedUser } from '@web/hooks/use-user' -import { api } from '@web/lib/api' 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' diff --git a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx index fe0e6a2..ac44003 100644 --- a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx +++ b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx @@ -6,7 +6,6 @@ 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' diff --git a/@web/app/_providers/auth.tsx b/@web/app/_providers/auth.tsx index 16e3d31..157a86a 100644 --- a/@web/app/_providers/auth.tsx +++ b/@web/app/_providers/auth.tsx @@ -24,7 +24,7 @@ export function SyncSession() { if (query.status !== 'success') return setSession(query.data.session) - }, [query.data]) + }, [query.data, query.status, setSession]) return null } From dfd864f557191bd47c3904745fec4319a826922f Mon Sep 17 00:00:00 2001 From: Din Date: Thu, 21 Dec 2023 16:09:53 +0700 Subject: [PATCH 08/12] Add new files and update existing files --- @api/{routes => features}/auth/email.send-otp.ts | 0 @api/{routes => features}/auth/email.validate-otp.ts | 6 +++--- .../auth/helpers/create-session.ts} | 0 .../auth/helpers/find-session-for-auth.ts} | 0 .../auth/helpers/get-oauth-user.ts} | 0 @api/{routes => features}/auth/infos.ts | 2 +- @api/{routes => features}/auth/logout.ts | 0 @api/{routes => features}/auth/oauth.authorization-url.ts | 0 @api/{routes => features}/auth/oauth.connect.ts | 4 ++-- @api/{routes => features}/auth/oauth.disconnect.ts | 0 @api/{routes => features}/auth/oauth.login.ts | 8 ++++---- @api/{routes => features}/auth/organization-switch.ts | 0 @api/{routes => features}/auth/profile.ts | 0 @api/{routes/auth/index.ts => features/auth/router.ts} | 0 @api/{routes => features}/organization/change-logo.ts | 0 @api/{routes => features}/organization/create.ts | 2 +- @api/{routes => features}/organization/detail.ts | 0 @api/{routes => features}/organization/list.ts | 0 .../organization/member.accept-invitation.ts | 0 .../organization/member.invitation-info.ts | 0 @api/{routes => features}/organization/member.invite.ts | 0 @api/{routes => features}/organization/member.remove.ts | 0 .../index.ts => features/organization/router.ts} | 0 @api/{routes => features}/organization/update.ts | 0 @api/router.ts | 4 ++-- .../utils.ts => utils/generate-fallback-avatar-url.ts} | 8 -------- @api/utils/generate-fallback-logo-url.ts | 3 +++ @shared/main.ts | 0 @shared/utils/uppercase-first-letter.ts | 3 +++ 29 files changed, 19 insertions(+), 21 deletions(-) rename @api/{routes => features}/auth/email.send-otp.ts (100%) rename @api/{routes => features}/auth/email.validate-otp.ts (91%) rename @api/{routes/auth/_create-session.ts => features/auth/helpers/create-session.ts} (100%) rename @api/{routes/auth/_find-session-for-auth.ts => features/auth/helpers/find-session-for-auth.ts} (100%) rename @api/{routes/auth/_get-oauth-user.ts => features/auth/helpers/get-oauth-user.ts} (100%) rename @api/{routes => features}/auth/infos.ts (87%) rename @api/{routes => features}/auth/logout.ts (100%) rename @api/{routes => features}/auth/oauth.authorization-url.ts (100%) rename @api/{routes => features}/auth/oauth.connect.ts (92%) rename @api/{routes => features}/auth/oauth.disconnect.ts (100%) rename @api/{routes => features}/auth/oauth.login.ts (89%) rename @api/{routes => features}/auth/organization-switch.ts (100%) rename @api/{routes => features}/auth/profile.ts (100%) rename @api/{routes/auth/index.ts => features/auth/router.ts} (100%) rename @api/{routes => features}/organization/change-logo.ts (100%) rename @api/{routes => features}/organization/create.ts (95%) rename @api/{routes => features}/organization/detail.ts (100%) rename @api/{routes => features}/organization/list.ts (100%) rename @api/{routes => features}/organization/member.accept-invitation.ts (100%) rename @api/{routes => features}/organization/member.invitation-info.ts (100%) rename @api/{routes => features}/organization/member.invite.ts (100%) rename @api/{routes => features}/organization/member.remove.ts (100%) rename @api/{routes/organization/index.ts => features/organization/router.ts} (100%) rename @api/{routes => features}/organization/update.ts (100%) rename @api/{lib/utils.ts => utils/generate-fallback-avatar-url.ts} (56%) create mode 100644 @api/utils/generate-fallback-logo-url.ts delete mode 100644 @shared/main.ts create mode 100644 @shared/utils/uppercase-first-letter.ts diff --git a/@api/routes/auth/email.send-otp.ts b/@api/features/auth/email.send-otp.ts similarity index 100% rename from @api/routes/auth/email.send-otp.ts rename to @api/features/auth/email.send-otp.ts diff --git a/@api/routes/auth/email.validate-otp.ts b/@api/features/auth/email.validate-otp.ts similarity index 91% rename from @api/routes/auth/email.validate-otp.ts rename to @api/features/auth/email.validate-otp.ts index 111e904..f8b4b77 100644 --- a/@api/routes/auth/email.validate-otp.ts +++ b/@api/features/auth/email.validate-otp.ts @@ -1,12 +1,12 @@ import { EmailOtps, emailOtpSchema } from '@api/database/schema' import { createUser } from '@api/lib/db' -import { generateFallbackAvatarUrl } from '@api/lib/utils' import { procedure } from '@api/trpc' +import { generateFallbackAvatarUrl } from '@api/utils/generate-fallback-avatar-url' import { TRPCError } from '@trpc/server' import { eq } from 'drizzle-orm' import { z } from 'zod' -import { createSession } from './_create-session' -import { findSessionForAuth } from './_find-session-for-auth' +import { createSession } from './helpers/create-session' +import { findSessionForAuth } from './helpers/find-session-for-auth' export const authEmailValidateOtpRoute = procedure .input( diff --git a/@api/routes/auth/_create-session.ts b/@api/features/auth/helpers/create-session.ts similarity index 100% rename from @api/routes/auth/_create-session.ts rename to @api/features/auth/helpers/create-session.ts diff --git a/@api/routes/auth/_find-session-for-auth.ts b/@api/features/auth/helpers/find-session-for-auth.ts similarity index 100% rename from @api/routes/auth/_find-session-for-auth.ts rename to @api/features/auth/helpers/find-session-for-auth.ts diff --git a/@api/routes/auth/_get-oauth-user.ts b/@api/features/auth/helpers/get-oauth-user.ts similarity index 100% rename from @api/routes/auth/_get-oauth-user.ts rename to @api/features/auth/helpers/get-oauth-user.ts diff --git a/@api/routes/auth/infos.ts b/@api/features/auth/infos.ts similarity index 87% rename from @api/routes/auth/infos.ts rename to @api/features/auth/infos.ts index 76f21f3..1960d02 100644 --- a/@api/routes/auth/infos.ts +++ b/@api/features/auth/infos.ts @@ -1,5 +1,5 @@ import { authProcedure } from '@api/trpc' -import { findSessionForAuth } from './_find-session-for-auth' +import { findSessionForAuth } from './helpers/find-session-for-auth' export const authInfosRoute = authProcedure.query(async ({ ctx }) => { const findSession = findSessionForAuth({ ctx, sessionSecretKey: ctx.auth.session.secretKey }) diff --git a/@api/routes/auth/logout.ts b/@api/features/auth/logout.ts similarity index 100% rename from @api/routes/auth/logout.ts rename to @api/features/auth/logout.ts diff --git a/@api/routes/auth/oauth.authorization-url.ts b/@api/features/auth/oauth.authorization-url.ts similarity index 100% rename from @api/routes/auth/oauth.authorization-url.ts rename to @api/features/auth/oauth.authorization-url.ts diff --git a/@api/routes/auth/oauth.connect.ts b/@api/features/auth/oauth.connect.ts similarity index 92% rename from @api/routes/auth/oauth.connect.ts rename to @api/features/auth/oauth.connect.ts index bebd67c..1b50485 100644 --- a/@api/routes/auth/oauth.connect.ts +++ b/@api/features/auth/oauth.connect.ts @@ -1,9 +1,9 @@ import { OauthAccounts, oauthAccountSchema } from '@api/database/schema' -import { uppercaseFirstLetter } from '@api/lib/utils' import { authProcedure } from '@api/trpc' +import { uppercaseFirstLetter } from '@shared/utils/uppercase-first-letter' import { TRPCError } from '@trpc/server' import { z } from 'zod' -import { getOauthUser } from './_get-oauth-user' +import { getOauthUser } from './helpers/get-oauth-user' export const authOauthConnectRoute = authProcedure .input( diff --git a/@api/routes/auth/oauth.disconnect.ts b/@api/features/auth/oauth.disconnect.ts similarity index 100% rename from @api/routes/auth/oauth.disconnect.ts rename to @api/features/auth/oauth.disconnect.ts diff --git a/@api/routes/auth/oauth.login.ts b/@api/features/auth/oauth.login.ts similarity index 89% rename from @api/routes/auth/oauth.login.ts rename to @api/features/auth/oauth.login.ts index b11528d..a68e4a6 100644 --- a/@api/routes/auth/oauth.login.ts +++ b/@api/features/auth/oauth.login.ts @@ -1,12 +1,12 @@ import { oauthAccountSchema } from '@api/database/schema' import { createUser } from '@api/lib/db' -import { uppercaseFirstLetter } from '@api/lib/utils' import { procedure } from '@api/trpc' +import { uppercaseFirstLetter } from '@shared/utils/uppercase-first-letter' import { TRPCError } from '@trpc/server' import { z } from 'zod' -import { createSession } from './_create-session' -import { findSessionForAuth } from './_find-session-for-auth' -import { getOauthUser } from './_get-oauth-user' +import { createSession } from './helpers/create-session' +import { findSessionForAuth } from './helpers/find-session-for-auth' +import { getOauthUser } from './helpers/get-oauth-user' export const authOauthLoginRoute = procedure .input( diff --git a/@api/routes/auth/organization-switch.ts b/@api/features/auth/organization-switch.ts similarity index 100% rename from @api/routes/auth/organization-switch.ts rename to @api/features/auth/organization-switch.ts diff --git a/@api/routes/auth/profile.ts b/@api/features/auth/profile.ts similarity index 100% rename from @api/routes/auth/profile.ts rename to @api/features/auth/profile.ts diff --git a/@api/routes/auth/index.ts b/@api/features/auth/router.ts similarity index 100% rename from @api/routes/auth/index.ts rename to @api/features/auth/router.ts diff --git a/@api/routes/organization/change-logo.ts b/@api/features/organization/change-logo.ts similarity index 100% rename from @api/routes/organization/change-logo.ts rename to @api/features/organization/change-logo.ts diff --git a/@api/routes/organization/create.ts b/@api/features/organization/create.ts similarity index 95% rename from @api/routes/organization/create.ts rename to @api/features/organization/create.ts index 6213cfa..a70fbfb 100644 --- a/@api/routes/organization/create.ts +++ b/@api/features/organization/create.ts @@ -1,6 +1,6 @@ import { OrganizationMembers, Organizations, Sessions, organizationSchema } from '@api/database/schema' -import { generateFallbackLogoUrl } from '@api/lib/utils' import { authProcedure } from '@api/trpc' +import { generateFallbackLogoUrl } from '@api/utils/generate-fallback-logo-url' import { TRPCError } from '@trpc/server' import { eq } from 'drizzle-orm' import { z } from 'zod' diff --git a/@api/routes/organization/detail.ts b/@api/features/organization/detail.ts similarity index 100% rename from @api/routes/organization/detail.ts rename to @api/features/organization/detail.ts diff --git a/@api/routes/organization/list.ts b/@api/features/organization/list.ts similarity index 100% rename from @api/routes/organization/list.ts rename to @api/features/organization/list.ts diff --git a/@api/routes/organization/member.accept-invitation.ts b/@api/features/organization/member.accept-invitation.ts similarity index 100% rename from @api/routes/organization/member.accept-invitation.ts rename to @api/features/organization/member.accept-invitation.ts diff --git a/@api/routes/organization/member.invitation-info.ts b/@api/features/organization/member.invitation-info.ts similarity index 100% rename from @api/routes/organization/member.invitation-info.ts rename to @api/features/organization/member.invitation-info.ts diff --git a/@api/routes/organization/member.invite.ts b/@api/features/organization/member.invite.ts similarity index 100% rename from @api/routes/organization/member.invite.ts rename to @api/features/organization/member.invite.ts diff --git a/@api/routes/organization/member.remove.ts b/@api/features/organization/member.remove.ts similarity index 100% rename from @api/routes/organization/member.remove.ts rename to @api/features/organization/member.remove.ts diff --git a/@api/routes/organization/index.ts b/@api/features/organization/router.ts similarity index 100% rename from @api/routes/organization/index.ts rename to @api/features/organization/router.ts diff --git a/@api/routes/organization/update.ts b/@api/features/organization/update.ts similarity index 100% rename from @api/routes/organization/update.ts rename to @api/features/organization/update.ts diff --git a/@api/router.ts b/@api/router.ts index a9cda94..3739b13 100644 --- a/@api/router.ts +++ b/@api/router.ts @@ -1,6 +1,6 @@ import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server' -import { authRouter } from './routes/auth' -import { organizationRouter } from './routes/organization' +import { authRouter } from './features/auth/router' +import { organizationRouter } from './features/organization/router' import { procedure, router } from './trpc' export const appRouter = router({ diff --git a/@api/lib/utils.ts b/@api/utils/generate-fallback-avatar-url.ts similarity index 56% rename from @api/lib/utils.ts rename to @api/utils/generate-fallback-avatar-url.ts index 453d473..fe2689e 100644 --- a/@api/lib/utils.ts +++ b/@api/utils/generate-fallback-avatar-url.ts @@ -6,11 +6,3 @@ 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` -} - -export function uppercaseFirstLetter(str: string) { - return str.charAt(0).toUpperCase() + str.slice(1) -} diff --git a/@api/utils/generate-fallback-logo-url.ts b/@api/utils/generate-fallback-logo-url.ts new file mode 100644 index 0000000..433467b --- /dev/null +++ b/@api/utils/generate-fallback-logo-url.ts @@ -0,0 +1,3 @@ +export function generateFallbackLogoUrl(organization: { name: string }) { + return `https://ui-avatars.com/api/${organization.name}/96/f4f4f5/09090b/1` +} diff --git a/@shared/main.ts b/@shared/main.ts deleted file mode 100644 index e69de29..0000000 diff --git a/@shared/utils/uppercase-first-letter.ts b/@shared/utils/uppercase-first-letter.ts new file mode 100644 index 0000000..164d884 --- /dev/null +++ b/@shared/utils/uppercase-first-letter.ts @@ -0,0 +1,3 @@ +export function uppercaseFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1) +} From d5798cc005c949ae257950bc1dd6fe87507dd388 Mon Sep 17 00:00:00 2001 From: Din Date: Thu, 21 Dec 2023 16:17:43 +0700 Subject: [PATCH 09/12] Update import paths for utility functions --- @ui/components.json | 2 +- @ui/ui/alert-dialog.tsx | 2 +- @ui/ui/alert.tsx | 2 +- @ui/ui/avatar.tsx | 2 +- @ui/ui/button.tsx | 2 +- @ui/ui/card.tsx | 2 +- @ui/ui/checkbox.tsx | 2 +- @ui/ui/dropdown-menu.tsx | 2 +- @ui/ui/general-skeleton.tsx | 2 +- @ui/ui/input.tsx | 2 +- @ui/ui/label.tsx | 2 +- @ui/ui/mutation-status-icon.tsx | 2 +- @ui/ui/scroll-area.tsx | 2 +- @ui/ui/sheet.tsx | 2 +- @ui/ui/skeleton.tsx | 2 +- @ui/ui/toast.tsx | 2 +- @ui/{lib/utils.ts => utils/cn.ts} | 0 .../app/(auth)/(settings)/_components/nav.tsx | 2 +- .../_components/organization-infos-form.tsx | 2 +- .../_components/organization-members.tsx | 3 ++- .../_components/personal-infos-form.tsx | 2 +- @web/app/(auth)/_navbar.tsx | 3 ++- .../_components/invitation-card.tsx | 2 +- @web/app/_providers/turnstile.tsx | 2 +- @web/app/oauth/[provider]/callback/page.tsx | 2 +- @web/components/profile-dropdown-menu.tsx | 2 +- @web/lib/utils.ts | 23 ------------------- @web/utils/construct-public-resource-url.ts | 5 ++++ @web/utils/convert-file-to-base64.ts | 8 +++++++ @web/utils/is-active-pathname.ts | 4 ++++ 30 files changed, 44 insertions(+), 48 deletions(-) rename @ui/{lib/utils.ts => utils/cn.ts} (100%) delete mode 100644 @web/lib/utils.ts create mode 100644 @web/utils/construct-public-resource-url.ts create mode 100644 @web/utils/convert-file-to-base64.ts create mode 100644 @web/utils/is-active-pathname.ts diff --git a/@ui/components.json b/@ui/components.json index 231bf5b..2f64a01 100644 --- a/@ui/components.json +++ b/@ui/components.json @@ -11,6 +11,6 @@ }, "aliases": { "components": "@ui/_/..", - "utils": "@ui/lib/utils" + "utils": "@ui/utils/cn" } } diff --git a/@ui/ui/alert-dialog.tsx b/@ui/ui/alert-dialog.tsx index a3eefee..c84150e 100644 --- a/@ui/ui/alert-dialog.tsx +++ b/@ui/ui/alert-dialog.tsx @@ -3,7 +3,7 @@ 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' +import { cn } from '@ui/utils/cn' const AlertDialog = AlertDialogPrimitive.Root diff --git a/@ui/ui/alert.tsx b/@ui/ui/alert.tsx index 80aed8f..074ab49 100644 --- a/@ui/ui/alert.tsx +++ b/@ui/ui/alert.tsx @@ -1,6 +1,6 @@ import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const alertVariants = cva( 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', diff --git a/@ui/ui/avatar.tsx b/@ui/ui/avatar.tsx index 1ae2176..4764143 100644 --- a/@ui/ui/avatar.tsx +++ b/@ui/ui/avatar.tsx @@ -2,7 +2,7 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const Avatar = React.forwardRef< React.ElementRef, diff --git a/@ui/ui/button.tsx b/@ui/ui/button.tsx index 5487482..7ef36fd 100644 --- a/@ui/ui/button.tsx +++ b/@ui/ui/button.tsx @@ -1,7 +1,7 @@ import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', diff --git a/@ui/ui/card.tsx b/@ui/ui/card.tsx index c3bd783..40dc2b4 100644 --- a/@ui/ui/card.tsx +++ b/@ui/ui/card.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const Card = React.forwardRef>(({ className, ...props }, ref) => (
    diff --git a/@ui/ui/checkbox.tsx b/@ui/ui/checkbox.tsx index 96b75ff..cffbc36 100644 --- a/@ui/ui/checkbox.tsx +++ b/@ui/ui/checkbox.tsx @@ -3,7 +3,7 @@ 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' +import { cn } from '@ui/utils/cn' const Checkbox = React.forwardRef< React.ElementRef, diff --git a/@ui/ui/dropdown-menu.tsx b/@ui/ui/dropdown-menu.tsx index 1bcf22c..c95af5a 100644 --- a/@ui/ui/dropdown-menu.tsx +++ b/@ui/ui/dropdown-menu.tsx @@ -3,7 +3,7 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const DropdownMenu = DropdownMenuPrimitive.Root diff --git a/@ui/ui/general-skeleton.tsx b/@ui/ui/general-skeleton.tsx index 1e6ca49..e4b5ba6 100644 --- a/@ui/ui/general-skeleton.tsx +++ b/@ui/ui/general-skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' import { Skeleton } from './skeleton' type Props = { diff --git a/@ui/ui/input.tsx b/@ui/ui/input.tsx index e927544..674dcbf 100644 --- a/@ui/ui/input.tsx +++ b/@ui/ui/input.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' export interface InputProps extends React.InputHTMLAttributes {} diff --git a/@ui/ui/label.tsx b/@ui/ui/label.tsx index afe595b..138219a 100644 --- a/@ui/ui/label.tsx +++ b/@ui/ui/label.tsx @@ -3,7 +3,7 @@ import * as LabelPrimitive from '@radix-ui/react-label' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70') diff --git a/@ui/ui/mutation-status-icon.tsx b/@ui/ui/mutation-status-icon.tsx index 548f171..71559a4 100644 --- a/@ui/ui/mutation-status-icon.tsx +++ b/@ui/ui/mutation-status-icon.tsx @@ -3,7 +3,7 @@ import { CheckIcon, Cross2Icon, ReloadIcon } from '@radix-ui/react-icons' import { useState, useLayoutEffect } from 'react' import { match } from 'ts-pattern' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' export function MutationStatusIcon(props: { status: 'idle' | 'loading' | 'success' | 'error' diff --git a/@ui/ui/scroll-area.tsx b/@ui/ui/scroll-area.tsx index 1ff7f58..ab3bc72 100644 --- a/@ui/ui/scroll-area.tsx +++ b/@ui/ui/scroll-area.tsx @@ -2,7 +2,7 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const ScrollArea = React.forwardRef< React.ElementRef, diff --git a/@ui/ui/sheet.tsx b/@ui/ui/sheet.tsx index 1c1a6e5..320b184 100644 --- a/@ui/ui/sheet.tsx +++ b/@ui/ui/sheet.tsx @@ -4,7 +4,7 @@ import * as SheetPrimitive from '@radix-ui/react-dialog' import { Cross2Icon } from '@radix-ui/react-icons' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const Sheet = SheetPrimitive.Root diff --git a/@ui/ui/skeleton.tsx b/@ui/ui/skeleton.tsx index a03c9f2..9e6459f 100644 --- a/@ui/ui/skeleton.tsx +++ b/@ui/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' function Skeleton({ className, ...props }: React.HTMLAttributes) { return
    diff --git a/@ui/ui/toast.tsx b/@ui/ui/toast.tsx index 07ee3c0..900a5d7 100644 --- a/@ui/ui/toast.tsx +++ b/@ui/ui/toast.tsx @@ -2,7 +2,7 @@ import { Cross2Icon } from '@radix-ui/react-icons' import * as ToastPrimitives from '@radix-ui/react-toast' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' const ToastProvider = ToastPrimitives.Provider diff --git a/@ui/lib/utils.ts b/@ui/utils/cn.ts similarity index 100% rename from @ui/lib/utils.ts rename to @ui/utils/cn.ts diff --git a/@web/app/(auth)/(settings)/_components/nav.tsx b/@web/app/(auth)/(settings)/_components/nav.tsx index 37730f2..9de22e5 100644 --- a/@web/app/(auth)/(settings)/_components/nav.tsx +++ b/@web/app/(auth)/(settings)/_components/nav.tsx @@ -1,7 +1,7 @@ 'use client' import { api } from '@web/lib/api' -import { isActivePathname } from '@web/lib/utils' +import { isActivePathname } from '@web/utils/is-active-pathname' import Link from 'next/link' import { usePathname, useSearchParams } from 'next/navigation' import { Skeleton } from '@ui/ui/skeleton' 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 6222bb8..c145c1a 100644 --- a/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx +++ b/@web/app/(auth)/(settings)/organization/_components/organization-infos-form.tsx @@ -1,7 +1,7 @@ 'use client' import { api } from '@web/lib/api' -import { constructPublicResourceUrl } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' import imageCompression from 'browser-image-compression' import { Base64 } from 'js-base64' import { useSearchParams } from 'next/navigation' diff --git a/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx index 290ada5..f7b2816 100644 --- a/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx +++ b/@web/app/(auth)/(settings)/organization/_components/organization-members.tsx @@ -1,9 +1,10 @@ 'use client' import { PlusIcon } from '@radix-ui/react-icons' +import { uppercaseFirstLetter } from '@shared/utils/uppercase-first-letter' import { useAuthenticatedUser } from '@web/hooks/use-user' import { api } from '@web/lib/api' -import { constructPublicResourceUrl, uppercaseFirstLetter } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' import { useSearchParams } from 'next/navigation' import { useRef } from 'react' import { match } from 'ts-pattern' 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 8b43dbd..3eb39bc 100644 --- a/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx +++ b/@web/app/(auth)/(settings)/profile/_components/personal-infos-form.tsx @@ -1,7 +1,7 @@ 'use client' import { api } from '@web/lib/api' -import { constructPublicResourceUrl } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' import imageCompression from 'browser-image-compression' import { Base64 } from 'js-base64' import { useId, useRef } from 'react' diff --git a/@web/app/(auth)/_navbar.tsx b/@web/app/(auth)/_navbar.tsx index 2b2304b..21af9ba 100644 --- a/@web/app/(auth)/_navbar.tsx +++ b/@web/app/(auth)/_navbar.tsx @@ -5,7 +5,8 @@ import { LogoDropdownMenu } from '@web/components/logo-dropdown-menu' import { ProfileDropdownMenu } from '@web/components/profile-dropdown-menu' import { ThemeToggle } from '@web/components/theme-toggle' import { useAuthenticatedOrganizationMember } from '@web/hooks/use-organization-member' -import { constructPublicResourceUrl, isActivePathname } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' +import { isActivePathname } from '@web/utils/is-active-pathname' import Link from 'next/link' import { usePathname } from 'next/navigation' import { Avatar, AvatarFallback, AvatarImage } from '@ui/ui/avatar' diff --git a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx index ac44003..06e7241 100644 --- a/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx +++ b/@web/app/(auth)/invitation-accept/_components/invitation-card.tsx @@ -2,7 +2,7 @@ import { organizationInvitationSchema } from '@api/database/schema' import { api } from '@web/lib/api' -import { constructPublicResourceUrl } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { match } from 'ts-pattern' diff --git a/@web/app/_providers/turnstile.tsx b/@web/app/_providers/turnstile.tsx index acaa68b..c94a886 100644 --- a/@web/app/_providers/turnstile.tsx +++ b/@web/app/_providers/turnstile.tsx @@ -9,7 +9,7 @@ import { useTheme } from 'next-themes' import { useId, useRef } from 'react' import { match } from 'ts-pattern' import { useIsRendered } from '@ui/hooks/use-is-rendered' -import { cn } from '@ui/lib/utils' +import { cn } from '@ui/utils/cn' export const turnstileTokenAtom = atom(null) diff --git a/@web/app/oauth/[provider]/callback/page.tsx b/@web/app/oauth/[provider]/callback/page.tsx index 54be8c3..2dcebea 100644 --- a/@web/app/oauth/[provider]/callback/page.tsx +++ b/@web/app/oauth/[provider]/callback/page.tsx @@ -1,6 +1,6 @@ import { oauthAccountProviders } from '@api/database/schema' import { ReloadIcon } from '@radix-ui/react-icons' -import { uppercaseFirstLetter } from '@web/lib/utils' +import { uppercaseFirstLetter } from '@shared/utils/uppercase-first-letter' import type { Metadata } from 'next' import { CallbackHandler } from './_components/callback-handler' diff --git a/@web/components/profile-dropdown-menu.tsx b/@web/components/profile-dropdown-menu.tsx index b708cd3..b4e518e 100644 --- a/@web/components/profile-dropdown-menu.tsx +++ b/@web/components/profile-dropdown-menu.tsx @@ -2,7 +2,7 @@ import { ExitIcon, PersonIcon, PlusIcon } from '@radix-ui/react-icons' import { sessionAtom } from '@web/atoms/auth' import { useAuthenticatedOrganizationMember } from '@web/hooks/use-organization-member' import { api } from '@web/lib/api' -import { constructPublicResourceUrl } from '@web/lib/utils' +import { constructPublicResourceUrl } from '@web/utils/construct-public-resource-url' import { useAtom } from 'jotai' import { RESET } from 'jotai/utils' import Link from 'next/link' diff --git a/@web/lib/utils.ts b/@web/lib/utils.ts deleted file mode 100644 index c01cc85..0000000 --- a/@web/lib/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { env } from '@web/env' - -export function isActivePathname(url: string, currentPathname: string) { - const { pathname } = new URL(url, 'https://dinsterizer.com') - return `${currentPathname}/`.startsWith(`${pathname}/`) -} - -export function uppercaseFirstLetter(string: string) { - return string.charAt(0).toUpperCase() + string.slice(1) -} - -export function constructPublicResourceUrl(url: string): string { - return new URL(url, env.NEXT_PUBLIC_PUBLIC_BUCKET_URL).toString() -} - -export function convertFileToBase64(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - }) -} diff --git a/@web/utils/construct-public-resource-url.ts b/@web/utils/construct-public-resource-url.ts new file mode 100644 index 0000000..5a50c35 --- /dev/null +++ b/@web/utils/construct-public-resource-url.ts @@ -0,0 +1,5 @@ +import { env } from '@web/env' + +export function constructPublicResourceUrl(url: string): string { + return new URL(url, env.NEXT_PUBLIC_PUBLIC_BUCKET_URL).toString() +} diff --git a/@web/utils/convert-file-to-base64.ts b/@web/utils/convert-file-to-base64.ts new file mode 100644 index 0000000..ffee137 --- /dev/null +++ b/@web/utils/convert-file-to-base64.ts @@ -0,0 +1,8 @@ +export function convertFileToBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + }) +} diff --git a/@web/utils/is-active-pathname.ts b/@web/utils/is-active-pathname.ts new file mode 100644 index 0000000..8786043 --- /dev/null +++ b/@web/utils/is-active-pathname.ts @@ -0,0 +1,4 @@ +export function isActivePathname(url: string, currentPathname: string) { + const { pathname } = new URL(url, 'https://dinsterizer.com') + return `${currentPathname}/`.startsWith(`${pathname}/`) +} From a50349a459924138a71348fbb6d6f6fc3f15898e Mon Sep 17 00:00:00 2001 From: Din Date: Thu, 21 Dec 2023 16:26:54 +0700 Subject: [PATCH 10/12] update formatting --- .prettierrc | 2 +- @api/context.ts | 17 ++++++- @api/emails/login/template.html | 14 +++++- .../organization-invitation/template.html | 23 ++++++++-- .../auth/helpers/find-session-for-auth.ts | 8 +++- @api/features/auth/helpers/get-oauth-user.ts | 6 ++- @api/features/auth/oauth.connect.ts | 9 +++- @api/features/auth/oauth.disconnect.ts | 7 ++- @api/features/auth/oauth.login.ts | 9 +++- @api/features/organization/create.ts | 7 ++- @api/features/organization/detail.ts | 4 +- .../organization/member.accept-invitation.ts | 9 +++- @api/features/organization/member.invite.ts | 11 ++++- @api/features/organization/member.remove.ts | 4 +- @api/lib/auth.ts | 5 +- @api/lib/db.ts | 5 +- @api/trpc.ts | 16 +++++-- @ui/icons/google-logo.tsx | 8 +++- @ui/ui/alert-dialog.tsx | 17 +++++-- @ui/ui/alert.tsx | 20 +++++--- @ui/ui/avatar.tsx | 11 ++++- @ui/ui/button.tsx | 7 ++- @ui/ui/card.tsx | 33 +++++++++---- @ui/ui/dropdown-menu.tsx | 6 ++- @ui/ui/input.tsx | 28 +++++------ @ui/ui/label.tsx | 4 +- @ui/ui/mutation-status-icon.tsx | 10 +++- @ui/ui/scroll-area.tsx | 10 +++- @ui/ui/sheet.tsx | 46 ++++++++++++------- @ui/ui/toast.tsx | 23 ++++++++-- @ui/ui/toaster.tsx | 9 +++- .../app/(auth)/(settings)/_components/nav.tsx | 13 ++++-- .../_components/organization-infos-form.tsx | 8 +++- .../organization-member-invite-sheet.tsx | 28 +++++++++-- .../_components/organization-members.tsx | 25 +++++++--- .../profile/_components/oauth-connections.tsx | 15 ++++-- .../_components/personal-infos-form.tsx | 12 +++-- @web/app/(auth)/_navbar.tsx | 11 +++-- .../_components/invitation-card.tsx | 11 +++-- @web/app/error.tsx | 8 +++- @web/atoms/auth.ts | 13 +++++- @web/components/login-screen.tsx | 11 ++++- @web/components/logo-dropdown-menu.tsx | 7 ++- @web/components/organization-create-sheet.tsx | 12 ++++- @web/components/profile-dropdown-menu.tsx | 12 ++++- @web/components/theme-toggle.tsx | 7 ++- @web/lib/jotai.ts | 6 ++- 47 files changed, 452 insertions(+), 135 deletions(-) diff --git a/.prettierrc b/.prettierrc index 597dfae..e7c50d3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "printWidth": 120, + "printWidth": 100, "semi": false, "singleQuote": true, "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], diff --git a/@api/context.ts b/@api/context.ts index d941237..ab2d39c 100644 --- a/@api/context.ts +++ b/@api/context.ts @@ -1,9 +1,22 @@ import type { Env } from './env' -import { createAuthGithub, createAuthGoogle, createCreateAuthJwtFn, createValidateAuthJwtFn } from './lib/auth' +import { + createAuthGithub, + createAuthGoogle, + createCreateAuthJwtFn, + createValidateAuthJwtFn, +} from './lib/auth' import { createDb } from './lib/db' import { createSendEmailFn } from './lib/email' -export function createContext({ env, ec, request }: { env: Env; ec: ExecutionContext; request?: Request }) { +export function createContext({ + env, + ec, + request, +}: { + env: Env + ec: ExecutionContext + request?: Request +}) { const db = createDb({ env }) const createAuthJwt = createCreateAuthJwtFn({ env }) const validateAuthJwt = createValidateAuthJwtFn({ env }) diff --git a/@api/emails/login/template.html b/@api/emails/login/template.html index acd03e7..37d6b48 100644 --- a/@api/emails/login/template.html +++ b/@api/emails/login/template.html @@ -5,7 +5,14 @@