Cleanup
This commit is contained in:
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "@acme/api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./env": "./src/env.mjs",
|
||||
"./edge": "./src/edge.ts",
|
||||
"./lambda": "./src/lambda.ts",
|
||||
"./transformer": "./src/transformer.ts",
|
||||
"./validators": "./src/validators.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check \"**/*.{mjs,ts,json}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@acme/db": "^0.1.0",
|
||||
"@acme/stripe": "^0.1.0",
|
||||
"@clerk/nextjs": "^4.29.4",
|
||||
"@dinero.js/currencies": "2.0.0-alpha.14",
|
||||
"@t3-oss/env-nextjs": "^0.7.3",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/server": "next",
|
||||
"dinero.js": "2.0.0-alpha.14",
|
||||
"superjson": "2.2.1",
|
||||
"zod": "^3.22.4",
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@acme/eslint-config": "^0.2.0",
|
||||
"@acme/prettier-config": "^0.1.0",
|
||||
"@acme/tsconfig": "^0.1.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.4",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@acme/eslint-config/base"
|
||||
]
|
||||
},
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { authRouter } from "./router/auth";
|
||||
import { organizationsRouter } from "./router/organizations";
|
||||
import { projectRouter } from "./router/project";
|
||||
import { stripeRouter } from "./router/stripe";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
// Deployed to /trpc/edge/**
|
||||
export const edgeRouter = createTRPCRouter({
|
||||
project: projectRouter,
|
||||
auth: authRouter,
|
||||
stripe: stripeRouter,
|
||||
organization: organizationsRouter,
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import * as z from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {},
|
||||
server: {
|
||||
NEXTJS_URL: z.preprocess(
|
||||
(str) =>
|
||||
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : str,
|
||||
process.env.VERCEL_URL ? z.string().min(1) : z.string().url(),
|
||||
),
|
||||
},
|
||||
// Client side variables gets destructured here due to Next.js static analysis
|
||||
// Shared ones are also included here for good measure since the behavior has been inconsistent
|
||||
experimental__runtimeEnv: {},
|
||||
skipValidation:
|
||||
!!process.env.SKIP_ENV_VALIDATION ||
|
||||
process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
import type { AppRouter } from "./root";
|
||||
|
||||
export { createTRPCContext, createInnerTRPCContext } from "./trpc";
|
||||
|
||||
// TODO: Maybe just export `createAction` instead of the whole `trpc` object?
|
||||
export { t } from "./trpc";
|
||||
|
||||
export type { AppRouter } from "./root";
|
||||
export { appRouter } from "./root";
|
||||
/**
|
||||
* Inference helpers for input types
|
||||
* @example type HelloInput = RouterInputs['example']['hello']
|
||||
**/
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
|
||||
/**
|
||||
* Inference helpers for output types
|
||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||
**/
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ingestionRouter } from "./router/ingestion";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
// Deployed to /trpc/lambda/**
|
||||
export const lambdaRouter = createTRPCRouter({
|
||||
ingestion: ingestionRouter,
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { edgeRouter } from "./edge";
|
||||
import { lambdaRouter } from "./lambda";
|
||||
import { mergeRouters } from "./trpc";
|
||||
|
||||
// Used to provide a good DX with a single client
|
||||
// Then, a custom link is used to generate the correct URL for the request
|
||||
export const appRouter = mergeRouters(edgeRouter, lambdaRouter);
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { clerkClient } from "@clerk/nextjs";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
mySubscription: protectedProcedure.query(async (opts) => {
|
||||
const customer = await opts.ctx.db
|
||||
.selectFrom("Customer")
|
||||
.select(["plan", "endsAt"])
|
||||
.where("clerkUserId", "=", opts.ctx.auth.userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!customer) return null;
|
||||
|
||||
return { plan: customer.plan ?? null, endsAt: customer.endsAt ?? null };
|
||||
}),
|
||||
listOrganizations: protectedProcedure.query(async (opts) => {
|
||||
const memberships = await clerkClient.users.getOrganizationMembershipList({
|
||||
userId: opts.ctx.auth.userId,
|
||||
});
|
||||
|
||||
return memberships.map(({ organization }) => ({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
image: organization.imageUrl,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { genId } from "@acme/db";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedApiFormDataProcedure,
|
||||
protectedProcedure,
|
||||
} from "../trpc";
|
||||
|
||||
globalThis.File = File;
|
||||
|
||||
const myFileValidator = z.preprocess(
|
||||
// @ts-expect-error - this is a hack. not sure why it's needed since it should already be a File
|
||||
(file: File) =>
|
||||
new File([file], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}),
|
||||
zfd.file(z.instanceof(File)),
|
||||
);
|
||||
|
||||
/**
|
||||
* FIXME: Not all of these have to run on lambda, just the upload one
|
||||
*/
|
||||
|
||||
export const ingestionRouter = createTRPCRouter({
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async (opts) => {
|
||||
const ingestion = await opts.ctx.db
|
||||
.selectFrom("Ingestion")
|
||||
.select(["id", "createdAt", "hash", "schema", "origin", "parent"])
|
||||
.where("id", "=", opts.input.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return ingestion;
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async (opts) => {
|
||||
let query = opts.ctx.db
|
||||
.selectFrom("Ingestion")
|
||||
.select(["id", "createdAt", "hash"])
|
||||
.where("projectId", "=", opts.input.projectId);
|
||||
|
||||
if (opts.input.limit) {
|
||||
query = query.limit(opts.input.limit).orderBy("createdAt", "desc");
|
||||
}
|
||||
const ingestions = await query.execute();
|
||||
|
||||
return ingestions.map((ingestion) => ({
|
||||
...ingestion,
|
||||
adds: Math.floor(Math.random() * 10),
|
||||
subs: Math.floor(Math.random() * 10),
|
||||
}));
|
||||
}),
|
||||
upload: protectedApiFormDataProcedure
|
||||
.input(
|
||||
zfd.formData({
|
||||
hash: zfd.text(),
|
||||
parent: zfd.text().optional(),
|
||||
origin: zfd.text(),
|
||||
schema: myFileValidator,
|
||||
}),
|
||||
)
|
||||
.mutation(async (opts) => {
|
||||
const fileContent = await opts.input.schema.text();
|
||||
|
||||
const id = "ingest_" + genId();
|
||||
await opts.ctx.db
|
||||
.insertInto("Ingestion")
|
||||
.values({
|
||||
id,
|
||||
projectId: opts.ctx.apiKey.projectId,
|
||||
hash: opts.input.hash,
|
||||
parent: opts.input.parent,
|
||||
origin: opts.input.origin,
|
||||
schema: fileContent,
|
||||
apiKeyId: opts.ctx.apiKey.id,
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return { status: "ok" };
|
||||
}),
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import { clerkClient } from "@clerk/nextjs";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedAdminProcedure,
|
||||
protectedOrgProcedure,
|
||||
} from "../trpc";
|
||||
import { inviteOrgMemberSchema } from "../validators";
|
||||
|
||||
export const organizationsRouter = createTRPCRouter({
|
||||
listMembers: protectedOrgProcedure.query(async (opts) => {
|
||||
const { orgId } = opts.ctx.auth;
|
||||
|
||||
const members =
|
||||
await clerkClient.organizations.getOrganizationMembershipList({
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
return members.map((member) => ({
|
||||
id: member.id,
|
||||
email: member.publicUserData?.identifier ?? "",
|
||||
role: member.role,
|
||||
joinedAt: member.createdAt,
|
||||
avatarUrl: member.publicUserData?.imageUrl,
|
||||
name: [
|
||||
member.publicUserData?.firstName,
|
||||
member.publicUserData?.lastName,
|
||||
].join(" "),
|
||||
}));
|
||||
}),
|
||||
|
||||
deleteMember: protectedAdminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.mutation(async (opts) => {
|
||||
const { orgId } = opts.ctx.auth;
|
||||
|
||||
try {
|
||||
const member =
|
||||
await clerkClient.organizations.deleteOrganizationMembership({
|
||||
organizationId: orgId,
|
||||
userId: opts.input.userId,
|
||||
});
|
||||
|
||||
return { memberName: member.publicUserData?.firstName };
|
||||
} catch (e) {
|
||||
console.log("Error deleting member", e);
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
inviteMember: protectedAdminProcedure
|
||||
.input(inviteOrgMemberSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { orgId } = opts.ctx.auth;
|
||||
|
||||
const { email } = opts.input;
|
||||
const users = await clerkClient.users.getUserList({
|
||||
emailAddress: [email],
|
||||
});
|
||||
const user = users[0];
|
||||
|
||||
if (users.length === 0 || !user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (users.length > 1) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Multiple users found with that email address",
|
||||
});
|
||||
}
|
||||
|
||||
const member =
|
||||
await clerkClient.organizations.createOrganizationMembership({
|
||||
organizationId: orgId,
|
||||
userId: user.id,
|
||||
role: opts.input.role,
|
||||
});
|
||||
|
||||
const { firstName, lastName } = member.publicUserData ?? {};
|
||||
return { name: [firstName, lastName].join(" ") };
|
||||
}),
|
||||
|
||||
deleteOrganization: protectedAdminProcedure.mutation(async (opts) => {
|
||||
const { orgId } = opts.ctx.auth;
|
||||
|
||||
await clerkClient.organizations.deleteOrganization(orgId);
|
||||
}),
|
||||
});
|
||||
@@ -1,414 +0,0 @@
|
||||
import { clerkClient } from "@clerk/nextjs";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { genId } from "@acme/db";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedAdminProcedure,
|
||||
protectedProcedure,
|
||||
} from "../trpc";
|
||||
import {
|
||||
createApiKeySchema,
|
||||
createProjectSchema,
|
||||
renameProjectSchema,
|
||||
transferToOrgSchema,
|
||||
} from "../validators";
|
||||
|
||||
const PROJECT_LIMITS = {
|
||||
FREE: 1,
|
||||
PRO: 3,
|
||||
} as const;
|
||||
|
||||
export const projectRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createProjectSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { userId, orgId } = opts.ctx.auth;
|
||||
const { name } = opts.input;
|
||||
|
||||
// Check if limit is reached
|
||||
let query = opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(({ fn }) => [fn.count<number>("id").as("projects")]);
|
||||
if (orgId) {
|
||||
query = query.where("organizationId", "=", orgId);
|
||||
} else {
|
||||
query = query.where("userId", "=", userId);
|
||||
}
|
||||
const projects = (await query.executeTakeFirst())?.projects ?? 0;
|
||||
|
||||
// FIXME: Don't hardcode the limit to PRO
|
||||
if (projects >= PROJECT_LIMITS.PRO) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Limit reached" });
|
||||
}
|
||||
|
||||
const projectId = "project_" + genId();
|
||||
|
||||
await opts.ctx.db
|
||||
.insertInto("Project")
|
||||
.values({
|
||||
id: projectId,
|
||||
name,
|
||||
userId: orgId ? null : userId,
|
||||
organizationId: orgId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return projectId;
|
||||
}),
|
||||
|
||||
rename: protectedProcedure
|
||||
.input(renameProjectSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { projectId, name } = opts.input;
|
||||
|
||||
// TODO: Validate permissions, should anyone with access to the project be able to change the name?
|
||||
|
||||
await opts.ctx.db
|
||||
.updateTable("Project")
|
||||
.set({
|
||||
name,
|
||||
})
|
||||
.where("id", "=", projectId)
|
||||
.execute();
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async (opts) => {
|
||||
const { userId, orgId } = opts.ctx.auth;
|
||||
|
||||
const deleteQuery = opts.ctx.db
|
||||
.deleteFrom("Project")
|
||||
.where("id", "=", opts.input.id);
|
||||
|
||||
// TODO: Check billing etc
|
||||
|
||||
if (orgId) {
|
||||
// TODO: Check permissions
|
||||
await deleteQuery.where("organizationId", "=", orgId).execute();
|
||||
} else {
|
||||
await deleteQuery.where("userId", "=", userId).execute();
|
||||
}
|
||||
}),
|
||||
|
||||
transferToPersonal: protectedAdminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async (opts) => {
|
||||
const project = await opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(["id", "userId", "organizationId"])
|
||||
.where("id", "=", opts.input.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!project.organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Project is already personal",
|
||||
});
|
||||
}
|
||||
|
||||
await opts.ctx.db
|
||||
.updateTable("Project")
|
||||
.set({
|
||||
userId: opts.ctx.auth.userId,
|
||||
organizationId: null,
|
||||
})
|
||||
.where("id", "=", project.id)
|
||||
.execute();
|
||||
}),
|
||||
|
||||
transferToOrganization: protectedProcedure
|
||||
.input(transferToOrgSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { userId, orgId: userOrgId, orgRole } = opts.ctx.auth;
|
||||
const { orgId: targetOrgId } = opts.input;
|
||||
|
||||
const orgs = await clerkClient.users.getOrganizationMembershipList({
|
||||
userId: userId,
|
||||
});
|
||||
const org = orgs.find((org) => org.organization.id === targetOrgId);
|
||||
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You're not a member of the target organization",
|
||||
});
|
||||
}
|
||||
|
||||
const project = await opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(["id", "userId", "organizationId"])
|
||||
.where(({ eb, and, or }) =>
|
||||
and([
|
||||
eb("id", "=", opts.input.projectId),
|
||||
or([
|
||||
eb("userId", "=", userId),
|
||||
eb("organizationId", "=", userOrgId ?? ""),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (project.organizationId === targetOrgId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Project is already in the target organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
project.organizationId &&
|
||||
project.organizationId !== userOrgId &&
|
||||
orgRole !== "admin"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You must be an admin to transfer this project",
|
||||
});
|
||||
}
|
||||
|
||||
await opts.ctx.db
|
||||
.updateTable("Project")
|
||||
.set({
|
||||
userId: null,
|
||||
organizationId: targetOrgId,
|
||||
})
|
||||
.where("id", "=", project.id)
|
||||
.execute();
|
||||
}),
|
||||
|
||||
listByActiveWorkspace: protectedProcedure.query(async (opts) => {
|
||||
const { userId, orgId } = opts.ctx.auth;
|
||||
|
||||
let query = opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(["id", "name", "url", "tier"]);
|
||||
if (orgId) {
|
||||
query = query.where("organizationId", "=", orgId);
|
||||
} else {
|
||||
query = query.where("userId", "=", userId);
|
||||
}
|
||||
|
||||
const projects = await query.execute();
|
||||
|
||||
// FIXME: Don't hardcode the limit to PRO
|
||||
return {
|
||||
projects,
|
||||
limit: PROJECT_LIMITS.PRO,
|
||||
limitReached: projects.length >= PROJECT_LIMITS.PRO,
|
||||
};
|
||||
}),
|
||||
|
||||
byId: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async (opts) => {
|
||||
const { userId } = opts.ctx.auth;
|
||||
const { id } = opts.input;
|
||||
|
||||
const orgs = await clerkClient.users.getOrganizationMembershipList({
|
||||
userId: userId,
|
||||
});
|
||||
const orgIds = orgs.map((org) => org.organization.id);
|
||||
|
||||
// Verify the user has access to the project
|
||||
const query = opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(["id", "name", "url", "tier", "organizationId"])
|
||||
.where(({ eb, and, or }) =>
|
||||
and([
|
||||
eb("id", "=", id),
|
||||
orgIds.length > 0
|
||||
? or([
|
||||
eb("userId", "=", userId),
|
||||
eb("organizationId", "in", orgIds),
|
||||
])
|
||||
: eb("userId", "=", userId),
|
||||
]),
|
||||
);
|
||||
|
||||
const project = await query.executeTakeFirst();
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
return project;
|
||||
}),
|
||||
|
||||
listApiKeys: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async (opts) => {
|
||||
const { userId } = opts.ctx.auth;
|
||||
const { projectId } = opts.input;
|
||||
|
||||
const apiKeys = await opts.ctx.db
|
||||
.selectFrom("ApiKey")
|
||||
.select([
|
||||
"id",
|
||||
"name",
|
||||
"key",
|
||||
"createdAt",
|
||||
"lastUsed",
|
||||
"expiresAt",
|
||||
"revokedAt",
|
||||
])
|
||||
.where("projectId", "=", projectId)
|
||||
.where("clerkUserId", "=", userId)
|
||||
// first active, then expired, then revoked
|
||||
.orderBy((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when("revokedAt", "is not", null)
|
||||
.then(3)
|
||||
.when(
|
||||
eb.and([
|
||||
eb("expiresAt", "is not", null),
|
||||
eb("expiresAt", "<", new Date()),
|
||||
]),
|
||||
)
|
||||
.then(2)
|
||||
.else(1)
|
||||
.end(),
|
||||
)
|
||||
.orderBy("createdAt", "desc")
|
||||
.execute();
|
||||
|
||||
// TODO: Project admins should maybe be able to see all keys for the project?
|
||||
|
||||
return apiKeys;
|
||||
}),
|
||||
|
||||
createApiKey: protectedProcedure
|
||||
.input(createApiKeySchema)
|
||||
.mutation(async (opts) => {
|
||||
const projectId = opts.input.projectId;
|
||||
const userId = opts.ctx.auth.userId;
|
||||
|
||||
// Verify the user has access to the project
|
||||
const project = await opts.ctx.db
|
||||
.selectFrom("Project")
|
||||
.select(["id", "name", "userId", "organizationId"])
|
||||
.where("id", "=", projectId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (project.userId && project.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
|
||||
if (project.organizationId) {
|
||||
const orgs = await clerkClient.users.getOrganizationMembershipList({
|
||||
userId,
|
||||
});
|
||||
const isMemberInProjectOrg = orgs.some(
|
||||
(org) => org.organization.id === project.organizationId,
|
||||
);
|
||||
|
||||
if (!isMemberInProjectOrg) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the key
|
||||
const apiKey = "sk_live_" + genId();
|
||||
const apiKeyId = "api_key_" + genId();
|
||||
await opts.ctx.db
|
||||
.insertInto("ApiKey")
|
||||
.values({
|
||||
id: apiKeyId,
|
||||
name: opts.input.name,
|
||||
key: apiKey,
|
||||
expiresAt: opts.input.expiresAt,
|
||||
projectId: opts.input.projectId,
|
||||
clerkUserId: userId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
return apiKey;
|
||||
}),
|
||||
|
||||
revokeApiKeys: protectedProcedure
|
||||
.input(z.object({ ids: z.string().array() }))
|
||||
.mutation(async (opts) => {
|
||||
const { userId } = opts.ctx.auth;
|
||||
|
||||
const result = await opts.ctx.db
|
||||
.updateTable("ApiKey")
|
||||
.set({ revokedAt: new Date() })
|
||||
.where("id", "in", opts.input.ids)
|
||||
.where("clerkUserId", "=", String(userId))
|
||||
.where("revokedAt", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (result.numUpdatedRows === BigInt(0)) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "API key not found",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, numRevoked: result.numUpdatedRows };
|
||||
}),
|
||||
|
||||
rollApiKey: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async (opts) => {
|
||||
const apiKey = await opts.ctx.db
|
||||
.selectFrom("ApiKey")
|
||||
.select(["id"])
|
||||
.where("id", "=", opts.input.id)
|
||||
.where("clerkUserId", "=", opts.ctx.auth.userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "API key not found",
|
||||
});
|
||||
}
|
||||
|
||||
const newKey = "sk_live_" + genId();
|
||||
await opts.ctx.db
|
||||
.updateTable("ApiKey")
|
||||
.set({ key: newKey })
|
||||
.where("id", "=", opts.input.id)
|
||||
.execute();
|
||||
|
||||
return newKey;
|
||||
}),
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { currentUser } from "@clerk/nextjs";
|
||||
import * as currencies from "@dinero.js/currencies";
|
||||
import { dinero } from "dinero.js";
|
||||
import * as z from "zod";
|
||||
|
||||
import { PLANS, stripe } from "@acme/stripe";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { purchaseOrgSchema } from "../validators";
|
||||
|
||||
export const stripeRouter = createTRPCRouter({
|
||||
createSession: protectedProcedure
|
||||
.input(z.object({ planId: z.string() }))
|
||||
.mutation(async (opts) => {
|
||||
const { userId } = opts.ctx.auth;
|
||||
|
||||
const customer = await opts.ctx.db
|
||||
.selectFrom("Customer")
|
||||
.select(["id", "plan", "stripeId"])
|
||||
.where("clerkUserId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const returnUrl = env.NEXTJS_URL + "/dashboard";
|
||||
|
||||
if (customer && customer.plan !== "FREE") {
|
||||
/**
|
||||
* User is subscribed, create a billing portal session
|
||||
*/
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.stripeId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
return { success: true as const, url: session.url };
|
||||
}
|
||||
|
||||
/**
|
||||
* User is not subscribed, create a checkout session
|
||||
* Use existing email address if available
|
||||
*/
|
||||
|
||||
const user = await currentUser();
|
||||
const email = user?.emailAddresses.find(
|
||||
(addr) => addr.id === user?.primaryEmailAddressId,
|
||||
)?.emailAddress;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: email,
|
||||
client_reference_id: userId,
|
||||
subscription_data: { metadata: { userId } },
|
||||
cancel_url: returnUrl,
|
||||
success_url: returnUrl,
|
||||
line_items: [{ price: PLANS.PRO?.priceId, quantity: 1 }],
|
||||
});
|
||||
|
||||
if (!session.url) return { success: false as const };
|
||||
return { success: true as const, url: session.url };
|
||||
}),
|
||||
|
||||
plans: publicProcedure.query(async () => {
|
||||
const proPrice = await stripe.prices.retrieve(PLANS.PRO.priceId);
|
||||
const stdPrice = await stripe.prices.retrieve(PLANS.STANDARD.priceId);
|
||||
|
||||
return [
|
||||
{
|
||||
...PLANS.STANDARD,
|
||||
price: dinero({
|
||||
amount: stdPrice.unit_amount!,
|
||||
currency:
|
||||
currencies[stdPrice.currency as keyof typeof currencies] ??
|
||||
currencies.USD,
|
||||
}),
|
||||
},
|
||||
{
|
||||
...PLANS.PRO,
|
||||
price: dinero({
|
||||
amount: proPrice.unit_amount!,
|
||||
currency:
|
||||
currencies[proPrice.currency as keyof typeof currencies] ??
|
||||
currencies.USD,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}),
|
||||
|
||||
purchaseOrg: protectedProcedure
|
||||
.input(purchaseOrgSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { userId } = opts.ctx.auth;
|
||||
const { orgName, planId } = opts.input;
|
||||
|
||||
const baseUrl = new URL(opts.ctx.req?.nextUrl ?? env.NEXTJS_URL).origin;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
client_reference_id: userId,
|
||||
subscription_data: {
|
||||
metadata: { userId, organizationName: orgName },
|
||||
},
|
||||
success_url: baseUrl + "/onboarding",
|
||||
cancel_url: baseUrl,
|
||||
line_items: [{ price: planId, quantity: 1 }],
|
||||
});
|
||||
|
||||
if (!session.url) return { success: false as const };
|
||||
return { success: true as const, url: session.url };
|
||||
}),
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { dinero } from "dinero.js";
|
||||
import type { Dinero, DineroSnapshot } from "dinero.js";
|
||||
import superjson from "superjson";
|
||||
import type { JSONValue } from "superjson/dist/types";
|
||||
|
||||
/**
|
||||
* TODO: Maybe put this in a shared package that can be safely shared between `api`, `nextjs` and `expo` packages
|
||||
*/
|
||||
superjson.registerCustom(
|
||||
{
|
||||
isApplicable: (val): val is Dinero<number> => {
|
||||
try {
|
||||
// if this doesn't crash we're kinda sure it's a Dinero instance
|
||||
(val as Dinero<number>).calculator.add(1, 2);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
serialize: (val) => {
|
||||
return val.toJSON() as JSONValue;
|
||||
},
|
||||
deserialize: (val) => {
|
||||
return dinero(val as DineroSnapshot<number>);
|
||||
},
|
||||
},
|
||||
"Dinero",
|
||||
);
|
||||
|
||||
export const transformer = superjson;
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
||||
* 1. You want to modify request context (see Part 1)
|
||||
* 2. You want to create a new middleware or type of procedure (see Part 3)
|
||||
*
|
||||
* tl;dr - this is where all the tRPC server stuff is created and plugged in.
|
||||
* The pieces you will need to use are documented accordingly near the end
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import type {
|
||||
SignedInAuthObject,
|
||||
SignedOutAuthObject,
|
||||
} from "@clerk/nextjs/server";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { db } from "@acme/db";
|
||||
|
||||
import { transformer } from "./transformer";
|
||||
|
||||
type AuthContext = SignedInAuthObject | SignedOutAuthObject;
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
*
|
||||
* This section defines the "contexts" that are available in the backend API
|
||||
*
|
||||
* These allow you to access things like the database, the session, etc, when
|
||||
* processing a request
|
||||
*
|
||||
*/
|
||||
interface CreateContextOptions {
|
||||
headers: Headers;
|
||||
auth: AuthContext;
|
||||
apiKey?: string | null;
|
||||
req?: NextRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper generates the "internals" for a tRPC context. If you need to use
|
||||
* it, you can export it from here
|
||||
*
|
||||
* Examples of things you may need it for:
|
||||
* - testing, so we dont have to mock Next.js' req/res
|
||||
* - trpc's `createSSGHelpers` where we don't have req/res
|
||||
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
|
||||
*/
|
||||
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
return {
|
||||
...opts,
|
||||
db,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the actual context you'll use in your router. It will be used to
|
||||
* process every request that goes through your tRPC endpoint
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: {
|
||||
headers: Headers;
|
||||
auth: AuthContext;
|
||||
req?: NextRequest;
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
}) => {
|
||||
const apiKey = opts.req?.headers.get("x-acme-api-key");
|
||||
|
||||
return createInnerTRPCContext({
|
||||
auth: opts.auth,
|
||||
apiKey,
|
||||
req: opts.req,
|
||||
headers: opts.headers,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
* This is where the trpc api is initialized, connecting the context and
|
||||
* transformer
|
||||
*/
|
||||
export const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||
*
|
||||
* These are the pieces you use to build your tRPC API. You should import these
|
||||
* a lot in the /src/server/api/routers folder
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is how you create new routers and subrouters in your tRPC API
|
||||
* @see https://trpc.io/docs/router
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
*
|
||||
* This is the base piece you use to build new queries and mutations on your
|
||||
* tRPC API. It does not guarantee that a user querying is authorized, but you
|
||||
* can still access user session data if they are logged in
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
/**
|
||||
* Reusable procedure that enforces users are logged in before running the
|
||||
* code
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.auth?.userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
auth: {
|
||||
...ctx.auth,
|
||||
userId: ctx.auth.userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Reusable procedure that enforces users are part of an organization before
|
||||
* running the code
|
||||
*/
|
||||
export const protectedOrgProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (!ctx.auth?.orgId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You must be in an organization to perform this action",
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
auth: {
|
||||
...ctx.auth,
|
||||
orgId: ctx.auth.orgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Procedure that enforces users are admins of an organization before running
|
||||
* the code
|
||||
*/
|
||||
export const protectedAdminProcedure = protectedOrgProcedure.use(
|
||||
({ ctx, next }) => {
|
||||
if (ctx.auth.orgRole !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You must be an admin to perform this action",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
auth: {
|
||||
...ctx.auth,
|
||||
orgRole: ctx.auth.orgRole,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Procedure to authenticate API requests with an API key
|
||||
*/
|
||||
export const protectedApiProcedure = t.procedure.use(async ({ ctx, next }) => {
|
||||
if (!ctx.apiKey) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
// Check db for API key
|
||||
const apiKey = await ctx.db
|
||||
.selectFrom("ApiKey")
|
||||
.select(["id", "key", "projectId"])
|
||||
.where("ApiKey.key", "=", ctx.apiKey)
|
||||
.where("revokedAt", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
void ctx.db
|
||||
.updateTable("ApiKey")
|
||||
.set({ lastUsed: new Date() })
|
||||
.where("id", "=", apiKey.id)
|
||||
.execute();
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
apiKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Procedure to parse form data and put it in the rawInput and authenticate requests with an API key
|
||||
*/
|
||||
export const protectedApiFormDataProcedure = protectedApiProcedure.use(
|
||||
async function formData(opts) {
|
||||
const formData = await opts.ctx.req?.formData?.();
|
||||
if (!formData) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||
|
||||
return opts.next({
|
||||
input: formData,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,55 +0,0 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { PLANS } from "@acme/stripe/plans";
|
||||
|
||||
/**
|
||||
* Shared validators used in both the frontend and backend
|
||||
*/
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(5, "Name must be at least 5 characters"),
|
||||
url: z.string().url("Must be a valid URL").optional(),
|
||||
});
|
||||
export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||
|
||||
export const renameProjectSchema = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string().min(5, "Name must be at least 5 characters"),
|
||||
});
|
||||
export type RenameProject = z.infer<typeof renameProjectSchema>;
|
||||
|
||||
export const purchaseOrgSchema = z.object({
|
||||
orgName: z.string().min(5, "Name must be at least 5 characters"),
|
||||
planId: z.string().refine(
|
||||
(str) =>
|
||||
Object.values(PLANS)
|
||||
.map((p) => p.priceId)
|
||||
.includes(str),
|
||||
"Invalid planId",
|
||||
),
|
||||
});
|
||||
export type PurchaseOrg = z.infer<typeof purchaseOrgSchema>;
|
||||
|
||||
export const createApiKeySchema = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
expiresAt: z.date().optional(),
|
||||
});
|
||||
export type CreateApiKey = z.infer<typeof createApiKeySchema>;
|
||||
|
||||
export const MEMBERSHIP = {
|
||||
Member: "basic_member",
|
||||
Admin: "admin",
|
||||
} as const;
|
||||
|
||||
export const inviteOrgMemberSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.nativeEnum(MEMBERSHIP),
|
||||
});
|
||||
export type InviteOrgMember = z.infer<typeof inviteOrgMemberSchema>;
|
||||
|
||||
export const transferToOrgSchema = z.object({
|
||||
projectId: z.string(),
|
||||
orgId: z.string(),
|
||||
});
|
||||
export type TransferToOrg = z.infer<typeof transferToOrgSchema>;
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@acme/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
45
packages/billing/package.json
Normal file
45
packages/billing/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@kit/billing",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/create-billing-schema.ts",
|
||||
"./components/*": "./src/components/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/ui": "0.1.0",
|
||||
"lucide-react": "^0.361.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
324
packages/billing/src/components/pricing-table.tsx
Normal file
324
packages/billing/src/components/pricing-table.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircleIcon, SparklesIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { BillingSchema, getPlanIntervals } from '../create-billing-schema';
|
||||
|
||||
type Config = z.infer<typeof BillingSchema>;
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
}
|
||||
|
||||
export function PricingTable({
|
||||
config,
|
||||
paths,
|
||||
CheckoutButtonRenderer,
|
||||
}: {
|
||||
config: Config;
|
||||
paths: Paths;
|
||||
|
||||
CheckoutButtonRenderer?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config);
|
||||
|
||||
const [planVariant, setPlanVariant] = useState<string>(
|
||||
intervals[0] as string,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={planVariant}
|
||||
setInterval={setPlanVariant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-start space-y-6 lg:space-y-0' +
|
||||
' justify-center lg:flex-row lg:space-x-4'
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === planVariant,
|
||||
);
|
||||
|
||||
if (!plan || product.hidden) {
|
||||
console.warn(`No plan found for ${product.name}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingItem(
|
||||
props: React.PropsWithChildren<{
|
||||
paths: {
|
||||
signUp: string;
|
||||
};
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
price: string;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
CheckoutButton?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
|
||||
product: {
|
||||
name: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
highlighted?: boolean;
|
||||
features: string[];
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const highlighted = props.product.highlighted ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
`
|
||||
relative flex w-full flex-col justify-between space-y-6 rounded-lg
|
||||
p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12
|
||||
`,
|
||||
{
|
||||
['dark:border-dark-900 border border-gray-100']: !highlighted,
|
||||
['border-primary border-2']: highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2.5'}>
|
||||
<div className={'flex items-center space-x-2.5'}>
|
||||
<Heading level={4}>
|
||||
<b className={'font-semibold'}>{props.product.name}</b>
|
||||
</Heading>
|
||||
|
||||
<If condition={props.product.badge}>
|
||||
<div
|
||||
className={cn(
|
||||
`flex space-x-1 rounded-md px-2 py-1 text-xs font-medium`,
|
||||
{
|
||||
['text-primary-foreground bg-primary']: highlighted,
|
||||
['bg-gray-50 text-gray-500 dark:text-gray-800']: !highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<If condition={highlighted}>
|
||||
<SparklesIcon className={'mr-1 h-4 w-4'} />
|
||||
</If>
|
||||
|
||||
<span>{props.product.badge}</span>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
{props.product.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>{props.plan.price}</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
<span className={cn(`text-muted-foreground text-base lowercase`)}>
|
||||
<span>/</span>
|
||||
<span>{props.plan.interval}</span>
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'text-current'}>
|
||||
<FeaturesList features={props.product.features} />
|
||||
</div>
|
||||
|
||||
<If condition={props.selectable}>
|
||||
<If
|
||||
condition={props.plan.id && props.CheckoutButton}
|
||||
fallback={
|
||||
<DefaultCheckoutButton
|
||||
signUpPath={props.paths.signUp}
|
||||
highlighted={highlighted}
|
||||
plan={props.plan}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(CheckoutButton) => (
|
||||
<CheckoutButton highlighted={highlighted} planId={props.plan.id} />
|
||||
)}
|
||||
</If>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesList(
|
||||
props: React.PropsWithChildren<{
|
||||
features: string[];
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-2'}>
|
||||
{props.features.map((feature) => {
|
||||
return (
|
||||
<ListItem key={feature}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.features.${feature}`}
|
||||
defaults={feature}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({ children }: React.PropsWithChildren) {
|
||||
// little trick to re-animate the price when switching plans
|
||||
const key = Math.random();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`animate-in slide-in-from-left-4 fade-in duration-500`}
|
||||
>
|
||||
<span
|
||||
className={'text-2xl font-bold lg:text-3xl xl:text-4xl 2xl:text-5xl'}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={'flex items-center space-x-3 font-medium'}>
|
||||
<div>
|
||||
<CheckCircleIcon className={'h-5 text-green-500'} />
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground text-sm'}>{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: string[];
|
||||
interval: string;
|
||||
setInterval: (interval: string) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
{props.intervals.map((plan, index) => {
|
||||
const selected = plan === props.interval;
|
||||
|
||||
const className = cn('focus:!ring-0 !outline-none', {
|
||||
'rounded-r-none border-r-transparent': index === 0,
|
||||
'rounded-l-none': index === props.intervals.length - 1,
|
||||
['hover:bg-gray-50 dark:hover:bg-background/80']: !selected,
|
||||
['text-primary-800 dark:text-primary-500 font-semibold' +
|
||||
' hover:bg-background hover:text-initial']: selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
variant={'outline'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<If condition={selected}>
|
||||
<CheckCircleIcon className={'h-4 text-green-500'} />
|
||||
</If>
|
||||
|
||||
<span className={'capitalize'}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.interval.${plan}`}
|
||||
defaults={plan}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultCheckoutButton(
|
||||
props: React.PropsWithChildren<{
|
||||
plan: {
|
||||
id: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
signUpPath: string;
|
||||
highlighted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const linkHref =
|
||||
props.plan.href ?? `${props.signUpPath}?utm_source=${props.plan.id}` ?? '';
|
||||
|
||||
const label = props.plan.label ?? 'common:getStarted';
|
||||
|
||||
return (
|
||||
<div className={'bottom-0 left-0 w-full p-0'}>
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={props.highlighted ? 'default' : 'outline'}
|
||||
>
|
||||
<Trans i18nKey={label} defaults={label} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
packages/billing/src/create-billing-schema.ts
Normal file
95
packages/billing/src/create-billing-schema.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const Interval = z.enum(['month', 'year']);
|
||||
const PaymentType = z.enum(['recurring', 'one-time']);
|
||||
const BillingProvider = z.enum(['stripe']);
|
||||
|
||||
const PlanSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
price: z.string().min(1).max(100),
|
||||
trialPeriodDays: z.number().optional(),
|
||||
interval: Interval,
|
||||
perSeat: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const ProductSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
currency: z.string().optional().default('USD'),
|
||||
plans: z.array(PlanSchema),
|
||||
features: z.array(z.string()),
|
||||
badge: z.string().optional(),
|
||||
highlighted: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
paymentType: PaymentType.optional().default('recurring'),
|
||||
});
|
||||
|
||||
export const BillingSchema = z
|
||||
.object({
|
||||
products: z.array(ProductSchema),
|
||||
provider: BillingProvider,
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe product ids
|
||||
const ids = schema.products.map((product) => product.id);
|
||||
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
return {
|
||||
message: 'Duplicate product IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe plan ids
|
||||
const planIds = schema.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.id),
|
||||
);
|
||||
|
||||
if (new Set(planIds).size !== planIds.length) {
|
||||
return {
|
||||
message: 'Duplicate plan IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Create and validate the billing schema
|
||||
* @param config
|
||||
*/
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of billing plans based on the provided configuration.
|
||||
*
|
||||
* @param {Object} config - The configuration object containing product and plan information.
|
||||
* @return {Array} - An array of billing plans.
|
||||
*/
|
||||
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
|
||||
return config.products.flatMap((product) => product.plans);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the intervals of all plans specified in the given configuration.
|
||||
*
|
||||
* @param {Object} config - The billing configuration containing products and plans.
|
||||
* @returns {Array} - An array of intervals.
|
||||
*/
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
config.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.interval),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
8
packages/billing/tsconfig.json
Normal file
8
packages/billing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Generated by prisma/post-generate.ts
|
||||
|
||||
import { Kysely } from "kysely";
|
||||
import { PlanetScaleDialect } from "kysely-planetscale";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
import type { DB } from "./prisma/types";
|
||||
|
||||
export { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
|
||||
|
||||
export * from "./prisma/types";
|
||||
export * from "./prisma/enums";
|
||||
|
||||
export const db = new Kysely<DB>({
|
||||
dialect: new PlanetScaleDialect({
|
||||
url: process.env.DATABASE_URL,
|
||||
}),
|
||||
});
|
||||
|
||||
// Use custom alphabet without special chars for less chaotic, copy-able URLs
|
||||
// Will not collide for a long long time: https://zelark.github.io/nano-id-cc/
|
||||
export const genId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16);
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "@acme/db",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"db:generate": "pnpm with-env prisma generate",
|
||||
"db:push": "pnpm with-env prisma db push --skip-generate",
|
||||
"studio": "pnpm with-env prisma studio --port 5556",
|
||||
"format": "prisma format && prettier --check \"**/*.{mjs,ts,json}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env.local --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@planetscale/database": "^1.14.0",
|
||||
"kysely": "^0.27.2",
|
||||
"kysely-planetscale": "^1.4.0",
|
||||
"nanoid": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@acme/eslint-config": "^0.2.0",
|
||||
"@acme/prettier-config": "^0.1.0",
|
||||
"@acme/tsconfig": "^0.1.0",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.8.1",
|
||||
"prisma-kysely": "^1.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"@acme/eslint-config/base"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-definitions": "off"
|
||||
}
|
||||
},
|
||||
"prettier": "@acme/prettier-config"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const ProjectTier = {
|
||||
FREE: "FREE",
|
||||
PRO: "PRO",
|
||||
} as const;
|
||||
export type ProjectTier = (typeof ProjectTier)[keyof typeof ProjectTier];
|
||||
export const SubscriptionPlan = {
|
||||
FREE: "FREE",
|
||||
STANDARD: "STANDARD",
|
||||
PRO: "PRO",
|
||||
} as const;
|
||||
export type SubscriptionPlan =
|
||||
(typeof SubscriptionPlan)[keyof typeof SubscriptionPlan];
|
||||
@@ -1,83 +0,0 @@
|
||||
generator kysely {
|
||||
provider = "prisma-kysely"
|
||||
output = "."
|
||||
enumFileName = "enums.ts"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
relationMode = "prisma"
|
||||
}
|
||||
|
||||
enum ProjectTier {
|
||||
FREE
|
||||
PRO
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// A project is tied to a Clerk User or Organization
|
||||
organizationId String? @db.VarChar(36) // uuid v4
|
||||
userId String? @db.VarChar(36) // uuid v4
|
||||
|
||||
name String
|
||||
tier ProjectTier @default(FREE)
|
||||
url String?
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
FREE
|
||||
STANDARD
|
||||
PRO
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
stripeId String @unique
|
||||
subscriptionId String?
|
||||
clerkUserId String
|
||||
clerkOrganizationId String?
|
||||
name String?
|
||||
plan SubscriptionPlan?
|
||||
paidUntil DateTime?
|
||||
endsAt DateTime?
|
||||
|
||||
@@index([clerkUserId])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
lastUsed DateTime?
|
||||
revokedAt DateTime?
|
||||
|
||||
projectId String @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
clerkUserId String @db.VarChar(36) // uuid v4
|
||||
|
||||
name String @default("Secret Key")
|
||||
key String @unique
|
||||
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
model Ingestion {
|
||||
id String @id @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
projectId String @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
apiKeyId String @db.VarChar(30) // prefix_ + nanoid (16)
|
||||
|
||||
schema Json
|
||||
hash String @db.VarChar(40) // sha1
|
||||
parent String? @db.VarChar(40) // sha1
|
||||
origin String @db.VarChar(100)
|
||||
|
||||
@@index([projectId])
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
import type { ProjectTier, SubscriptionPlan } from "./enums";
|
||||
|
||||
export type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
expiresAt: Timestamp | null;
|
||||
lastUsed: Timestamp | null;
|
||||
revokedAt: Timestamp | null;
|
||||
projectId: string;
|
||||
clerkUserId: string;
|
||||
name: Generated<string>;
|
||||
key: string;
|
||||
};
|
||||
export type Customer = {
|
||||
id: string;
|
||||
stripeId: string;
|
||||
subscriptionId: string | null;
|
||||
clerkUserId: string;
|
||||
clerkOrganizationId: string | null;
|
||||
name: string | null;
|
||||
plan: SubscriptionPlan | null;
|
||||
paidUntil: Timestamp | null;
|
||||
endsAt: Timestamp | null;
|
||||
};
|
||||
export type Ingestion = {
|
||||
id: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
projectId: string;
|
||||
apiKeyId: string;
|
||||
schema: unknown;
|
||||
hash: string;
|
||||
parent: string | null;
|
||||
origin: string;
|
||||
};
|
||||
export type Project = {
|
||||
id: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
organizationId: string | null;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
tier: Generated<ProjectTier>;
|
||||
url: string | null;
|
||||
};
|
||||
export type DB = {
|
||||
ApiKey: ApiKey;
|
||||
Customer: Customer;
|
||||
Ingestion: Ingestion;
|
||||
Project: Project;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@acme/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
},
|
||||
"include": ["*.ts", "prisma", "src"],
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
39
packages/emails/package.json
Normal file
39
packages/emails/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
{
|
||||
"name": "@kit/emails",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
62
packages/emails/src/account-delete.email.tsx
Normal file
62
packages/emails/src/account-delete.email.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
userDisplayName: string;
|
||||
}
|
||||
|
||||
export function renderAccountDeleteEmail(props: Props) {
|
||||
const previewText = `We have deleted your ${props.productName} account`;
|
||||
|
||||
return render(
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-gray-50 font-sans">
|
||||
<Container className="mx-auto my-[40px] w-[465px] rounded-lg border border-solid border-[#eaeaea] bg-white p-[20px]">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-bold text-black">
|
||||
{previewText}
|
||||
</Heading>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
Hello {props.userDisplayName},
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
This is to confirm that we've processed your request to
|
||||
delete your account with {props.productName}.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
We're sorry to see you go. Please note that this action is
|
||||
irreversible, and we'll make sure to delete all of your data
|
||||
from our systems.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
We thank you again for using {props.productName}.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
Best,
|
||||
<br />
|
||||
The {props.productName} Team
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>,
|
||||
);
|
||||
}
|
||||
2
packages/emails/src/index.ts
Normal file
2
packages/emails/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './invite.email';
|
||||
export * from './account-delete.email';
|
||||
90
packages/emails/src/invite.email.tsx
Normal file
90
packages/emails/src/invite.email.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
render,
|
||||
} from '@react-email/components';
|
||||
|
||||
interface Props {
|
||||
organizationName: string;
|
||||
organizationLogo?: string;
|
||||
inviter: string | undefined;
|
||||
invitedUserEmail: string;
|
||||
link: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export function renderInviteEmail(props: Props) {
|
||||
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
|
||||
|
||||
return render(
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-gray-50 font-sans">
|
||||
<Container className="mx-auto my-[40px] w-[465px] rounded-lg border border-solid border-[#eaeaea] bg-white p-[20px]">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
|
||||
Join <strong>{props.organizationName}</strong> on{' '}
|
||||
<strong>{props.productName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
Hello {props.invitedUserEmail},
|
||||
</Text>
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<strong>{props.inviter}</strong> has invited you to the{' '}
|
||||
<strong>{props.organizationName}</strong> team on{' '}
|
||||
<strong>{props.productName}</strong>.
|
||||
</Text>
|
||||
{props.organizationLogo && (
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Img
|
||||
className="rounded-full"
|
||||
src={props.organizationLogo}
|
||||
width="64"
|
||||
height="64"
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
)}
|
||||
<Section className="mb-[32px] mt-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-[20px] py-[12px] text-center text-[12px] font-semibold text-white no-underline"
|
||||
href={props.link}
|
||||
>
|
||||
Join {props.organizationName}
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
or copy and paste this URL into your browser:{' '}
|
||||
<Link href={props.link} className="text-blue-600 no-underline">
|
||||
{props.link}
|
||||
</Link>
|
||||
</Text>
|
||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
||||
This invitation was intended for{' '}
|
||||
<span className="text-black">{props.invitedUserEmail}</span>.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>,
|
||||
);
|
||||
}
|
||||
8
packages/emails/tsconfig.json
Normal file
8
packages/emails/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
49
packages/features/accounts/package.json
Normal file
49
packages/features/accounts/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@kit/accounts",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx",
|
||||
"./account-selector": "./src/components/account-selector.tsx",
|
||||
"./personal-account-settings": "./src/components/personal-account-settings/index.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"lucide-react": "^0.360.0",
|
||||
"@radix-ui/react-icons": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
218
packages/features/accounts/src/components/account-selector.tsx
Normal file
218
packages/features/accounts/src/components/account-selector.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
|
||||
import { CheckIcon, PlusIcon } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@kit/ui/command';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { CreateOrganizationAccountDialog } from './create-organization-account-dialog';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<{
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image?: string | null;
|
||||
}>;
|
||||
|
||||
features: {
|
||||
enableOrganizationAccounts: boolean;
|
||||
enableOrganizationCreation: boolean;
|
||||
};
|
||||
|
||||
selectedAccount?: string;
|
||||
collapsed?: boolean;
|
||||
|
||||
onAccountChange: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
const PERSONAL_ACCOUNT_SLUG = 'personal';
|
||||
|
||||
export function AccountSelector({
|
||||
accounts,
|
||||
selectedAccount,
|
||||
onAccountChange,
|
||||
features = {
|
||||
enableOrganizationAccounts: true,
|
||||
enableOrganizationCreation: true,
|
||||
},
|
||||
collapsed = false,
|
||||
}: React.PropsWithChildren<AccountSelectorProps>) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
|
||||
|
||||
const [value, setValue] = useState<string>(
|
||||
selectedAccount ?? PERSONAL_ACCOUNT_SLUG,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(selectedAccount ?? PERSONAL_ACCOUNT_SLUG);
|
||||
}, [selectedAccount]);
|
||||
|
||||
const Icon = (props: { item: string }) => {
|
||||
return (
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
value === props.item ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const selected = accounts.find((account) => account.value === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={collapsed ? 'icon' : 'default'}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full', {
|
||||
'justify-between': !collapsed,
|
||||
'justify-center': collapsed,
|
||||
})}
|
||||
>
|
||||
<If
|
||||
condition={selected}
|
||||
fallback={
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<PersonIcon className="h-4 w-4" />
|
||||
|
||||
<span
|
||||
className={cn({
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
Personal Account
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(account) => (
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<Avatar className={'h-6 w-6'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span
|
||||
className={cn({
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
{account.label}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<CaretSortIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search account..." className="h-9" />
|
||||
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onAccountChange(undefined)}
|
||||
value={PERSONAL_ACCOUNT_SLUG}
|
||||
>
|
||||
<PersonIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
<span>Personal Account</span>
|
||||
|
||||
<Icon item={PERSONAL_ACCOUNT_SLUG} />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<If condition={features.enableOrganizationAccounts}>
|
||||
<If condition={accounts.length > 0}>
|
||||
<CommandGroup heading={'Your Organizations'}>
|
||||
{(accounts ?? []).map((account) => (
|
||||
<CommandItem
|
||||
key={account.value}
|
||||
value={account.value ?? ''}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
|
||||
if (onAccountChange) {
|
||||
onAccountChange(currentValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar className={'mr-2 h-6 w-6'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{account.label}</span>
|
||||
|
||||
<Icon item={account.value ?? ''} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<If condition={features.enableOrganizationCreation}>
|
||||
<CommandGroup>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setIsCreatingAccount(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
|
||||
<span>Create Organization</span>
|
||||
</Button>
|
||||
</CommandGroup>
|
||||
</If>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<If condition={features.enableOrganizationCreation}>
|
||||
<CreateOrganizationAccountDialog
|
||||
isOpen={isCreatingAccount}
|
||||
setIsOpen={setIsCreatingAccount}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { CreateOrganizationAccountSchema } from '../schema/create-organization.schema';
|
||||
import { createOrganizationAccountAction } from '../server/accounts-server-actions';
|
||||
|
||||
export function CreateOrganizationAccountDialog(
|
||||
props: React.PropsWithChildren<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:createOrganizationModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<CreateOrganizationAccountForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateOrganizationAccountForm() {
|
||||
const [error, setError] = useState<boolean>();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm<z.infer<typeof CreateOrganizationAccountSchema>>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
resolver: zodResolver(CreateOrganizationAccountSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createOrganizationAccountAction(data);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={error}>
|
||||
<CreateOrganizationErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'name'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'organization:organizationNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'create-organization-name-input'}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Your organization name should be unique and descriptive.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-create-organization-button'}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trans i18nKey={'organization:createOrganizationSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateOrganizationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:createOrganizationErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:createOrganizationErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Session } from '@supabase/gotrue-js';
|
||||
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
HomeIcon,
|
||||
LogOutIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
ShieldIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { usePersonalAccountData } from '../hooks/use-personal-account-data';
|
||||
|
||||
export function PersonalAccountDropdown({
|
||||
className,
|
||||
session,
|
||||
signOutRequested,
|
||||
showProfileName,
|
||||
paths,
|
||||
}: {
|
||||
className?: string;
|
||||
session: Session | undefined;
|
||||
signOutRequested: () => unknown;
|
||||
showProfileName?: boolean;
|
||||
paths: {
|
||||
home: string;
|
||||
};
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData();
|
||||
const authUser = session?.user;
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = authUser?.email ?? undefined;
|
||||
const phone = authUser?.phone ?? undefined;
|
||||
|
||||
return email ?? phone;
|
||||
}, [authUser?.email, authUser?.phone]);
|
||||
|
||||
const displayName = personalAccountData?.name ?? authUser?.email ?? '';
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
return authUser?.app_metadata.role === 'super-admin';
|
||||
}, [authUser]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open your profile menu"
|
||||
data-test={'profile-dropdown-trigger'}
|
||||
className={cn(
|
||||
'animate-in fade-in group flex cursor-pointer items-center focus:outline-none',
|
||||
className ?? '',
|
||||
{
|
||||
['items-center space-x-2.5 rounded-lg border' +
|
||||
' hover:bg-muted p-2 transition-colors']: showProfileName,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ProfileAvatar
|
||||
displayName={displayName ?? authUser?.email ?? ''}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
|
||||
<If condition={showProfileName}>
|
||||
<div className={'flex w-full flex-col truncate text-left'}>
|
||||
<span className={'truncate text-sm'}>{displayName}</span>
|
||||
|
||||
<span className={'text-muted-foreground truncate text-xs'}>
|
||||
{signedInAsLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EllipsisVerticalIcon
|
||||
className={'text-muted-foreground hidden h-8 group-hover:flex'}
|
||||
/>
|
||||
</If>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={'!min-w-[15rem]'}
|
||||
collisionPadding={{ right: 20, left: 20 }}
|
||||
sideOffset={20}
|
||||
>
|
||||
<DropdownMenuItem className={'!h-10 rounded-none'}>
|
||||
<div
|
||||
className={'flex flex-col justify-start truncate text-left text-xs'}
|
||||
>
|
||||
<div className={'text-gray-500'}>
|
||||
<Trans i18nKey={'common:signedInAs'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'block truncate'}>{signedInAsLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex items-center space-x-2'}
|
||||
href={paths.home}
|
||||
>
|
||||
<HomeIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:homeTabLabel'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className={'s-full flex items-center space-x-2'} href={'/docs'}>
|
||||
<MessageCircleQuestionIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common:documentation'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={isSuperAdmin}>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex items-center space-x-2'}
|
||||
href={'/admin'}
|
||||
>
|
||||
<ShieldIcon className={'h-5'} />
|
||||
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
role={'button'}
|
||||
className={'cursor-pointer'}
|
||||
onClick={signOutRequested}
|
||||
>
|
||||
<span className={'flex w-full items-center space-x-2'}>
|
||||
<LogOutIcon className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'auth:signOut'} />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import { ErrorBoundary } from '@kit/ui/error-boundary';
|
||||
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function AccountDangerZone() {
|
||||
return <DeleteAccountContainer />;
|
||||
}
|
||||
|
||||
function DeleteAccountContainer() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Heading>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'profile:deleteAccountDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DeleteAccountModal />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountModal() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-test={'delete-account-button'} variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
|
||||
<DeleteAccountForm />
|
||||
</ErrorBoundary>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountForm() {
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={deleteUserAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={'border-destructive text-destructive border p-4 text-sm'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
<Trans i18nKey={'profile:deleteAccountDescription'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:deleteProfileConfirmationInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'delete-account-input-field'}
|
||||
required
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={`DELETE`}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<DeleteAccountSubmitButton />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton() {
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
name={'action'}
|
||||
value={'delete'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
<Trans i18nKey={'profile:deleteAccount'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:deleteAccountErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { AccountDangerZone } from './account-danger-zone';
|
||||
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
|
||||
import { UpdateAccountImageContainer } from './update-account-image-container';
|
||||
import { UpdateEmailFormContainer } from './update-email-form-container';
|
||||
import { UpdatePasswordFormContainer } from './update-password-container';
|
||||
|
||||
export function PersonalAccountSettingsContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
features: {
|
||||
enableAccountDeletion: boolean;
|
||||
};
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-8 pb-32'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Profile Picture</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountImageContainer />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateAccountDetailsFormContainer />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update your Email</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdateEmailFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Update your Password</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<UpdatePasswordFormContainer callbackPath={props.paths.callback} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<If condition={props.features.enableAccountDeletion}>
|
||||
<Card className={'border-destructive border-2'}>
|
||||
<CardHeader>
|
||||
<CardTitle>Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AccountDangerZone />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account-settings-container';
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
import { Alert } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function MultiFactorAuthSetupModal(
|
||||
props: React.PropsWithChildren<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onEnrollSuccess = useCallback(() => {
|
||||
props.setIsOpen(false);
|
||||
|
||||
return toast.success(t(`profile:multiFactorSetupSuccess`));
|
||||
}, [props, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'profile:setupMfaButtonLabel'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<MultiFactorAuthSetupForm
|
||||
onCancel={() => props.setIsOpen(false)}
|
||||
onEnrolled={onEnrollSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiFactorAuthSetupForm({
|
||||
onEnrolled,
|
||||
onCancel,
|
||||
}: React.PropsWithChildren<{
|
||||
onCancel: () => void;
|
||||
onEnrolled: () => void;
|
||||
}>) {
|
||||
const verifyCodeMutation = useVerifyCodeMutation();
|
||||
const [factorId, setFactorId] = useState<string | undefined>();
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const [state, setState] = useState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
setState({
|
||||
loading: true,
|
||||
error: '',
|
||||
});
|
||||
|
||||
if (!factorId || !verificationCode) {
|
||||
return setState({
|
||||
loading: false,
|
||||
error: 'No factor ID or verification code found',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
factorId,
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
onEnrolled();
|
||||
} catch (error) {
|
||||
const message = (error as Error).message || `Unknown error`;
|
||||
|
||||
setState({
|
||||
loading: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}, [onEnrolled, verifyCodeMutation, factorId, verificationCode]);
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:multiFactorSetupError'} />
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<FactorQrCode onCancel={onCancel} onSetFactorId={setFactorId} />
|
||||
</div>
|
||||
|
||||
<If condition={factorId}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
return onSubmit();
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:verificationCode'} />
|
||||
|
||||
<OtpInput
|
||||
onInvalid={() => setVerificationCode('')}
|
||||
onValid={setVerificationCode}
|
||||
/>
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'profile:verifyActivationCodeDescription'} />
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button disabled={!verificationCode} type={'submit'}>
|
||||
{state.loading ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:enableMfaFactor'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorQrCode({
|
||||
onSetFactorId,
|
||||
onCancel,
|
||||
}: React.PropsWithChildren<{
|
||||
onCancel: () => void;
|
||||
onSetFactorId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}>) {
|
||||
const enrollFactorMutation = useEnrollFactor();
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const [factor, setFactor] = useState({
|
||||
name: '',
|
||||
qrCode: '',
|
||||
});
|
||||
|
||||
const factorName = factor.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (!factorName) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await enrollFactorMutation.mutateAsync(factorName);
|
||||
|
||||
if (!data) {
|
||||
return setError(true);
|
||||
}
|
||||
|
||||
// set image
|
||||
setFactor((factor) => {
|
||||
return {
|
||||
...factor,
|
||||
qrCode: data.totp.qr_code,
|
||||
};
|
||||
});
|
||||
|
||||
// dispatch event to set factor ID
|
||||
onSetFactorId(data.id);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
})();
|
||||
}, [onSetFactorId, factorName, enrollFactorMutation]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-2'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<Trans i18nKey={'profile:qrCodeError'} />
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!factorName) {
|
||||
return (
|
||||
<FactorNameForm
|
||||
onCancel={onCancel}
|
||||
onSetFactorName={(name) => {
|
||||
setFactor((factor) => ({ ...factor, name }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<p>
|
||||
<span className={'text-base'}>
|
||||
<Trans i18nKey={'profile:multiFactorModalHeading'} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<QrImage src={factor.qrCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorNameForm(
|
||||
props: React.PropsWithChildren<{
|
||||
onSetFactorName: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}>,
|
||||
) {
|
||||
const inputName = 'factorName';
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = new FormData(event.currentTarget);
|
||||
const name = data.get(inputName) as string;
|
||||
|
||||
props.onSetFactorName(name);
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:factorNameLabel'} />
|
||||
|
||||
<Input autoComplete={'off'} required name={inputName} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'profile:factorNameHint'} />
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'submit'}>
|
||||
<Trans i18nKey={'profile:factorNameSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function QrImage({ src }: { src: string }) {
|
||||
return <Image alt={'QR Code'} src={src} width={160} height={160} />;
|
||||
}
|
||||
|
||||
export default MultiFactorAuthSetupModal;
|
||||
|
||||
function useEnrollFactor() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = useFactorsMutationKey();
|
||||
|
||||
const mutationFn = async (factorName: string) => {
|
||||
const { data, error } = await client.auth.mfa.enroll({
|
||||
friendlyName: factorName,
|
||||
factorType: 'totp',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
|
||||
function useVerifyCodeMutation() {
|
||||
const mutationKey = useFactorsMutationKey();
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationFn = async (params: { factorId: string; code: string }) => {
|
||||
const challenge = await client.auth.mfa.challenge({
|
||||
factorId: params.factorId,
|
||||
});
|
||||
|
||||
if (challenge.error) {
|
||||
throw challenge.error;
|
||||
}
|
||||
|
||||
const challengeId = challenge.data.id;
|
||||
|
||||
const verify = await client.auth.mfa.verify({
|
||||
factorId: params.factorId,
|
||||
code: params.code,
|
||||
challengeId,
|
||||
});
|
||||
|
||||
if (verify.error) {
|
||||
throw verify.error;
|
||||
}
|
||||
|
||||
return verify;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
usePersonalAccountData,
|
||||
useRevalidatePersonalAccountDataQuery,
|
||||
} from '../../hooks/use-personal-account-data';
|
||||
import { UpdateAccountDetailsForm } from './update-account-details-form';
|
||||
|
||||
export function UpdateAccountDetailsFormContainer() {
|
||||
const user = usePersonalAccountData();
|
||||
const invalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
if (!user.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateAccountDetailsForm
|
||||
displayName={user.data.name ?? ''}
|
||||
userId={user.data.id}
|
||||
onUpdate={invalidateUserDataQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { useUpdateAccountData } from '../../hooks/use-update-account';
|
||||
|
||||
type UpdateUserDataParams = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
const AccountInfoSchema = z.object({
|
||||
displayName: z.string().min(2).max(100),
|
||||
});
|
||||
|
||||
export function UpdateAccountDetailsForm({
|
||||
displayName,
|
||||
onUpdate,
|
||||
}: {
|
||||
displayName: string;
|
||||
userId: string;
|
||||
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
|
||||
}) {
|
||||
const updateAccountMutation = useUpdateAccountData();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AccountInfoSchema),
|
||||
defaultValues: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ displayName }: { displayName: string }) => {
|
||||
const data = { name: displayName };
|
||||
|
||||
const promise = updateAccountMutation.mutateAsync(data).then(() => {
|
||||
onUpdate(data);
|
||||
});
|
||||
|
||||
return toast.promise(promise, {
|
||||
success: t(`profile:updateProfileSuccess`),
|
||||
error: t(`profile:updateProfileError`),
|
||||
loading: t(`profile:updateProfileLoading`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-profile-form'}
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name={'displayName'}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:displayNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'profile-display-name'}
|
||||
minLength={2}
|
||||
placeholder={''}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateAccountMutation.isPending}>
|
||||
<Trans i18nKey={'profile:updateProfileSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import ImageUploader from '@kit/ui/image-uploader';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import {
|
||||
usePersonalAccountData,
|
||||
useRevalidatePersonalAccountDataQuery,
|
||||
} from '../../hooks/use-personal-account-data';
|
||||
|
||||
const AVATARS_BUCKET = 'account_image';
|
||||
|
||||
export function UpdateAccountImageContainer() {
|
||||
const accountData = usePersonalAccountData();
|
||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
if (!accountData.data) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadProfileAvatarForm
|
||||
currentPhotoURL={accountData.data.picture_url}
|
||||
userId={accountData.data.id}
|
||||
onAvatarUpdated={revalidateUserDataQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadProfileAvatarForm(props: {
|
||||
currentPhotoURL: string | null;
|
||||
userId: string;
|
||||
onAvatarUpdated: () => void;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const { t } = useTranslation('profile');
|
||||
|
||||
const createToaster = useCallback(
|
||||
(promise: Promise<unknown>) => {
|
||||
return toast.promise(promise, {
|
||||
success: t(`updateProfileSuccess`),
|
||||
error: t(`updateProfileError`),
|
||||
loading: t(`updateProfileLoading`),
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(file: File | null) => {
|
||||
const removeExistingStorageFile = () => {
|
||||
if (props.currentPhotoURL) {
|
||||
return (
|
||||
deleteProfilePhoto(client, props.currentPhotoURL) ??
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
if (file) {
|
||||
const promise = removeExistingStorageFile().then(() =>
|
||||
uploadUserProfilePhoto(client, file, props.userId)
|
||||
.then((pictureUrl) => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: pictureUrl,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
}),
|
||||
);
|
||||
|
||||
createToaster(promise);
|
||||
} else {
|
||||
const promise = removeExistingStorageFile()
|
||||
.then(() => {
|
||||
return client
|
||||
.from('accounts')
|
||||
.update({
|
||||
picture_url: null,
|
||||
})
|
||||
.eq('id', props.userId)
|
||||
.throwOnError();
|
||||
})
|
||||
.then(() => {
|
||||
props.onAvatarUpdated();
|
||||
});
|
||||
|
||||
createToaster(promise);
|
||||
}
|
||||
},
|
||||
[client, createToaster, props],
|
||||
);
|
||||
|
||||
return (
|
||||
<ImageUploader value={props.currentPhotoURL} onValueChange={onValueChange}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'profile:profilePictureHeading'} />
|
||||
</span>
|
||||
|
||||
<span className={'text-xs'}>
|
||||
<Trans i18nKey={'profile:profilePictureSubheading'} />
|
||||
</span>
|
||||
</div>
|
||||
</ImageUploader>
|
||||
);
|
||||
}
|
||||
|
||||
function deleteProfilePhoto(client: SupabaseClient, url: string) {
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const fileName = url.split('/').pop()?.split('?')[0];
|
||||
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
return bucket.remove([fileName]);
|
||||
}
|
||||
|
||||
async function uploadUserProfilePhoto(
|
||||
client: SupabaseClient,
|
||||
photoFile: File,
|
||||
userId: string,
|
||||
) {
|
||||
const bytes = await photoFile.arrayBuffer();
|
||||
const bucket = client.storage.from(AVATARS_BUCKET);
|
||||
const extension = photoFile.name.split('.').pop();
|
||||
const fileName = await getAvatarFileName(userId, extension);
|
||||
|
||||
const result = await bucket.upload(fileName, bytes, {
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
return bucket.getPublicUrl(fileName).data.publicUrl;
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
async function getAvatarFileName(
|
||||
userId: string,
|
||||
extension: string | undefined,
|
||||
) {
|
||||
const { nanoid } = await import('nanoid');
|
||||
const uniqueId = nanoid(16);
|
||||
|
||||
return `${userId}.${extension}?v=${uniqueId}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
|
||||
import { UpdateEmailForm } from './update-email-form';
|
||||
|
||||
export function UpdateEmailFormContainer(props: { callbackPath: string }) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UpdateEmailForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const UpdateEmailSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
repeatEmail: z.string().email(),
|
||||
})
|
||||
.refine(
|
||||
(values) => {
|
||||
return values.email === values.repeatEmail;
|
||||
},
|
||||
{
|
||||
path: ['repeatEmail'],
|
||||
message: 'Emails do not match',
|
||||
},
|
||||
);
|
||||
|
||||
function createEmailResolver(currentEmail: string) {
|
||||
return zodResolver(
|
||||
UpdateEmailSchema.refine(
|
||||
(values) => {
|
||||
return values.email !== currentEmail;
|
||||
},
|
||||
{
|
||||
path: ['email'],
|
||||
message: 'New email must be different from current email',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateEmailForm({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
|
||||
const updateEmail = useCallback(
|
||||
(email: string) => {
|
||||
const redirectTo = new URL(callbackPath, window.location.host).toString();
|
||||
|
||||
// then, we update the user's email address
|
||||
const promise = updateUserMutation.mutateAsync({ email, redirectTo });
|
||||
|
||||
return toast.promise(promise, {
|
||||
success: t(`profile:updateEmailSuccess`),
|
||||
loading: t(`profile:updateEmailLoading`),
|
||||
error: (error: Error) => {
|
||||
return error.message ?? t(`profile:updateEmailError`);
|
||||
},
|
||||
});
|
||||
},
|
||||
[callbackPath, t, updateUserMutation],
|
||||
);
|
||||
|
||||
const currentEmail = user.email;
|
||||
|
||||
const form = useForm({
|
||||
resolver: createEmailResolver(currentEmail!),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
repeatEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
data-test={'update-email-form'}
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
return updateEmail(values.email);
|
||||
})}
|
||||
>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updateEmailSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updateEmailSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:newEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'profile-new-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'profile:repeatEmail'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
data-test={'profile-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button>
|
||||
<Trans i18nKey={'profile:updateEmailSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { UpdatePasswordForm } from './update-password-form';
|
||||
|
||||
export function UpdatePasswordFormContainer(
|
||||
props: React.PropsWithChildren<{
|
||||
callbackPath: string;
|
||||
}>,
|
||||
) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canUpdatePassword = user.identities?.some(
|
||||
(item) => item.provider === `email`,
|
||||
);
|
||||
|
||||
if (!canUpdatePassword) {
|
||||
return <WarnCannotUpdatePasswordAlert />;
|
||||
}
|
||||
|
||||
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
|
||||
}
|
||||
|
||||
function WarnCannotUpdatePasswordAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<Trans i18nKey={'profile:cannotUpdatePassword'} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const PasswordUpdateSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(8).max(99),
|
||||
newPassword: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine(
|
||||
(values) => {
|
||||
return values.newPassword === values.repeatPassword;
|
||||
},
|
||||
{
|
||||
path: ['repeatPassword'],
|
||||
message: 'Passwords do not match',
|
||||
},
|
||||
);
|
||||
|
||||
export const UpdatePasswordForm = ({
|
||||
user,
|
||||
callbackPath,
|
||||
}: {
|
||||
user: User;
|
||||
callbackPath: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const [needsReauthentication, setNeedsReauthentication] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordUpdateSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const updatePasswordFromCredential = useCallback(
|
||||
(password: string) => {
|
||||
const redirectTo = [window.location.origin, callbackPath].join('');
|
||||
|
||||
const promise = updateUserMutation
|
||||
.mutateAsync({ password, redirectTo })
|
||||
.then(() => {
|
||||
form.reset();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.includes('Password update requires reauthentication')) {
|
||||
setNeedsReauthentication(true);
|
||||
}
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
success: t(`profile:updatePasswordSuccess`),
|
||||
error: t(`profile:updatePasswordError`),
|
||||
loading: t(`profile:updatePasswordLoading`),
|
||||
});
|
||||
},
|
||||
[callbackPath, updateUserMutation, t, form],
|
||||
);
|
||||
|
||||
const updatePasswordCallback = useCallback(
|
||||
async ({ newPassword }: { newPassword: string }) => {
|
||||
const email = user.email;
|
||||
|
||||
// if the user does not have an email assigned, it's possible they
|
||||
// don't have an email/password factor linked, and the UI is out of sync
|
||||
if (!email) {
|
||||
return Promise.reject(t(`profile:cannotUpdatePassword`));
|
||||
}
|
||||
|
||||
updatePasswordFromCredential(newPassword);
|
||||
},
|
||||
[user.email, updatePasswordFromCredential, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'update-password-form'}
|
||||
onSubmit={form.handleSubmit(updatePasswordCallback)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={needsReauthentication}>
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:needsReauthentication'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:needsReauthenticationDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:newPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'profile:repeatPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'repeat-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button>
|
||||
<Trans i18nKey={'profile:updatePasswordSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
const queryKey = ['personal-account:data'];
|
||||
|
||||
export function usePersonalAccountData() {
|
||||
const client = useSupabase();
|
||||
|
||||
const queryFn = async () => {
|
||||
const { data, error } = await client.auth.getSession();
|
||||
|
||||
if (!data.session || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await client
|
||||
.from('accounts')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
name,
|
||||
picture_url
|
||||
`,
|
||||
)
|
||||
.eq('primary_owner_user_id', data.session.user.id)
|
||||
.eq('is_personal_account', true)
|
||||
.single();
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevalidatePersonalAccountDataQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
}),
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
29
packages/features/accounts/src/hooks/use-update-account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
type UpdateData = Database['public']['Tables']['accounts']['Update'];
|
||||
|
||||
export function useUpdateAccountData(accountId: string) {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['account:data', accountId];
|
||||
|
||||
const mutationFn = async (data: UpdateData) => {
|
||||
const response = await client.from('accounts').update(data).match({
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateOrganizationAccountSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { CreateOrganizationAccountSchema } from '../schema/create-organization.schema';
|
||||
import { AccountsService } from './services/accounts.service';
|
||||
|
||||
const ORGANIZATION_ACCOUNTS_PATH = z
|
||||
.string({
|
||||
required_error: 'Organization accounts path is required',
|
||||
})
|
||||
.min(1)
|
||||
.parse(process.env.ORGANIZATION_ACCOUNTS_PATH);
|
||||
|
||||
export async function createOrganizationAccountAction(
|
||||
params: z.infer<typeof CreateOrganizationAccountSchema>,
|
||||
) {
|
||||
const { name: accountName } = CreateOrganizationAccountSchema.parse(params);
|
||||
|
||||
const client = getSupabaseServerActionClient();
|
||||
const accountsService = new AccountsService(client);
|
||||
const session = await requireAuth(client);
|
||||
|
||||
if (session.error) {
|
||||
redirect(session.redirectTo);
|
||||
}
|
||||
|
||||
const createAccountResponse =
|
||||
await accountsService.createNewOrganizationAccount({
|
||||
name: accountName,
|
||||
userId: session.data.user.id,
|
||||
});
|
||||
|
||||
if (createAccountResponse.error) {
|
||||
return handleError(
|
||||
createAccountResponse.error,
|
||||
`Error creating organization`,
|
||||
);
|
||||
}
|
||||
|
||||
const accountHomePath =
|
||||
ORGANIZATION_ACCOUNTS_PATH + createAccountResponse.data.slug;
|
||||
|
||||
redirect(accountHomePath);
|
||||
}
|
||||
|
||||
function handleError<Error = unknown>(
|
||||
error: Error,
|
||||
message: string,
|
||||
organizationId?: string,
|
||||
) {
|
||||
const exception = error instanceof Error ? error.message : undefined;
|
||||
|
||||
Logger.error(
|
||||
{
|
||||
exception,
|
||||
organizationId,
|
||||
},
|
||||
message,
|
||||
);
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name AccountsService
|
||||
* @description Service for managing accounts in the application
|
||||
* @param Database - The Supabase database type to use
|
||||
* @example
|
||||
* const client = getSupabaseClient();
|
||||
* const accountsService = new AccountsService(client);
|
||||
*
|
||||
* accountsService.createNewOrganizationAccount({
|
||||
* name: 'My Organization',
|
||||
* userId: '123',
|
||||
* });
|
||||
*/
|
||||
export class AccountsService {
|
||||
private readonly logger = new AccountsServiceLogger();
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
createNewOrganizationAccount(params: { name: string; userId: string }) {
|
||||
this.logger.logCreateNewOrganizationAccount(params);
|
||||
|
||||
return this.client.rpc('create_account', {
|
||||
account_name: params.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AccountsServiceLogger {
|
||||
private namespace = 'accounts';
|
||||
|
||||
logCreateNewOrganizationAccount(params: { name: string; userId: string }) {
|
||||
Logger.info(
|
||||
this.withNamespace(params),
|
||||
`Creating new organization account...`,
|
||||
);
|
||||
}
|
||||
|
||||
private withNamespace(params: object) {
|
||||
return { ...params, name: this.namespace };
|
||||
}
|
||||
}
|
||||
8
packages/features/accounts/tsconfig.json
Normal file
8
packages/features/accounts/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
18
packages/features/admin/package.json
Normal file
18
packages/features/admin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@kit/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint ",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0"
|
||||
}
|
||||
}
|
||||
77
packages/features/admin/src/components/AdminDashboard.tsx
Normal file
77
packages/features/admin/src/components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
interface Data {
|
||||
usersCount: number;
|
||||
organizationsCount: number;
|
||||
activeSubscriptions: number;
|
||||
trialSubscriptions: number;
|
||||
}
|
||||
|
||||
function AdminDashboard({
|
||||
data,
|
||||
}: React.PropsWithChildren<{
|
||||
data: Data;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3' +
|
||||
' xl:grid-cols-4'
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.usersCount}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.organizationsCount}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Paying Customers</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.activeSubscriptions}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trials</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex justify-between'}>
|
||||
<Figure>{data.trialSubscriptions}</Figure>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
function Figure(props: React.PropsWithChildren) {
|
||||
return <div className={'text-3xl font-bold'}>{props.children}</div>;
|
||||
}
|
||||
22
packages/features/admin/src/components/AdminGuard.tsx
Normal file
22
packages/features/admin/src/components/AdminGuard.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import isUserSuperAdmin from '../../../app/admin/utils/is-user-super-admin';
|
||||
|
||||
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
|
||||
|
||||
function AdminGuard<Params extends object>(
|
||||
Component: LayoutOrPageComponent<Params>,
|
||||
) {
|
||||
return async function AdminGuardServerComponentWrapper(params: Params) {
|
||||
const isAdmin = await isUserSuperAdmin();
|
||||
|
||||
// if the user is not a super-admin, we redirect to a 404
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <Component {...params} />;
|
||||
};
|
||||
}
|
||||
|
||||
export default AdminGuard;
|
||||
27
packages/features/admin/src/components/AdminHeader.tsx
Normal file
27
packages/features/admin/src/components/AdminHeader.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import pathsConfig from '@/config/paths.config';
|
||||
|
||||
import { PageHeader } from '@/components/app/Page';
|
||||
|
||||
function AdminHeader({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<PageHeader
|
||||
title={children}
|
||||
description={`Manage your app from the admin dashboard.`}
|
||||
>
|
||||
<Link href={pathsConfig.appHome}>
|
||||
<Button variant={'link'}>
|
||||
<ArrowLeftIcon className={'h-4'} />
|
||||
<span>Back to App</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminHeader;
|
||||
38
packages/features/admin/src/components/AdminSidebar.tsx
Normal file
38
packages/features/admin/src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { HomeIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import Logo from '@/components/app/Logo';
|
||||
import { Sidebar, SidebarContent, SidebarItem } from '@/components/app/Sidebar';
|
||||
|
||||
function AdminSidebar() {
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent className={'mb-6 mt-4 pt-2'}>
|
||||
<Logo href={'/admin'} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarItem end path={'/admin'} Icon={<HomeIcon className={'h-4'} />}>
|
||||
Admin
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
path={'/admin/users'}
|
||||
Icon={<UserIcon className={'h-4'} />}
|
||||
>
|
||||
Users
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
path={'/admin/organizations'}
|
||||
Icon={<UsersIcon className={'h-4'} />}
|
||||
>
|
||||
Organizations
|
||||
</SidebarItem>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSidebar;
|
||||
10
packages/features/admin/tsconfig.json
Normal file
10
packages/features/admin/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
47
packages/features/auth/package.json
Normal file
47
packages/features/auth/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@kit/auth",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./sign-in": "./src/sign-in.ts",
|
||||
"./sign-up": "./src/sign-up.ts",
|
||||
"./password-reset": "./src/password-reset.ts",
|
||||
"./shared": "./src/shared.ts",
|
||||
"./mfa": "./src/mfa.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"react-i18next": "14.1.0",
|
||||
"sonner": "^1.4.41",
|
||||
"@tanstack/react-query": "5.28.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
46
packages/features/auth/src/components/auth-error-alert.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
/**
|
||||
* @name AuthErrorAlert
|
||||
* @param error This error comes from Supabase as the code returned on errors
|
||||
* This error is mapped from the translation auth:errors.{error}
|
||||
* To update the error messages, please update the translation file
|
||||
* https://github.com/supabase/gotrue-js/blob/master/src/lib/errors.ts
|
||||
* @constructor
|
||||
*/
|
||||
export function AuthErrorAlert({
|
||||
error,
|
||||
}: {
|
||||
error: Error | null | undefined | string;
|
||||
}) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
||||
const errorCode = error instanceof Error ? error.message : error;
|
||||
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<ExclamationTriangleIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={`auth:errorAlertHeading`} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription
|
||||
className={'text-sm font-medium'}
|
||||
data-test={'auth-error-message'}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`auth:errors.${errorCode}`}
|
||||
defaults={'<DefaultError />'}
|
||||
components={{ DefaultError }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
24
packages/features/auth/src/components/auth-layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export function AuthLayoutShell({
|
||||
children,
|
||||
Logo,
|
||||
}: React.PropsWithChildren<{
|
||||
Logo: React.ComponentType;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-screen flex-col items-center justify-center space-y-4' +
|
||||
' dark:lg:bg-background md:space-y-8 lg:space-y-12 lg:bg-gray-50' +
|
||||
' animate-in fade-in slide-in-from-top-8 duration-1000'
|
||||
}
|
||||
>
|
||||
{Logo && <Logo />}
|
||||
|
||||
<div
|
||||
className={`bg-background dark:border-border flex w-full max-w-sm flex-col items-center space-y-4 rounded-lg border-transparent md:w-8/12 md:border md:px-8 md:py-6 md:shadow lg:w-5/12 lg:px-6 xl:w-4/12 2xl:w-3/12`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
34
packages/features/auth/src/components/auth-link-redirect.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function AuthLinkRedirect(props: { redirectPath?: string }) {
|
||||
const params = useSearchParams();
|
||||
|
||||
const redirectPath = params?.get('redirectPath') ?? props.redirectPath ?? '/';
|
||||
|
||||
useRedirectOnSignIn(redirectPath);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AuthLinkRedirect;
|
||||
|
||||
function useRedirectOnSignIn(redirectPath: string) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange((_, session) => {
|
||||
if (session) {
|
||||
router.push(redirectPath);
|
||||
}
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, router, redirectPath]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
|
||||
|
||||
export function AuthProviderButton({
|
||||
providerId,
|
||||
onClick,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
providerId: string;
|
||||
onClick: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<Button
|
||||
className={'flex w-full space-x-2 text-center'}
|
||||
data-provider={providerId}
|
||||
data-test={'auth-provider-button'}
|
||||
variant={'outline'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<OauthProviderLogoImage providerId={providerId} />
|
||||
|
||||
<span>{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
137
packages/features/auth/src/components/email-otp-container.tsx
Normal file
137
packages/features/auth/src/components/email-otp-container.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function EmailOtpContainer({
|
||||
shouldCreateUser,
|
||||
onSignIn,
|
||||
inviteCode,
|
||||
redirectUrl,
|
||||
}: React.PropsWithChildren<{
|
||||
inviteCode?: string;
|
||||
redirectUrl: string;
|
||||
shouldCreateUser: boolean;
|
||||
onSignIn?: () => void;
|
||||
}>) {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
redirectUrl={redirectUrl}
|
||||
inviteCode={inviteCode}
|
||||
onSuccess={onSignIn}
|
||||
email={email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailOtpForm onSuccess={setEmail} shouldCreateUser={shouldCreateUser} />
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyOtpForm({
|
||||
email,
|
||||
inviteCode,
|
||||
onSuccess,
|
||||
redirectUrl,
|
||||
}: {
|
||||
email: string;
|
||||
redirectUrl: string;
|
||||
onSuccess?: () => void;
|
||||
inviteCode?: string;
|
||||
}) {
|
||||
const verifyOtpMutation = useVerifyOtp();
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
|
||||
const redirectTo = [redirectUrl, queryParams].join('');
|
||||
|
||||
await verifyOtpMutation.mutateAsync({
|
||||
email,
|
||||
token: verifyCode,
|
||||
type: 'email',
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess && onSuccess();
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<OtpInput onValid={setVerifyCode} onInvalid={() => setVerifyCode('')} />
|
||||
|
||||
<Button disabled={verifyOtpMutation.isPending || !verifyCode}>
|
||||
{verifyOtpMutation.isPending ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:submitVerificationCode'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailOtpForm({
|
||||
shouldCreateUser,
|
||||
onSuccess,
|
||||
}: React.PropsWithChildren<{
|
||||
shouldCreateUser: boolean;
|
||||
onSuccess: (email: string) => void;
|
||||
}>) {
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'w-full'}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const email = event.currentTarget.email.value;
|
||||
|
||||
await signInWithOtpMutation.mutateAsync({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser,
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess(email);
|
||||
}}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'auth:emailAddress'} />
|
||||
<Input name={'email'} type={'email'} placeholder={''} />
|
||||
</Label>
|
||||
|
||||
<Button disabled={signInWithOtpMutation.isPending}>
|
||||
<If
|
||||
condition={signInWithOtpMutation.isPending}
|
||||
fallback={<Trans i18nKey={'auth:sendEmailCode'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:sendingEmailCode'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MagicLinkAuthContainer({
|
||||
inviteCode,
|
||||
redirectUrl,
|
||||
}: {
|
||||
inviteCode?: string;
|
||||
redirectUrl: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const target = event.currentTarget;
|
||||
const data = new FormData(target);
|
||||
const email = data.get('email') as string;
|
||||
const queryParams = inviteCode ? `?inviteCode=${inviteCode}` : '';
|
||||
|
||||
const emailRedirectTo = [redirectUrl, queryParams].join('');
|
||||
|
||||
const promise = signInWithOtpMutation.mutateAsync({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
},
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t('auth:sendingEmailLink'),
|
||||
success: t(`auth:sendLinkSuccessToast`),
|
||||
error: t(`auth:errors.link`),
|
||||
});
|
||||
},
|
||||
[inviteCode, redirectUrl, signInWithOtpMutation, t],
|
||||
);
|
||||
|
||||
if (signInWithOtpMutation.data) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={'w-full'} onSubmit={onSubmit}>
|
||||
<If condition={signInWithOtpMutation.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:errors.link'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Label>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('auth:emailPlaceholder')}
|
||||
name={'email'}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Button disabled={signInWithOtpMutation.isPending}>
|
||||
<If
|
||||
condition={signInWithOtpMutation.isPending}
|
||||
fallback={<Trans i18nKey={'auth:sendEmailLink'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:sendingEmailLink'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import useFetchAuthFactors from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import useSignOut from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { OtpInput } from '@kit/ui/otp-input';
|
||||
import Spinner from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function MultiFactorChallengeContainer({
|
||||
onSuccess,
|
||||
}: React.PropsWithChildren<{
|
||||
onSuccess: () => void;
|
||||
}>) {
|
||||
const [factorId, setFactorId] = useState('');
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const verifyMFAChallenge = useVerifyMFAChallenge();
|
||||
|
||||
const onSubmitClicked: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(event) => {
|
||||
void (async () => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!factorId || !verifyCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyMFAChallenge.mutateAsync({
|
||||
factorId,
|
||||
verifyCode,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
})();
|
||||
},
|
||||
[factorId, verifyMFAChallenge, onSuccess, verifyCode],
|
||||
);
|
||||
|
||||
if (!factorId) {
|
||||
return (
|
||||
<FactorsListContainer onSelect={setFactorId} onSuccess={onSuccess} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitClicked}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'profile:verifyActivationCodeDescription'} />
|
||||
</span>
|
||||
|
||||
<div className={'flex w-full flex-col space-y-2.5'}>
|
||||
<OtpInput
|
||||
onInvalid={() => setVerifyCode('')}
|
||||
onValid={setVerifyCode}
|
||||
/>
|
||||
|
||||
<If condition={verifyMFAChallenge.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:invalidVerificationCode'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<Button disabled={verifyMFAChallenge.isPending || !verifyCode}>
|
||||
{verifyMFAChallenge.isPending ? (
|
||||
<Trans i18nKey={'profile:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'profile:submitVerificationCode'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function useVerifyMFAChallenge() {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['mfa-verify-challenge'];
|
||||
const mutationFn = async (params: {
|
||||
factorId: string;
|
||||
verifyCode: string;
|
||||
}) => {
|
||||
const { factorId, verifyCode: code } = params;
|
||||
|
||||
const response = await client.auth.mfa.challengeAndVerify({
|
||||
factorId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
|
||||
function FactorsListContainer({
|
||||
onSuccess,
|
||||
onSelect,
|
||||
}: React.PropsWithChildren<{
|
||||
onSuccess: () => void;
|
||||
onSelect: (factor: string) => void;
|
||||
}>) {
|
||||
const signOut = useSignOut();
|
||||
|
||||
const { data: factors, isLoading, error } = useFetchAuthFactors();
|
||||
|
||||
const isSuccess = factors && !isLoading && !error;
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no factors, continue
|
||||
if (isSuccess && !factors.totp.length) {
|
||||
onSuccess();
|
||||
}
|
||||
}, [factors?.totp.length, isSuccess, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is an error, sign out
|
||||
if (error) {
|
||||
void signOut.mutateAsync();
|
||||
}
|
||||
}, [error, signOut]);
|
||||
|
||||
useEffect(() => {
|
||||
// If there is only one factor, select it automatically
|
||||
if (isSuccess && factors.totp.length === 1) {
|
||||
const factorId = factors.totp[0]?.id;
|
||||
|
||||
if (factorId) {
|
||||
onSelect(factorId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={'flex flex-col items-center space-y-4 py-8'}>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'profile:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'w-full'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:factorsListError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const verifiedFactors = factors?.totp ?? [];
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<Heading level={6}>
|
||||
<Trans i18nKey={'profile:selectFactor'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
{verifiedFactors.map((factor) => (
|
||||
<div key={factor.id}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className={'w-full border-gray-50'}
|
||||
onClick={() => onSelect(factor.id)}
|
||||
>
|
||||
{factor.friendly_name}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { AtSignIcon, PhoneIcon } from 'lucide-react';
|
||||
|
||||
const DEFAULT_IMAGE_SIZE = 18;
|
||||
|
||||
export const OauthProviderLogoImage: React.FC<{
|
||||
providerId: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}> = ({ providerId, width, height }) => {
|
||||
const image = getOAuthProviderLogos()[providerId];
|
||||
|
||||
if (typeof image === `string`) {
|
||||
return (
|
||||
<Image
|
||||
decoding={'async'}
|
||||
loading={'lazy'}
|
||||
src={image}
|
||||
alt={`${providerId} logo`}
|
||||
width={width ?? DEFAULT_IMAGE_SIZE}
|
||||
height={height ?? DEFAULT_IMAGE_SIZE}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{image}</>;
|
||||
};
|
||||
|
||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||
return {
|
||||
password: <AtSignIcon className={'s-[18px]'} />,
|
||||
phone: <PhoneIcon className={'s-[18px]'} />,
|
||||
google: '/assets/images/google.webp',
|
||||
facebook: '/assets/images/facebook.webp',
|
||||
twitter: '/assets/images/twitter.webp',
|
||||
github: '/assets/images/github.webp',
|
||||
microsoft: '/assets/images/microsoft.webp',
|
||||
apple: '/assets/images/apple.webp',
|
||||
};
|
||||
}
|
||||
113
packages/features/auth/src/components/oauth-providers.tsx
Normal file
113
packages/features/auth/src/components/oauth-providers.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { useSignInWithProvider } from '@kit/supabase/hooks/use-sign-in-with-provider';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { AuthProviderButton } from './auth-provider-button';
|
||||
|
||||
export const OauthProviders: React.FC<{
|
||||
returnUrl?: string;
|
||||
inviteCode?: string;
|
||||
enabledProviders: Provider[];
|
||||
redirectUrl: string;
|
||||
}> = (props) => {
|
||||
const signInWithProviderMutation = useSignInWithProvider();
|
||||
|
||||
// we make the UI "busy" until the next page is fully loaded
|
||||
const loading = signInWithProviderMutation.isPending;
|
||||
|
||||
const onSignInWithProvider = useCallback(
|
||||
async (signInRequest: () => Promise<unknown>) => {
|
||||
const credential = await signInRequest();
|
||||
|
||||
if (!credential) {
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const enabledProviders = props.enabledProviders;
|
||||
|
||||
if (!enabledProviders?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={loading}>
|
||||
<LoadingOverlay />
|
||||
</If>
|
||||
|
||||
<div className={'flex w-full flex-1 flex-col space-y-3'}>
|
||||
<div className={'flex-col space-y-2'}>
|
||||
{enabledProviders.map((provider) => {
|
||||
return (
|
||||
<AuthProviderButton
|
||||
key={provider}
|
||||
providerId={provider}
|
||||
onClick={() => {
|
||||
const origin = window.location.origin;
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (props.returnUrl) {
|
||||
queryParams.set('next', props.returnUrl);
|
||||
}
|
||||
|
||||
if (props.inviteCode) {
|
||||
queryParams.set('inviteCode', props.inviteCode);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.redirectUrl,
|
||||
queryParams.toString(),
|
||||
].join('?');
|
||||
|
||||
const redirectTo = [origin, redirectPath].join('');
|
||||
|
||||
const credentials = {
|
||||
provider,
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
};
|
||||
|
||||
return onSignInWithProvider(() =>
|
||||
signInWithProviderMutation.mutateAsync(credentials),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
/>
|
||||
</AuthProviderButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AuthErrorAlert error={signInWithProviderMutation.error} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getProviderName(providerId: string) {
|
||||
const capitalize = (value: string) =>
|
||||
value.slice(0, 1).toUpperCase() + value.slice(1);
|
||||
|
||||
if (providerId.endsWith('.com')) {
|
||||
return capitalize(providerId.split('.com')[0]!);
|
||||
}
|
||||
|
||||
return capitalize(providerId);
|
||||
}
|
||||
154
packages/features/auth/src/components/password-reset-form.tsx
Normal file
154
packages/features/auth/src/components/password-reset-form.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordResetSchema } from '../schemas/password-reset.schema';
|
||||
|
||||
function PasswordResetForm(params: { redirectTo: string }) {
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (updateUser.error) {
|
||||
return <ErrorState onRetry={() => updateUser.reset()} />;
|
||||
}
|
||||
|
||||
if (updateUser.data && !updateUser.isPending) {
|
||||
return <SuccessState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-6'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<Heading level={5}>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex w-full flex-1 flex-col'}
|
||||
onSubmit={form.handleSubmit(({ password }) => {
|
||||
return updateUser.mutateAsync({
|
||||
password,
|
||||
redirectTo: params.redirectTo,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className={'flex-col space-y-4'}>
|
||||
<FormField
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input required type="password" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={updateUser.isPending}
|
||||
type="submit"
|
||||
className={'w-full'}
|
||||
>
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordResetForm;
|
||||
|
||||
function SuccessState() {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'profile:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Link href={'/'}>
|
||||
<Button variant={'outline'}>
|
||||
<Trans i18nKey={'common:backToHomePage'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState(props: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:resetPasswordError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={props.onRetry} variant={'outline'}>
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
|
||||
const PasswordResetSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export function PasswordResetRequestContainer(params: { redirectTo: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
const resetPasswordMutation = useRequestResetPassword();
|
||||
const error = resetPasswordMutation.error;
|
||||
const success = resetPasswordMutation.data;
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordResetSchema>>({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={success}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:passwordResetSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!resetPasswordMutation.data}>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(({ email }) => {
|
||||
return resetPasswordMutation.mutateAsync({
|
||||
email,
|
||||
redirectTo: params.redirectTo,
|
||||
});
|
||||
})}
|
||||
className={'w-full'}
|
||||
>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div>
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'auth:passwordResetSubheading'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<FormField
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button disabled={resetPasswordMutation.isPending} type="submit">
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
|
||||
|
||||
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignInForm } from './password-sign-in-form';
|
||||
|
||||
export const PasswordSignInContainer: React.FC<{
|
||||
onSignIn?: (userId?: string) => unknown;
|
||||
}> = ({ onSignIn }) => {
|
||||
const signInMutation = useSignInWithEmailPassword();
|
||||
const isLoading = signInMutation.isPending;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
|
||||
try {
|
||||
const data = await signInMutation.mutateAsync(credentials);
|
||||
const userId = data?.user?.id;
|
||||
|
||||
if (onSignIn) {
|
||||
onSignIn(userId);
|
||||
}
|
||||
} catch (e) {
|
||||
// wrong credentials, do nothing
|
||||
}
|
||||
},
|
||||
[onSignIn, signInMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthErrorAlert error={signInMutation.error} />
|
||||
|
||||
<PasswordSignInForm onSubmit={onSubmit} loading={isLoading} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
120
packages/features/auth/src/components/password-sign-in-form.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
|
||||
|
||||
export const PasswordSignInForm: React.FC<{
|
||||
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
|
||||
loading: boolean;
|
||||
}> = ({ onSubmit, loading }) => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const form = useForm<z.infer<typeof PasswordSignInSchema>>({
|
||||
resolver: zodResolver(PasswordSignInSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'w-full space-y-2.5'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<Link href={'/auth/password-reset'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
size={'sm'}
|
||||
variant={'link'}
|
||||
className={'text-xs'}
|
||||
>
|
||||
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test="auth-submit-button"
|
||||
className={'w-full'}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<If
|
||||
condition={loading}
|
||||
fallback={<Trans i18nKey={'auth:signInWithEmail'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:signingIn'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AuthErrorAlert } from './auth-error-alert';
|
||||
import { PasswordSignUpForm } from './password-sign-up-form';
|
||||
|
||||
export function EmailPasswordSignUpContainer({
|
||||
onSignUp,
|
||||
onError,
|
||||
emailRedirectTo,
|
||||
}: React.PropsWithChildren<{
|
||||
onSignUp?: (userId?: string) => unknown;
|
||||
onError?: (error?: unknown) => unknown;
|
||||
emailRedirectTo: string;
|
||||
}>) {
|
||||
const signUpMutation = useSignUpWithEmailAndPassword();
|
||||
const redirecting = useRef(false);
|
||||
const loading = signUpMutation.isPending || redirecting.current;
|
||||
const [showVerifyEmailAlert, setShowVerifyEmailAlert] = useState(false);
|
||||
|
||||
const callOnErrorCallback = useCallback(() => {
|
||||
if (signUpMutation.error && onError) {
|
||||
onError(signUpMutation.error);
|
||||
}
|
||||
}, [signUpMutation.error, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
callOnErrorCallback();
|
||||
}, [callOnErrorCallback]);
|
||||
|
||||
const onSignupRequested = useCallback(
|
||||
async (credentials: { email: string; password: string }) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await signUpMutation.mutateAsync({
|
||||
...credentials,
|
||||
emailRedirectTo,
|
||||
});
|
||||
|
||||
setShowVerifyEmailAlert(true);
|
||||
|
||||
if (onSignUp) {
|
||||
onSignUp(data.user?.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[emailRedirectTo, loading, onError, onSignUp, signUpMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={showVerifyEmailAlert}>
|
||||
<Alert variant={'success'}>
|
||||
<CheckIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!showVerifyEmailAlert}>
|
||||
<AuthErrorAlert error={signUpMutation.error} />
|
||||
|
||||
<PasswordSignUpForm onSubmit={onSignupRequested} loading={loading} />
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
140
packages/features/auth/src/components/password-sign-up-form.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PasswordSignUpSchema } from '../schemas/password-sign-up.schema';
|
||||
|
||||
export const PasswordSignUpForm: React.FC<{
|
||||
onSubmit: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
repeatPassword: string;
|
||||
}) => unknown;
|
||||
loading: boolean;
|
||||
}> = ({ onSubmit, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordSignUpSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'w-full space-y-2.5'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'email'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'email-input'}
|
||||
required
|
||||
type="email"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'password'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:password'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'auth:repeatPassword'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
data-test={'repeat-password-input'}
|
||||
type="password"
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<FormDescription className={'pb-2 text-xs'}>
|
||||
<Trans i18nKey={'auth:repeatPasswordHint'} />
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
data-test={'auth-submit-button'}
|
||||
className={'w-full'}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
<If
|
||||
condition={loading}
|
||||
fallback={<Trans i18nKey={'auth:signUpWithEmail'} />}
|
||||
>
|
||||
<Trans i18nKey={'auth:signingUp'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function ResendAuthLinkForm() {
|
||||
const resendLink = useResendLink();
|
||||
|
||||
if (resendLink.data && !resendLink.isPending) {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:resendLinkSuccess'} defaults={'Success!'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={'flex flex-col space-y-2'}
|
||||
onSubmit={(data) => {
|
||||
data.preventDefault();
|
||||
|
||||
const email = new FormData(data.currentTarget).get('email') as string;
|
||||
|
||||
return resendLink.mutateAsync(email);
|
||||
}}
|
||||
>
|
||||
<Label>
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
<Input name={'email'} required placeholder={''} />
|
||||
</Label>
|
||||
|
||||
<Button disabled={resendLink.isPending}>
|
||||
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResendAuthLinkForm;
|
||||
|
||||
function useResendLink() {
|
||||
const supabase = useSupabase();
|
||||
|
||||
const mutationKey = ['resend-link'];
|
||||
const mutationFn = async (email: string) => {
|
||||
const response = await supabase.auth.resend({
|
||||
email,
|
||||
type: 'signup',
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@supabase/ssr';
|
||||
|
||||
import { Divider } from '@kit/ui/divider';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { EmailOtpContainer } from './email-otp-container';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
paths: {
|
||||
callback: string;
|
||||
home: string;
|
||||
};
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
}) {
|
||||
const redirectUrl = new URL(
|
||||
props.paths.callback,
|
||||
isBrowser() ? window?.location.origin : '',
|
||||
).toString();
|
||||
|
||||
const router = useRouter();
|
||||
const onSignIn = () => router.replace(props.paths.home);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.providers.password}>
|
||||
<PasswordSignInContainer onSignIn={onSignIn} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer redirectUrl={redirectUrl} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<EmailOtpContainer
|
||||
onSignIn={onSignIn}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={false}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<Divider />
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@supabase/ssr';
|
||||
|
||||
import { Divider } from '@kit/ui/divider';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { EmailOtpContainer } from './email-otp-container';
|
||||
import { MagicLinkAuthContainer } from './magic-link-auth-container';
|
||||
import { OauthProviders } from './oauth-providers';
|
||||
import { EmailPasswordSignUpContainer } from './password-sign-up-container';
|
||||
|
||||
export function SignUpMethodsContainer(props: {
|
||||
callbackPath: string;
|
||||
|
||||
providers: {
|
||||
password: boolean;
|
||||
magicLink: boolean;
|
||||
otp: boolean;
|
||||
oAuth: Provider[];
|
||||
};
|
||||
|
||||
inviteCode?: string;
|
||||
}) {
|
||||
const redirectUrl = new URL(
|
||||
props.callbackPath,
|
||||
isBrowser() ? window?.location.origin : '',
|
||||
).toString();
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={props.providers.password}>
|
||||
<EmailPasswordSignUpContainer emailRedirectTo={redirectUrl} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteCode={props.inviteCode}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<EmailOtpContainer
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={true}
|
||||
inviteCode={props.inviteCode}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
<Divider />
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
redirectUrl={redirectUrl}
|
||||
inviteCode={props.inviteCode}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
packages/features/auth/src/mfa.ts
Normal file
1
packages/features/auth/src/mfa.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/multi-factor-challenge-container';
|
||||
1
packages/features/auth/src/password-reset.ts
Normal file
1
packages/features/auth/src/password-reset.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/password-reset-request-container';
|
||||
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal file
11
packages/features/auth/src/schemas/password-reset.schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordResetSchema = z
|
||||
.object({
|
||||
password: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatPassword'],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordSignInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordSignUpSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(99),
|
||||
repeatPassword: z.string().min(8).max(99),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.password === schema.repeatPassword;
|
||||
},
|
||||
{
|
||||
message: 'Passwords do not match',
|
||||
path: ['repeatPassword'],
|
||||
},
|
||||
);
|
||||
1
packages/features/auth/src/shared.ts
Normal file
1
packages/features/auth/src/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './components/auth-layout';
|
||||
2
packages/features/auth/src/sign-in.ts
Normal file
2
packages/features/auth/src/sign-in.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-in-methods-container';
|
||||
export * from './schemas/password-sign-in.schema';
|
||||
2
packages/features/auth/src/sign-up.ts
Normal file
2
packages/features/auth/src/sign-up.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components/sign-up-methods-container';
|
||||
export * from './schemas/password-sign-up.schema';
|
||||
8
packages/features/auth/tsconfig.json
Normal file
8
packages/features/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
48
packages/features/team-accounts/package.json
Normal file
48
packages/features/team-accounts/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@kit/team-accounts",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"@kit/accounts": "0.1.0",
|
||||
"@kit/mailers": "0.1.0",
|
||||
"@kit/emails": "0.1.0",
|
||||
"lucide-react": "^0.360.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { InviteMembersSchema } from '../schema/invite-members.schema';
|
||||
import { AccountInvitationsService } from '../services/account-invitations.service';
|
||||
|
||||
/**
|
||||
* Creates invitations for inviting members.
|
||||
*/
|
||||
export async function createInvitationsAction(params: {
|
||||
account: string;
|
||||
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const { invitations } = InviteMembersSchema.parse({
|
||||
invitations: params.invitations,
|
||||
});
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.sendInvitations({ invitations, account: params.account });
|
||||
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an invitation specified by the invitation ID.
|
||||
*
|
||||
* @param {Object} params - The parameters for the method.
|
||||
* @param {string} params.invitationId - The ID of the invitation to be deleted.
|
||||
*
|
||||
* @return {Object} - The result of the delete operation.
|
||||
*/
|
||||
export async function deleteInvitationAction(params: { invitationId: string }) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.removeInvitation({
|
||||
invitationId: params.invitationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateInvitationAction(params: {
|
||||
invitationId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.updateInvitation({
|
||||
invitationId: params.invitationId,
|
||||
role: params.role,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function assertSession(client: SupabaseClient<Database>) {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use server';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { AccountMembersService } from '../services/account-members.service';
|
||||
|
||||
export async function removeMemberFromAccountAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.removeMemberFromAccount({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateMemberRoleAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.updateMemberRole({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
role: params.role,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function transferOwnershipAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
await assertSession(client);
|
||||
|
||||
const service = new AccountMembersService(client);
|
||||
|
||||
await service.transferOwnership({
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function assertSession(client: SupabaseClient<Database>) {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error(`Authentication required`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
|
||||
import { DeleteAccountService } from '../services/delete-account.service';
|
||||
|
||||
export async function deleteTeamAccountAction(formData: FormData) {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = DeleteTeamAccountSchema.parse(body);
|
||||
const client = getSupabaseServerActionClient();
|
||||
const service = new DeleteAccountService(client);
|
||||
|
||||
await service.deleteTeamAccount(params);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { LeaveTeamAccountSchema } from '../schema/leave-team-account.schema';
|
||||
import { LeaveAccountService } from '../services/leave-account.service';
|
||||
|
||||
export async function leaveTeamAccountAction(formData: FormData) {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = LeaveTeamAccountSchema.parse(body);
|
||||
const service = new LeaveAccountService(getSupabaseServerActionClient());
|
||||
|
||||
await service.leaveTeamAccount(params);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
5
packages/features/team-accounts/src/components/index.ts
Normal file
5
packages/features/team-accounts/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './members/account-members-table';
|
||||
export * from './update-organization-form';
|
||||
export * from './members/invite-members-dialog-container';
|
||||
export * from './team-account-danger-zone';
|
||||
export * from './invitations/account-invitations-table';
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisIcon } from 'lucide-react';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { DataTable } from '@kit/ui/data-table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
|
||||
import { RoleBadge } from '../role-badge';
|
||||
import { DeleteInvitationDialog } from './delete-invitation-dialog';
|
||||
import { UpdateInvitationDialog } from './update-invitation-dialog';
|
||||
|
||||
type Invitations =
|
||||
Database['public']['Functions']['get_account_invitations']['Returns'];
|
||||
|
||||
type AccountInvitationsTableProps = {
|
||||
invitations: Invitations;
|
||||
|
||||
permissions: {
|
||||
canUpdateInvitation: boolean;
|
||||
canRemoveInvitation: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function AccountInvitationsTable({
|
||||
invitations,
|
||||
permissions,
|
||||
}: AccountInvitationsTableProps) {
|
||||
const columns = useMemo(() => getColumns(permissions), [permissions]);
|
||||
|
||||
return <DataTable columns={columns} data={invitations} />;
|
||||
}
|
||||
|
||||
function getColumns(permissions: {
|
||||
canUpdateInvitation: boolean;
|
||||
canRemoveInvitation: boolean;
|
||||
}): ColumnDef<Invitations[0]>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Email',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const member = row.original;
|
||||
const email = member.email;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-4 text-left'}>
|
||||
<span>
|
||||
<ProfileAvatar text={email} />
|
||||
</span>
|
||||
|
||||
<span>{email}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
const { role } = row.original;
|
||||
|
||||
return <RoleBadge role={role} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Invited At',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.created_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<ActionsDropdown permissions={permissions} invitation={row.original} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ActionsDropdown({
|
||||
permissions,
|
||||
invitation,
|
||||
}: {
|
||||
permissions: AccountInvitationsTableProps['permissions'];
|
||||
invitation: Invitations[0];
|
||||
}) {
|
||||
const [isDeletingInvite, setIsDeletingInvite] = useState(false);
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'} size={'icon'}>
|
||||
<EllipsisIcon className={'h-5 w-5'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={permissions.canUpdateInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
Update Invitation
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canRemoveInvitation}>
|
||||
<DropdownMenuItem onClick={() => setIsDeletingInvite(true)}>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={isDeletingInvite}>
|
||||
<DeleteInvitationDialog
|
||||
isOpen
|
||||
setIsOpen={setIsDeletingInvite}
|
||||
invitationId={invitation.id}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isUpdatingRole}>
|
||||
<UpdateInvitationDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
invitationId={invitation.id}
|
||||
userRole={invitation.role}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { deleteInvitationAction } from '../../actions/account-invitations-server-actions';
|
||||
|
||||
export const DeleteInvitationDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
invitationId: string;
|
||||
}> = ({ isOpen, setIsOpen, invitationId }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="organization:deleteInvitationDialogTitle" />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Remove the invitation to join this account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DeleteInvitationForm
|
||||
setIsOpen={setIsOpen}
|
||||
invitationId={invitationId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function DeleteInvitationForm({
|
||||
invitationId,
|
||||
setIsOpen,
|
||||
}: {
|
||||
invitationId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onInvitationRemoved = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteInvitationAction({ invitationId });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={onInvitationRemoved}>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<p className={'text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
|
||||
<If condition={error}>
|
||||
<RemoveInvitationErrorAlert />
|
||||
</If>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-delete-invitation'}
|
||||
variant={'destructive'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Delete Invitation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveInvitationErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:deleteInvitationErrorTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:deleteInvitationErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { updateInvitationAction } from '../../actions/account-invitations-server-actions';
|
||||
import { UpdateRoleSchema } from '../../schema/update-role-schema';
|
||||
import { MembershipRoleSelector } from '../membership-role-selector';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const UpdateInvitationDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
invitationId: string;
|
||||
userRole: Role;
|
||||
}> = ({ isOpen, setIsOpen, invitationId, userRole }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<UpdateInvitationForm
|
||||
setIsOpen={setIsOpen}
|
||||
invitationId={invitationId}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function UpdateInvitationForm({
|
||||
invitationId,
|
||||
userRole,
|
||||
setIsOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
invitationId: string;
|
||||
userRole: Role;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onSubmit = ({ role }: { role: Role }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateInvitationAction({ invitationId, role });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
UpdateRoleSchema.refine(
|
||||
(data) => {
|
||||
return data.role !== userRole;
|
||||
},
|
||||
{
|
||||
message: 'Role must be different from the current role.',
|
||||
path: ['role'],
|
||||
},
|
||||
),
|
||||
),
|
||||
reValidateMode: 'onChange',
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
role: userRole,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={'flex flex-col space-y-6'}
|
||||
>
|
||||
<If condition={error}>
|
||||
<UpdateRoleErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'role'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>New Role</FormLabel>
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
currentUserRole={userRole}
|
||||
value={field.value}
|
||||
onChange={(newRole) => form.setValue('role', newRole)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>Pick a role for this member.</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button data-test={'confirm-update-member-role'} disabled={pending}>
|
||||
<Trans i18nKey={'organization:updateRoleSubmitLabel'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRoleErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:updateRoleErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:updateRoleErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisIcon } from 'lucide-react';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { DataTable } from '@kit/ui/data-table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
|
||||
import { RoleBadge } from '../role-badge';
|
||||
import { RemoveMemberDialog } from './remove-member-dialog';
|
||||
import { TransferOwnershipDialog } from './transfer-ownership-dialog';
|
||||
import { UpdateMemberRoleDialog } from './update-member-role-dialog';
|
||||
|
||||
type Members =
|
||||
Database['public']['Functions']['get_account_members']['Returns'];
|
||||
|
||||
type AccountMembersTableProps = {
|
||||
members: Members;
|
||||
|
||||
currentUserId: string;
|
||||
|
||||
permissions: {
|
||||
canUpdateRole: boolean;
|
||||
canTransferOwnership: boolean;
|
||||
canRemoveFromAccount: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function AccountMembersTable({
|
||||
members,
|
||||
permissions,
|
||||
currentUserId,
|
||||
}: AccountMembersTableProps) {
|
||||
const columns = useMemo(
|
||||
() => getColumns(permissions, currentUserId),
|
||||
[currentUserId, permissions],
|
||||
);
|
||||
|
||||
return <DataTable columns={columns} data={members} />;
|
||||
}
|
||||
|
||||
function getColumns(
|
||||
permissions: {
|
||||
canUpdateRole: boolean;
|
||||
canTransferOwnership: boolean;
|
||||
canRemoveFromAccount: boolean;
|
||||
},
|
||||
currentUserId: string,
|
||||
): ColumnDef<Members[0]>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Name',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const member = row.original;
|
||||
const displayName = member.name ?? member.email.split('@')[0];
|
||||
const isSelf = member.user_id === currentUserId;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-4 text-left'}>
|
||||
<span>
|
||||
<ProfileAvatar
|
||||
displayName={displayName}
|
||||
pictureUrl={member.picture_url}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span>{displayName}</span>
|
||||
|
||||
<If condition={isSelf}>
|
||||
<span
|
||||
className={
|
||||
'bg-muted rounded-md px-2.5 py-1 text-xs font-medium'
|
||||
}
|
||||
>
|
||||
You
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
cell: ({ row }) => {
|
||||
return row.original.email ?? '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
const { role, primary_owner_user_id, user_id } = row.original;
|
||||
const isPrimaryOwner = primary_owner_user_id === user_id;
|
||||
|
||||
return (
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<RoleBadge role={role} />
|
||||
|
||||
<If condition={isPrimaryOwner}>
|
||||
<span
|
||||
className={
|
||||
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium'
|
||||
}
|
||||
>
|
||||
Primary
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Joined At',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.created_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<ActionsDropdown
|
||||
permissions={permissions}
|
||||
member={row.original}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ActionsDropdown({
|
||||
permissions,
|
||||
member,
|
||||
currentUserId,
|
||||
}: {
|
||||
permissions: AccountMembersTableProps['permissions'];
|
||||
member: Members[0];
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||
|
||||
const isCurrentUser = member.user_id === currentUserId;
|
||||
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
||||
|
||||
if (isCurrentUser || isPrimaryOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'} size={'icon'}>
|
||||
<EllipsisIcon className={'h-5 w-5'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={permissions.canUpdateRole}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
Update Role
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canTransferOwnership}>
|
||||
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
|
||||
Transfer Ownership
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={permissions.canRemoveFromAccount}>
|
||||
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
|
||||
Remove from Account
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={isRemoving}>
|
||||
<RemoveMemberDialog
|
||||
isOpen
|
||||
setIsOpen={setIsRemoving}
|
||||
accountId={member.id}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isUpdatingRole}>
|
||||
<UpdateMemberRoleDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
accountId={member.id}
|
||||
userId={member.user_id}
|
||||
userRole={member.role}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={isTransferring}>
|
||||
<TransferOwnershipDialog
|
||||
isOpen
|
||||
setIsOpen={setIsTransferring}
|
||||
targetDisplayName={member.name ?? member.email}
|
||||
accountId={member.id}
|
||||
userId={member.user_id}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { PlusIcon, XIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kit/ui/tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createInvitationsAction } from '../../actions/account-invitations-server-actions';
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { MembershipRoleSelector } from '../membership-role-selector';
|
||||
|
||||
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export function InviteMembersDialogContainer({
|
||||
account,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
account: string;
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Members to Organization</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Invite members to your organization by entering their email and
|
||||
role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<InviteMembersForm
|
||||
pending={pending}
|
||||
onSubmit={(data) => {
|
||||
startTransition(async () => {
|
||||
await createInvitationsAction({
|
||||
account,
|
||||
invitations: data.invitations,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteMembersForm({
|
||||
onSubmit,
|
||||
pending,
|
||||
}: {
|
||||
onSubmit: (data: { invitations: InviteModel[] }) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('organization');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(InviteMembersSchema),
|
||||
shouldUseNativeValidation: true,
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
invitations: [createEmptyInviteModel()],
|
||||
},
|
||||
});
|
||||
|
||||
const fieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'invitations',
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-8'}
|
||||
data-test={'invite-members-form'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const emailInputName = `invitations.${index}.email` as const;
|
||||
const roleInputName = `invitations.${index}.role` as const;
|
||||
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<div className={'flex items-end space-x-0.5 md:space-x-2'}>
|
||||
<div className={'w-7/12'}>
|
||||
<FormField
|
||||
name={emailInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t('emailLabel')}</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'invite-email-input'}
|
||||
placeholder="member@email.com"
|
||||
type="email"
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'w-4/12'}>
|
||||
<FormField
|
||||
name={roleInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
value={field.value}
|
||||
onChange={(role) => {
|
||||
form.setValue(field.name, role);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-[60px] justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size={'icon'}
|
||||
type={'button'}
|
||||
disabled={fieldArray.fields.length <= 1}
|
||||
data-test={'remove-invite-button'}
|
||||
aria-label={t('removeInviteButtonLabel')}
|
||||
onClick={() => {
|
||||
fieldArray.remove(index);
|
||||
form.clearErrors(emailInputName);
|
||||
}}
|
||||
>
|
||||
<XIcon className={'h-4 lg:h-5'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
{t('removeInviteButtonLabel')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-test={'append-new-invite-button'}
|
||||
type={'button'}
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
fieldArray.append(createEmptyInviteModel());
|
||||
}}
|
||||
>
|
||||
<span className={'flex items-center space-x-2'}>
|
||||
<PlusIcon className={'h-4'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'organization:addAnotherMemberButtonLabel'} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button disabled={pending}>
|
||||
{pending ? 'Inviting...' : 'Invite Members'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function createEmptyInviteModel() {
|
||||
return { email: '', role: 'member' as Role };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { removeMemberFromAccountAction } from '../../actions/account-members-server-actions';
|
||||
|
||||
export const RemoveMemberDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}> = ({ isOpen, setIsOpen, accountId, userId }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="organization:removeMemberModalHeading" />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Remove this member from the organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RemoveMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
accountId={accountId}
|
||||
userId={userId}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function RemoveMemberForm({
|
||||
accountId,
|
||||
userId,
|
||||
setIsOpen,
|
||||
}: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onMemberRemoved = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await removeMemberFromAccountAction({ accountId, userId });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={onMemberRemoved}>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<p className={'text-sm'}>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
|
||||
<If condition={error}>
|
||||
<RemoveMemberErrorAlert />
|
||||
</If>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-remove-member'}
|
||||
variant={'destructive'}
|
||||
disabled={isSubmitting}
|
||||
onClick={onMemberRemoved}
|
||||
>
|
||||
<Trans i18nKey={'organization:removeMemberSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveMemberErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:removeMemberErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:removeMemberErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { transferOwnershipAction } from '../../actions/account-members-server-actions';
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
|
||||
export const TransferOwnershipDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
targetDisplayName: string;
|
||||
}> = ({ isOpen, setIsOpen, targetDisplayName, accountId, userId }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey="organization:transferOwnership" />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Transfer ownership of the organization to another member.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<TransferOrganizationOwnershipForm
|
||||
accountId={accountId}
|
||||
userId={userId}
|
||||
targetDisplayName={targetDisplayName}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function TransferOrganizationOwnershipForm({
|
||||
accountId,
|
||||
userId,
|
||||
targetDisplayName,
|
||||
setIsOpen,
|
||||
}: {
|
||||
userId: string;
|
||||
accountId: string;
|
||||
targetDisplayName: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onSubmit = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await transferOwnershipAction({
|
||||
accountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(TransferOwnershipConfirmationSchema),
|
||||
defaultValues: {
|
||||
confirmation: '',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-2 text-sm'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<If condition={error}>
|
||||
<TransferOwnershipErrorAlert />
|
||||
</If>
|
||||
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'organization:transferOwnershipDisclaimer'}
|
||||
values={{
|
||||
member: targetDisplayName,
|
||||
}}
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name={'confirmation'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Please type TRANSFER to confirm the transfer of ownership.
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type={'text'} required {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Please make sure you understand the implications of this
|
||||
action.
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type={'submit'}
|
||||
data-test={'confirm-transfer-ownership-button'}
|
||||
variant={'destructive'}
|
||||
disabled={pending}
|
||||
>
|
||||
<If
|
||||
condition={pending}
|
||||
fallback={<Trans i18nKey={'organization:transferOwnership'} />}
|
||||
>
|
||||
<Trans i18nKey={'organization:transferringOwnership'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnershipErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:transferOrganizationErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:transferOrganizationErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { updateMemberRoleAction } from '../../actions/account-members-server-actions';
|
||||
import { UpdateRoleSchema } from '../../schema/update-role-schema';
|
||||
import { MembershipRoleSelector } from '../membership-role-selector';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const UpdateMemberRoleDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
userId: string;
|
||||
accountId: string;
|
||||
userRole: Role;
|
||||
}> = ({ isOpen, setIsOpen, userId, accountId, userRole }) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalHeading'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'organization:updateMemberRoleModalDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<UpdateMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
userId={userId}
|
||||
accountId={accountId}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function UpdateMemberForm({
|
||||
userId,
|
||||
userRole,
|
||||
accountId,
|
||||
setIsOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
userId: string;
|
||||
userRole: Role;
|
||||
accountId: string;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}>) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const onSubmit = ({ role }: { role: Role }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateMemberRoleAction({ accountId, userId, role });
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
UpdateRoleSchema.refine(
|
||||
(data) => {
|
||||
return data.role !== userRole;
|
||||
},
|
||||
{
|
||||
message: 'Role must be different from the current role.',
|
||||
path: ['role'],
|
||||
},
|
||||
),
|
||||
),
|
||||
reValidateMode: 'onChange',
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
role: userRole,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={'flex flex-col space-y-6'}
|
||||
>
|
||||
<If condition={error}>
|
||||
<UpdateRoleErrorAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'role'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>New Role</FormLabel>
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
currentUserRole={userRole}
|
||||
value={field.value}
|
||||
onChange={(newRole) => form.setValue('role', newRole)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>Pick a role for this member.</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button data-test={'confirm-update-member-role'} disabled={pending}>
|
||||
<Trans i18nKey={'organization:updateRoleSubmitLabel'} />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateRoleErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'organization:updateRoleErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'organization:updateRoleErrorMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const MembershipRoleSelector: React.FC<{
|
||||
value: Role;
|
||||
currentUserRole?: Role;
|
||||
onChange: (role: Role) => unknown;
|
||||
}> = ({ value, currentUserRole, onChange }) => {
|
||||
const rolesList: Role[] = ['owner', 'member'];
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger data-test={'role-selector-trigger'}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{rolesList.map((role) => {
|
||||
return (
|
||||
<SelectItem
|
||||
key={role}
|
||||
data-test={`role-item-${role}`}
|
||||
disabled={currentUserRole && currentUserRole === role}
|
||||
value={role}
|
||||
>
|
||||
<span className={'text-sm capitalize'}>
|
||||
<Trans i18nKey={`common.roles.${role}`} defaults={role} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user