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 () {
- form.onSubmit()}>
+ form.onSubmit()}
+ >
Save
@@ -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) => (
+
+ confirm({
+ title: "Delete tenant?",
+ description: () => (
+ <>
+ This will delete all of your tenant data, including all
+ devices and blueprints!{" "}
+
+ Please be careful as this action is not reversible!
+
+
+
+ {/* // TODO: Remove this once the backend is implemented properly */}
+ Be aware it can take up to 24 hours for your tenant to
+ be fully deleted. It will continue to show up in the UI
+ for that time.
+ >
+ ),
+ action: "Delete",
+ closeButton: null,
+ inputText: tenant()?.name,
+ onConfirm: async () =>
+ deleteTenant.mutateAsync({
+ tenantId: tenantId(),
+ }),
+ })
+ }
+ >
+ Delete
+
+ )}
+
+
+
);
}
-
-// export default function () {
-// return
-//
Profile
-//
This is how others will see you on the site.
-//
-//
Add URL Update profile
-// }
-
-// 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!
-// //
-// //
-
-// //
fullResync.mutate()}
-// // >
-// // Resync
-// //
-// //
-
-// 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();
-// }}
-// />
-// }>
-//
-// submit()}
-// >
-//
-//
-// setNewDomain(undefined)}
-// >
-//
-//
-//
-//
-//
-//
-//
-// {(error) => (
-//
-// {error()}
-//
-// )}
-//
-//
-//
-//
-
-//
-// setNewDomain("")}
-// disabled={newDomain() !== undefined}
-// >
-// Add new domain...
-//
-//
-// >
-// );
-// }
-
-// 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.
-//
-//
-//
-//
-//
-//
-// }
-// >
-//
-//
-//
-//
-// TXT
-//
-//
-// MX
-//
-//
-
-//
-//
-// No supported verification methods where found!
-//
-//
-// }
-// >
-//
-//
-//
-// {([key, value]) => (
-//
-//
-// {key}
-//
-//
-//
-//
-//
-// )}
-//
-
-// verify.mutate({ domain: props.domain })}
-// disabled={verify.isPending}
-// >
-//
-//
-//
-//
-//
-//
-//
-//
-//
-//
-//
-// );
-// }
-
-// 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}
-//
-//
-// )}
-//
-// alert("todo")}
-// >
-//
-//
-//
-//
-//
-//
-// )}
-//
-//
-//
-// );
-// }
-
-// 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
-//
-//
-//
-// (
-// {props.item.rawValue}
-// )}
-// >
-//
-// >
-// {(state) => state.selectedOption()}
-//
-//
-//
-//
-//
-//
-//
-//
-// Link with Git provider
-//
-// Connect with a Git provider for version control
-//
-//
-//
-// alert("todo")}
-// >
-//
-//
-//
-//
-//
-
-//
-//
-// 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 */}
-//
deleteDb.mutate()}
-// >
-// Delete
-//
-//
-//
-// );
-// }
-
-// 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.
+
+
+
+
+
+
+
+
+
+ form.onSubmit()}
+ >
+ Create
+
+
+
+
+ );
}
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 (
-
+
diff --git a/apps/web/src/db/rust.ts b/apps/web/src/db/rust.ts
index cf9b2e12..6a6374cd 100644
--- a/apps/web/src/db/rust.ts
+++ b/apps/web/src/db/rust.ts
@@ -8,447 +8,429 @@ import {
import dotenv from "dotenv";
import { and, eq, max, min, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/mysql-core";
-import {
- accounts,
- db,
- deviceActions,
- devices,
- domains,
- groupAssignables,
- kv,
- organisationMembers,
- organisations,
- policies,
- policyAssignments,
- policyDeploy,
- policyDeployStatus,
- sessions,
-} from ".";
-
-// TODO:
-console.log("DEBUG PATH", import.meta.dirname);
+import { accounts, db, deviceActions, devices, sessions } from ".";
dotenv.config({
path: "../../../../.env",
});
-export function scopedPoliciesForDeviceSubquery(device_pk: number) {
- const policiesScopedDirectly = db
- .select({
- pk: policies.pk,
- scope: sql`'direct'`.mapWith(asString).as("scope"),
- })
- .from(policies)
- .innerJoin(policyAssignments, eq(policies.pk, policyAssignments.policyPk))
- .where(
- and(
- eq(policyAssignments.variant, "device"),
- eq(policyAssignments.pk, device_pk),
- ),
- );
- const policiesScopedViaGroup = db
- .select({
- pk: policies.pk,
- scope: sql`'group'`.mapWith(asString).as("scope"),
- })
- .from(policies)
- .innerJoin(
- policyAssignments,
- and(
- eq(policies.pk, policyAssignments.policyPk),
- eq(policyAssignments.variant, "group"),
- ),
- )
- .innerJoin(
- groupAssignables,
- eq(groupAssignables.groupPk, policyAssignments.pk),
- )
- .where(
- and(
- eq(groupAssignables.variant, "device"),
- eq(groupAssignables.pk, device_pk),
- ),
- );
+// export function scopedPoliciesForDeviceSubquery(device_pk: number) {
+// const policiesScopedDirectly = db
+// .select({
+// pk: policies.pk,
+// scope: sql`'direct'`.mapWith(asString).as("scope"),
+// })
+// .from(policies)
+// .innerJoin(policyAssignments, eq(policies.pk, policyAssignments.policyPk))
+// .where(
+// and(
+// eq(policyAssignments.variant, "device"),
+// eq(policyAssignments.pk, device_pk),
+// ),
+// );
+// const policiesScopedViaGroup = db
+// .select({
+// pk: policies.pk,
+// scope: sql`'group'`.mapWith(asString).as("scope"),
+// })
+// .from(policies)
+// .innerJoin(
+// policyAssignments,
+// and(
+// eq(policies.pk, policyAssignments.policyPk),
+// eq(policyAssignments.variant, "group"),
+// ),
+// )
+// .innerJoin(
+// groupAssignables,
+// eq(groupAssignables.groupPk, policyAssignments.pk),
+// )
+// .where(
+// and(
+// eq(groupAssignables.variant, "device"),
+// eq(groupAssignables.pk, device_pk),
+// ),
+// );
- const allEntries = unionAll(
- policiesScopedDirectly,
- policiesScopedViaGroup,
- ).as("scoped");
+// const allEntries = unionAll(
+// policiesScopedDirectly,
+// policiesScopedViaGroup,
+// ).as("scoped");
- // Basically `SELECT DISTINCT ON`. Device scope takes precedence over group scope.
- return db
- .select({
- pk: allEntries.pk,
- // We use `min` to prioritize 'direct' over 'group'
- scope: min(allEntries.scope).as("scope"),
- })
- .from(allEntries)
- .groupBy(allEntries.pk)
- .as("sorted");
-}
+// // Basically `SELECT DISTINCT ON`. Device scope takes precedence over group scope.
+// return db
+// .select({
+// pk: allEntries.pk,
+// // We use `min` to prioritize 'direct' over 'group'
+// scope: min(allEntries.scope).as("scope"),
+// })
+// .from(allEntries)
+// .groupBy(allEntries.pk)
+// .as("sorted");
+// }
exportQueries(
[
- defineOperation({
- name: "get_config",
- args: {},
- query: (args) =>
- db
- .select({
- value: kv.value,
- })
- .from(kv)
- .where(eq(kv.key, "config")),
- }),
- defineOperation({
- name: "set_config",
- args: {
- config: "String",
- },
- query: (args) =>
- db
- .insert(kv)
- .values({
- key: "config",
- value: args.config,
- })
- .onDuplicateKeyUpdate({
- set: {
- value: args.config,
- },
- }),
- }),
- defineOperation({
- name: "get_domain",
- args: {
- domain: "String",
- },
- query: (args) =>
- db
- .select({
- createdAt: domains.createdAt,
- })
- .from(domains)
- .where(eq(domains.domain, args.domain)),
- }),
- defineOperation({
- name: "get_session_and_user",
- args: {
- session_id: "String",
- },
- query: (args) =>
- db
- .select({
- account: {
- pk: accounts.pk,
- id: accounts.id,
- },
- session: {
- id: sessions.id,
- expires_at: sessions.expiresAt,
- },
- })
- .from(sessions)
- .innerJoin(accounts, eq(sessions.userId, accounts.id))
- .where(eq(sessions.id, args.session_id)),
- }),
- defineOperation({
- name: "is_org_member",
- args: {
- org_slug: "String",
- account_pk: "u64",
- },
- query: (args) =>
- db
- .select({
- id: organisations.id,
- })
- .from(organisations)
- .where(eq(organisations.slug, args.org_slug))
- .innerJoin(
- organisationMembers,
- and(
- eq(organisations.pk, organisationMembers.orgPk),
- eq(organisationMembers.accountPk, args.account_pk),
- ),
- ),
- }),
- defineOperation({
- name: "create_device",
- args: {
- id: "String",
- mdm_id: "String",
- name: "String",
- enrollmentType: "String", // TODO: Enum
- os: "String", // TODO: Enum
- serial_number: "String",
- tenant_pk: "u64",
- owner_pk: "Option",
- enrolled_by_pk: "Option",
- },
- query: (args) =>
- db
- .insert(devices)
- .values({
- id: args.id,
- mdm_id: args.mdm_id,
- name: args.name,
- enrollmentType: args.enrollmentType as any,
- os: args.os as any,
- serialNumber: args.serial_number,
- tenantPk: args.tenant_pk,
- owner: args.owner_pk,
- enrolledBy: args.enrolled_by_pk,
- })
- .onDuplicateKeyUpdate({
- set: {
- mdm_id: args.mdm_id,
- name: args.name,
- enrollmentType: args.enrollmentType as any,
- os: args.os as any,
- serialNumber: args.serial_number,
- tenantPk: args.tenant_pk,
- owner: args.owner_pk,
- enrolledBy: args.enrolled_by_pk,
- },
- }),
- }),
- defineOperation({
- name: "get_device",
- args: {
- mdm_device_id: "String",
- },
- query: (args) =>
- db
- .select({
- pk: devices.pk,
- tenantPk: devices.tenantPk,
- })
- .from(devices)
- .where(eq(devices.mdm_id, args.mdm_device_id)),
- }),
- defineOperation({
- name: "get_device_by_serial",
- args: {
- serial_number: "String",
- },
- query: (args) =>
- db
- .select({
- id: devices.id,
- tenantPk: devices.tenantPk,
- })
- .from(devices)
- .where(eq(devices.serialNumber, args.serial_number)),
- }),
- defineOperation({
- name: "get_policy_data_for_checkin",
- args: {
- device_pk: "u64",
- },
- query: (args) => {
- const scopedPolicies = scopedPoliciesForDeviceSubquery(args.device_pk);
-
- // We get the latest deploy of each policy.
- const latestDeploy_inner = db
- .select({
- // We get the latest deploy of the policy (Eg. highest primary key)
- deployPk: max(policyDeploy.pk).as("deployPk"),
- policyPk: policyDeploy.policyPk,
- // `scopedPoliciesForDeviceSubquery` ensures each policy only shows up once so we know the `max` will be correct.
- scope: max(scopedPolicies.scope).mapWith(asString).as("scope_li"),
- })
- .from(scopedPolicies)
- .innerJoin(policyDeploy, eq(scopedPolicies.pk, policyDeploy.policyPk))
- .groupBy(policyDeploy.policyPk)
- .as("li");
-
- // We join back in the data into `latestDeploy_inner` as `groupBy` limits the columns we can select in the inner query.
- const latestDeploy = db
- .select({
- deployPk: policyDeploy.pk,
- policyPk: policyDeploy.policyPk,
- data: policyDeploy.data,
- scope: latestDeploy_inner.scope,
- })
- .from(policyDeploy)
- .innerJoin(
- latestDeploy_inner,
- eq(latestDeploy_inner.deployPk, policyDeploy.pk),
- )
- .as("l");
-
- // We get the last deployed version of each policy for this device.
- const lastDeploy_inner = db
- .select({
- // We get the last deploy of the policy (Eg. highest primary key)
- deployPk: max(policyDeploy.pk).as("deployPk"),
- policyPk: policyDeploy.policyPk,
- // `scopedPoliciesForDeviceSubquery` ensures each policy only shows up once so we know the `max` will be correct.
- scope: max(scopedPolicies.scope).mapWith(asString).as("ji_scope"),
- })
- .from(scopedPolicies)
- .innerJoin(policyDeploy, eq(scopedPolicies.pk, policyDeploy.policyPk))
- .innerJoin(
- policyDeployStatus,
- and(
- eq(policyDeploy.pk, policyDeployStatus.deployPk),
- eq(policyDeployStatus.devicePk, args.device_pk),
- ),
- )
- .groupBy(policyDeploy.policyPk)
- .as("ji");
-
- // We join back in the data into `lastDeploy_inner` as `groupBy` limits the columns we can select in the inner query.
- const lastDeploy = db
- .select({
- deployPk: policyDeploy.pk,
- policyPk: policyDeploy.policyPk,
- data: policyDeploy.data,
- conflicts: policyDeployStatus.conflicts,
- scope: lastDeploy_inner.scope,
- })
- .from(policyDeploy)
- .innerJoin(
- lastDeploy_inner,
- eq(lastDeploy_inner.deployPk, policyDeploy.pk),
- )
- .innerJoin(
- policyDeployStatus,
- and(
- eq(policyDeploy.pk, policyDeployStatus.deployPk),
- eq(policyDeployStatus.devicePk, args.device_pk),
- ),
- )
- .as("j");
-
- return db
- .select({
- scope: latestDeploy.scope,
- policyPk: latestDeploy.policyPk,
- latestDeploy: {
- pk: latestDeploy.deployPk,
- data: latestDeploy.data,
- },
- lastDeploy: leftJoinHint({
- pk: lastDeploy.deployPk,
- data: lastDeploy.data,
- conflicts: lastDeploy.conflicts,
- }),
- })
- .from(latestDeploy)
- .leftJoin(lastDeploy, eq(lastDeploy.policyPk, latestDeploy.policyPk));
- },
- }),
// defineOperation({
- // name: "get_policies_requiring_removal",
+ // name: "get_config",
+ // args: {},
+ // query: (args) =>
+ // db
+ // .select({
+ // value: kv.value,
+ // })
+ // .from(kv)
+ // .where(eq(kv.key, "config")),
+ // }),
+ // defineOperation({
+ // name: "set_config",
+ // args: {
+ // config: "String",
+ // },
+ // query: (args) =>
+ // db
+ // .insert(kv)
+ // .values({
+ // key: "config",
+ // value: args.config,
+ // })
+ // .onDuplicateKeyUpdate({
+ // set: {
+ // value: args.config,
+ // },
+ // }),
+ // }),
+ // defineOperation({
+ // name: "get_domain",
+ // args: {
+ // domain: "String",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // createdAt: domains.createdAt,
+ // })
+ // .from(domains)
+ // .where(eq(domains.domain, args.domain)),
+ // }),
+ // defineOperation({
+ // name: "get_session_and_user",
+ // args: {
+ // session_id: "String",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // account: {
+ // pk: accounts.pk,
+ // id: accounts.id,
+ // },
+ // session: {
+ // id: sessions.id,
+ // expires_at: sessions.expiresAt,
+ // },
+ // })
+ // .from(sessions)
+ // .innerJoin(accounts, eq(sessions.userId, accounts.id))
+ // .where(eq(sessions.id, args.session_id)),
+ // }),
+ // defineOperation({
+ // name: "is_org_member",
+ // args: {
+ // org_slug: "String",
+ // account_pk: "u64",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // id: organisations.id,
+ // })
+ // .from(organisations)
+ // .where(eq(organisations.slug, args.org_slug))
+ // .innerJoin(
+ // organisationMembers,
+ // and(
+ // eq(organisations.pk, organisationMembers.orgPk),
+ // eq(organisationMembers.accountPk, args.account_pk),
+ // ),
+ // ),
+ // }),
+ // defineOperation({
+ // name: "create_device",
+ // args: {
+ // id: "String",
+ // mdm_id: "String",
+ // name: "String",
+ // enrollmentType: "String", // TODO: Enum
+ // os: "String", // TODO: Enum
+ // serial_number: "String",
+ // tenant_pk: "u64",
+ // owner_pk: "Option",
+ // enrolled_by_pk: "Option",
+ // },
+ // query: (args) =>
+ // db
+ // .insert(devices)
+ // .values({
+ // id: args.id,
+ // mdm_id: args.mdm_id,
+ // name: args.name,
+ // enrollmentType: args.enrollmentType as any,
+ // os: args.os as any,
+ // serialNumber: args.serial_number,
+ // tenantPk: args.tenant_pk,
+ // owner: args.owner_pk,
+ // enrolledBy: args.enrolled_by_pk,
+ // })
+ // .onDuplicateKeyUpdate({
+ // set: {
+ // mdm_id: args.mdm_id,
+ // name: args.name,
+ // enrollmentType: args.enrollmentType as any,
+ // os: args.os as any,
+ // serialNumber: args.serial_number,
+ // tenantPk: args.tenant_pk,
+ // owner: args.owner_pk,
+ // enrolledBy: args.enrolled_by_pk,
+ // },
+ // }),
+ // }),
+ // defineOperation({
+ // name: "get_device",
+ // args: {
+ // mdm_device_id: "String",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // pk: devices.pk,
+ // tenantPk: devices.tenantPk,
+ // })
+ // .from(devices)
+ // .where(eq(devices.mdm_id, args.mdm_device_id)),
+ // }),
+ // defineOperation({
+ // name: "get_device_by_serial",
+ // args: {
+ // serial_number: "String",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // id: devices.id,
+ // tenantPk: devices.tenantPk,
+ // })
+ // .from(devices)
+ // .where(eq(devices.serialNumber, args.serial_number)),
+ // }),
+ // defineOperation({
+ // name: "get_policy_data_for_checkin",
// args: {
// device_pk: "u64",
// },
// query: (args) => {
// const scopedPolicies = scopedPoliciesForDeviceSubquery(args.device_pk);
- // // TODO: Avoid selecting policies we've already removed -> Maybe we add a marker to `result`
+ // // We get the latest deploy of each policy.
+ // const latestDeploy_inner = db
+ // .select({
+ // // We get the latest deploy of the policy (Eg. highest primary key)
+ // deployPk: max(policyDeploy.pk).as("deployPk"),
+ // policyPk: policyDeploy.policyPk,
+ // // `scopedPoliciesForDeviceSubquery` ensures each policy only shows up once so we know the `max` will be correct.
+ // scope: max(scopedPolicies.scope).mapWith(asString).as("scope_li"),
+ // })
+ // .from(scopedPolicies)
+ // .innerJoin(policyDeploy, eq(scopedPolicies.pk, policyDeploy.policyPk))
+ // .groupBy(policyDeploy.policyPk)
+ // .as("li");
- // return db
+ // // We join back in the data into `latestDeploy_inner` as `groupBy` limits the columns we can select in the inner query.
+ // const latestDeploy = db
// .select({
- // pk: policyDeploy.pk,
+ // deployPk: policyDeploy.pk,
// policyPk: policyDeploy.policyPk,
// data: policyDeploy.data,
- // result: policyDeployStatus.result,
+ // scope: latestDeploy_inner.scope,
// })
// .from(policyDeploy)
// .innerJoin(
+ // latestDeploy_inner,
+ // eq(latestDeploy_inner.deployPk, policyDeploy.pk),
+ // )
+ // .as("l");
+
+ // // We get the last deployed version of each policy for this device.
+ // const lastDeploy_inner = db
+ // .select({
+ // // We get the last deploy of the policy (Eg. highest primary key)
+ // deployPk: max(policyDeploy.pk).as("deployPk"),
+ // policyPk: policyDeploy.policyPk,
+ // // `scopedPoliciesForDeviceSubquery` ensures each policy only shows up once so we know the `max` will be correct.
+ // scope: max(scopedPolicies.scope).mapWith(asString).as("ji_scope"),
+ // })
+ // .from(scopedPolicies)
+ // .innerJoin(policyDeploy, eq(scopedPolicies.pk, policyDeploy.policyPk))
+ // .innerJoin(
// policyDeployStatus,
// and(
- // eq(policyDeployStatus.deployPk, policyDeploy.pk),
+ // eq(policyDeploy.pk, policyDeployStatus.deployPk),
// eq(policyDeployStatus.devicePk, args.device_pk),
// ),
// )
- // .leftJoin(
- // scopedPolicies,
- // eq(scopedPolicies.pk, policyDeploy.policyPk),
+ // .groupBy(policyDeploy.policyPk)
+ // .as("ji");
+
+ // // We join back in the data into `lastDeploy_inner` as `groupBy` limits the columns we can select in the inner query.
+ // const lastDeploy = db
+ // .select({
+ // deployPk: policyDeploy.pk,
+ // policyPk: policyDeploy.policyPk,
+ // data: policyDeploy.data,
+ // conflicts: policyDeployStatus.conflicts,
+ // scope: lastDeploy_inner.scope,
+ // })
+ // .from(policyDeploy)
+ // .innerJoin(
+ // lastDeploy_inner,
+ // eq(lastDeploy_inner.deployPk, policyDeploy.pk),
+ // )
+ // .innerJoin(
+ // policyDeployStatus,
+ // and(
+ // eq(policyDeploy.pk, policyDeployStatus.deployPk),
+ // eq(policyDeployStatus.devicePk, args.device_pk),
+ // ),
// )
- // .where(isNull(scopedPolicies.pk));
+ // .as("j");
+
+ // return db
+ // .select({
+ // scope: latestDeploy.scope,
+ // policyPk: latestDeploy.policyPk,
+ // latestDeploy: {
+ // pk: latestDeploy.deployPk,
+ // data: latestDeploy.data,
+ // },
+ // lastDeploy: leftJoinHint({
+ // pk: lastDeploy.deployPk,
+ // data: lastDeploy.data,
+ // conflicts: lastDeploy.conflicts,
+ // }),
+ // })
+ // .from(latestDeploy)
+ // .leftJoin(lastDeploy, eq(lastDeploy.policyPk, latestDeploy.policyPk));
+ // },
+ // }),
+ // // defineOperation({
+ // // name: "get_policies_requiring_removal",
+ // // args: {
+ // // device_pk: "u64",
+ // // },
+ // // query: (args) => {
+ // // const scopedPolicies = scopedPoliciesForDeviceSubquery(args.device_pk);
+
+ // // // TODO: Avoid selecting policies we've already removed -> Maybe we add a marker to `result`
+
+ // // return db
+ // // .select({
+ // // pk: policyDeploy.pk,
+ // // policyPk: policyDeploy.policyPk,
+ // // data: policyDeploy.data,
+ // // result: policyDeployStatus.result,
+ // // })
+ // // .from(policyDeploy)
+ // // .innerJoin(
+ // // policyDeployStatus,
+ // // and(
+ // // eq(policyDeployStatus.deployPk, policyDeploy.pk),
+ // // eq(policyDeployStatus.devicePk, args.device_pk),
+ // // ),
+ // // )
+ // // .leftJoin(
+ // // scopedPolicies,
+ // // eq(scopedPolicies.pk, policyDeploy.policyPk),
+ // // )
+ // // .where(isNull(scopedPolicies.pk));
+ // // },
+ // // }),
+ // defineOperation({
+ // name: "queued_device_actions",
+ // args: {
+ // device_id: "u64",
+ // },
+ // // TODO: Enum on `action` field of the result
+ // query: (args) =>
+ // db
+ // .select()
+ // .from(deviceActions)
+ // .where(eq(deviceActions.devicePk, args.device_id)),
+ // }),
+ // defineOperation({
+ // name: "update_device_lastseen",
+ // args: {
+ // device_id: "u64",
+ // last_synced: "Now",
+ // },
+ // query: (args) =>
+ // db
+ // .update(devices)
+ // .set({
+ // lastSynced: args.last_synced,
+ // })
+ // .where(eq(devices.pk, args.device_id)),
+ // }),
+ // defineOperation({
+ // name: "get_pending_deploy_statuses",
+ // args: {
+ // device_pk: "u64",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // deploy_pk: policyDeployStatus.deployPk,
+ // conflicts: policyDeployStatus.conflicts,
+ // })
+ // .from(policyDeployStatus)
+ // .where(
+ // and(
+ // eq(policyDeployStatus.status, "pending"),
+ // eq(policyDeployStatus.devicePk, args.device_pk),
+ // ),
+ // ),
+ // }),
+ // defineOperation({
+ // name: "create_policy_deploy_status",
+ // insertMany: true,
+ // args: {
+ // device_pk: "u64",
+ // deploy_pk: "u64",
+ // conflicts: "Option",
+ // doneAt: "Now",
// },
+ // query: (args) =>
+ // db.insert(policyDeployStatus).values({
+ // devicePk: args.device_pk,
+ // deployPk: args.deploy_pk,
+ // status: "pending",
+ // conflicts: args.conflicts as any,
+ // doneAt: args.doneAt,
+ // }),
+ // }),
+ // defineOperation({
+ // name: "get_full_account",
+ // args: {
+ // pk: "u64",
+ // },
+ // query: (args) =>
+ // db
+ // .select({
+ // name: accounts.name,
+ // email: accounts.email,
+ // })
+ // .from(accounts)
+ // .where(eq(accounts.pk, args.pk)),
// }),
- defineOperation({
- name: "queued_device_actions",
- args: {
- device_id: "u64",
- },
- // TODO: Enum on `action` field of the result
- query: (args) =>
- db
- .select()
- .from(deviceActions)
- .where(eq(deviceActions.devicePk, args.device_id)),
- }),
- defineOperation({
- name: "update_device_lastseen",
- args: {
- device_id: "u64",
- last_synced: "Now",
- },
- query: (args) =>
- db
- .update(devices)
- .set({
- lastSynced: args.last_synced,
- })
- .where(eq(devices.pk, args.device_id)),
- }),
- defineOperation({
- name: "get_pending_deploy_statuses",
- args: {
- device_pk: "u64",
- },
- query: (args) =>
- db
- .select({
- deploy_pk: policyDeployStatus.deployPk,
- conflicts: policyDeployStatus.conflicts,
- })
- .from(policyDeployStatus)
- .where(
- and(
- eq(policyDeployStatus.status, "pending"),
- eq(policyDeployStatus.devicePk, args.device_pk),
- ),
- ),
- }),
- defineOperation({
- name: "create_policy_deploy_status",
- insertMany: true,
- args: {
- device_pk: "u64",
- deploy_pk: "u64",
- conflicts: "Option",
- doneAt: "Now",
- },
- query: (args) =>
- db.insert(policyDeployStatus).values({
- devicePk: args.device_pk,
- deployPk: args.deploy_pk,
- status: "pending",
- conflicts: args.conflicts as any,
- doneAt: args.doneAt,
- }),
- }),
- defineOperation({
- name: "get_full_account",
- args: {
- pk: "u64",
- },
- query: (args) =>
- db
- .select({
- name: accounts.name,
- email: accounts.email,
- })
- .from(accounts)
- .where(eq(accounts.pk, args.pk)),
- }),
],
path.join(import.meta.dirname, "../../../../crates/mx-db/src/db.rs"),
);
diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts
index b6cbeb0a..c2eb3282 100644
--- a/apps/web/src/db/schema.ts
+++ b/apps/web/src/db/schema.ts
@@ -1,20 +1,13 @@
-import type { PolicyData } from "@mattrax/policy";
import { createId } from "@paralleldrive/cuid2";
-import type { AuthenticatorTransportFuture } from "@simplewebauthn/types";
import {
bigint,
- boolean,
- int,
json,
mysqlEnum,
mysqlTable,
primaryKey,
serial,
- smallint,
- text,
timestamp,
unique,
- varbinary,
varchar,
} from "drizzle-orm/mysql-core";
@@ -91,148 +84,67 @@ export const accountLoginCodes = mysqlTable("account_login_codes", {
createdAt: timestamp("created_at").notNull().defaultNow(),
});
-// // Organisations represent the physical entity that is using Mattrax.
-// // Eg. a company, school district, or MSP.
-// //
-// // An organisation is just a collect of many tenants, billing information and a set of accounts which have access to it.
-// //
-// export const organisations = mysqlTable("organisations", {
-// pk: serial("pk").primaryKey(),
-// id: cuid("id").notNull().unique(),
-// name: varchar("name", { length: 100 }).notNull(),
-// slug: varchar("slug", { length: 256 }).notNull().unique(),
-// billingEmail: varchar("billing_email", { length: 256 }),
-// stripeCustomerId: varchar("stripe_customer_id", { length: 256 }),
-// ownerPk: serialRelation("owner")
-// .references(() => accounts.pk)
-// .notNull(),
-// });
-
-// export const organisationMembers = mysqlTable(
-// "organisation_members",
-// {
-// orgPk: serialRelation("org")
-// .references(() => organisations.pk)
-// .notNull(),
-// accountPk: serialRelation("account")
-// .references(() => accounts.pk)
-// .notNull(),
-// },
-// (table) => ({ pk: primaryKey({ columns: [table.orgPk, table.accountPk] }) }),
-// );
-
-// export const organisationInvites = mysqlTable(
-// "organisation_invites",
-// {
-// code: varchar("code", { length: 256 }).primaryKey(),
-// orgPk: serialRelation("org")
-// .references(() => organisations.pk)
-// .notNull(),
-// email: varchar("email", { length: 256 }).notNull(),
-// createdAt: timestamp("created_at").defaultNow(),
-// },
-// (table) => ({
-// emailUnique: unique().on(table.orgPk, table.email),
-// }),
-// );
-
+// A tenant represents a distinct entity using Mattrax.
+// All groups devices, users, policies and applications are owned by a tenant.
+//
+// A tenant will generally map to a company or organisation.
export const tenants = mysqlTable("tenant", {
pk: serial("pk").primaryKey(),
id: cuid("id").notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
- slug: varchar("slug", { length: 256 }).notNull().unique(),
- ownerPk: serialRelation("owner").references(() => accounts.pk),
+ stripeCustomerId: varchar("stripe_customer_id", { length: 256 }),
+ billingEmail: varchar("billing_email", { length: 256 }),
});
-// TODO: `deviceEnrollment`
-// TODO: `blueprint`, `blueprintAssignment`
-
-// const policyDataCol = json("data").notNull().default({}).$type();
-
-// export const policies = mysqlTable("policies", {
-// pk: serial("pk").primaryKey(),
+export const tenantMembers = mysqlTable(
+ "tenant_members",
+ {
+ tenantPk: serialRelation("tenant")
+ .references(() => tenants.pk)
+ .notNull(),
+ accountPk: serialRelation("account")
+ .references(() => accounts.pk)
+ .notNull(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.tenantPk, table.accountPk] }),
+ }),
+);
+
+export const tenantInvites = mysqlTable(
+ "tenant_invites",
+ {
+ code: varchar("code", { length: 256 }).primaryKey(),
+ tenantPk: serialRelation("tenant")
+ .references(() => tenants.pk)
+ .notNull(),
+ email: varchar("email", { length: 256 }).notNull(),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (table) => ({
+ emailUnique: unique().on(table.tenantPk, table.email),
+ }),
+);
+
+// export const enrollmentAttempts = mysqlTable("enrollment_attempts", {
// id: cuid("id").notNull().unique(),
-// priority: smallint("priority").notNull().default(128),
-// name: varchar("name", { length: 256 }).notNull(),
-// data: policyDataCol,
-// tenantPk: serialRelation("tenant")
-// .references(() => tenants.pk)
-// .notNull(),
-// lastModified: timestamp("last_modified").notNull().defaultNow(),
-// createdAt: timestamp("created_at").notNull().defaultNow(),
+// status: mysqlEnum("status", ["pending", "success", "failed"]).notNull(),
+// // TODO: Device information, error information
+// doneAt: timestamp("created_at").notNull().defaultNow(),
// });
-// export const PolicyAssignableVariants = {
-// user: "user",
-// device: "device",
-// group: "group",
-// } as const;
-
-// export const policyAssignableVariants = [
-// PolicyAssignableVariants.user,
-// PolicyAssignableVariants.device,
-// PolicyAssignableVariants.group,
-// ] as const;
-// export type PolicyAssignableVariant = (typeof policyAssignableVariants)[number];
-
-// export const policyAssignments = mysqlTable(
-// "policy_assignables",
-// {
-// policyPk: serialRelation("policy")
-// .references(() => policies.pk)
-// .notNull(),
-// // The primary key of the user or device or group
-// pk: serialRelation("pk").notNull(),
-// variant: mysqlEnum("variant", policyAssignableVariants).notNull(),
-// },
-// (table) => ({
-// pk: primaryKey({
-// columns: [table.policyPk, table.pk, table.variant],
-// }),
-// }),
-// );
-
-// // A deployment is an immutable snapshot of the policy at a point in time when it was deployed.
-// // Deployments are linear by `createdAt` and are immutable.
-// export const policyDeploy = mysqlTable("policy_deploy", {
-// pk: serial("pk").primaryKey(),
-// id: cuid("id")
-// .notNull()
-// .unique()
-// .$default(() => createId()),
-// policyPk: serialRelation("policy")
-// .references(() => policies.pk)
-// .notNull(),
-// data: policyDataCol,
-// comment: varchar("comment", { length: 256 }).notNull(),
-// author: serialRelation("author")
-// .references(() => accounts.pk)
-// .notNull(),
-// doneAt: timestamp("done_at").notNull().defaultNow(),
-// });
-
-// // The status of applying a policy deploy to a specific device.
-// //
-// // Policy deploy status's are not immutable. A redeploy will cause it to update.
-// export const policyDeployStatus = mysqlTable(
-// "policy_deploy_status",
-// {
-// deployPk: serialRelation("deploy")
-// .references(() => policyDeploy.pk)
-// .notNull(),
-// devicePk: serialRelation("device")
-// .references(() => devices.pk)
-// .notNull(),
-// status: mysqlEnum("variant", ["pending", "success", "failed"]).notNull(),
-// conflicts: json("conflicts").$type(), // TODO: Proper type using Specta
-// doneAt: timestamp("done_at").notNull().defaultNow(),
-// },
-// (table) => ({
-// pk: primaryKey({
-// columns: [table.deployPk, table.devicePk],
-// }),
-// }),
-// );
+export const blueprints = mysqlTable("blueprints", {
+ pk: serial("pk").primaryKey(),
+ id: cuid("id").notNull().unique(),
+ name: varchar("name", { length: 256 }).notNull(),
+ description: varchar("description", { length: 256 }),
+ data: json("data").notNull().default({}), // TODO: .$type()
+ tenantPk: serialRelation("tenant")
+ .references(() => tenants.pk)
+ .notNull(),
+ lastModified: timestamp("last_modified").notNull().defaultNow(),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+});
export const possibleOSes = [
"Windows",
@@ -250,7 +162,7 @@ export const devices = mysqlTable("devices", {
// This will always change between re-enrollments
// which is desired for the frontend cache key to stay consistent.
- mdm_id: cuid("mdm_id").notNull().unique(),
+ mdm_id: cuid("mdm_id").notNull().unique(), // TODO: Do we need this?
name: varchar("name", { length: 256 }).notNull(),
description: varchar("description", { length: 256 }),
@@ -277,123 +189,35 @@ export const devices = mysqlTable("devices", {
enrolledBy: serialRelation("enrolled_by"),
lastSynced: timestamp("last_synced").notNull().defaultNow(),
+ blueprint: serialRelation("blueprint").references(() => blueprints.pk),
+
tenantPk: serialRelation("tenant")
.references(() => tenants.pk)
.notNull(),
});
-// export const possibleDeviceActions = [
-// "restart",
-// "shutdown",
-// "lost",
-// "wipe",
-// "retire",
-// ] as const;
-
-// // Device actions are ephemeral. They will be deleted after they are completed.
-// export const deviceActions = mysqlTable(
-// "device_actions",
-// {
-// action: mysqlEnum("action", possibleDeviceActions).notNull(),
-// devicePk: serialRelation("device")
-// .notNull()
-// .references(() => devices.pk),
-// createdBy: serialRelation("created_by")
-// .notNull()
-// .references(() => accounts.pk),
-// createdAt: timestamp("created_at").notNull().defaultNow(),
-// },
-// (table) => ({
-// pk: primaryKey({ columns: [table.action, table.devicePk] }),
-// }),
-// );
-
-// export const GroupMemberVariants = {
-// user: "user",
-// device: "device",
-// } as const;
-
-// export const groupMemberVariants = [
-// GroupMemberVariants.user,
-// GroupMemberVariants.device,
-// ] as const;
-// export type GroupMemberVariant = (typeof groupMemberVariants)[number];
-
-// export const groupAssignables = mysqlTable(
-// "group_assignables",
-// {
-// groupPk: serialRelation("group")
-// .references(() => groups.pk)
-// .notNull(),
-// // The primary key of the user or device
-// pk: serialRelation("pk").notNull(),
-// variant: mysqlEnum("variant", groupMemberVariants).notNull(),
-// },
-// (table) => ({
-// pk: primaryKey({ columns: [table.groupPk, table.pk, table.variant] }),
-// }),
-// );
-
-// export const groups = mysqlTable("groups", {
-// pk: serial("pk").primaryKey(),
-// id: cuid("id").notNull().unique(),
-// name: varchar("name", { length: 256 }).notNull(),
-// tenantPk: serialRelation("tenant")
-// .references(() => tenants.pk)
-// .notNull(),
-// });
-
-// export const applications = mysqlTable("apps", {
-// pk: serial("pk").primaryKey(),
-// id: cuid("id").notNull().unique(),
-// name: varchar("name", { length: 256 }).notNull(),
-// description: varchar("description", { length: 256 }),
-// tenantPk: serialRelation("tenant")
-// .references(() => tenants.pk)
-// .notNull(),
-// });
-
-// export const ApplicationAssignablesVariants = {
-// user: "user",
-// device: "device",
-// group: "group",
-// } as const;
-
-// export const applicationAssignablesVariants = [
-// ApplicationAssignablesVariants.user,
-// ApplicationAssignablesVariants.device,
-// ApplicationAssignablesVariants.group,
-// ] as const;
-// export type ApplicationAssignableVariant =
-// (typeof applicationAssignablesVariants)[number];
-
-// export const applicationAssignables = mysqlTable(
-// "application_assignments",
-// {
-// applicationPk: serialRelation("appPk")
-// .references(() => applications.pk)
-// .notNull(),
-// // The primary key of the user or device or group
-// pk: serialRelation("pk").notNull(),
-// variant: mysqlEnum("variant", applicationAssignablesVariants).notNull(),
-// },
-// (table) => ({
-// pk: primaryKey({
-// columns: [table.applicationPk, table.pk, table.variant],
-// }),
-// }),
-// );
+export const possibleDeviceActions = [
+ "restart",
+ "shutdown",
+ "lost",
+ "wipe",
+ "retire",
+] as const;
-// export const domains = mysqlTable("domains", {
-// domain: varchar("domain", { length: 256 }).primaryKey(),
-// tenantPk: serialRelation("tenant")
-// .references(() => tenants.pk)
-// .notNull(),
-// createdAt: timestamp("created_at").notNull().defaultNow(),
-// enterpriseEnrollmentAvailable: boolean("enterprise_enrollment_available")
-// .notNull()
-// .default(false),
-// identityProviderPk: serialRelation("identity_provider")
-// .notNull()
-// .references(() => identityProviders.pk),
-// });
+// Device actions are ephemeral. They will be deleted after they are completed.
+export const deviceActions = mysqlTable(
+ "device_actions",
+ {
+ action: mysqlEnum("action", possibleDeviceActions).notNull(),
+ devicePk: serialRelation("device")
+ .notNull()
+ .references(() => devices.pk),
+ createdBy: serialRelation("created_by")
+ .notNull()
+ .references(() => accounts.pk),
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.action, table.devicePk] }),
+ }),
+);
diff --git a/apps/web/src/lib/data.tsx b/apps/web/src/lib/data.tsx
index eba90456..f27d03cf 100644
--- a/apps/web/src/lib/data.tsx
+++ b/apps/web/src/lib/data.tsx
@@ -1,6 +1,7 @@
import { createComputed } from "solid-js";
import { trpc } from "./trpc";
import type { RouterOutput } from "~/api";
+import { useTenantId } from "~/app/(dash)";
// TODO: Can we allow disabling the cache for testing & artificially slowing down queries
@@ -66,8 +67,11 @@ export const getCachedTenants = () => {
}
};
-export const useTenantStats = (tenantId: string) => {
- const data = trpc.tenant.stats.createQuery();
+export const useTenantStats = () => {
+ const tenantId = useTenantId();
+ const data = trpc.tenant.stats.createQuery(() => ({
+ tenantId: tenantId(),
+ }));
// TODO: Caching
return data;
};
diff --git a/crates/mx-db/src/db.rs b/crates/mx-db/src/db.rs
index 73512438..47e32600 100644
--- a/crates/mx-db/src/db.rs
+++ b/crates/mx-db/src/db.rs
@@ -21,88 +21,6 @@ fn from_value(row: &mut mysql_async::Row, index: usize) -> T {
}
}
-#[derive(Debug)]
-pub struct GetConfigResult {
- pub value: Vec,
-}
-
-#[derive(Debug)]
-pub struct GetDomainResult {
- pub created_at: NaiveDateTime,
-}
-#[derive(Debug)]
-pub struct GetSessionAndUserAccountResult {
- pub pk: u64,
- pub id: String,
-}
-#[derive(Debug)]
-pub struct GetSessionAndUserSessionResult {
- pub id: String,
- pub expires_at: NaiveDateTime,
-}
-#[derive(Debug)]
-pub struct GetSessionAndUserResult {
- pub account: GetSessionAndUserAccountResult,
- pub session: GetSessionAndUserSessionResult,
-}
-#[derive(Debug)]
-pub struct IsOrgMemberResult {
- pub id: String,
-}
-
-#[derive(Debug)]
-pub struct GetDeviceResult {
- pub pk: u64,
- pub tenant_pk: u64,
-}
-#[derive(Debug)]
-pub struct GetDeviceBySerialResult {
- pub id: String,
- pub tenant_pk: u64,
-}
-#[derive(Debug)]
-pub struct GetPolicyDataForCheckinLatestDeployResult {
- pub pk: u64,
- pub data: Deserialized,
-}
-#[derive(Debug)]
-pub struct GetPolicyDataForCheckinLastDeployResult {
- pub pk: u64,
- pub data: Deserialized,
- pub conflicts: Option>,
-}
-#[derive(Debug)]
-pub struct GetPolicyDataForCheckinResult {
- pub scope: String,
- pub policy_pk: u64,
- pub latest_deploy: GetPolicyDataForCheckinLatestDeployResult,
- pub last_deploy: Option,
-}
-#[derive(Debug)]
-pub struct QueuedDeviceActionsResult {
- pub action: String,
- pub device_pk: u64,
- pub created_by: u64,
- pub created_at: NaiveDateTime,
-}
-
-#[derive(Debug)]
-pub struct GetPendingDeployStatusesResult {
- pub deploy_pk: u64,
- pub conflicts: Option>,
-}
-#[derive(Debug)]
-pub struct CreatePolicyDeployStatus {
- pub device_pk: u64,
- pub deploy_pk: u64,
- pub conflicts: Option,
-}
-#[derive(Debug)]
-pub struct GetFullAccountResult {
- pub name: String,
- pub email: String,
-}
-
#[derive(Clone)]
pub struct Db {
pool: mysql_async::Pool,
@@ -129,253 +47,3 @@ impl Db {
}
}
}
-
-impl Db {
- pub async fn get_config(&self) -> Result, mysql_async::Error> {
- let mut result = r#"select `value` from `kv` where `kv`.`key` = ?"#
- .with(mysql_async::Params::Positional(vec!["config"
- .clone()
- .into()]))
- .run(&self.pool)
- .await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetConfigResult {
- value: from_value(&mut row, 0),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn set_config(&self, config: String) -> Result<(), mysql_async::Error> {
- let mut result = r#"insert into `kv` (`key`, `value`, `last_modified`) values (?, ?, default) on duplicate key update `value` = ?"#.with(mysql_async::Params::Positional(vec!["config".clone().into(),config.clone().into(),config.clone().into()])).run(&self.pool).await?;
- Ok(())
- }
-}
-impl Db {
- pub async fn get_domain(
- &self,
- domain: String,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `created_at` from `domains` where `domains`.`domain` = ?"#
- .with(mysql_async::Params::Positional(vec![domain.clone().into()]))
- .run(&self.pool)
- .await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetDomainResult {
- created_at: from_value(&mut row, 0),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn get_session_and_user(
- &self,
- session_id: String,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `accounts`.`pk`, `accounts`.`id`, `session`.`id`, `session`.`expires_at` from `session` inner join `accounts` on `session`.`account` = `accounts`.`id` where `session`.`id` = ?"#.with(mysql_async::Params::Positional(vec![session_id.clone().into()])).run(&self.pool).await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetSessionAndUserResult {
- account: GetSessionAndUserAccountResult {
- pk: from_value(&mut row, 0),
- id: from_value(&mut row, 1),
- },
- session: GetSessionAndUserSessionResult {
- id: from_value(&mut row, 2),
- expires_at: from_value(&mut row, 3),
- },
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn is_org_member(
- &self,
- org_slug: String,
- account_pk: u64,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `organisations`.`id` from `organisations` inner join `organisation_members` on (`organisations`.`pk` = `organisation_members`.`org` and `organisation_members`.`account` = ?) where `organisations`.`slug` = ?"#.with(mysql_async::Params::Positional(vec![account_pk.clone().into(),org_slug.clone().into()])).run(&self.pool).await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(IsOrgMemberResult {
- id: from_value(&mut row, 0),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn create_device(
- &self,
- id: String,
- mdm_id: String,
- name: String,
- enrollment_type: String,
- os: String,
- serial_number: String,
- tenant_pk: u64,
- owner_pk: Option,
- enrolled_by_pk: Option,
- ) -> Result<(), mysql_async::Error> {
- let mut result = r#"insert into `devices` (`pk`, `id`, `mdm_id`, `name`, `description`, `enrollment_type`, `os`, `serial_number`, `manufacturer`, `model`, `os_version`, `imei`, `free_storage`, `total_storage`, `owner`, `azure_ad_did`, `enrolled_at`, `enrolled_by`, `last_synced`, `tenant`) values (default, ?, ?, ?, default, ?, ?, ?, default, default, default, default, default, default, ?, default, default, ?, default, ?) on duplicate key update `mdm_id` = ?, `name` = ?, `enrollment_type` = ?, `os` = ?, `serial_number` = ?, `owner` = ?, `enrolled_by` = ?, `tenant` = ?"#.with(mysql_async::Params::Positional(vec![id.clone().into(),mdm_id.clone().into(),name.clone().into(),enrollment_type.clone().into(),os.clone().into(),serial_number.clone().into(),owner_pk.clone().into(),enrolled_by_pk.clone().into(),tenant_pk.clone().into(),mdm_id.clone().into(),name.clone().into(),enrollment_type.clone().into(),os.clone().into(),serial_number.clone().into(),owner_pk.clone().into(),enrolled_by_pk.clone().into(),tenant_pk.clone().into()])).run(&self.pool).await?;
- Ok(())
- }
-}
-impl Db {
- pub async fn get_device(
- &self,
- mdm_device_id: String,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `pk`, `tenant` from `devices` where `devices`.`mdm_id` = ?"#
- .with(mysql_async::Params::Positional(vec![mdm_device_id
- .clone()
- .into()]))
- .run(&self.pool)
- .await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetDeviceResult {
- pk: from_value(&mut row, 0),
- tenant_pk: from_value(&mut row, 1),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn get_device_by_serial(
- &self,
- serial_number: String,
- ) -> Result, mysql_async::Error> {
- let mut result =
- r#"select `id`, `tenant` from `devices` where `devices`.`serial_number` = ?"#
- .with(mysql_async::Params::Positional(vec![serial_number
- .clone()
- .into()]))
- .run(&self.pool)
- .await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetDeviceBySerialResult {
- id: from_value(&mut row, 0),
- tenant_pk: from_value(&mut row, 1),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn get_policy_data_for_checkin(
- &self,
- device_pk: u64,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `scope_li`, `l`.`policy`, `l`.`pk`, `l`.`data`, `j`.`pk`, `j`.`data`, `j`.`conflicts` from (select `policy_deploy`.`pk`, `policy_deploy`.`policy`, `policy_deploy`.`data`, `scope_li` from `policy_deploy` inner join (select max(`policy_deploy`.`pk`) as `deployPk`, `policy_deploy`.`policy`, max(`scope`) as `scope_li` from (select `pk`, min(`scope`) as `scope` from ((select `policies`.`pk`, 'direct' as `scope` from `policies` inner join `policy_assignables` on `policies`.`pk` = `policy_assignables`.`policy` where (`policy_assignables`.`variant` = ? and `policy_assignables`.`pk` = ?)) union all (select `policies`.`pk`, 'group' as `scope` from `policies` inner join `policy_assignables` on (`policies`.`pk` = `policy_assignables`.`policy` and `policy_assignables`.`variant` = ?) inner join `group_assignables` on `group_assignables`.`group` = `policy_assignables`.`pk` where (`group_assignables`.`variant` = ? and `group_assignables`.`pk` = ?))) `scoped` group by `scoped`.`pk`) `sorted` inner join `policy_deploy` on `sorted`.`pk` = `policy_deploy`.`policy` group by `policy_deploy`.`policy`) `li` on `deployPk` = `policy_deploy`.`pk`) `l` left join (select `policy_deploy`.`pk`, `policy_deploy`.`policy`, `policy_deploy`.`data`, `policy_deploy_status`.`conflicts`, `ji_scope` from `policy_deploy` inner join (select max(`policy_deploy`.`pk`) as `deployPk`, `policy_deploy`.`policy`, max(`scope`) as `ji_scope` from (select `pk`, min(`scope`) as `scope` from ((select `policies`.`pk`, 'direct' as `scope` from `policies` inner join `policy_assignables` on `policies`.`pk` = `policy_assignables`.`policy` where (`policy_assignables`.`variant` = ? and `policy_assignables`.`pk` = ?)) union all (select `policies`.`pk`, 'group' as `scope` from `policies` inner join `policy_assignables` on (`policies`.`pk` = `policy_assignables`.`policy` and `policy_assignables`.`variant` = ?) inner join `group_assignables` on `group_assignables`.`group` = `policy_assignables`.`pk` where (`group_assignables`.`variant` = ? and `group_assignables`.`pk` = ?))) `scoped` group by `scoped`.`pk`) `sorted` inner join `policy_deploy` on `sorted`.`pk` = `policy_deploy`.`policy` inner join `policy_deploy_status` on (`policy_deploy`.`pk` = `policy_deploy_status`.`deploy` and `policy_deploy_status`.`device` = ?) group by `policy_deploy`.`policy`) `ji` on `deployPk` = `policy_deploy`.`pk` inner join `policy_deploy_status` on (`policy_deploy`.`pk` = `policy_deploy_status`.`deploy` and `policy_deploy_status`.`device` = ?)) `j` on `j`.`policy` = `l`.`policy`"#.with(mysql_async::Params::Positional(vec!["device".clone().into(),device_pk.clone().into(),"group".clone().into(),"device".clone().into(),device_pk.clone().into(),"device".clone().into(),device_pk.clone().into(),"group".clone().into(),"device".clone().into(),device_pk.clone().into(),device_pk.clone().into(),device_pk.clone().into()])).run(&self.pool).await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetPolicyDataForCheckinResult {
- scope: from_value(&mut row, 0),
- policy_pk: from_value(&mut row, 1),
- latest_deploy: GetPolicyDataForCheckinLatestDeployResult {
- pk: from_value(&mut row, 2),
- data: from_value(&mut row, 3),
- },
- last_deploy: {
- let pk = from_value(&mut row, 4);
- let data = from_value(&mut row, 5);
- let conflicts = from_value(&mut row, 6);
-
- match (pk, data) {
- (Some(pk), Some(data)) => Some(GetPolicyDataForCheckinLastDeployResult {
- pk,
- data,
- conflicts,
- }),
- _ => None,
- }
- },
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn queued_device_actions(
- &self,
- device_id: u64,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `action`, `device`, `created_by`, `created_at` from `device_actions` where `device_actions`.`device` = ?"#.with(mysql_async::Params::Positional(vec![device_id.clone().into()])).run(&self.pool).await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(QueuedDeviceActionsResult {
- action: from_value(&mut row, 0),
- device_pk: from_value(&mut row, 1),
- created_by: from_value(&mut row, 2),
- created_at: from_value(&mut row, 3),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn update_device_lastseen(&self, device_id: u64) -> Result<(), mysql_async::Error> {
- let last_synced = chrono::Utc::now().naive_utc();
- let mut result = r#"update `devices` set `last_synced` = ? where `devices`.`pk` = ?"#
- .with(mysql_async::Params::Positional(vec![
- last_synced.clone().into(),
- device_id.clone().into(),
- ]))
- .run(&self.pool)
- .await?;
- Ok(())
- }
-}
-impl Db {
- pub async fn get_pending_deploy_statuses(
- &self,
- device_pk: u64,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `deploy`, `conflicts` from `policy_deploy_status` where (`policy_deploy_status`.`variant` = ? and `policy_deploy_status`.`device` = ?)"#.with(mysql_async::Params::Positional(vec!["pending".clone().into(),device_pk.clone().into()])).run(&self.pool).await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetPendingDeployStatusesResult {
- deploy_pk: from_value(&mut row, 0),
- conflicts: from_value(&mut row, 1),
- });
- }
- Ok(ret)
- }
-}
-impl Db {
- pub async fn create_policy_deploy_status(
- &self,
- values: Vec,
- ) -> Result<(), mysql_async::Error> {
- let done_at = chrono::Utc::now().naive_utc();
- let mut result = format!(r#"insert into `policy_deploy_status` (`deploy`, `device`, `variant`, `conflicts`, `done_at`) values {}"#, (0..values.len()).map(|_| "(?, ?, ?, ?, ?)").collect::>().join(",")).with(mysql_async::Params::Positional(values.into_iter().map(|v| vec![v.deploy_pk.clone().into(),v.device_pk.clone().into(),"pending".clone().into(),v.conflicts.clone().into(),done_at.clone().into()]).flatten().collect())).run(&self.pool).await?;
- Ok(())
- }
-}
-impl Db {
- pub async fn get_full_account(
- &self,
- pk: u64,
- ) -> Result, mysql_async::Error> {
- let mut result = r#"select `name`, `email` from `accounts` where `accounts`.`pk` = ?"#
- .with(mysql_async::Params::Positional(vec![pk.clone().into()]))
- .run(&self.pool)
- .await?;
- let mut ret = vec![];
- while let Some(mut row) = result.next().await.unwrap() {
- ret.push(GetFullAccountResult {
- name: from_value(&mut row, 0),
- email: from_value(&mut row, 1),
- });
- }
- Ok(ret)
- }
-}
diff --git a/packages/ui/src/forms/createForm.tsx b/packages/ui/src/forms/createForm.tsx
index 5428b877..6f0166fb 100644
--- a/packages/ui/src/forms/createForm.tsx
+++ b/packages/ui/src/forms/createForm.tsx
@@ -112,7 +112,11 @@ export function createForm(
// Actions
onSubmit() {
- if (this.isSubmitting || this.isDisabled || !this.isValid) {
+ if (!this.isValid) {
+ console.warn("Form is not valid!");
+ return;
+ }
+ if (this.isSubmitting || this.isDisabled) {
console.warn("Form is disabled or already submitting. Skipping...");
return;
}