Add organisation invitations and one-time-code gateway enrolment
Invitations: token shown once, only its hash stored, 7-day expiry, the accepting account's email must match. Enrolment codes: 15 minutes, single use, hashed; POST /auth/enrol returns the farm's existing gateway token without rotating it and is rate-limited per IP. No email is sent; the inviter shares the link. Migration 0007, unit tests and scripts/e2e-onboarding.sh.
This commit is contained in:
@@ -4,3 +4,4 @@ export * from "./printers";
|
||||
export * from "./print_jobs";
|
||||
export * from "./embeddings";
|
||||
export * from "./alerts";
|
||||
export * from "./onboarding";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { orgRoleEnum, organisations } from "./orgs";
|
||||
import { farms } from "./farms";
|
||||
|
||||
/**
|
||||
* An invitation to join an organisation, addressed to an email.
|
||||
*
|
||||
* Only a hash of the token is stored: the token is the whole credential, and
|
||||
* the table is the kind of thing that ends up in a backup or a screenshot.
|
||||
* It is shown to the inviter once, when the invitation is made.
|
||||
*/
|
||||
export const orgInvitations = pgTable(
|
||||
"org_invitations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
orgId: uuid("org_id")
|
||||
.notNull()
|
||||
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||
/** Lower-cased: addresses are compared case-insensitively. */
|
||||
email: text("email").notNull(),
|
||||
role: orgRoleEnum("role").notNull(),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
invitedBy: uuid("invited_by").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||
acceptedBy: uuid("accepted_by"),
|
||||
},
|
||||
(table) => ({ orgIdx: index("org_invitations_org_idx").on(table.orgId) }),
|
||||
);
|
||||
|
||||
/**
|
||||
* A short-lived, single-use code that lets a gateway fetch its farm's
|
||||
* credentials without anyone copying a token around. Hashed like an
|
||||
* invitation token, and for the same reason.
|
||||
*/
|
||||
export const enrolmentCodes = pgTable(
|
||||
"enrolment_codes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
farmId: uuid("farm_id")
|
||||
.notNull()
|
||||
.references(() => farms.id, { onDelete: "cascade" }),
|
||||
codeHash: text("code_hash").notNull().unique(),
|
||||
createdBy: uuid("created_by").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({ farmIdx: index("enrolment_codes_farm_idx").on(table.farmId) }),
|
||||
);
|
||||
@@ -47,6 +47,12 @@ export const orgMembers = pgTable(
|
||||
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id").notNull(),
|
||||
role: orgRoleEnum("role").notNull().default("viewer"),
|
||||
/**
|
||||
* Shown in member lists, which would otherwise be bare ids. Taken from
|
||||
* the token's `email` claim when someone creates an organisation or
|
||||
* accepts an invitation; null for a member added by id.
|
||||
*/
|
||||
email: text("email"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user