@protobase/server 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/better-auth/auth-mail/invitation-email.js +23 -27
- package/dist/better-auth/create-auth.d.ts +460 -24
- package/dist/better-auth/create-auth.js +12 -1
- package/dist/better-auth/invitations/account-from-invitation.js +50 -0
- package/dist/better-auth/invitations/invitation-endpoints.js +102 -0
- package/dist/better-auth/invitations/invitation-link-hook.js +34 -0
- package/dist/better-auth/invitations/invitation-of-token.js +24 -0
- package/dist/better-auth/{organizations → invitations}/invitation-token.js +7 -2
- package/dist/better-auth/last-admin-guard.js +62 -0
- package/dist/better-auth/last-admin.js +18 -0
- package/dist/better-auth/organizations/accept-invitation.js +4 -8
- package/dist/better-auth/organizations/invitation-endpoints.js +17 -114
- package/dist/better-auth/organizations/options.js +2 -1
- package/dist/better-auth/organizations/organization-plugin.js +1 -1
- package/dist/better-auth/organizations/protobase-plugin.js +7 -9
- package/dist/better-auth/organizations/role-definitions.js +5 -4
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/roles/role-labels.js +14 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
- package/dist/better-auth/status-route.js +3 -8
- package/dist/better-auth/users/app-invitation-store.js +62 -0
- package/dist/better-auth/users/user-endpoints.js +79 -0
- package/dist/better-auth/users/users-plugin.js +22 -0
- package/dist/better-auth/users.js +1 -2
- package/dist/check-views.js +5 -3
- package/dist/index.js +1 -1
- package/dist/view-related.js +11 -1
- package/package.json +4 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { noGlobalRole } from "../organizations/role-definitions.js";
|
|
2
|
+
import { findOrganization } from "../organizations/store.js";
|
|
3
|
+
import { acceptInvitation } from "../organizations/accept-invitation.js";
|
|
4
|
+
import { deleteAppInvitation } from "../users/app-invitation-store.js";
|
|
5
|
+
import { createInvitedAccount, signInInvitedAccount } from "./account-from-invitation.js";
|
|
6
|
+
import { invitationOfToken } from "./invitation-of-token.js";
|
|
7
|
+
import * as z$1 from "zod";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
//#region src/better-auth/invitations/invitation-endpoints.ts
|
|
10
|
+
const tokenQuery = z$1.object({ token: z$1.string().min(1) });
|
|
11
|
+
/**
|
|
12
|
+
* The endpoints an emailed invitation link opens, to the app or, with organizations, to one of them: what it is for,
|
|
13
|
+
* and creating an account from it for someone who has none. Open sign-up stays off; an invitation is an address someone
|
|
14
|
+
* vouched for, and the link in the mail proves the person reads it. Accepting an organization's invitation signed in is
|
|
15
|
+
* the organization plugin's.
|
|
16
|
+
*/
|
|
17
|
+
const invitationEndpoints = ({ mail, organizations }) => ({
|
|
18
|
+
/**
|
|
19
|
+
* `GET /invitation?token=`: who invites, the address and roles, until when, and whether the address has an account;
|
|
20
|
+
* for an invitation to the app also the app's name, and for an organization's the organization and the role there.
|
|
21
|
+
*/
|
|
22
|
+
getInvitation: createAuthEndpoint("/invitation", {
|
|
23
|
+
method: "GET",
|
|
24
|
+
query: tokenQuery
|
|
25
|
+
}, async (ctx) => {
|
|
26
|
+
const found = await invitationOfToken(ctx.context, ctx.query.token, { organizations: Boolean(organizations) });
|
|
27
|
+
const { invitation } = found;
|
|
28
|
+
const inviter = await ctx.context.internalAdapter.findUserById(invitation.inviterId);
|
|
29
|
+
const account = await ctx.context.internalAdapter.findUserByEmail(invitation.email.toLowerCase());
|
|
30
|
+
const common = {
|
|
31
|
+
inviter: { name: inviter?.name || inviter?.email || "" },
|
|
32
|
+
email: invitation.email,
|
|
33
|
+
expiresAt: new Date(invitation.expiresAt).toISOString(),
|
|
34
|
+
hasAccount: Boolean(account)
|
|
35
|
+
};
|
|
36
|
+
if (found.kind === "app") return ctx.json({
|
|
37
|
+
app: { name: ctx.context.appName },
|
|
38
|
+
...common,
|
|
39
|
+
appRoles: found.invitation.roles
|
|
40
|
+
});
|
|
41
|
+
const organization = await findOrganization(ctx.context.adapter, found.invitation.organizationId);
|
|
42
|
+
return ctx.json({
|
|
43
|
+
organization: {
|
|
44
|
+
id: found.invitation.organizationId,
|
|
45
|
+
name: organization?.name ?? ""
|
|
46
|
+
},
|
|
47
|
+
...common,
|
|
48
|
+
role: found.invitation.role,
|
|
49
|
+
appRoles: found.invitation.appRoles
|
|
50
|
+
});
|
|
51
|
+
}),
|
|
52
|
+
/**
|
|
53
|
+
* `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
|
|
54
|
+
* verified, and signs the person in. An invitation to the app gives the account its roles; one to an organization
|
|
55
|
+
* gives no global role, and accepts the membership, where the session then works.
|
|
56
|
+
*/
|
|
57
|
+
signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
body: z$1.object({
|
|
60
|
+
token: z$1.string().min(1),
|
|
61
|
+
name: z$1.string().trim().min(1).max(200),
|
|
62
|
+
password: z$1.string().optional()
|
|
63
|
+
})
|
|
64
|
+
}, async (ctx) => {
|
|
65
|
+
const found = await invitationOfToken(ctx.context, ctx.body.token, { organizations: Boolean(organizations) });
|
|
66
|
+
const { email } = found.invitation;
|
|
67
|
+
if (await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase())) {
|
|
68
|
+
const message = found.kind === "app" ? "This address has an account already: sign in." : "This address has an account already: sign in to accept the invitation.";
|
|
69
|
+
throw new APIError("BAD_REQUEST", {
|
|
70
|
+
code: "ACCOUNT_EXISTS",
|
|
71
|
+
message
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const account = {
|
|
75
|
+
email,
|
|
76
|
+
name: ctx.body.name,
|
|
77
|
+
...ctx.body.password !== void 0 && { password: ctx.body.password },
|
|
78
|
+
mail
|
|
79
|
+
};
|
|
80
|
+
if (found.kind === "app") {
|
|
81
|
+
const user = await createInvitedAccount(ctx, {
|
|
82
|
+
...account,
|
|
83
|
+
role: found.invitation.roles.join(",")
|
|
84
|
+
});
|
|
85
|
+
await deleteAppInvitation(ctx.context, found.invitation.id);
|
|
86
|
+
await signInInvitedAccount(ctx, user);
|
|
87
|
+
return ctx.json({ user: {
|
|
88
|
+
id: user.id,
|
|
89
|
+
email: user.email
|
|
90
|
+
} });
|
|
91
|
+
}
|
|
92
|
+
const user = await createInvitedAccount(ctx, {
|
|
93
|
+
...account,
|
|
94
|
+
role: noGlobalRole
|
|
95
|
+
});
|
|
96
|
+
const member = await acceptInvitation(ctx.context, found.invitation, user, organizations.membershipLimit);
|
|
97
|
+
await signInInvitedAccount(ctx, user, found.invitation.organizationId);
|
|
98
|
+
return ctx.json({ member });
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
//#endregion
|
|
102
|
+
export { invitationEndpoints };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
2
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
3
|
+
//#region src/better-auth/invitations/invitation-link-hook.ts
|
|
4
|
+
/**
|
|
5
|
+
* Without a mailer an invitation cannot be emailed, so whoever invites, at one of `paths`, gets its link in the answer,
|
|
6
|
+
* once, to pass on. The answer carries the invitation's `id` and `expiresAt`. Inviting again gives a new link, since it
|
|
7
|
+
* moves the expiry.
|
|
8
|
+
*/
|
|
9
|
+
const invitationLinkHook = (paths, invitationUrl) => ({
|
|
10
|
+
matcher: (context) => paths.includes(context.path ?? ""),
|
|
11
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
12
|
+
const answer = await withInvitationLink(ctx.context.returned, ctx.context.secret, invitationUrl);
|
|
13
|
+
if (answer) ctx.context.returned = answer;
|
|
14
|
+
})
|
|
15
|
+
});
|
|
16
|
+
/** An invitation's answer with its `link` added; `undefined` for an error or an answer without an invitation. */
|
|
17
|
+
const withInvitationLink = async (returned, secret, invitationUrl) => {
|
|
18
|
+
const answer = returned;
|
|
19
|
+
if (!answer || answer instanceof Response || answer instanceof Error || !answer.id || !answer.expiresAt) return void 0;
|
|
20
|
+
const token = await invitationToken(secret, {
|
|
21
|
+
id: answer.id,
|
|
22
|
+
expiresAt: new Date(answer.expiresAt)
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
...answer,
|
|
26
|
+
link: invitationLink(invitationUrl, token)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** The page an invitation link opens, with `?token=`, unless the app names another. */
|
|
30
|
+
const defaultInvitationUrl = (baseURL) => `${baseURL.replace(/\/$/, "")}/-/invitation`;
|
|
31
|
+
/** How long an invitation works unless the app says otherwise: 7 days, in seconds. */
|
|
32
|
+
const defaultInvitationSeconds = 604800;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { defaultInvitationSeconds, defaultInvitationUrl, invitationLinkHook, withInvitationLink };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
|
|
2
|
+
import { invitationOfToken as invitationOfToken$1 } from "../organizations/accept-invitation.js";
|
|
3
|
+
import { findAppInvitation } from "../users/app-invitation-store.js";
|
|
4
|
+
import { APIError } from "better-auth/api";
|
|
5
|
+
//#region src/better-auth/invitations/invitation-of-token.ts
|
|
6
|
+
/** The pending invitation an emailed link's token names, either kind, or a 400 `INVITATION_INVALID`. */
|
|
7
|
+
const invitationOfToken = async (context, token, { organizations }) => {
|
|
8
|
+
const id = invitationIdOf(token);
|
|
9
|
+
const app = id ? await findAppInvitation(context, id) : void 0;
|
|
10
|
+
if (app) {
|
|
11
|
+
if (!await verifyInvitationToken(context.secret, token, app)) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
12
|
+
return {
|
|
13
|
+
kind: "app",
|
|
14
|
+
invitation: app
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (!organizations) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
18
|
+
return {
|
|
19
|
+
kind: "organization",
|
|
20
|
+
invitation: await invitationOfToken$1(context, token)
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { invitationOfToken };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region src/better-auth/
|
|
1
|
+
//#region src/better-auth/invitations/invitation-token.ts
|
|
2
2
|
const encoder = new TextEncoder();
|
|
3
3
|
const base64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
4
4
|
const signature = async (secret, id, expiresAt) => {
|
|
@@ -23,5 +23,10 @@ const verifyInvitationToken = async (secret, token, invitation) => {
|
|
|
23
23
|
};
|
|
24
24
|
/** The link in the email: the app's invitation page with the token. */
|
|
25
25
|
const invitationLink = (pageUrl, token) => `${pageUrl}?token=${encodeURIComponent(token)}`;
|
|
26
|
+
/** Why a link's token finds no pending invitation, of either kind: the body of its 400. */
|
|
27
|
+
const invalidInvitation = {
|
|
28
|
+
code: "INVITATION_INVALID",
|
|
29
|
+
message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
|
|
30
|
+
};
|
|
26
31
|
//#endregion
|
|
27
|
-
export { invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
|
|
32
|
+
export { invalidInvitation, invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
import { wouldLeaveNoAdmin } from "./last-admin.js";
|
|
3
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
4
|
+
//#region src/better-auth/last-admin-guard.ts
|
|
5
|
+
const rolesOf = (role) => typeof role === "string" || Array.isArray(role) ? parseRoles(role) : void 0;
|
|
6
|
+
/** The change an admin endpoint makes to its user, from its path and body; `undefined` when it touches no role or block. */
|
|
7
|
+
const adminChange = (path, body) => {
|
|
8
|
+
if (typeof body.userId !== "string") return void 0;
|
|
9
|
+
const userId = body.userId;
|
|
10
|
+
if (path === "/admin/set-role") return {
|
|
11
|
+
userId,
|
|
12
|
+
roles: rolesOf(body.role) ?? []
|
|
13
|
+
};
|
|
14
|
+
if (path === "/admin/ban-user") return {
|
|
15
|
+
userId,
|
|
16
|
+
banned: true
|
|
17
|
+
};
|
|
18
|
+
if (path === "/admin/remove-user") return {
|
|
19
|
+
userId,
|
|
20
|
+
removed: true
|
|
21
|
+
};
|
|
22
|
+
if (path !== "/admin/update-user") return void 0;
|
|
23
|
+
const roles = rolesOf(body.data?.role);
|
|
24
|
+
const banned = typeof body.data?.banned === "boolean" ? body.data.banned : void 0;
|
|
25
|
+
if (roles === void 0 && banned === void 0) return void 0;
|
|
26
|
+
return {
|
|
27
|
+
userId,
|
|
28
|
+
...roles && { roles },
|
|
29
|
+
...banned !== void 0 && { banned }
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const guardedPaths = [
|
|
33
|
+
"/admin/set-role",
|
|
34
|
+
"/admin/ban-user",
|
|
35
|
+
"/admin/remove-user",
|
|
36
|
+
"/admin/update-user"
|
|
37
|
+
];
|
|
38
|
+
/** Refuses, with `400 LAST_ADMIN`, an admin endpoint that would demote, block or remove the last active admin. */
|
|
39
|
+
const lastAdminGuardPlugin = () => ({
|
|
40
|
+
id: "protobase-last-admin",
|
|
41
|
+
hooks: { before: [{
|
|
42
|
+
matcher: (ctx) => guardedPaths.includes(ctx.path ?? ""),
|
|
43
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
44
|
+
const change = adminChange(ctx.path, ctx.body ?? {});
|
|
45
|
+
if (!change) return;
|
|
46
|
+
const users = await ctx.context.adapter.findMany({
|
|
47
|
+
model: "user",
|
|
48
|
+
select: [
|
|
49
|
+
"id",
|
|
50
|
+
"role",
|
|
51
|
+
"banned"
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
if (wouldLeaveNoAdmin(users, change)) throw new APIError("BAD_REQUEST", {
|
|
55
|
+
code: "LAST_ADMIN",
|
|
56
|
+
message: "This is the last admin: make someone else an admin first."
|
|
57
|
+
});
|
|
58
|
+
})
|
|
59
|
+
}] }
|
|
60
|
+
});
|
|
61
|
+
//#endregion
|
|
62
|
+
export { adminChange, lastAdminGuardPlugin };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
//#region src/better-auth/last-admin.ts
|
|
3
|
+
/** Holds the admin role and is not blocked: the last of these can never be demoted, blocked or removed. */
|
|
4
|
+
const isActiveAdmin = (user) => parseRoles(user.role).includes("admin") && !user.banned;
|
|
5
|
+
/** Whether `change` would leave `users` without an active admin, while they have one now. */
|
|
6
|
+
const wouldLeaveNoAdmin = (users, change) => {
|
|
7
|
+
const target = users.find((user) => user.id === change.userId);
|
|
8
|
+
if (!target || !isActiveAdmin(target)) return false;
|
|
9
|
+
if (change.removed) return users.filter(isActiveAdmin).length <= 1;
|
|
10
|
+
const after = {
|
|
11
|
+
...target,
|
|
12
|
+
...change.roles && { role: change.roles },
|
|
13
|
+
...change.banned !== void 0 && { banned: change.banned }
|
|
14
|
+
};
|
|
15
|
+
return !isActiveAdmin(after) && users.filter(isActiveAdmin).length <= 1;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { isActiveAdmin, wouldLeaveNoAdmin };
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
+
import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "../invitations/invitation-token.js";
|
|
1
2
|
import { parseAppRoles } from "./app-roles.js";
|
|
2
3
|
import { findMember, membersOf } from "./store.js";
|
|
3
|
-
import { invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
|
|
4
4
|
import { APIError } from "better-auth/api";
|
|
5
5
|
//#region src/better-auth/organizations/accept-invitation.ts
|
|
6
|
-
const invalid = {
|
|
7
|
-
code: "INVITATION_INVALID",
|
|
8
|
-
message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
|
|
9
|
-
};
|
|
10
6
|
/** The pending invitation an emailed link's token names, or a 400 `INVITATION_INVALID`. */
|
|
11
7
|
const invitationOfToken = async (context, token) => {
|
|
12
8
|
const id = invitationIdOf(token);
|
|
@@ -17,8 +13,8 @@ const invitationOfToken = async (context, token) => {
|
|
|
17
13
|
value: id
|
|
18
14
|
}]
|
|
19
15
|
});
|
|
20
|
-
if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST",
|
|
21
|
-
if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST",
|
|
16
|
+
if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
17
|
+
if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
22
18
|
return {
|
|
23
19
|
...row,
|
|
24
20
|
appRoles: parseAppRoles(row.appRoles)
|
|
@@ -60,7 +56,7 @@ const acceptInvitation = async (context, invitation, user, membershipLimit) => {
|
|
|
60
56
|
value: "pending"
|
|
61
57
|
}],
|
|
62
58
|
update: { status: "accepted" }
|
|
63
|
-
})) throw new APIError("BAD_REQUEST",
|
|
59
|
+
})) throw new APIError("BAD_REQUEST", invalidInvitation);
|
|
64
60
|
return {
|
|
65
61
|
...await trx.create({
|
|
66
62
|
model: "member",
|
|
@@ -1,120 +1,23 @@
|
|
|
1
|
-
import { noGlobalRole } from "./role-definitions.js";
|
|
2
|
-
import { findOrganization } from "./store.js";
|
|
3
|
-
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
4
|
-
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
5
|
-
import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
6
|
-
import { readSignInPolicy } from "../policy-store.js";
|
|
7
1
|
import { acceptInvitation, invitationOfToken } from "./accept-invitation.js";
|
|
8
2
|
import * as z$1 from "zod";
|
|
9
|
-
import {
|
|
10
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
3
|
+
import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
|
11
4
|
//#region src/better-auth/organizations/invitation-endpoints.ts
|
|
12
|
-
const tokenBody = z$1.object({ token: z$1.string().min(1) });
|
|
13
|
-
const startIn = (context, sessionToken, organizationId) => context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
|
|
14
5
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the mail proves the person reads it.
|
|
6
|
+
* Accepting an organization's emailed invitation signed in. What a link is for, and making an account from it, are the
|
|
7
|
+
* shared invitation endpoints (`invitations/invitation-endpoints.ts`), which serve invitations to the app as well.
|
|
18
8
|
*/
|
|
19
|
-
const invitationEndpoints = ({ resolved
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
name: organization?.name ?? ""
|
|
33
|
-
},
|
|
34
|
-
inviter: { name: inviter?.name || inviter?.email || "" },
|
|
35
|
-
email: invitation.email,
|
|
36
|
-
role: invitation.role,
|
|
37
|
-
appRoles: invitation.appRoles,
|
|
38
|
-
expiresAt: new Date(invitation.expiresAt).toISOString(),
|
|
39
|
-
hasAccount: Boolean(account)
|
|
40
|
-
});
|
|
41
|
-
}),
|
|
42
|
-
/** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
|
|
43
|
-
acceptInvitation: createAuthEndpoint("/invitation/accept", {
|
|
44
|
-
method: "POST",
|
|
45
|
-
body: tokenBody,
|
|
46
|
-
use: [sessionMiddleware]
|
|
47
|
-
}, async (ctx) => {
|
|
48
|
-
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
49
|
-
const { user, session } = ctx.context.session;
|
|
50
|
-
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
51
|
-
await startIn(ctx.context, session.token, invitation.organizationId);
|
|
52
|
-
return ctx.json({ member });
|
|
53
|
-
}),
|
|
54
|
-
/**
|
|
55
|
-
* `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
|
|
56
|
-
* verified and with no global role, accepts the invitation and signs the person in there. A password is needed unless
|
|
57
|
-
* the sign-in policy offers emailed codes; the policy's required methods are then set up as after any sign-in.
|
|
58
|
-
*/
|
|
59
|
-
signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
|
|
60
|
-
method: "POST",
|
|
61
|
-
body: z$1.object({
|
|
62
|
-
token: z$1.string().min(1),
|
|
63
|
-
name: z$1.string().trim().min(1).max(200),
|
|
64
|
-
password: z$1.string().optional()
|
|
65
|
-
})
|
|
66
|
-
}, async (ctx) => {
|
|
67
|
-
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
68
|
-
const { internalAdapter } = ctx.context;
|
|
69
|
-
if (await internalAdapter.findUserByEmail(invitation.email.toLowerCase())) throw new APIError("BAD_REQUEST", {
|
|
70
|
-
code: "ACCOUNT_EXISTS",
|
|
71
|
-
message: "This address has an account already: sign in to accept the invitation."
|
|
72
|
-
});
|
|
73
|
-
const { password } = ctx.body;
|
|
74
|
-
checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.context.adapter)).policy, { mail }), password);
|
|
75
|
-
const user = await internalAdapter.createUser({
|
|
76
|
-
email: invitation.email.toLowerCase(),
|
|
77
|
-
name: ctx.body.name,
|
|
78
|
-
emailVerified: true,
|
|
79
|
-
role: noGlobalRole
|
|
80
|
-
}, { method: "invitation" });
|
|
81
|
-
if (password !== void 0) await internalAdapter.linkAccount({
|
|
82
|
-
userId: user.id,
|
|
83
|
-
providerId: "credential",
|
|
84
|
-
accountId: user.id,
|
|
85
|
-
password: await ctx.context.password.hash(password)
|
|
86
|
-
});
|
|
87
|
-
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
88
|
-
const session = await internalAdapter.createSession(user.id, false);
|
|
89
|
-
await startIn(ctx.context, session.token, invitation.organizationId);
|
|
90
|
-
await setSessionCookie(ctx, {
|
|
91
|
-
session: {
|
|
92
|
-
...session,
|
|
93
|
-
activeOrganizationId: invitation.organizationId
|
|
94
|
-
},
|
|
95
|
-
user
|
|
96
|
-
});
|
|
97
|
-
return ctx.json({ member });
|
|
98
|
-
})
|
|
99
|
-
});
|
|
100
|
-
/**
|
|
101
|
-
* Without a mailer the invitation cannot be emailed, so whoever invites gets its link in the answer, once, to pass on.
|
|
102
|
-
* Resending gives a new link, since it moves the expiry.
|
|
103
|
-
*/
|
|
104
|
-
const invitationLinkHook = (resolved) => ({
|
|
105
|
-
matcher: (context) => context.path === "/organization/invite-member",
|
|
106
|
-
handler: createAuthMiddleware(async (ctx) => {
|
|
107
|
-
const returned = ctx.context.returned;
|
|
108
|
-
if (!returned || returned instanceof Response || returned instanceof Error || !returned.id || !returned.expiresAt) return;
|
|
109
|
-
const token = await invitationToken(ctx.context.secret, {
|
|
110
|
-
id: returned.id,
|
|
111
|
-
expiresAt: new Date(returned.expiresAt)
|
|
112
|
-
});
|
|
113
|
-
ctx.context.returned = {
|
|
114
|
-
...returned,
|
|
115
|
-
link: invitationLink(resolved.invitationUrl, token)
|
|
116
|
-
};
|
|
117
|
-
})
|
|
118
|
-
});
|
|
9
|
+
const invitationEndpoints = ({ resolved }) => ({
|
|
10
|
+
/** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
|
|
11
|
+
acceptInvitation: createAuthEndpoint("/invitation/accept", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
body: z$1.object({ token: z$1.string().min(1) }),
|
|
14
|
+
use: [sessionMiddleware]
|
|
15
|
+
}, async (ctx) => {
|
|
16
|
+
const invitation = await invitationOfToken(ctx.context, ctx.body.token);
|
|
17
|
+
const { user, session } = ctx.context.session;
|
|
18
|
+
const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
|
|
19
|
+
await ctx.context.internalAdapter.updateSession(session.token, { activeOrganizationId: invitation.organizationId });
|
|
20
|
+
return ctx.json({ member });
|
|
21
|
+
}) });
|
|
119
22
|
//#endregion
|
|
120
|
-
export { invitationEndpoints
|
|
23
|
+
export { invitationEndpoints };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultInvitationUrl } from "../invitations/invitation-link-hook.js";
|
|
1
2
|
import { membershipRoles } from "./role-definitions.js";
|
|
2
3
|
//#region src/better-auth/organizations/options.ts
|
|
3
4
|
const resolveOrganizations = (options, definitions, baseURL) => {
|
|
@@ -15,7 +16,7 @@ const resolveOrganizations = (options, definitions, baseURL) => {
|
|
|
15
16
|
membershipLimit: options.membershipLimit ?? 1e3,
|
|
16
17
|
generateId: options.generateId ?? (() => crypto.randomUUID()),
|
|
17
18
|
deleteRecords: options.deleteRecords ?? "refuse",
|
|
18
|
-
invitationUrl: options.invitationUrl ??
|
|
19
|
+
invitationUrl: options.invitationUrl ?? defaultInvitationUrl(baseURL),
|
|
19
20
|
options
|
|
20
21
|
};
|
|
21
22
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
|
|
1
2
|
import "./role-definitions.js";
|
|
2
3
|
import { checkAppRoleChange, effectiveRoles, globalRoles, parseAppRoles } from "./app-roles.js";
|
|
3
4
|
import { findMember } from "./store.js";
|
|
4
5
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
5
|
-
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
6
6
|
import { APIError } from "better-auth/api";
|
|
7
7
|
import { organization } from "better-auth/plugins/organization";
|
|
8
8
|
//#region src/better-auth/organizations/organization-plugin.ts
|
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { invitationLinkHook } from "../invitations/invitation-link-hook.js";
|
|
2
|
+
import { invitationEndpoints } from "./invitation-endpoints.js";
|
|
2
3
|
import { memberEndpoints } from "./member-endpoints.js";
|
|
3
4
|
//#region src/better-auth/organizations/protobase-plugin.ts
|
|
4
5
|
/**
|
|
5
|
-
* Protobase's endpoints beside Better Auth's organization plugin:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Protobase's endpoints beside Better Auth's organization plugin: accepting invitations signed in, app roles, handing
|
|
7
|
+
* ownership over, and finding organizations for people with a global role. Without a mailer, whoever invites gets the
|
|
8
|
+
* link in the answer.
|
|
8
9
|
*/
|
|
9
10
|
const protobaseOrganizationPlugin = ({ resolved, mail }) => ({
|
|
10
11
|
id: "protobase-organizations",
|
|
11
12
|
endpoints: {
|
|
12
|
-
...invitationEndpoints({
|
|
13
|
-
resolved,
|
|
14
|
-
mail
|
|
15
|
-
}),
|
|
13
|
+
...invitationEndpoints({ resolved }),
|
|
16
14
|
...memberEndpoints({ resolved })
|
|
17
15
|
},
|
|
18
|
-
hooks: { after: mail ? [] : [invitationLinkHook(resolved)] }
|
|
16
|
+
hooks: { after: mail ? [] : [invitationLinkHook(["/organization/invite-member"], resolved.invitationUrl)] }
|
|
19
17
|
});
|
|
20
18
|
//#endregion
|
|
21
19
|
export { protobaseOrganizationPlugin };
|
|
@@ -5,10 +5,10 @@ const superuserRole = "admin";
|
|
|
5
5
|
/** The admin plugin's role for an account without a global role; in organization mode it grants nothing. */
|
|
6
6
|
const noGlobalRole = "user";
|
|
7
7
|
const organizationRoleNames = ["owner", "member"];
|
|
8
|
-
const builtInLabels = {
|
|
8
|
+
const builtInLabels = (organizations) => ({
|
|
9
9
|
[superuserRole]: "Superuser",
|
|
10
|
-
[noGlobalRole]: "No global role"
|
|
11
|
-
};
|
|
10
|
+
[noGlobalRole]: organizations ? "No global role" : "User"
|
|
11
|
+
});
|
|
12
12
|
const entriesOf = (input) => Array.isArray(input) ? input.map((name) => [name, void 0]) : Object.entries(input);
|
|
13
13
|
/**
|
|
14
14
|
* The roles with `admin` first (added when missing), their labels and grants. With `organizations`, every role needs a
|
|
@@ -26,10 +26,11 @@ const roleDefinitions = (input = ["admin", "user"], { organizations = false } =
|
|
|
26
26
|
...organizations && !given.has("user") ? [noGlobalRole] : []
|
|
27
27
|
];
|
|
28
28
|
const labels = {};
|
|
29
|
+
const builtIn = builtInLabels(organizations);
|
|
29
30
|
const grants = {};
|
|
30
31
|
for (const name of names) {
|
|
31
32
|
const definition = given.get(name);
|
|
32
|
-
const label = typeof definition === "string" ? definition : definition?.label ??
|
|
33
|
+
const label = typeof definition === "string" ? definition : definition?.label ?? builtIn[name];
|
|
33
34
|
if (label !== void 0) labels[name] = label;
|
|
34
35
|
grants[name] = typeof definition === "object" ? definition.grants ?? [] : [];
|
|
35
36
|
const unknown = grants[name].find((granted) => !names.includes(granted));
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { pluginNames } from "./snake-case-names.js";
|
|
2
2
|
import * as z$1 from "zod";
|
|
3
3
|
import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
|
4
|
-
import { setSessionCookie } from "better-auth/cookies";
|
|
5
4
|
import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
|
|
6
5
|
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
6
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
7
7
|
import { generateRandomString } from "better-auth/crypto";
|
|
8
8
|
//#region src/better-auth/passkey-plugin.ts
|
|
9
9
|
const challengeCookie = "better-auth-passkey";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { membershipRoles } from "../organizations/role-definitions.js";
|
|
2
|
+
//#region src/better-auth/roles/role-labels.ts
|
|
3
|
+
/** Every role with its label, `membership` for those a member can hold, and the roles each one's holders may also give. */
|
|
4
|
+
const roleLabels = (definitions) => {
|
|
5
|
+
const membership = membershipRoles(definitions);
|
|
6
|
+
return definitions.names.map((name) => ({
|
|
7
|
+
name,
|
|
8
|
+
label: definitions.labels[name] ?? name,
|
|
9
|
+
membership: membership.includes(name),
|
|
10
|
+
grants: definitions.grants[name] ?? []
|
|
11
|
+
}));
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { roleLabels };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
2
|
-
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
3
2
|
import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
4
3
|
import { readSignInPolicy } from "../policy-store.js";
|
|
4
|
+
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
5
5
|
import { consumeSignUpCode, issueSignUpCode, signUpCodeLifetime } from "./sign-up-code.js";
|
|
6
6
|
import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
|
|
7
7
|
import * as z$1 from "zod";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { signInPasskeyDomains } from "./passkey-domain.js";
|
|
2
2
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
3
3
|
import { readSignInPolicy } from "./policy-store.js";
|
|
4
|
-
import {
|
|
4
|
+
import { roleLabels } from "./roles/role-labels.js";
|
|
5
5
|
import { hasUsers } from "./users.js";
|
|
6
6
|
import { Hono } from "hono";
|
|
7
7
|
//#region src/better-auth/status-route.ts
|
|
@@ -46,12 +46,7 @@ const statusRoute = (auth, { anonymous = false, plugins = [] } = {}) => {
|
|
|
46
46
|
...plugins.length > 0 && { plugins },
|
|
47
47
|
...auth.organizations && { organizations: {
|
|
48
48
|
create: auth.organizations.create,
|
|
49
|
-
roles: auth.roleDefinitions
|
|
50
|
-
name,
|
|
51
|
-
label: auth.roleDefinitions.labels[name] ?? name,
|
|
52
|
-
membership: membershipRoles(auth.roleDefinitions).includes(name),
|
|
53
|
-
grants: auth.roleDefinitions.grants[name] ?? []
|
|
54
|
-
}))
|
|
49
|
+
roles: roleLabels(auth.roleDefinitions)
|
|
55
50
|
} }
|
|
56
51
|
});
|
|
57
52
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/better-auth/users/app-invitation-store.ts
|
|
2
|
+
const prefix = "app-invitation:";
|
|
3
|
+
const invitationOf = (row) => {
|
|
4
|
+
const { email, roles, inviterId } = JSON.parse(row.value);
|
|
5
|
+
return {
|
|
6
|
+
id: row.identifier.slice(15),
|
|
7
|
+
email,
|
|
8
|
+
roles,
|
|
9
|
+
inviterId,
|
|
10
|
+
expiresAt: new Date(row.expiresAt)
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
const pending = (invitation) => invitation.expiresAt.getTime() > Date.now();
|
|
14
|
+
const allAppInvitations = async (context) => {
|
|
15
|
+
return (await context.adapter.findMany({
|
|
16
|
+
model: "verification",
|
|
17
|
+
where: [{
|
|
18
|
+
field: "identifier",
|
|
19
|
+
operator: "starts_with",
|
|
20
|
+
value: prefix
|
|
21
|
+
}],
|
|
22
|
+
sortBy: {
|
|
23
|
+
field: "createdAt",
|
|
24
|
+
direction: "asc"
|
|
25
|
+
},
|
|
26
|
+
limit: 1e5
|
|
27
|
+
})).map(invitationOf);
|
|
28
|
+
};
|
|
29
|
+
/** The pending app invitations, oldest first; expired ones are left out. */
|
|
30
|
+
const listAppInvitations = async (context) => (await allAppInvitations(context)).filter(pending);
|
|
31
|
+
/** The pending app invitation with `id`, or `undefined` when there is none or it expired. */
|
|
32
|
+
const findAppInvitation = async (context, id) => {
|
|
33
|
+
const row = await context.internalAdapter.findVerificationValue(`${prefix}${id}`);
|
|
34
|
+
const invitation = row ? invitationOf(row) : void 0;
|
|
35
|
+
return invitation && pending(invitation) ? invitation : void 0;
|
|
36
|
+
};
|
|
37
|
+
const deleteAppInvitation = (context, id) => context.internalAdapter.deleteVerificationByIdentifier(`${prefix}${id}`);
|
|
38
|
+
/** Invites `email` with `roles` for `lifetimeSeconds`; an earlier invitation of the same address is replaced, so its link stops working. */
|
|
39
|
+
const createAppInvitation = async (context, input, lifetimeSeconds) => {
|
|
40
|
+
const email = input.email.toLowerCase();
|
|
41
|
+
const earlier = (await allAppInvitations(context)).filter((invitation) => invitation.email === email);
|
|
42
|
+
for (const invitation of earlier) await deleteAppInvitation(context, invitation.id);
|
|
43
|
+
const invitation = {
|
|
44
|
+
id: crypto.randomUUID(),
|
|
45
|
+
email,
|
|
46
|
+
roles: input.roles,
|
|
47
|
+
inviterId: input.inviterId,
|
|
48
|
+
expiresAt: new Date(Date.now() + lifetimeSeconds * 1e3)
|
|
49
|
+
};
|
|
50
|
+
await context.internalAdapter.createVerificationValue({
|
|
51
|
+
identifier: `${prefix}${invitation.id}`,
|
|
52
|
+
value: JSON.stringify({
|
|
53
|
+
email,
|
|
54
|
+
roles: invitation.roles,
|
|
55
|
+
inviterId: invitation.inviterId
|
|
56
|
+
}),
|
|
57
|
+
expiresAt: invitation.expiresAt
|
|
58
|
+
});
|
|
59
|
+
return invitation;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { createAppInvitation, deleteAppInvitation, findAppInvitation, listAppInvitations };
|