This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

@@ -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"
}

View File

@@ -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,
});

View File

@@ -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",
});

View File

@@ -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>;

View File

@@ -1,7 +0,0 @@
import { ingestionRouter } from "./router/ingestion";
import { createTRPCRouter } from "./trpc";
// Deployed to /trpc/lambda/**
export const lambdaRouter = createTRPCRouter({
ingestion: ingestionRouter,
});

View File

@@ -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;

View File

@@ -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,
}));
}),
});

View File

@@ -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" };
}),
});

View File

@@ -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);
}),
});

View File

@@ -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;
}),
});

View File

@@ -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 };
}),
});

View File

@@ -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;

View File

@@ -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,
});
},
);

View File

@@ -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>;

View File

@@ -1,8 +0,0 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["src"],
"exclude": ["node_modules"],
}

View 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/*"
]
}
}
}

View 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>
);
}

View 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),
),
),
);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -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);

View File

@@ -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"
}

View File

@@ -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];

View File

@@ -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])
}

View File

@@ -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;
};

View File

@@ -1,8 +0,0 @@
{
"extends": "@acme/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
},
"include": ["*.ts", "prisma", "src"],
"exclude": ["node_modules"],
}

View 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/*"
]
}
}
}

View 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&apos;ve processed your request to
delete your account with {props.productName}.
</Text>
<Text className="text-[14px] leading-[24px] text-black">
We&apos;re sorry to see you go. Please note that this action is
irreversible, and we&apos;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>,
);
}

View File

@@ -0,0 +1,2 @@
export * from './invite.email';
export * from './account-delete.email';

View 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>,
);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View 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/*"
]
}
}
}

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1 @@
export * from './account-settings-container';

View File

@@ -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 });
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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}`;
}

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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],
);
}

View 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,
});
}

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const CreateOrganizationAccountSchema = z.object({
name: z.string().min(2).max(50),
});

View File

@@ -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);
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "*.tsx", "src"],
"exclude": ["node_modules"]
}

View 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"
}
}

View 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>;
}

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,10 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": [
"node_modules"
]
}

View 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/*"
]
}
}
}

View 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>
);
}

View 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>
);
}

View 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]);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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',
};
}

View 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);
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />
</>
);
};

View 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>
);
};

View File

@@ -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>
</>
);
}

View 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>
);
};

View File

@@ -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,
});
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './components/multi-factor-challenge-container';

View File

@@ -0,0 +1 @@
export * from './components/password-reset-request-container';

View 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'],
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const PasswordSignInSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(99),
});

View File

@@ -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'],
},
);

View File

@@ -0,0 +1 @@
export * from './components/auth-layout';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-in-methods-container';
export * from './schemas/password-sign-in.schema';

View File

@@ -0,0 +1,2 @@
export * from './components/sign-up-methods-container';
export * from './schemas/password-sign-up.schema';

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View 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/*"
]
}
}
}

View File

@@ -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`);
}
}

View File

@@ -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`);
}
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View 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';

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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