From 4ca30811b8af71df5d97ac217792a8189dc185de Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Wed, 18 Sep 2024 17:28:11 +0800 Subject: [PATCH] Overhaul backend + tenant settings & create --- apps/web/src/api/tenant/index.ts | 6 - apps/web/src/api/trpc/helpers.ts | 181 +-- apps/web/src/api/trpc/index.ts | 9 +- apps/web/src/api/trpc/routers/auth.ts | 27 +- apps/web/src/api/trpc/routers/device.ts | 494 ++++--- apps/web/src/api/trpc/routers/org/admins.ts | 191 --- apps/web/src/api/trpc/routers/org/billing.ts | 69 - apps/web/src/api/trpc/routers/org/index.ts | 140 -- apps/web/src/api/trpc/routers/tenant/index.ts | 376 ++---- .../src/api/trpc/routers/tenant/members.ts | 66 - .../src/api/trpc/routers/tenant/settings.ts | 201 +++ apps/web/src/api/utils/index.ts | 20 + apps/web/src/app.tsx | 4 +- apps/web/src/app/(dash).tsx | 7 + apps/web/src/app/(dash)/account.tsx | 10 +- .../app/(dash)/t/[tenantId]/(overview).tsx | 8 +- .../src/app/(dash)/t/[tenantId]/settings.tsx | 1190 ++--------------- .../web/src/app/(dash)/t/[tenantId]/users.tsx | 3 - apps/web/src/app/(dash)/t/new.tsx | 69 +- apps/web/src/app/login.tsx | 22 +- apps/web/src/components/Page.tsx | 2 +- apps/web/src/db/rust.ts | 800 ++++++----- apps/web/src/db/schema.ts | 334 ++--- apps/web/src/lib/data.tsx | 8 +- crates/mx-db/src/db.rs | 332 ----- packages/ui/src/forms/createForm.tsx | 6 +- 26 files changed, 1343 insertions(+), 3232 deletions(-) delete mode 100644 apps/web/src/api/tenant/index.ts delete mode 100644 apps/web/src/api/trpc/routers/org/admins.ts delete mode 100644 apps/web/src/api/trpc/routers/org/billing.ts delete mode 100644 apps/web/src/api/trpc/routers/org/index.ts delete mode 100644 apps/web/src/api/trpc/routers/tenant/members.ts create mode 100644 apps/web/src/api/trpc/routers/tenant/settings.ts delete mode 100644 apps/web/src/app/(dash)/t/[tenantId]/users.tsx diff --git a/apps/web/src/api/tenant/index.ts b/apps/web/src/api/tenant/index.ts deleted file mode 100644 index 870e2231..00000000 --- a/apps/web/src/api/tenant/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Context } from "../utils/context"; - -const TenantContext = Context.create<{ id: string; pk: number }>("tenant"); - -export const useTenant = TenantContext.use; -export const withTenant = TenantContext.with; diff --git a/apps/web/src/api/trpc/helpers.ts b/apps/web/src/api/trpc/helpers.ts index 384dd88e..db6aaf84 100644 --- a/apps/web/src/api/trpc/helpers.ts +++ b/apps/web/src/api/trpc/helpers.ts @@ -3,14 +3,12 @@ import { flushResponse } from "@mattrax/trpc-server-function/server"; import { cache } from "@solidjs/router"; import { TRPCError, initTRPC } from "@trpc/server"; import { and, eq } from "drizzle-orm"; -import type { User } from "lucia"; import type { H3Event } from "vinxi/server"; import { ZodError, z } from "zod"; -import { db, organisationMembers, organisations, tenants } from "~/db"; +import { db, tenantMembers, tenants } from "~/db"; import { withAccount } from "../account"; import { checkAuth } from "../auth"; -import { withTenant } from "../tenant"; export const createTRPCContext = (event: H3Event) => { return { @@ -47,41 +45,21 @@ export const publicProcedure = t.procedure.use(async ({ next }) => { } }); -const isOrganisationMember = cache(async (orgPk: number, accountPk: number) => { +const isTenantMember = cache(async (tenantPk: number, accountPk: number) => { const [org] = await db .select({}) - .from(organisations) - .where(eq(organisations.pk, orgPk)) + .from(tenants) + .where(eq(tenants.pk, tenantPk)) .innerJoin( - organisationMembers, + tenantMembers, and( - eq(organisations.pk, organisationMembers.orgPk), - eq(organisationMembers.accountPk, accountPk), + eq(tenants.pk, tenantMembers.tenantPk), + eq(tenantMembers.accountPk, accountPk), ), ); return org !== undefined; -}, "isOrganisationMember"); - -export const getTenantList = cache( - (accountPk: number) => - db - .select({ - id: tenants.id, - pk: tenants.pk, - name: tenants.name, - slug: tenants.slug, - orgSlug: organisations.slug, - }) - .from(tenants) - .innerJoin(organisations, eq(tenants.orgPk, organisations.pk)) - .innerJoin( - organisationMembers, - eq(organisations.pk, organisationMembers.orgPk), - ) - .where(eq(organisationMembers.accountPk, accountPk)), - "getTenantList", -); +}, "isTenantMember"); // Authenticated procedure export const authedProcedure = publicProcedure.use(async ({ next }) => { @@ -93,132 +71,41 @@ export const authedProcedure = publicProcedure.use(async ({ next }) => { next({ ctx: { ...data, - ensureOrganisationMember: async (orgPk: number) => { - if (!isOrganisationMember(orgPk, data.account.pk)) - throw new TRPCError({ code: "FORBIDDEN", message: "organisation" }); - }, ensureTenantMember: async (tenantPk: number) => { - const tenantList = await getTenantList(data.account.pk); - - const tenant = tenantList.find((t) => t.pk === tenantPk); - if (!tenant) + if (!isTenantMember(tenantPk, data.account.pk)) throw new TRPCError({ code: "FORBIDDEN", message: "tenant" }); - - return tenant; }, }, }), ); }); -export const isSuperAdmin = (account: { email: string } | User) => - account.email.endsWith("@otbeaumont.me") || - account.email.endsWith("@mattrax.app"); - -// Authenticated procedure requiring a superadmin (Mattrax employee) -export const superAdminProcedure = authedProcedure.use((opts) => { - const { ctx } = opts; - if (!isSuperAdmin(ctx.account)) throw new TRPCError({ code: "FORBIDDEN" }); - - return opts.next({ ctx }); -}); - -// const getMemberOrg = cache(async (slug: string, accountPk: number) => { -// const [org] = await db -// .select({ -// pk: organisations.pk, -// slug: organisations.slug, -// name: organisations.name, -// ownerPk: organisations.ownerPk, -// }) -// .from(organisations) -// .where( -// and( -// eq(organisations.slug, slug), -// eq(organisationMembers.accountPk, accountPk), -// ), -// ) -// .innerJoin( -// organisationMembers, -// eq(organisations.pk, organisationMembers.orgPk), -// ); - -// return org; -// }, "getMemberOrg"); - -// export const orgProcedure = authedProcedure -// .input(z.object({ orgSlug: z.string() })) -// .use(async (opts) => { -// const { ctx, input, type } = opts; - -// const org = await getMemberOrg(input.orgSlug, ctx.account.pk); - -// if (!org) -// throw new TRPCError({ code: "FORBIDDEN", message: "organisation" }); - -// return opts.next({ ctx: { ...ctx, org } }).then((result) => { -// // TODO: Right now we invalidate everything but we will need to be more specific in the future -// // if (type === "mutation") invalidate(org.slug); -// return result; -// }); -// }); - -// const getMemberTenant = cache(async (slug: string, accountPk: number) => { -// const [tenant] = await db -// .select({ -// pk: tenants.pk, -// id: tenants.id, -// name: tenants.name, -// orgSlug: organisations.slug, -// }) -// .from(tenants) -// .innerJoin(organisations, eq(tenants.orgPk, organisations.pk)) -// .innerJoin( -// organisationMembers, -// eq(organisations.pk, organisationMembers.orgPk), -// ) -// .where( -// and(eq(tenants.slug, slug), eq(organisationMembers.accountPk, accountPk)), -// ); - -// return tenant; -// }, "getMemberTenant"); - -// // Authenticated procedure w/ a tenant -// export const tenantProcedure = authedProcedure -// .input(z.object({ tenantSlug: z.string() })) -// .use(async (opts) => { -// const { ctx, input, type } = opts; +const getTenant = cache(async (tenantId: string, accountPk: number) => { + const [tenant] = await db + .select({ + id: tenants.id, + pk: tenants.pk, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .innerJoin( + tenantMembers, + and( + eq(tenants.pk, tenantMembers.tenantPk), + eq(tenantMembers.accountPk, accountPk), + ), + ); -// const tenant = await getMemberTenant(input.tenantSlug, ctx.account.pk); + return tenant; +}, "isTenantMemberById"); -// if (!tenant) throw new TRPCError({ code: "FORBIDDEN", message: "tenant" }); +export const tenantProcedure = authedProcedure + .input(z.object({ tenantId: z.string() })) + .use(async (opts) => { + const { ctx, input, type } = opts; -// return withTenant(tenant, () => -// opts.next({ ctx: { ...ctx, tenant } }).then((result) => { -// // TODO: Right now we invalidate everything but we will need to be more specific in the future -// // if (type === "mutation") invalidate(tenant.orgSlug, input.tenantSlug); -// return result; -// }), -// ); -// }); + const tenant = await getTenant(input.tenantId, ctx.account.pk); + if (!tenant) throw new TRPCError({ code: "FORBIDDEN", message: "tenant" }); -export const restricted = new Set([ - // Misleading names - "admin", - "administrator", - "help", - "mod", - "moderator", - "staff", - "mattrax", - "root", - "contact", - "support", - "home", - "employee", - // Reserved Mattrax routes - "enroll", - "profile", - "account", -]); + return opts.next({ ctx: { ...ctx, tenant } }); + }); diff --git a/apps/web/src/api/trpc/index.ts b/apps/web/src/api/trpc/index.ts index c366f1b6..468932b1 100644 --- a/apps/web/src/api/trpc/index.ts +++ b/apps/web/src/api/trpc/index.ts @@ -1,17 +1,16 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { createTRPCContext, createTRPCRouter } from "./helpers"; import { authRouter } from "./routers/auth"; -// import { deviceRouter } from "./routers/device"; +import { deviceRouter } from "./routers/device"; import { metaRouter } from "./routers/meta"; -// import { orgRouter } from "./routers/org"; import { tenantRouter } from "./routers/tenant/index"; -// import { blueprintRouter } from "./routers/blueprint"; +import { blueprintRouter } from "./routers/blueprint"; export const appRouter = createTRPCRouter({ auth: authRouter, tenant: tenantRouter, - // blueprint: blueprintRouter, - // device: deviceRouter, + blueprint: blueprintRouter, + device: deviceRouter, meta: metaRouter, }); diff --git a/apps/web/src/api/trpc/routers/auth.ts b/apps/web/src/api/trpc/routers/auth.ts index f30e75b9..73a0ce56 100644 --- a/apps/web/src/api/trpc/routers/auth.ts +++ b/apps/web/src/api/trpc/routers/auth.ts @@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { generateId, type User } from "lucia"; import { alphabet, generateRandomString } from "oslo/crypto"; -import { appendResponseHeader, deleteCookie, useSession } from "vinxi/server"; +import { appendResponseHeader, deleteCookie } from "vinxi/server"; import { z } from "zod"; import { @@ -15,18 +15,11 @@ import { } from "~/api/auth"; import { sendEmail } from "~/api/emails"; import { accountLoginCodes, accounts, db, tenants } from "~/db"; -import { - authedProcedure, - createTRPCRouter, - getTenantList, - publicProcedure, -} from "../helpers"; +import { authedProcedure, createTRPCRouter, publicProcedure } from "../helpers"; import { sendDiscordMessage } from "./meta"; import { env } from "~/env"; async function mapAccount(account: DatabaseUserAttributes) { - // await new Promise((resolve) => setTimeout(resolve, 10000)); // TODO - return { id: account.id, name: account.name, @@ -44,7 +37,7 @@ export const authRouter = createTRPCRouter({ ) .mutation(async ({ input }) => { const parts = input.email.split("@"); - // This should be impossible due to input validation on the frontend but we guard just in case + // This should be impossible due to input validation but we guard just in case if (parts.length !== 2) throw new Error("Invalid email provided!"); const account = await db.query.accounts.findFirst({ @@ -106,7 +99,10 @@ export const authRouter = createTRPCRouter({ }); if (!code) - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid code" }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invalid login code", + }); const [_, [account]] = await Promise.all([ ctx.db @@ -152,7 +148,7 @@ export const authRouter = createTRPCRouter({ await handleLoginSuccess(account.id); flushResponse(); - revalidate([checkAuth.key, getTenantList.key]); + revalidate(checkAuth.key); return mapAccount(account); }), @@ -173,7 +169,7 @@ export const authRouter = createTRPCRouter({ } }), - // `authedProcedure` implies `flushResponse` and we need manual control for the cookies! + // `authedProcedure` calls `flushResponse` before the handler so we can't use it here! logout: publicProcedure.mutation(async () => { const data = await checkAuth(); if (!data) throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -182,11 +178,9 @@ export const authRouter = createTRPCRouter({ deleteCookie("isLoggedIn"); await lucia.invalidateSession(data.session.id); - - flushResponse(); }), - // `authedProcedure` implies `flushResponse` and we need manual control for the cookies! + // `authedProcedure` calls `flushResponse` before the handler so we can't use it here! delete: publicProcedure.mutation(async ({ ctx }) => { const data = await checkAuth(); if (!data) throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -202,7 +196,6 @@ export const authRouter = createTRPCRouter({ deleteCookie(lucia.sessionCookieName); deleteCookie("isLoggedIn"); await lucia.invalidateSession(data.session.id); - flushResponse(); }), }); diff --git a/apps/web/src/api/trpc/routers/device.ts b/apps/web/src/api/trpc/routers/device.ts index 85325779..228ae508 100644 --- a/apps/web/src/api/trpc/routers/device.ts +++ b/apps/web/src/api/trpc/routers/device.ts @@ -2,263 +2,251 @@ import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { withTenant } from "~/api/tenant"; import { omit } from "~/api/utils"; -import { createEnrollmentSession } from "~/app/enroll/util"; -import { - applicationAssignables, - applications, - db, - deviceActions, - devices, - policies, - policyAssignments, - possibleDeviceActions, - users, -} from "~/db"; +import { db, deviceActions, devices, possibleDeviceActions } from "~/db"; import { env } from "~/env"; import { authedProcedure, createTRPCRouter, tenantProcedure } from "../helpers"; -const deviceProcedure = authedProcedure - .input(z.object({ id: z.string() })) - .use(async ({ next, input, ctx, type }) => { - const device = await ctx.db.query.devices.findFirst({ - where: eq(devices.id, input.id), - }); - if (!device) throw new TRPCError({ code: "NOT_FOUND", message: "device" }); - - const tenant = await ctx.ensureTenantMember(device.tenantPk); - - return withTenant(tenant, () => - next({ ctx: { device, tenant } }).then((result) => { - // TODO: Right now we invalidate everything but we will need to be more specific in the future - // if (type === "mutation") invalidate(tenant.orgSlug, tenant.slug); - return result; - }), - ); - }); +// const deviceProcedure = authedProcedure +// .input(z.object({ id: z.string() })) +// .use(async ({ next, input, ctx, type }) => { +// const device = await ctx.db.query.devices.findFirst({ +// where: eq(devices.id, input.id), +// }); +// if (!device) throw new TRPCError({ code: "NOT_FOUND", message: "device" }); + +// const tenant = await ctx.ensureTenantMember(device.tenantPk); + +// return withTenant(tenant, () => +// next({ ctx: { device, tenant } }).then((result) => { +// // TODO: Right now we invalidate everything but we will need to be more specific in the future +// // if (type === "mutation") invalidate(tenant.orgSlug, tenant.slug); +// return result; +// }), +// ); +// }); export const deviceRouter = createTRPCRouter({ - list: tenantProcedure.query(async ({ ctx }) => { - return await ctx.db - .select({ - id: devices.id, - name: devices.name, - enrollmentType: devices.enrollmentType, - os: devices.os, - serialNumber: devices.serialNumber, - lastSynced: devices.lastSynced, - owner: { - id: users.id, - name: users.name, - }, - enrolledAt: devices.enrolledAt, - }) - .from(devices) - .leftJoin(users, eq(users.pk, devices.owner)) - .where(and(eq(devices.tenantPk, ctx.tenant.pk))); - }), - - get: authedProcedure - .input(z.object({ deviceId: z.string() })) - .query(async ({ ctx, input }) => { - const [device] = await ctx.db - .select({ - id: devices.id, - name: devices.name, - description: devices.description, - enrollmentType: devices.enrollmentType, - os: devices.os, - serialNumber: devices.serialNumber, - manufacturer: devices.manufacturer, - azureADDeviceId: devices.azureADDeviceId, - freeStorageSpaceInBytes: devices.freeStorageSpaceInBytes, - totalStorageSpaceInBytes: devices.totalStorageSpaceInBytes, - imei: devices.imei, - model: devices.model, - lastSynced: devices.lastSynced, - enrolledAt: devices.enrolledAt, - tenantPk: devices.tenantPk, - ownerId: users.id, - ownerName: users.name, - }) - .from(devices) - .leftJoin(users, eq(users.pk, devices.owner)) - .where(eq(devices.id, input.deviceId)); - if (!device) return null; - - await ctx.ensureTenantMember(device.tenantPk); - - return omit(device, ["tenantPk"]); - }), - - action: authedProcedure - .input( - z.object({ - deviceId: z.string(), - action: z.enum([...possibleDeviceActions, "sync"]), - }), - ) - .mutation(async ({ ctx, input }) => { - const device = await ctx.db.query.devices.findFirst({ - where: eq(devices.id, input.deviceId), - }); - if (!device) return null; - - await ctx.ensureTenantMember(device.tenantPk); - - if (input.action !== "sync") { - await ctx.db.insert(deviceActions).values({ - action: input.action, - devicePk: device.pk, - createdBy: ctx.account.pk, - }); - } - - // TODO: Talk with WNS or APNS to ask the device to checkin to MDM. - console.log("TODO: Trigger MDM device checkin"); - - return {}; - }), - - assignments: deviceProcedure.query(async ({ ctx }) => { - const { device } = ctx; - - const [p, a] = await Promise.all([ - ctx.db - .select({ pk: policies.pk, id: policies.id, name: policies.name }) - .from(policyAssignments) - .where( - and( - eq(policyAssignments.variant, "device"), - eq(policyAssignments.pk, device.pk), - ), - ) - .innerJoin(policies, eq(policyAssignments.policyPk, policies.pk)), - ctx.db - .select({ - pk: applications.pk, - id: applications.id, - name: applications.name, - }) - .from(applicationAssignables) - .where( - and( - eq(applicationAssignables.variant, "device"), - eq(applicationAssignables.pk, device.pk), - ), - ) - .innerJoin( - applications, - eq(applicationAssignables.applicationPk, applications.pk), - ), - ]); - - return { policies: p, apps: a }; - }), - - addAssignments: deviceProcedure - .input( - z.object({ - assignments: z.array( - z.object({ - pk: z.number(), - variant: z.enum(["policy", "application"]), - }), - ), - }), - ) - .mutation(async ({ ctx: { device, db }, input }) => { - // biome-ignore lint/style/useSingleVarDeclarator: - const pols: Array = [], - apps: Array = []; - - input.assignments.forEach((a) => { - if (a.variant === "policy") pols.push(a.pk); - else apps.push(a.pk); - }); - - const ops: Promise[] = []; - if (pols.length > 0) - ops.push( - db - .insert(policyAssignments) - .values( - pols.map((pk) => ({ - pk: device.pk, - policyPk: pk, - variant: sql`'device'`, - })), - ) - .onDuplicateKeyUpdate({ - set: { - pk: sql`${policyAssignments.pk}`, - }, - }), - ); - - if (apps.length > 0) - ops.push( - db - .insert(applicationAssignables) - .values( - apps.map((pk) => ({ - pk: device.pk, - applicationPk: pk, - variant: sql`'device'`, - })), - ) - .onDuplicateKeyUpdate({ - set: { - pk: sql`${applicationAssignables.pk}`, - }, - }), - ); - - await db.transaction((db) => Promise.all(ops)); - }), - - generateEnrollmentSession: tenantProcedure - .input(z.object({ userId: z.string().nullable() })) - .mutation(async ({ ctx, input }) => { - const p = new URLSearchParams(); - p.set("mode", "mdm"); - p.set("servername", env.VITE_PROD_ORIGIN); - - let data: { uid: number; upn: string } | undefined = undefined; - if (input.userId) { - const [user] = await db - .select({ - pk: users.pk, - upn: users.upn, - }) - .from(users) - .where(eq(users.id, input.userId)); - if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "user" }); // TODO: Handle this on the frontend - - p.set("username", user.upn); - data = { - uid: user.pk, - upn: user.upn, - }; - } - - const jwt = await createEnrollmentSession( - data - ? { - tid: ctx.tenant.pk, - ...data, - } - : { - tid: ctx.tenant.pk, - aid: ctx.account.pk, - }, - // 7 days - 7 * 24 * 60, - ); - - p.set("accesstoken", jwt); - - return `ms-device-enrollment:?${p.toString()}`; - }), + // list: tenantProcedure.query(async ({ ctx }) => { + // return await ctx.db + // .select({ + // id: devices.id, + // name: devices.name, + // enrollmentType: devices.enrollmentType, + // os: devices.os, + // serialNumber: devices.serialNumber, + // lastSynced: devices.lastSynced, + // owner: { + // id: users.id, + // name: users.name, + // }, + // enrolledAt: devices.enrolledAt, + // }) + // .from(devices) + // .leftJoin(users, eq(users.pk, devices.owner)) + // .where(and(eq(devices.tenantPk, ctx.tenant.pk))); + // }), + + // get: authedProcedure + // .input(z.object({ deviceId: z.string() })) + // .query(async ({ ctx, input }) => { + // const [device] = await ctx.db + // .select({ + // id: devices.id, + // name: devices.name, + // description: devices.description, + // enrollmentType: devices.enrollmentType, + // os: devices.os, + // serialNumber: devices.serialNumber, + // manufacturer: devices.manufacturer, + // azureADDeviceId: devices.azureADDeviceId, + // freeStorageSpaceInBytes: devices.freeStorageSpaceInBytes, + // totalStorageSpaceInBytes: devices.totalStorageSpaceInBytes, + // imei: devices.imei, + // model: devices.model, + // lastSynced: devices.lastSynced, + // enrolledAt: devices.enrolledAt, + // tenantPk: devices.tenantPk, + // ownerId: users.id, + // ownerName: users.name, + // }) + // .from(devices) + // .leftJoin(users, eq(users.pk, devices.owner)) + // .where(eq(devices.id, input.deviceId)); + // if (!device) return null; + + // await ctx.ensureTenantMember(device.tenantPk); + + // return omit(device, ["tenantPk"]); + // }), + + // action: authedProcedure + // .input( + // z.object({ + // deviceId: z.string(), + // action: z.enum([...possibleDeviceActions, "sync"]), + // }), + // ) + // .mutation(async ({ ctx, input }) => { + // const device = await ctx.db.query.devices.findFirst({ + // where: eq(devices.id, input.deviceId), + // }); + // if (!device) return null; + + // await ctx.ensureTenantMember(device.tenantPk); + + // if (input.action !== "sync") { + // await ctx.db.insert(deviceActions).values({ + // action: input.action, + // devicePk: device.pk, + // createdBy: ctx.account.pk, + // }); + // } + + // // TODO: Talk with WNS or APNS to ask the device to checkin to MDM. + // console.log("TODO: Trigger MDM device checkin"); + + // return {}; + // }), + + // assignments: deviceProcedure.query(async ({ ctx }) => { + // const { device } = ctx; + + // const [p, a] = await Promise.all([ + // ctx.db + // .select({ pk: policies.pk, id: policies.id, name: policies.name }) + // .from(policyAssignments) + // .where( + // and( + // eq(policyAssignments.variant, "device"), + // eq(policyAssignments.pk, device.pk), + // ), + // ) + // .innerJoin(policies, eq(policyAssignments.policyPk, policies.pk)), + // ctx.db + // .select({ + // pk: applications.pk, + // id: applications.id, + // name: applications.name, + // }) + // .from(applicationAssignables) + // .where( + // and( + // eq(applicationAssignables.variant, "device"), + // eq(applicationAssignables.pk, device.pk), + // ), + // ) + // .innerJoin( + // applications, + // eq(applicationAssignables.applicationPk, applications.pk), + // ), + // ]); + + // return { policies: p, apps: a }; + // }), + + // addAssignments: deviceProcedure + // .input( + // z.object({ + // assignments: z.array( + // z.object({ + // pk: z.number(), + // variant: z.enum(["policy", "application"]), + // }), + // ), + // }), + // ) + // .mutation(async ({ ctx: { device, db }, input }) => { + // // biome-ignore lint/style/useSingleVarDeclarator: + // const pols: Array = [], + // apps: Array = []; + + // input.assignments.forEach((a) => { + // if (a.variant === "policy") pols.push(a.pk); + // else apps.push(a.pk); + // }); + + // const ops: Promise[] = []; + // if (pols.length > 0) + // ops.push( + // db + // .insert(policyAssignments) + // .values( + // pols.map((pk) => ({ + // pk: device.pk, + // policyPk: pk, + // variant: sql`'device'`, + // })), + // ) + // .onDuplicateKeyUpdate({ + // set: { + // pk: sql`${policyAssignments.pk}`, + // }, + // }), + // ); + + // if (apps.length > 0) + // ops.push( + // db + // .insert(applicationAssignables) + // .values( + // apps.map((pk) => ({ + // pk: device.pk, + // applicationPk: pk, + // variant: sql`'device'`, + // })), + // ) + // .onDuplicateKeyUpdate({ + // set: { + // pk: sql`${applicationAssignables.pk}`, + // }, + // }), + // ); + + // await db.transaction((db) => Promise.all(ops)); + // }), + + // generateEnrollmentSession: tenantProcedure + // .input(z.object({ userId: z.string().nullable() })) + // .mutation(async ({ ctx, input }) => { + // const p = new URLSearchParams(); + // p.set("mode", "mdm"); + // p.set("servername", env.VITE_PROD_ORIGIN); + + // let data: { uid: number; upn: string } | undefined = undefined; + // if (input.userId) { + // const [user] = await db + // .select({ + // pk: users.pk, + // upn: users.upn, + // }) + // .from(users) + // .where(eq(users.id, input.userId)); + // if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "user" }); // TODO: Handle this on the frontend + + // p.set("username", user.upn); + // data = { + // uid: user.pk, + // upn: user.upn, + // }; + // } + + // const jwt = await createEnrollmentSession( + // data + // ? { + // tid: ctx.tenant.pk, + // ...data, + // } + // : { + // tid: ctx.tenant.pk, + // aid: ctx.account.pk, + // }, + // // 7 days + // 7 * 24 * 60, + // ); + + // p.set("accesstoken", jwt); + + // return `ms-device-enrollment:?${p.toString()}`; + // }), }); diff --git a/apps/web/src/api/trpc/routers/org/admins.ts b/apps/web/src/api/trpc/routers/org/admins.ts deleted file mode 100644 index 9e0de9a8..00000000 --- a/apps/web/src/api/trpc/routers/org/admins.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { flushResponse } from "@mattrax/trpc-server-function/server"; -import { TRPCError } from "@trpc/server"; -import { and, eq, sql } from "drizzle-orm"; -import { generateId } from "lucia"; -import { appendResponseHeader, setCookie } from "vinxi/server"; -import { z } from "zod"; - -import { lucia, setIsLoggedInCookie } from "~/api/auth"; -import { sendEmail } from "~/api/emails"; -import { - accounts, - organisationInvites, - organisationMembers, - organisations, -} from "~/db"; -import { env } from "~/env"; -import { createTRPCRouter, orgProcedure, publicProcedure } from "../../helpers"; -import { handleLoginSuccess } from "../auth"; - -export const adminsRouter = createTRPCRouter({ - list: orgProcedure.query(async ({ ctx }) => { - const [ownerId, rows] = await Promise.allSettled([ - ctx.db - .select({ ownerPk: organisations.ownerPk }) - .from(organisations) - .where(eq(organisations.pk, ctx.org.pk)) - .then((v) => v?.[0]?.ownerPk), - ctx.db - .select({ - pk: accounts.pk, - id: accounts.id, - name: accounts.name, - email: accounts.email, - }) - .from(accounts) - .leftJoin( - organisationMembers, - eq(organisationMembers.accountPk, accounts.pk), - ) - .where(eq(organisationMembers.orgPk, ctx.org.pk)), - ]); - - // This is required. If the owner is not found, we gracefully continue. - if (rows.status === "rejected") throw rows.reason; - - return rows.value.map(({ pk, ...row }) => ({ - ...row, - isOwner: ownerId.status === "fulfilled" ? pk === ownerId.value : false, - })); - }), - - sendInvite: orgProcedure - .input(z.object({ email: z.string() })) - .mutation(async ({ ctx, input }) => { - const org = await ctx.db.query.organisations.findFirst({ - columns: { name: true }, - where: eq(organisations.pk, ctx.org.pk), - }); - if (!org) - throw new TRPCError({ - code: "NOT_FOUND", - message: "tenant", - }); - - const code = crypto.randomUUID(); - - // try { - await ctx.db.insert(organisationInvites).values({ - orgPk: ctx.org.pk, - email: input.email, - code, - }); - // } catch { - // throw new TRPCError({ - // code: "PRECONDITION_FAILED", - // message: "Invite already sent", - // }); - // } - - await sendEmail({ - to: input.email, - subject: "Invitation to Mattrax Tenant", - type: "tenantAdminInvite", - invitedByEmail: ctx.account.email, - tenantName: org.name, - inviteLink: `${env.VITE_PROD_ORIGIN}/invite/organisation/${code}`, - }); - }), - - acceptInvite: publicProcedure - .input(z.object({ code: z.string() })) - .mutation(async ({ ctx, input }) => { - const invite = await ctx.db.query.organisationInvites.findFirst({ - where: eq(organisationInvites.code, input.code), - }); - if (!invite) { - flushResponse(); - return null; - } - - const name = invite.email.split("@")[0] ?? ""; - - const account = await ctx.db.transaction(async (db) => { - await db - .insert(accounts) - .values({ name, email: invite.email, id: generateId(16) }) - .onDuplicateKeyUpdate({ - set: { pk: sql`${accounts.pk}` }, - }); - - const [account] = await db - .select({ pk: accounts.pk, id: accounts.id }) - .from(accounts); - - await db - .insert(organisationMembers) - .values({ orgPk: invite.orgPk, accountPk: account!.pk }) - .onDuplicateKeyUpdate({ - set: { accountPk: sql`${organisationMembers.accountPk}` }, - }); - - await db - .delete(organisationInvites) - .where(eq(organisationInvites.code, input.code)); - - return account!; - }); - - await handleLoginSuccess(account.id); - - flushResponse(); - - const org = await ctx.db.query.organisations.findFirst({ - where: eq(organisations.pk, invite.orgPk), - }); - if (!org) - throw new TRPCError({ - code: "NOT_FOUND", - message: "tenant", - }); - - return { slug: org.slug, name: org.name }; - }), - - remove: orgProcedure - .input(z.object({ adminId: z.string() })) - .mutation(async ({ ctx, input }) => { - const account = await ctx.db.query.accounts.findFirst({ - where: eq(accounts.id, input.adminId), - }); - if (!account) - throw new TRPCError({ - code: "NOT_FOUND", - message: "account", - }); - - if (account.pk === ctx.org.ownerPk) - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Cannot remove tenant owner", - }); - - await ctx.db - .delete(organisationMembers) - .where( - and( - eq(organisationMembers.accountPk, account.pk), - eq(organisationMembers.orgPk, ctx.org.pk), - ), - ); - }), - - invites: orgProcedure.query(async ({ ctx }) => { - return await ctx.db.query.organisationInvites.findMany({ - where: eq(organisationInvites.orgPk, ctx.org.pk), - }); - }), - - removeInvite: orgProcedure - .input(z.object({ email: z.string() })) - .mutation(async ({ ctx, input }) => { - await ctx.db - .delete(organisationInvites) - .where( - and( - eq(organisationInvites.orgPk, ctx.org.pk), - eq(organisationInvites.email, input.email), - ), - ); - }), -}); diff --git a/apps/web/src/api/trpc/routers/org/billing.ts b/apps/web/src/api/trpc/routers/org/billing.ts deleted file mode 100644 index becba287..00000000 --- a/apps/web/src/api/trpc/routers/org/billing.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; -import type Stripe from "stripe"; -// import { useStripe } from "~/api/stripe"; -import { organisations } from "~/db"; -import { env } from "~/env"; -import { createTRPCRouter, orgProcedure } from "../../helpers"; - -export const billingRouter = createTRPCRouter({ - // portalUrl: orgProcedure.mutation(async ({ ctx }) => { - // const [org] = await ctx.db - // .select({ - // name: organisations.name, - // billingEmail: organisations.billingEmail, - // stripeCustomerId: organisations.stripeCustomerId, - // }) - // .from(organisations) - // .where(eq(organisations.pk, ctx.org.pk)); - // if (!org) - // throw new TRPCError({ code: "NOT_FOUND", message: "organisation" }); // TODO: Proper error code which the frontend knows how to handle - // let customerId: string; - // const stripe = await useStripe(); - // if (!org.stripeCustomerId) { - // try { - // const customer = await stripe.customers.create({ - // name: org.name, - // email: org.billingEmail || undefined, - // }); - // await ctx.db - // .update(organisations) - // .set({ stripeCustomerId: customer.id }) - // .where(eq(organisations.pk, ctx.org.pk)); - // customerId = customer.id; - // } catch (err) { - // console.error("Error creating customer", err); - // throw new Error("Error creating customer"); - // } - // } else { - // customerId = org.stripeCustomerId; - // } - // // TODO: When using the official Stripe SDK, this endpoint causes the entire Edge Function to hang and i'm at a loss to why. - // // TODO: This will do for now but we should try and fix it. - // const body = new URLSearchParams({ - // customer: customerId, - // return_url: `${env.VITE_PROD_ORIGIN}/o/${ctx.org.slug}/settings`, - // }); - // const resp = await fetch( - // "https://api.stripe.com/v1/billing_portal/sessions", - // { - // method: "POST", - // headers: { - // Authorization: `Bearer ${stripe.secret}`, - // "Content-Type": "application/x-www-form-urlencoded", - // }, - // body: body.toString(), - // }, - // ); - // if (!resp.ok) { - // const body = await resp.text(); - // console.error("Error creating billing portal session", resp.status, body); - // throw new Error( - // `Error creating billing portal session: '${resp.status}' '${body}'`, - // ); - // } - // const session: Stripe.Response = - // await resp.json(); - // return session.url; - // }), -}); diff --git a/apps/web/src/api/trpc/routers/org/index.ts b/apps/web/src/api/trpc/routers/org/index.ts deleted file mode 100644 index 0be045f6..00000000 --- a/apps/web/src/api/trpc/routers/org/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { count, eq } from "drizzle-orm"; -import { z } from "zod"; - -import { randomSlug } from "~/api/utils"; -import { - accounts, - organisationInvites, - organisationMembers, - organisations, - tenants, -} from "~/db"; -import { - authedProcedure, - createTRPCRouter, - orgProcedure, - restricted, -} from "../../helpers"; - -import { adminsRouter } from "./admins"; -// import { billingRouter } from "./billing"; - -export const orgRouter = createTRPCRouter({ - admins: adminsRouter, - // billing: billingRouter, - - list: authedProcedure.query(async ({ ctx }) => { - return await ctx.db - .select({ - id: organisations.id, - name: organisations.name, - slug: organisations.slug, - ownerId: accounts.id, - }) - .from(organisations) - .where(eq(organisationMembers.accountPk, ctx.account.pk)) - .innerJoin( - organisationMembers, - eq(organisations.pk, organisationMembers.orgPk), - ) - .innerJoin(accounts, eq(organisations.ownerPk, accounts.pk)) - .orderBy(organisations.id); - }), - - tenants: orgProcedure.query(async ({ ctx }) => { - return ctx.db - .select({ - id: tenants.id, - name: tenants.name, - slug: tenants.slug, - orgId: organisations.id, - }) - .from(tenants) - .where(eq(organisations.pk, ctx.org.pk)) - .innerJoin(organisations, eq(organisations.pk, tenants.orgPk)) - .orderBy(tenants.id); - }), - - delete: orgProcedure.query(async ({ ctx }) => { - const [[a], [b]] = await Promise.all([ - ctx.db - .select({ count: count() }) - .from(tenants) - .where(eq(tenants.orgPk, ctx.org.pk)), - ctx.db - .select({ count: count() }) - .from(organisationMembers) - .where(eq(organisations.pk, ctx.org.pk)), - ]); - - if (a!.count !== 0) - throw new Error("Cannot delete organisation with tenants"); // TODO: handle this error on the frontend - - if (b!.count !== 1) - throw new Error( - "Cannot delete organisation with administrators other than yourself", - ); // TODO: handle this error on the frontend - - // TODO: Ensure no outstanding payments - - await ctx.db.transaction(async (db) => { - await db.delete(organisations).where(eq(organisations.pk, ctx.org.pk)); - await db - .delete(organisationMembers) - .where(eq(organisations.pk, ctx.org.pk)); - await db - .delete(organisationInvites) - .where(eq(organisations.pk, ctx.org.pk)); - }); - }), - - edit: orgProcedure - .input( - z.object({ - name: z.string().min(1).max(100).optional(), - slug: z.string().min(1).max(100).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - if (input.name === undefined) return; - - if (input.name && restricted.has(input.name.toLowerCase())) { - throw new Error("Name is restricted"); // TODO: Properly handle this on the frontend - } - if (input.slug && restricted.has(input.slug.toLowerCase())) { - throw new Error("Slug is restricted"); // TODO: Properly handle this on the frontend - } - - await ctx.db - .update(organisations) - .set({ - ...(input.name !== undefined && { name: input.name }), - ...(input.slug !== undefined && { slug: input.slug }), - }) - .where(eq(organisations.pk, ctx.org.pk)); - }), - - create: authedProcedure - .input(z.object({ name: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const slug = await ctx.db.transaction(async (db) => { - const slug = randomSlug(input.name); - - const result = await db.insert(organisations).values({ - name: input.name, - slug, - ownerPk: ctx.account.pk, - }); - - const orgPk = Number.parseInt(result.insertId); - await db.insert(organisationMembers).values({ - orgPk, - accountPk: ctx.account.pk, - }); - - return slug; - }); - - return slug; - }), -}); diff --git a/apps/web/src/api/trpc/routers/tenant/index.ts b/apps/web/src/api/trpc/routers/tenant/index.ts index 7e8ca7d6..a1c8f828 100644 --- a/apps/web/src/api/trpc/routers/tenant/index.ts +++ b/apps/web/src/api/trpc/routers/tenant/index.ts @@ -1,288 +1,116 @@ -import { and, count, desc, eq, sql } from "drizzle-orm"; +import { and, count, eq, sql } from "drizzle-orm"; import { union } from "drizzle-orm/mysql-core"; import { z } from "zod"; -import { randomSlug } from "~/api/utils"; -import { accounts, devices, tenants } from "~/db"; +import { blueprints, devices, tenantMembers, tenants } from "~/db"; import { env } from "~/env"; -import { authedProcedure, createTRPCRouter } from "../../helpers"; -import { variantTableRouter } from "./members"; - -// export type StatsTarget = -// | "devices" -// | "users" -// | "policies" -// | "applications" -// | "groups"; +import { + authedProcedure, + createTRPCRouter, + tenantProcedure, +} from "../../helpers"; +import { TRPCError } from "@trpc/server"; +import { createId } from "@paralleldrive/cuid2"; +import { sendDiscordMessage } from "../meta"; +import { settingsRouter } from "./settings"; export const tenantRouter = createTRPCRouter({ - list: authedProcedure.query(async ({ ctx }) => { - // await new Promise((resolve) => setTimeout(resolve, 1000)); // TODO: Remove + list: authedProcedure.query(({ ctx }) => + ctx.db + .select({ + id: tenants.id, + name: tenants.name, + }) + .from(tenants) + .innerJoin( + tenantMembers, + and( + eq(tenants.pk, tenantMembers.tenantPk), + eq(tenantMembers.accountPk, ctx.account.pk), + ), + ) + .orderBy(tenants.id), + ), - // TODO: Hook this up properly - return [ - { - id: "abc", - name: "Acme School Inc", - // slug: "acme-school-inc", - }, - { - id: "def", - name: "Oscar's Tenant", - // slug: "acme-school-inc", - }, - ]; - }), - stats: authedProcedure.query(async ({ ctx }) => { - // await new Promise((resolve) => setTimeout(resolve, 1000)); // TODO: Remove + stats: tenantProcedure.query(async ({ ctx }) => { + const [a, b] = await union( + ctx.db + .select({ count: count() }) + .from(devices) + .where(eq(devices.tenantPk, ctx.tenant.pk)), + ctx.db + .select({ count: count() }) + .from(blueprints) + .where(eq(blueprints.tenantPk, ctx.tenant.pk)), + ); - // TODO: Hook this up properly return { - devices: 5, - blueprints: 6, + devices: a?.count || 0, + blueprints: b?.count || 0, }; }), - // TODO: Fix up auth and stuff - // list: orgProcedure.query(async ({ ctx }) => - // ctx.db - // .select({ - // id: tenants.id, - // name: tenants.name, - // slug: tenants.slug, - // }) - // .from(tenants) - // // .where(eq(tenants.orgPk, ctx.org.pk)) - // // .innerJoin(organisations, eq(organisations.pk, tenants.orgPk)) - // .orderBy(tenants.id), - // ), + create: authedProcedure + .input( + z.object({ + name: z.string().min(1).max(255), + billingEmail: z.string().email().min(1).max(255), + }), + ) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (db) => { + const id = createId(); + await db.insert(tenants).values({ + id, + name: input.name, + billingEmail: input.billingEmail, + }); + + const [tenant] = await db + .select({ pk: tenants.pk }) + .from(tenants) + .where(eq(tenants.id, id)); + if (!tenant) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + + await db.insert(tenantMembers).values({ + tenantPk: tenant.pk, + accountPk: ctx.account.pk, + }); + + return id; + }), + ), + + update: tenantProcedure + .input( + z.object({ + name: z.string().min(1).max(100).optional(), + billingEmail: z.string().email().min(1).max(255).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.name === undefined && input.billingEmail === undefined) return; + + await ctx.db + .update(tenants) + .set({ + ...(input.name !== undefined && { name: input.name }), + ...(input.billingEmail !== undefined && { + billingEmail: input.billingEmail, + }), + }) + .where(eq(tenants.pk, ctx.tenant.pk)); + }), + + delete: tenantProcedure.mutation(async ({ ctx }) => { + // TODO: Require the user to remove all devices first + + // TODO: Implement this properly + await sendDiscordMessage( + `User \`${ctx.account.id}\` with email \`${ctx.account.email}\` requested deletion of tenant \`${ctx.tenant.id}\`!`, + env.DO_THE_THING_WEBHOOK_URL, + ); + }), - // create: orgProcedure - // .input(z.object({ name: z.string().min(1) })) - // .mutation(async ({ ctx, input }) => { - // const slug = await ctx.db.transaction(async (db) => { - // const slug = randomSlug(input.name); - // await db.insert(tenants).values({ - // name: input.name, - // slug, - // orgPk: ctx.org.pk, - // }); - // return slug; - // }); - // // TODO: Invalidate `tenants` - // return slug; - // }), - // edit: tenantProcedure - // .input( - // z.object({ - // name: z.string().min(1).max(100).optional(), - // slug: z.string().min(1).max(100).optional(), - // }), - // ) - // .mutation(async ({ ctx, input }) => { - // if (input.name === undefined) return; - // if (input.name && restricted.has(input.name.toLowerCase())) { - // throw new Error("Name is restricted"); // TODO: Properly handle this on the frontend - // } - // if (input.slug && restricted.has(input.slug.toLowerCase())) { - // throw new Error("Slug is restricted"); // TODO: Properly handle this on the frontend - // } - // await ctx.db - // .update(tenants) - // .set({ - // ...(input.name !== undefined && { name: input.name }), - // ...(input.slug !== undefined && { slug: input.slug }), - // }) - // .where(eq(tenants.pk, ctx.tenant.pk)); - // }), - // stats: tenantProcedure.query(async ({ ctx }) => { - // return await union( - // ctx.db - // .select({ count: count(), variant: sql`'users'` }) - // .from(users) - // .where(eq(users.tenantPk, ctx.tenant.pk)), - // ctx.db - // .select({ count: count(), variant: sql`'devices'` }) - // .from(devices) - // .where(eq(devices.tenantPk, ctx.tenant.pk)), - // ctx.db - // .select({ count: count(), variant: sql`'policies'` }) - // .from(policies) - // .where(eq(policies.tenantPk, ctx.tenant.pk)), - // ctx.db - // .select({ count: count(), variant: sql`'applications'` }) - // .from(applications) - // .where(eq(applications.tenantPk, ctx.tenant.pk)), - // ctx.db - // .select({ count: count(), variant: sql`'groups'` }) - // .from(groups) - // .where(eq(groups.tenantPk, ctx.tenant.pk)), - // ); - // }), - // // TODO: Pagination - // auditLog: tenantProcedure - // .input(z.object({ limit: z.number().optional() })) - // .query(({ ctx, input }) => - // ctx.db - // .select({ - // action: auditLog.action, - // data: auditLog.data, - // doneAt: auditLog.doneAt, - // user: sql`IFNULL(${accounts.name}, 'system')`, - // }) - // .from(auditLog) - // .where(eq(auditLog.tenantPk, ctx.tenant.pk)) - // .leftJoin(accounts, eq(accounts.pk, auditLog.accountPk)) - // .orderBy(desc(auditLog.doneAt)) - // .limit(input.limit ?? 9999999), - // ), - // gettingStarted: tenantProcedure.query(async ({ ctx }) => { - // const [[a], [b], [c]] = await Promise.all([ - // ctx.db - // .select({ count: count() }) - // .from(identityProviders) - // .where(eq(identityProviders.tenantPk, ctx.tenant.pk)) - // // We don't care about the actual count, just if there are any - // .limit(1), - // ctx.db - // .select({ count: count() }) - // .from(devices) - // .where(eq(devices.tenantPk, ctx.tenant.pk)) - // // We don't care about the actual count, just if there are any - // .limit(1), - // ctx.db - // .select({ count: count() }) - // .from(policies) - // .where(eq(policies.tenantPk, ctx.tenant.pk)) - // // We don't care about the actual count, just if there are any - // .limit(1), - // ]); - // return { - // connectedIdentityProvider: a!.count > 0, - // enrolledADevice: b!.count > 0, - // createdFirstPolicy: c!.count > 0, - // }; - // }), - // enrollmentInfo: tenantProcedure.query(async ({ ctx }) => { - // // TODO: We only grab only the first provider. Right now we only support a single identity provider so this is fine but it won't stay fine. - // const [provider] = await ctx.db - // .select({ - // remoteId: identityProviders.remoteId, - // linkerRefreshToken: identityProviders.linkerRefreshToken, - // }) - // .from(identityProviders) - // .where( - // and( - // eq(identityProviders.provider, "entraId"), - // eq(identityProviders.tenantPk, ctx.tenant.pk), - // ), - // ); - // if (!provider) return null; - // const [activeSkus, mobilityConfig] = await Promise.all([ - // msGraphClient(provider.remoteId) - // .api("/directory/subscriptions") - // .get() - // .then( - // (data) => - // data.value - // .filter((sub: any) => sub.status === "Enabled") - // .map((sub: any) => sub.skuPartNumber) as string[], - // ), - // provider.linkerRefreshToken - // ? msClientFromRefreshToken( - // provider.remoteId, - // provider.linkerRefreshToken, - // ) - // .api("/policies/mobileDeviceManagementPolicies") - // .version("beta") - // .get() - // .then((data) => data.value.filter((p: any) => p.isValid)) - // : Promise.resolve(null), - // ]); - // const cfg = mobilityConfig.find( - // (r: any) => - // r.discoveryUrl === `${env.MANAGE_URL}/EnrollmentServer/Discovery.svc`, - // ); - // const result = !cfg - // ? "MISSING_PROVIDER" - // : cfg.appliesTo === "none" - // ? microsoftSkusThatSupportMobility.some((r) => activeSkus.includes(r)) - // ? "INVALID_SCOPE" - // : "INVALID_SUBSCRIPTION" - // : "VALID"; - // return { - // winMobilityConfig: result, - // } as const; - // }), - // delete: tenantProcedure.mutation(async ({ ctx }) => { - // const [[a], [b]] = await Promise.all([ - // ctx.db - // .select({ count: count() }) - // .from(devices) - // .where(eq(devices.tenantPk, ctx.tenant.pk)), - // ctx.db - // .select({ count: count() }) - // .from(identityProviders) - // .where(eq(identityProviders.tenantPk, ctx.tenant.pk)), - // ]); - // if (a!.count !== 0) throw new Error("Cannot delete tenant with devices"); // TODO: handle this error on the frontend - // if (b!.count !== 0) - // throw new Error("Cannot delete tenant with identity providers"); // TODO: handle this error on the frontend - // // MySQL is really fussy about CTE's + deletes so we end up with a lotta raw SQL here sadly. - // await ctx.db.transaction(async (db) => { - // await db.delete(users).where(eq(users.tenantPk, ctx.tenant.pk)); - // const device_actions = db - // .select({ id: devices.id }) - // .from(deviceActions) - // .innerJoin(devices, eq(devices.pk, deviceActions.devicePk)) - // .where(eq(devices.tenantPk, ctx.tenant.pk)); - // const device_actions_query = db.$with("inner").as(device_actions); - // await db.execute( - // sql`with ${device_actions_query} as ${device_actions} delete from ${deviceActions} using ${deviceActions} join ${device_actions_query} on ${deviceActions.devicePk} = ${device_actions_query.id};`, - // ); - // await db.delete(devices).where(eq(devices.tenantPk, ctx.tenant.pk)); - // const group_assignable = db - // .select({ id: groups.id }) - // .from(groupAssignables) - // .innerJoin(groups, eq(groups.pk, groupAssignables.groupPk)) - // .where(eq(groups.tenantPk, ctx.tenant.pk)); - // const group_assignable_query = db.$with("inner").as(group_assignable); - // await db.execute( - // sql`with ${group_assignable_query} as ${group_assignable} delete from ${groupAssignables} using ${groupAssignables} join ${group_assignable_query} on ${groupAssignables.groupPk} = ${group_assignable_query.id};`, - // ); - // await db.delete(groups).where(eq(groups.tenantPk, ctx.tenant.pk)); - // const policy_assignment = db - // .select({ id: policies.id }) - // .from(policyAssignments) - // .innerJoin(policies, eq(policies.pk, policyAssignments.policyPk)) - // .where(eq(policies.tenantPk, ctx.tenant.pk)); - // const policy_assignment_query = db.$with("inner").as(policy_assignment); - // await db.execute( - // sql`with ${policy_assignment_query} as ${policy_assignment} delete from ${policyAssignments} using ${policyAssignments} join ${policy_assignment_query} on ${policyAssignments.policyPk} = ${policy_assignment_query.id};`, - // ); - // const policy_deploy_status = db - // .select({ id: policyDeploy.id }) - // .from(policyDeployStatus) - // .innerJoin( - // policyDeploy, - // eq(policyDeploy.pk, policyDeployStatus.deployPk), - // ) - // .innerJoin(policies, eq(policies.pk, policyDeploy.policyPk)) - // .where(eq(policies.tenantPk, ctx.tenant.pk)); - // const policy_deploy_status_query = db - // .$with("inner") - // .as(policy_deploy_status); - // await db.execute( - // sql`with ${policy_deploy_status_query} as ${policy_deploy_status} delete from ${policyDeployStatus} using ${policyDeployStatus} join ${policy_deploy_status_query} on ${policyDeployStatus.deployPk} = ${policy_deploy_status_query.id};`, - // ); - // await db.delete(policies).where(eq(policies.tenantPk, ctx.tenant.pk)); - // await db - // .delete(applications) - // .where(eq(applications.tenantPk, ctx.tenant.pk)); - // await db.delete(domains).where(eq(domains.tenantPk, ctx.tenant.pk)); - // await db.delete(auditLog).where(eq(auditLog.tenantPk, ctx.tenant.pk)); - // await db.delete(tenants).where(eq(tenants.pk, ctx.tenant.pk)); - // }); - // }), - // variantTable: variantTableRouter, + settings: settingsRouter, }); diff --git a/apps/web/src/api/trpc/routers/tenant/members.ts b/apps/web/src/api/trpc/routers/tenant/members.ts deleted file mode 100644 index c1463c3e..00000000 --- a/apps/web/src/api/trpc/routers/tenant/members.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { eq } from "drizzle-orm"; -import { applications, devices, groups, policies, users } from "~/db"; -import { createTRPCRouter, tenantProcedure } from "../../helpers"; - -export const variantTableRouter = createTRPCRouter({ - users: tenantProcedure - // TODO: Pagination - .query(async ({ ctx }) => { - return await ctx.db - .select({ - name: users.name, - id: users.id, - pk: users.pk, - }) - .from(users) - .where(eq(users.tenantPk, ctx.tenant.pk)); - }), - - devices: tenantProcedure - // TODO: Pagination - .query(async ({ ctx }) => { - return await ctx.db - .select({ - name: devices.name, - id: devices.id, - pk: devices.pk, - }) - .from(devices) - .where(eq(devices.tenantPk, ctx.tenant.pk)); - }), - - groups: tenantProcedure - // TODO: Pagination - .query(async ({ ctx }) => { - return await ctx.db - .select({ - name: groups.name, - id: groups.id, - pk: groups.pk, - }) - .from(groups) - .where(eq(groups.tenantPk, ctx.tenant.pk)); - }), - - policies: tenantProcedure.query(async ({ ctx }) => { - return await ctx.db - .select({ - name: policies.name, - id: policies.id, - pk: policies.pk, - }) - .from(policies) - .where(eq(policies.tenantPk, ctx.tenant.pk)); - }), - - apps: tenantProcedure.query(async ({ ctx }) => { - return await ctx.db - .select({ - name: applications.name, - id: applications.id, - pk: applications.pk, - }) - .from(applications) - .where(eq(applications.tenantPk, ctx.tenant.pk)); - }), -}); diff --git a/apps/web/src/api/trpc/routers/tenant/settings.ts b/apps/web/src/api/trpc/routers/tenant/settings.ts new file mode 100644 index 00000000..15d3aaca --- /dev/null +++ b/apps/web/src/api/trpc/routers/tenant/settings.ts @@ -0,0 +1,201 @@ +import { eq, and } from "drizzle-orm"; +import { accounts, tenantInvites, tenantMembers, tenants } from "~/db"; +import { + createTRPCRouter, + publicProcedure, + tenantProcedure, +} from "../../helpers"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { sendEmail } from "~/api/emails"; +import { waitUntil } from "@mattrax/trpc-server-function/server"; +import { env } from "~/env"; + +export const settingsRouter = createTRPCRouter({ + get: tenantProcedure.query(async ({ ctx }) => { + const [[tenant], members, invites] = await Promise.all([ + ctx.db + .select({ + billingEmail: tenants.billingEmail, + }) + .from(tenants) + .where(eq(tenants.pk, ctx.tenant.pk)), + ctx.db + .select({ + id: accounts.id, + name: accounts.name, + email: accounts.email, + }) + .from(accounts) + .leftJoin(tenantMembers, eq(tenantMembers.tenantPk, ctx.tenant.pk)), + ctx.db + .select({ + email: tenantInvites.email, + }) + .from(tenantInvites) + .where(eq(tenantInvites.tenantPk, ctx.tenant.pk)), + ]); + if (!tenant) throw new TRPCError({ code: "FORBIDDEN", message: "tenant" }); + + return { + billingEmail: tenant.billingEmail, + members, + invites, + }; + }), + + inviteAdmin: tenantProcedure + .input(z.object({ email: z.string() })) + .mutation(async ({ ctx, input }) => { + const [tenant] = await ctx.db + .select({ name: tenants.name }) + .from(tenants) + .where(eq(tenants.pk, ctx.tenant.pk)); + if (!tenant) + throw new TRPCError({ code: "NOT_FOUND", message: "tenant" }); + + const alreadyAMember = await ctx.db + .select({ pk: accounts.pk }) + .from(accounts) + .innerJoin( + tenantMembers, + and( + eq(tenantMembers.accountPk, accounts.pk), + eq(tenantMembers.tenantPk, ctx.tenant.pk), + ), + ) + .where(eq(accounts.email, input.email)); + if (alreadyAMember) return; + + const code = crypto.randomUUID(); + try { + await ctx.db.insert(tenantInvites).values({ + tenantPk: ctx.tenant.pk, + email: "oscar@otbeaumont.me", + code, + }); + } catch (err) { + // Invite already created in this tenant for this email + if ((err as { errno: number }).errno === 1062) return; + throw err; + } + + waitUntil( + sendEmail({ + to: input.email, + subject: "Invitation to Mattrax Tenant", + type: "tenantAdminInvite", + invitedByEmail: ctx.account.email, + tenantName: tenant.name, + inviteLink: `${env.VITE_PROD_ORIGIN}/invite/organisation/${code}`, + }), + ); + }), + + acceptInvite: publicProcedure + .input(z.object({ code: z.string(), email: z.string() })) + .mutation(async ({ ctx, input }) => { + const [invite] = await ctx.db + .select({ + tenantPk: tenantInvites.tenantPk, + }) + .from(tenantInvites) + .where( + and( + eq(tenantInvites.email, input.email), + eq(tenantInvites.code, input.code), + ), + ); + if (!invite) return null; + + await ctx.db + .delete(tenantInvites) + .where( + and( + eq(tenantInvites.email, input.email), + eq(tenantInvites.code, input.code), + ), + ); + + throw new Error("Not implemented"); + + // const name = invite.email.split("@")[0] ?? ""; + // const account = await ctx.db.transaction(async (db) => { + // await db + // .insert(accounts) + // .values({ name, email: invite.email, id: generateId(16) }) + // .onDuplicateKeyUpdate({ + // set: { pk: sql`${accounts.pk}` }, + // }); + // const [account] = await db + // .select({ pk: accounts.pk, id: accounts.id }) + // .from(accounts); + // await db + // .insert(organisationMembers) + // .values({ orgPk: invite.orgPk, accountPk: account!.pk }) + // .onDuplicateKeyUpdate({ + // set: { accountPk: sql`${organisationMembers.accountPk}` }, + // }); + // await db + // .delete(organisationInvites) + // .where(eq(organisationInvites.code, input.code)); + // return account!; + // }); + // await handleLoginSuccess(account.id); + // flushResponse(); + // const org = await ctx.db.query.organisations.findFirst({ + // where: eq(organisations.pk, invite.orgPk), + // }); + // if (!org) + // throw new TRPCError({ + // code: "NOT_FOUND", + // message: "tenant", + // }); + // return { slug: org.slug, name: org.name }; + }), + + // remove: orgProcedure + // .input(z.object({ adminId: z.string() })) + // .mutation(async ({ ctx, input }) => { + // const account = await ctx.db.query.accounts.findFirst({ + // where: eq(accounts.id, input.adminId), + // }); + // if (!account) + // throw new TRPCError({ + // code: "NOT_FOUND", + // message: "account", + // }); + // if (account.pk === ctx.org.ownerPk) + // throw new TRPCError({ + // code: "PRECONDITION_FAILED", + // message: "Cannot remove tenant owner", + // }); + // await ctx.db + // .delete(organisationMembers) + // .where( + // and( + // eq(organisationMembers.accountPk, account.pk), + // eq(organisationMembers.orgPk, ctx.org.pk), + // ), + // ); + // }), + + // invites: orgProcedure.query(async ({ ctx }) => { + // return await ctx.db.query.organisationInvites.findMany({ + // where: eq(organisationInvites.orgPk, ctx.org.pk), + // }); + // }), + + // removeInvite: orgProcedure + // .input(z.object({ email: z.string() })) + // .mutation(async ({ ctx, input }) => { + // await ctx.db + // .delete(organisationInvites) + // .where( + // and( + // eq(organisationInvites.orgPk, ctx.org.pk), + // eq(organisationInvites.email, input.email), + // ), + // ); + // }), +}); diff --git a/apps/web/src/api/utils/index.ts b/apps/web/src/api/utils/index.ts index d60f30c6..6ade2984 100644 --- a/apps/web/src/api/utils/index.ts +++ b/apps/web/src/api/utils/index.ts @@ -50,3 +50,23 @@ export function urlWithSearchParams(url: string, query: URLSearchParams) { if (search) return `${url}?${search}`; return url; } + +export const restricted = new Set([ + // Misleading names + "admin", + "administrator", + "help", + "mod", + "moderator", + "staff", + "mattrax", + "root", + "contact", + "support", + "home", + "employee", + // Reserved Mattrax routes + "enroll", + "profile", + "account", +]); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index b7f71ff3..36523335 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -107,9 +107,7 @@ export default function App() { return; } else if (error.data?.code === "FORBIDDEN") { if (error.message === "tenant") navigate("/"); - else - errorMsg = - "You are not allowed to access this resource!,"; + errorMsg = `This ${error.message} does not exist or you are not allowed to access it!`; } else if (error.data?.code === "NOT_FOUND") { // not founds are handled at an app level with `.get` queries returning `null` return; diff --git a/apps/web/src/app/(dash).tsx b/apps/web/src/app/(dash).tsx index 1194eeb0..430ae921 100644 --- a/apps/web/src/app/(dash).tsx +++ b/apps/web/src/app/(dash).tsx @@ -26,6 +26,13 @@ export const useTenantId = () => { return () => params.tenantId; }; +export const useTenant = () => { + const tenantId = useTenantId(); + const tenants = useTenants(); + console.log(tenants.data, tenantId()); + return () => tenants.data?.find((tenant) => tenant.id === tenantId()); +}; + export default function (props: ParentProps) { const params = useZodParams({ // This is optional as the sidebar should be available on `/account`, etc diff --git a/apps/web/src/app/(dash)/account.tsx b/apps/web/src/app/(dash)/account.tsx index aabfcc98..0d2e56cf 100644 --- a/apps/web/src/app/(dash)/account.tsx +++ b/apps/web/src/app/(dash)/account.tsx @@ -42,9 +42,8 @@ export default function () { return ( Settings]} + breadcrumbs={[Account]} class="max-w-4xl flex flex-col space-y-6" - // hideSearch > @@ -60,7 +59,10 @@ export default function () { - @@ -88,7 +90,7 @@ export default function () { <> This will delete all of your account data, along with any orphaned tenants.{" "} - + Please be careful as this action is not reversible!
diff --git a/apps/web/src/app/(dash)/t/[tenantId]/(overview).tsx b/apps/web/src/app/(dash)/t/[tenantId]/(overview).tsx index 2991f26a..a9a8b323 100644 --- a/apps/web/src/app/(dash)/t/[tenantId]/(overview).tsx +++ b/apps/web/src/app/(dash)/t/[tenantId]/(overview).tsx @@ -1,12 +1,10 @@ import { Show, Suspense, type JSX } from "solid-js"; -import { useTenantId } from "~/app/(dash)"; import { createCounter } from "~/components/Counter"; import { Page } from "~/components/Page"; import { useTenantStats } from "~/lib/data"; export default function () { - const tenantId = useTenantId(); - const stats = useTenantStats(tenantId()); + const stats = useTenantStats(); return ( @@ -19,7 +17,7 @@ export default function () { } - value={stats.data?.devices || 0} + value={stats.data?.blueprints || 0} /> @@ -36,7 +34,7 @@ function StatItem(props: { title: string; icon: JSX.Element; value: number }) {
- + {(value) => { const counter = createCounter(() => ({ value: props.value!, diff --git a/apps/web/src/app/(dash)/t/[tenantId]/settings.tsx b/apps/web/src/app/(dash)/t/[tenantId]/settings.tsx index d42c822d..0f36dbf6 100644 --- a/apps/web/src/app/(dash)/t/[tenantId]/settings.tsx +++ b/apps/web/src/app/(dash)/t/[tenantId]/settings.tsx @@ -1,1056 +1,156 @@ -import { BreadcrumbItem } from "@mattrax/ui"; +import { + BreadcrumbItem, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@mattrax/ui"; +import { createForm, Form, InputField } from "@mattrax/ui/forms"; +import { useNavigate } from "@solidjs/router"; +import { startTransition } from "solid-js"; +import { z } from "zod"; +import { useTenant, useTenantId } from "~/app/(dash)"; +import { ConfirmDialog } from "~/components/ConfirmDialog"; import { Page } from "~/components/Page"; +import { trpc } from "~/lib"; export default function () { + const tenantId = useTenantId(); + const tenant = useTenant(); + const navigate = useNavigate(); + const ctx = trpc.useContext(); + + const tenantSettings = trpc.tenant.settings.get.createQuery(() => ({ + tenantId: tenantId(), + })); + + const updateTenant = trpc.tenant.update.createMutation(() => ({ + // TODO: dependant queries + onSuccess: () => + Promise.all([ + ctx.tenant.list.invalidate(), + ctx.tenant.settings.get.invalidate(), + ]), + })); + const deleteTenant = trpc.tenant.delete.createMutation(() => ({ + onSuccess: () => startTransition(() => navigate("/")), + })); + + const form = createForm({ + schema: () => + z.object({ + name: z + .string() + .min(1) + .max(255) + .default(tenant()?.name || ""), + billingEmail: z + .string() + .email() + .min(1) + .max(255) + .default(tenantSettings.data?.billingEmail || ""), + }), + onSubmit: (data) => + updateTenant.mutateAsync({ + tenantId: tenantId(), + ...data, + }), + }); + return ( Settings]} + class="max-w-4xl flex flex-col space-y-6" > -
- -
-
-
-
Name
-
- Mattrax -
-
-
-
- License -
-
- Microsoft Entra ID P2 -
-
-
-
- Identifier -
-
-
3509b545-2799-4c5c-a0d2-f822ddbd416c
-
-
-
-
- Country or region -
-
- Singapore -
-
-
-
-
+ Save + + + + + + + Billing + + Manage your Mattrax plan and monitor usage + + + +

Coming soon

+
+
+ + + + Delete tenant + + Permanently remove your tenant and all related data! This action is + not reversible, so please take care. + + + +
+ + {(confirm) => ( + + )} + + + ); } - -// export default function () { -// return
-//

Profile

-//

This is how others will see you on the site.

-//
-//
-//

This is your public display name. It can be your real name or a pseudonym. You can only change this once every 30 days.

-//

You can manage verified email addresses in your email settings.

You can @mention other users and organizations to link to them.

Add links to your website, blog, or social media profiles.

Add links to your website, blog, or social media profiles.

-// } - -// import { -// Avatar, -// AvatarFallback, -// Badge, -// Button, -// Card, -// CardContent, -// CardTitle, -// Dialog, -// DialogContent, -// DialogDescription, -// DialogHeader, -// DialogTitle, -// DoubleClickButton, -// Input, -// Progress, -// Select, -// SelectContent, -// SelectItem, -// SelectTrigger, -// SelectValue, -// Tabs, -// TabsContent, -// TabsList, -// TabsTrigger, -// Tooltip, -// TooltipContent, -// TooltipTrigger, -// Switch as USwitch, -// badgeVariants, -// buttonVariants, -// } from "@mattrax/ui"; -// import { latest } from "@mattrax/ui/solid"; -// import { A, useLocation, useNavigate } from "@solidjs/router"; -// import { createMutation, createQuery } from "@tanstack/solid-query"; -// import clsx from "clsx"; -// import { -// For, -// Match, -// Show, -// Suspense, -// Switch, -// createEffect, -// createMemo, -// createSignal, -// onCleanup, -// onMount, -// } from "solid-js"; -// import { toast } from "solid-sonner"; -// import { z } from "zod"; -// import { PageLayout, PageLayoutHeading } from "~/components/PageLayout"; -// import { -// setShowKdbShortcuts, -// setSyncDisabled, -// showKdbShortcuts, -// syncDisabled, -// } from "~/lib/config"; -// import { getKey } from "~/lib/kv"; -// import { createDbQuery } from "~/lib/query"; -// import { useSync } from "~/lib/sync"; -// import { useEphemeralAction } from "~/lib/sync/action"; -// import { -// createDomain, -// deleteDomain, -// verifyDomain, -// } from "~/lib/sync/actions/tenant"; -// import { resetSyncState } from "~/lib/sync/operation"; -// import { useZodParams } from "~/lib/useZodParams"; -// import { getInitials } from "../(dash)"; - -// const domainRegex = -// /^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$/; -// const zDomain = z -// .string() -// .refine((v) => v.includes(".") && domainRegex.test(v), { -// message: ( -// <> -// Please enter a valid domain name. For example{" "} -// example.com -// -// ) as string, -// }); - -// export default function Page() { -// const org = createDbQuery((db) => getKey(db, "org")); - -// return ( -// Settings}> -// -// -// -//
-//
-//
Name
-//
-// {org()?.name} -//
-//
-//
-//
-// License -//
-//
-// {org()?.plan} -//
-//
-//
-//
-// Identifier -//
-//
-//
-// 									{org()?.id}
-// 								
-//
-//
-//
-//
-// Country or region -//
-//
-// -// {getCountryNameFromCode(org()?.countryLetterCode)} -// -//
-//
-//
-//
-//
- -// -// {/* */} -// -// -// -// -//
-// ); -// } - -// const plans = { -// trial: { -// color: "bg-primary", -// devices: 10, -// cost: 0, -// }, -// pro: { -// color: "bg-green-500", -// devices: 100, -// cost: 10, -// }, -// enterprise: { -// color: "bg-orange-500", -// devices: 500, -// cost: 20, -// }, -// custom: { -// color: "bg-red-500", -// devices: 99999999, -// cost: 30, -// }, -// }; - -// function BillingPanel() { -// const devicesRaw = createDbQuery((db) => db.count("devices")); -// const devices = () => devicesRaw() ?? 0; - -// const getPlan = () => { -// for (const [key, plan] of Object.entries(plans)) { -// if (devices() <= plan?.devices) return [key, plan] as const; -// } -// return undefined; -// }; - -// return ( -// -//
-// Billing -//
-// -//
-//
-//
-// Active plan -//
-//
-// -// {getPlan()?.[0]} -// -// -// {" "} -// - While in alpha billing is disabled! -// -//
-//
-//
-//
-// Price (monthly) -//
-//
-// {getPlan()?.[1].cost}$ -//
-//
-//
-// -//
Usage
-//
-//

Devices:

-// Failed to find plan!

} -// > -// {([_, plan]) => ( -// Math.floor((plan.devices / 4) * 3) -// ? "bg-orange-500" -// : undefined -// } -// /> -// )} -//
-//

-// Your currently manage {devices()} devices out of the{" "} -// {getPlan()?.[1].devices} supported by your plan. -//

-//
-//
-//
-//
-//
-//
-// ); -// } - -// function SyncPanel() { -// const sync = useSync(); - -// const abort = new AbortController(); -// onCleanup(() => abort.abort()); - -// const fullResync = createMutation(() => ({ -// mutationFn: async (data) => { -// await resetSyncState(sync.db); -// await sync.syncAll(abort); -// }, -// })); - -// //
-// //
-// //
Refresh database
-// //

-// // Completely refresh the local database with Microsoft! -// //

-// //
- -// // -// //
- -// return ( -// -//
-// Sync -//
-// -// {/*
-//
-//

Show keyboard shortcuts

-//

-// Show hints across the UI for keyboard shortcuts -//

-//
-//
-//
*/} - -// {/* // TODO: Full-sync button + show sync errors */} -//
-//
-// ); -// } - -// function DomainsPanel() { -// const org = createDbQuery((db) => getKey(db, "org")); -// const remove = useEphemeralAction(deleteDomain, () => ({ -// onError(err) { -// toast.error("Failed to delete domain!", { -// id: "delete-domain", -// description: err.message, -// }); -// }, -// })); - -// return ( -// -// -//
-// Domains -//
-// -//
-// -// No domains found! -//

-// } -// > -// {(domain) => { -// const count = createDbQuery((db) => -// db.countFromIndex("users", "domain", domain.id), -// ); - -// return ( -//
-//
-// -// {domain.id} -// -//
-//
-//
-// -// -// {domain.isDefault -// ? "Primary" -// : domain.isInitial -// ? "Initial" -// : "Verified"} -// - -// {/* // TODO: Turn this into a link to the search interface w/ the query prefilled */} -//

-// Used by{" "} -// -// {count()} -// {" "} -// users -//

-// -// } -// > -// -// -// Managed by Microsoft 365 -// -// -// -// -// -// Unverified -// -// -// Click here to verify this domain for use. -// -// -// -//
-//
- -// -// remove.mutate({ domain: domain.id })} -// disabled={remove.isPending} -// > -// {(c) => (c ? "Confirm" : "Delete")} -// -// -//
-//
-// ); -// }} -//
- -// -//
-//
-//
-// ); -// } - -// function CreateDomain() { -// const navigate = useNavigate(); -// const [newDomain, setNewDomain] = createSignal(); -// const create = useEphemeralAction(createDomain, () => ({ -// onSuccess(_, { domain }) { -// navigate(`domains/${encodeURIComponent(domain)}`); -// }, -// onError(err) { -// toast.error("Failed to create domain!", { -// id: "create-domain", -// description: err.message, -// }); -// }, -// onSettled() { -// setNewDomain(undefined); -// }, -// })); - -// let ref!: HTMLParagraphElement; - -// const validationError = createMemo(() => { -// const d = newDomain(); -// if (!d) return; -// return zDomain.safeParse(d).error?.errors?.[0]?.message; -// }); - -// // TODO: Warn if adding domain that is already managed - -// const submit = () => { -// if (create.isPending) return; - -// const domain = newDomain(); -// if (domain === undefined) return; -// if (domain === "") { -// setNewDomain(undefined); -// return; -// } -// if (validationError()) { -// if (!ref.classList.contains("animate-shake")) { -// ref.classList.add("animate-shake"); -// setTimeout(() => ref.classList.remove("animate-shake"), 500); -// } -// return; -// } - -// create.mutate({ -// domain, -// }); -// }; - -// return ( -// <> -// -//
-//
-//

{ -// onMount(() => ref.focus()); -// ref = r; -// }} -// onInput={(e) => setNewDomain(e.currentTarget.textContent || "")} -// onKeyDown={(e) => { -// if (e.key === "Escape") setNewDomain(undefined); -// if (e.key === "Enter") { -// // Prevent actually adding newlines -// e.preventDefault(); -// submit(); -// } -// }} -// onFocusOut={(e) => { -// // TODO: I'm not sure if this is *good* for accessibility -// // TODO: but if we don't do this we need a good way to warn about unsaved changes -// // TODO: or to just wipe out the changes and idk how I feel about either of them. -// e.currentTarget.focus(); -// }} -// /> -// }> -//

-// -// -//
-// -//
-//
-// -// {(error) => ( -//

-// {error()} -//

-// )} -//
-//
-//
-//
- -//
-// -//
-// -// ); -// } - -// function DomainVerificationModal() { -// const navigate = useNavigate(); -// const params = useZodParams({ -// domain: z -// .string() -// .transform((s) => decodeURIComponent(s)) -// .optional(), -// }); - -// return ( -// { -// if (!isOpen) navigate("../../"); -// }} -// > -// -// -// {(_) => { -// // `DialogContent` is keyed so this signal will be recreated each time the modal is opened. -// // It holds the domain so that the modal content continues rendering between `params.domain` being set `undefined` and the modal close animation finishing. -// const [domain] = createSignal(params.domain!); -// return ; -// }} -// -// -// -// ); -// } - -// function DomainVerificationModalBody(props: { domain: string }) { -// const sync = useSync(); -// const verify = useEphemeralAction(verifyDomain); -// const dnsVerification = createQuery(() => ({ -// queryKey: ["domain", props.domain, "verification"], -// // TODO: Break this into a helper? -// queryFn: async () => { -// const accessToken = await getKey(sync.db, "accessToken"); -// if (!accessToken) return null; // TODO: Redirect to login momentarily - -// const resp = await fetch( -// `https://graph.microsoft.com/v1.0/domains/${encodeURIComponent(props.domain)}/verificationDnsRecords`, -// { -// headers: { -// Authorization: `Bearer ${accessToken}`, -// }, -// }, -// ); -// if (!resp.ok) -// throw new Error( -// `Failed to fetch domain verification data. Got status ${resp.status}`, -// ); - -// // TODO: Zod validation -// const result = await resp.json(); - -// const txtRecord = result.value.find( -// (r: any) => r["@odata.type"] === "#microsoft.graph.domainDnsTxtRecord", -// ); -// const mxRecord = result.value.find( -// (r: any) => r["@odata.type"] === "#microsoft.graph.domainDnsMxRecord", -// ); - -// return { -// txt: txtRecord -// ? { -// Name: txtRecord.label, -// Target: txtRecord.text, -// TTL: txtRecord.ttl, -// } -// : undefined, -// mx: mxRecord -// ? { -// Name: mxRecord.label, -// Target: mxRecord.mailExchange, -// TTL: mxRecord.ttl, -// Priority: mxRecord.preference, -// } -// : undefined, -// }; -// }, -// })); - -// const [activeTab, setActiveTab] = createSignal<"txt" | "mx" | "fallback">( -// "fallback", -// ); -// createEffect(() => { -// if (activeTab() === "fallback" && dnsVerification.data?.txt) -// setActiveTab("txt"); -// if (activeTab() === "fallback" && dnsVerification.data?.mx) -// setActiveTab("mx"); -// }); - -// return ( -// -// Verify domain ownership -// -//

-// You must verify {props.domain} before it can be used. -//

-// -// -// -// -//
-// } -// > -// -// -// - -// -//

-// No supported verification methods where found! -//

-// -// } -// > -// -// -// -// {([key, value]) => ( -//
-//
-// {key} -//
-//
-// -//
-//
-// )} -//
- -// -//
-//
-//
-//
-//
-// -// -// ); -// } - -// function MobilityPanel() { -// const apps = createDbQuery((db) => getKey(db, "orgMobility")); - -// return ( -// -//
-// Mobility -//
-// -// -// No mobility applications found! -//

-// } -// > -// {(app) => ( -//
-//
-// -// {/* // TODO: Get the logo from Intune */} -// {/* */} -// -// {getInitials(app.displayName)} -// -// - -//
-//

{app.displayName}

-//

-// {app.description || ""} -//

-//
-//
-//
-// alert("todo")} -// > -// -// None -// All -// Some -// -// -// -// {(group) => ( -// -// -// {group.displayName} -// -// -// )} -// -// -// -// -//
-//
-// )} -//
-//
-//
-// ); -// } - -// function AdvancedPanel() { -// const location = useLocation(); -// const isDev = () => -// import.meta.env.MODE === "development" || location.query?.dev !== undefined; - -// return ( -// -//
-// Advanced -//
-// -//
-//
-//

Show keyboard shortcuts

-//

-// Show hints across the UI for keyboard shortcuts -//

-//
-//
-// setShowKdbShortcuts((v) => !v)} -// /> -//
-//
-//
-//
-//

MDM backend

-//

-// Configure the source of truth for device management -//

-//
-//
-// -//
-//
-//
-//
-//

Link with Git provider

-//

-// Connect with a Git provider for version control -//

-//
-//
-// -//
-//
-//
- -// -//
-// Development -//
-// -//
-//
-//

Disable sync

-//

-// Show hints across the UI for keyboard shortcuts -//

-//
-//
-// setSyncDisabled((v) => !v)} -// /> -//
-//
-//
-//
-//
-// ); -// } - -// function DangerZone() { -// const navigate = useNavigate(); -// const sync = useSync(); - -// const deleteDb = createMutation(() => ({ -// mutationFn: async (data) => { -// sync.db.close(); -// await window.indexedDB.deleteDatabase(sync.db.name); -// navigate("/"); -// }, -// })); - -// return ( -//
-//

Danger Zone

- -//
-//
-//
-// Delete the local database! -//
-//

-// This action will permanently remove all local data and you will be -// logged out! -//

-//
- -// {/* // TODO: Confirmation dialog */} -// -//
-//
-// ); -// } - -// function getCountryNameFromCode(code: string | undefined) { -// if (!code) return null; -// try { -// return new Intl.DisplayNames(["en"], { -// type: "region", -// }).of(code); -// } catch (t) { -// return code; -// } -// } diff --git a/apps/web/src/app/(dash)/t/[tenantId]/users.tsx b/apps/web/src/app/(dash)/t/[tenantId]/users.tsx deleted file mode 100644 index f2dce0ad..00000000 --- a/apps/web/src/app/(dash)/t/[tenantId]/users.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function () { - return

Hello World

; -} diff --git a/apps/web/src/app/(dash)/t/new.tsx b/apps/web/src/app/(dash)/t/new.tsx index f2dce0ad..786e495a 100644 --- a/apps/web/src/app/(dash)/t/new.tsx +++ b/apps/web/src/app/(dash)/t/new.tsx @@ -1,3 +1,70 @@ +import { + BreadcrumbItem, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@mattrax/ui"; +import { createForm, Form, InputField } from "@mattrax/ui/forms"; +import { useNavigate } from "@solidjs/router"; +import { startTransition } from "solid-js"; +import { z } from "zod"; +import { Page } from "~/components/Page"; +import { trpc } from "~/lib"; + export default function () { - return

Hello World

; + const navigate = useNavigate(); + const ctx = trpc.useContext(); + const createTenant = trpc.tenant.create.createMutation(() => ({ + onSuccess: async (id) => { + await ctx.tenant.list.invalidate(); + startTransition(() => navigate(`/t/${id}`)); + }, + onError: (err) => console.error("ERROR", err), + })); + + const form = createForm({ + schema: () => + z.object({ + name: z.string().min(1).max(255), + billingEmail: z.string().email().min(1).max(255), + }), + onSubmit: (data) => createTenant.mutateAsync(data), + }); + + return ( + Create Tenant]} + class="w-full h-full flex flex-col space-y-6 items-center justify-center" + > + + + Create Tenant + + A tenant represents your organisation and holds all of the devices, + blueprints, applications and more that you manage. + + + +
+ + + +
+ + + +
+
+ ); } diff --git a/apps/web/src/app/login.tsx b/apps/web/src/app/login.tsx index 87365b67..c6245e35 100644 --- a/apps/web/src/app/login.tsx +++ b/apps/web/src/app/login.tsx @@ -11,6 +11,7 @@ import { trpc } from "~/lib"; import { useQueryClient } from "@tanstack/solid-query"; import { doLogin } from "~/lib/data"; import { parse } from "cookie-es"; +import { toast } from "solid-sonner"; // Don't bundle split this Solid directive autofocus; @@ -25,7 +26,7 @@ export default function () { return ( }> - {(email) => } + {(email) => } ); @@ -73,7 +74,10 @@ function EmailPage(props: { setEmail: Setter }) { ); } -function CodePage(props: { email: string }) { +function CodePage(props: { + email: string; + setEmail: Setter; +}) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [query] = useSearchParams<{ next?: string }>(); @@ -95,6 +99,13 @@ function CodePage(props: { email: string }) { }), ); }, + onError: (err) => { + // TODO: Make it let you send another code to the same email cause this will be infuriating. + toast.error(err.message, { + description: "Please try again!", + }); + props.setEmail(undefined); + }, })); const form = createForm({ @@ -113,7 +124,12 @@ function CodePage(props: { email: string }) { maxLength={8} class="flex" onValueChange={(value) => (form.fields.code.value = value)} - onComplete={() => form.onSubmit()} + onComplete={(v) => { + // When pasting `onValueChange` doesn't fire so we must handle it here. + form.fields.code.value = v; + // When pasting `onComplete` double fires + if (!form.isSubmitting) form.onSubmit(); + }} ref={(el) => el.focus()} > matches()?.[0]?.path || "/"; return ( -
+