mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
feat: add organisation sso portal (#1946)
Allow organisations to manage an SSO OIDC compliant portal. This method is intended to streamline the onboarding process and paves the way to allow organisations to manage their members in a more strict way.
This commit is contained in:
@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import type { AuthAppType } from '../server';
|
||||
import type { SessionValidationResult } from '../server/lib/session/session';
|
||||
import type { PartialAccount } from '../server/lib/utils/get-accounts';
|
||||
import type { ActiveSession } from '../server/lib/utils/get-session';
|
||||
import { handleSignInRedirect } from '../server/lib/utils/redirect';
|
||||
import type {
|
||||
@ -96,6 +97,25 @@ export class AuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
public account = {
|
||||
getMany: async () => {
|
||||
const response = await this.client['accounts'].$get();
|
||||
|
||||
await this.handleError(response);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return superjson.deserialize<{ accounts: PartialAccount[] }>(result);
|
||||
},
|
||||
delete: async (accountId: string) => {
|
||||
const response = await this.client['account'][':accountId'].$delete({
|
||||
param: { accountId },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
},
|
||||
};
|
||||
|
||||
public emailPassword = {
|
||||
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
|
||||
let csrfToken = data.csrfToken;
|
||||
@ -214,6 +234,22 @@ export class AuthClient {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
org: {
|
||||
signIn: async ({ orgUrl }: { orgUrl: string }) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
|
||||
param: { orgUrl },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to external OIDC provider URL.
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { setCsrfCookie } from './lib/session/session-cookies';
|
||||
import { accountRoute } from './routes/account';
|
||||
import { callbackRoute } from './routes/callback';
|
||||
import { emailPasswordRoute } from './routes/email-password';
|
||||
import { oauthRoute } from './routes/oauth';
|
||||
@ -43,6 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
|
||||
})
|
||||
.route('/', sessionRoute)
|
||||
.route('/', signOutRoute)
|
||||
.route('/', accountRoute)
|
||||
.route('/callback', callbackRoute)
|
||||
.route('/oauth', oauthRoute)
|
||||
.route('/email-password', emailPasswordRoute)
|
||||
|
||||
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal file
37
packages/auth/server/lib/utils/delete-account-provider.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSession } from './get-session';
|
||||
|
||||
export const deleteAccountProvider = async (c: Context, accountId: string): Promise<void> => {
|
||||
const { user } = await getSession(c);
|
||||
|
||||
const requestMeta = c.get('requestMetadata');
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const deletedAccountProvider = await tx.account.delete({
|
||||
where: {
|
||||
id: accountId,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type:
|
||||
deletedAccountProvider.type === ORGANISATION_USER_ACCOUNT_TYPE
|
||||
? UserSecurityAuditLogType.ORGANISATION_SSO_UNLINK
|
||||
: UserSecurityAuditLogType.ACCOUNT_SSO_UNLINK,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
32
packages/auth/server/lib/utils/get-accounts.ts
Normal file
32
packages/auth/server/lib/utils/get-accounts.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSession } from './get-session';
|
||||
|
||||
export type PartialAccount = {
|
||||
id: string;
|
||||
userId: number;
|
||||
type: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export const getAccounts = async (c: Context | Request): Promise<PartialAccount[]> => {
|
||||
const { user } = await getSession(c);
|
||||
|
||||
return await prisma.account.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
type: true,
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -20,70 +20,10 @@ type HandleOAuthCallbackUrlOptions = {
|
||||
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const { c, clientOptions } = options;
|
||||
|
||||
if (!clientOptions.clientId || !clientOptions.clientSecret) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
|
||||
requiredScopes: clientOptions.scope,
|
||||
});
|
||||
|
||||
const oAuthClient = new OAuth2Client(
|
||||
clientOptions.clientId,
|
||||
clientOptions.clientSecret,
|
||||
clientOptions.redirectUrl,
|
||||
);
|
||||
|
||||
const requestMeta = c.get('requestMetadata');
|
||||
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
|
||||
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
|
||||
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
|
||||
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
|
||||
|
||||
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid or missing state',
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
storedCodeVerifier,
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
const idToken = tokens.idToken();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
|
||||
|
||||
const email = claims.email;
|
||||
const name = claims.name;
|
||||
const sub = claims.sub;
|
||||
|
||||
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Invalid claims',
|
||||
});
|
||||
}
|
||||
|
||||
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
|
||||
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
|
||||
message: 'Account email is not verified',
|
||||
});
|
||||
}
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
|
||||
await validateOauth({ c, clientOptions });
|
||||
|
||||
// Find the account if possible.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
@ -199,3 +139,92 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
|
||||
return c.redirect(redirectPath, 302);
|
||||
};
|
||||
|
||||
export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
||||
const { c, clientOptions } = options;
|
||||
|
||||
if (!clientOptions.clientId || !clientOptions.clientSecret) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
|
||||
requiredScopes: clientOptions.scope,
|
||||
});
|
||||
|
||||
const oAuthClient = new OAuth2Client(
|
||||
clientOptions.clientId,
|
||||
clientOptions.clientSecret,
|
||||
clientOptions.redirectUrl,
|
||||
);
|
||||
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
|
||||
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
|
||||
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
|
||||
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
|
||||
|
||||
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid or missing state',
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
|
||||
|
||||
if (redirectState !== storedState || !redirectPath) {
|
||||
redirectPath = '/';
|
||||
}
|
||||
|
||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||
token_endpoint,
|
||||
code,
|
||||
storedCodeVerifier,
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
const idToken = tokens.idToken();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
|
||||
|
||||
const email = claims.email;
|
||||
const name = claims.name;
|
||||
const sub = claims.sub;
|
||||
|
||||
if (typeof email !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing email',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof name !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing name',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof sub !== 'string') {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Missing sub claim',
|
||||
});
|
||||
}
|
||||
|
||||
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
|
||||
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
|
||||
message: 'Account email is not verified',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
sub,
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
idToken,
|
||||
redirectPath,
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../errors/error-codes';
|
||||
import { onAuthorize } from './authorizer';
|
||||
import { validateOauth } from './handle-oauth-callback-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
|
||||
|
||||
type HandleOAuthOrganisationCallbackUrlOptions = {
|
||||
c: Context;
|
||||
orgUrl: string;
|
||||
};
|
||||
|
||||
export const handleOAuthOrganisationCallbackUrl = async (
|
||||
options: HandleOAuthOrganisationCallbackUrlOptions,
|
||||
) => {
|
||||
const { c, orgUrl } = options;
|
||||
|
||||
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
|
||||
c,
|
||||
clientOptions: {
|
||||
...clientOptions,
|
||||
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
|
||||
},
|
||||
});
|
||||
|
||||
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
|
||||
|
||||
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
|
||||
message: 'Email domain not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
// Find the account if possible.
|
||||
const existingAccount = await prisma.account.findFirst({
|
||||
where: {
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: sub,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Directly log in user if account already exists.
|
||||
if (existingAccount) {
|
||||
await onAuthorize({ userId: existingAccount.user.id }, c);
|
||||
|
||||
return c.redirect(`/o/${orgUrl}`, 302);
|
||||
}
|
||||
|
||||
let userToLink = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle new user.
|
||||
if (!userToLink) {
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
name: name,
|
||||
emailVerified: null, // Do not verify email.
|
||||
},
|
||||
});
|
||||
|
||||
await onCreateUserHook(userToLink).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
await sendOrganisationAccountLinkConfirmationEmail({
|
||||
type: userToLink.emailVerified ? 'link' : 'create',
|
||||
userId: userToLink.id,
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
oauthConfig: {
|
||||
accessToken,
|
||||
idToken,
|
||||
providerAccountId: sub,
|
||||
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
|
||||
};
|
||||
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal file
94
packages/auth/server/lib/utils/organisation-portal.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatOrganisationCallbackUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions =
|
||||
| {
|
||||
type: 'url';
|
||||
organisationUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'id';
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortalOptions = async (
|
||||
options: GetOrganisationAuthenticationPortalOptions,
|
||||
) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where:
|
||||
options.type === 'url'
|
||||
? {
|
||||
url: options.organisationUrl,
|
||||
}
|
||||
: {
|
||||
id: options.organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: true,
|
||||
groups: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!organisation.organisationClaim.flags.authenticationPortal ||
|
||||
!organisation.organisationAuthenticationPortal.enabled
|
||||
) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not enabled for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
} = organisation.organisationAuthenticationPortal;
|
||||
|
||||
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Authentication portal is not configured for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Encryption key is not set',
|
||||
});
|
||||
}
|
||||
|
||||
const clientSecret = Buffer.from(
|
||||
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: encryptedClientSecret }),
|
||||
).toString('utf-8');
|
||||
|
||||
return {
|
||||
organisation,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
clientOptions: {
|
||||
id: organisation.id,
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
|
||||
wellKnownUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
25
packages/auth/server/routes/account.ts
Normal file
25
packages/auth/server/routes/account.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Hono } from 'hono';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { deleteAccountProvider } from '../lib/utils/delete-account-provider';
|
||||
import { getAccounts } from '../lib/utils/get-accounts';
|
||||
|
||||
export const accountRoute = new Hono()
|
||||
/**
|
||||
* Get all linked accounts.
|
||||
*/
|
||||
.get('/accounts', async (c) => {
|
||||
const accounts = await getAccounts(c);
|
||||
|
||||
return c.json(superjson.serialize({ accounts }));
|
||||
})
|
||||
/**
|
||||
* Delete an account linking method.
|
||||
*/
|
||||
.delete('/account/:accountId', async (c) => {
|
||||
const accountId = c.req.param('accountId');
|
||||
|
||||
await deleteAccountProvider(c, accountId);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
||||
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
/**
|
||||
@ -14,6 +17,31 @@ export const callbackRoute = new Hono<HonoAuthContext>()
|
||||
*/
|
||||
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
|
||||
|
||||
/**
|
||||
* Organisation OIDC callback verification.
|
||||
*/
|
||||
.get('/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
try {
|
||||
return await handleOAuthOrganisationCallbackUrl({
|
||||
c,
|
||||
orgUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
throw new AppError(err.name, {
|
||||
message: err.message,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Google callback verification.
|
||||
*/
|
||||
|
||||
@ -16,7 +16,7 @@ import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
@ -105,7 +105,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
|
||||
const ZOAuthAuthorizeSchema = z.object({
|
||||
@ -34,4 +35,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
clientOptions: OidcAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
/**
|
||||
* Organisation OIDC authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/oidc/org/:orgUrl', async (c) => {
|
||||
const orgUrl = c.req.param('orgUrl');
|
||||
|
||||
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'url',
|
||||
organisationUrl: orgUrl,
|
||||
});
|
||||
|
||||
return await handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions,
|
||||
});
|
||||
});
|
||||
|
||||
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal file
163
packages/ee/server-only/lib/link-organisation-account.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
requestMeta: RequestMetadata;
|
||||
}
|
||||
|
||||
export const linkOrganisationAccount = async ({
|
||||
token,
|
||||
requestMeta,
|
||||
}: LinkOrganisationAccountOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the token since it contains unnecessary sensitive data.
|
||||
const verificationToken = await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerAccountId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationToken.completed) {
|
||||
throw new AppError('ALREADY_USED');
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
|
||||
verificationToken.metadata,
|
||||
);
|
||||
|
||||
if (!tokenMetadata.success) {
|
||||
console.error('Invalid token metadata', tokenMetadata.error);
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Verification token not found, used or expired',
|
||||
});
|
||||
}
|
||||
|
||||
const user = verificationToken.user;
|
||||
|
||||
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
|
||||
type: 'id',
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
});
|
||||
|
||||
const organisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
|
||||
const userAlreadyLinked = user.accounts.find(
|
||||
(account) =>
|
||||
account.provider === clientOptions.id &&
|
||||
account.providerAccountId === oauthConfig.providerAccountId,
|
||||
);
|
||||
|
||||
if (organisationMember && userAlreadyLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole:
|
||||
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,119 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
|
||||
import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailContext } from '@documenso/lib/server-only/email/get-email-context';
|
||||
import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
|
||||
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
organisationName: string;
|
||||
};
|
||||
|
||||
export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
organisationName,
|
||||
oauthConfig,
|
||||
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [previousVerificationToken] = user.verificationTokens;
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
previousVerificationToken?.createdAt &&
|
||||
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token,
|
||||
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
|
||||
metadata: {
|
||||
type,
|
||||
userId,
|
||||
organisationId,
|
||||
oauthConfig,
|
||||
} satisfies TOrganisationAccountLinkMetadata,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const { emailLanguage } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId,
|
||||
},
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
|
||||
|
||||
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
|
||||
type,
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
organisationName,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
|
||||
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject:
|
||||
type === 'create'
|
||||
? i18n._(msg`Account creation request`)
|
||||
: i18n._(msg`Account linking request`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
BIN
packages/email/static/building-2.png
Normal file
BIN
packages/email/static/building-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 B |
@ -0,0 +1,145 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
type OrganisationAccountLinkConfirmationTemplateProps = {
|
||||
type: 'create' | 'link';
|
||||
confirmationLink: string;
|
||||
organisationName: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
type = 'link',
|
||||
confirmationLink = '<CONFIRMATION_LINK>',
|
||||
organisationName = '<ORGANISATION_NAME>',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: OrganisationAccountLinkConfirmationTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText =
|
||||
type === 'create'
|
||||
? msg`A request has been made to create an account for you`
|
||||
: msg`A request has been made to link your Documenso account`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto h-12 w-12"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="building-2.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
{type === 'create' ? (
|
||||
<Trans>Account creation request</Trans>
|
||||
) : (
|
||||
<Trans>Link your Documenso account</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
{type === 'create' ? (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to create an
|
||||
account on your behalf.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
<span className="font-bold">{organisationName}</span> has requested to link your
|
||||
current Documenso account to their organisation.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Placeholder text if we want to have the warning in the email as well. */}
|
||||
{/* <Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
<Trans>
|
||||
By accepting this request, you will be granting{' '}
|
||||
<strong>{organisationName}</strong> full access to:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
<Trans>Your account, and everything associated with it</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
<Trans>Something something something</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
<Trans>
|
||||
You can unlink your account at any time in your security settings on Documenso{' '}
|
||||
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section> */}
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Review request</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">
|
||||
<Trans>Link expires in 30 minutes.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationAccountLinkConfirmationTemplate;
|
||||
@ -23,6 +23,9 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
|
||||
ACCOUNT_SSO_LINK: 'Linked account to SSO',
|
||||
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
|
||||
ORGANISATION_SSO_LINK: 'Linked account to organisation',
|
||||
ORGANISATION_SSO_UNLINK: 'Unlinked account from organisation',
|
||||
ACCOUNT_PROFILE_UPDATE: 'Profile updated',
|
||||
AUTH_2FA_DISABLE: '2FA Disabled',
|
||||
AUTH_2FA_ENABLE: '2FA Enabled',
|
||||
|
||||
@ -16,3 +16,5 @@ export const EMAIL_VERIFICATION_STATE = {
|
||||
EXPIRED: 'EXPIRED',
|
||||
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
|
||||
} as const;
|
||||
|
||||
export const USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER = 'confirmation-email';
|
||||
|
||||
@ -126,3 +126,7 @@ export const PROTECTED_ORGANISATION_URLS = [
|
||||
export const isOrganisationUrlProtected = (url: string) => {
|
||||
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
|
||||
};
|
||||
|
||||
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
|
||||
|
||||
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';
|
||||
|
||||
@ -8,7 +8,10 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { env } from '../../utils/env';
|
||||
import {
|
||||
DOCUMENSO_INTERNAL_EMAIL,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
@ -16,15 +19,15 @@ export interface SendConfirmationEmailProps {
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const NEXT_PRIVATE_SMTP_FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME');
|
||||
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS');
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
verificationTokens: {
|
||||
where: {
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
@ -41,8 +44,6 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
@ -61,10 +62,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAddress,
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: i18n._(msg`Please confirm your email`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -23,11 +24,7 @@ export const acceptOrganisationInvitation = async ({
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
groups: {
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
},
|
||||
groups: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -45,6 +42,9 @@ export const acceptOrganisationInvitation = async ({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@ -55,10 +55,49 @@ export const acceptOrganisationInvitation = async ({
|
||||
|
||||
const { organisation } = organisationMemberInvite;
|
||||
|
||||
const organisationGroupToUse = organisation.groups.find(
|
||||
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUserPartOfOrganisation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisationMemberInvite.organisationRole,
|
||||
});
|
||||
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToOrganisation = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole,
|
||||
}: {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
organisationGroups: OrganisationGroup[];
|
||||
organisationMemberRole: OrganisationMemberRole;
|
||||
}) => {
|
||||
const organisationGroupToUse = organisationGroups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === organisationMemberInvite.organisationRole,
|
||||
group.organisationRole === organisationMemberRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
@ -72,8 +111,8 @@ export const acceptOrganisationInvitation = async ({
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member'),
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
userId,
|
||||
organisationId,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
id: generateDatabaseId('group_member'),
|
||||
@ -83,20 +122,11 @@ export const acceptOrganisationInvitation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId: organisation.id,
|
||||
memberUserId: user.id,
|
||||
organisationId,
|
||||
memberUserId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@ -75,6 +75,16 @@ export const createOrganisation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_sso'),
|
||||
enabled: false,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
wellKnownUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
const orgIdAndUrl = prefixedId('org');
|
||||
|
||||
const organisation = await tx.organisation
|
||||
@ -87,6 +97,7 @@ export const createOrganisation = async ({
|
||||
ownerUserId: userId,
|
||||
organisationGlobalSettingsId: organisationSetting.id,
|
||||
organisationClaimId: organisationClaim.id,
|
||||
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
|
||||
groups: {
|
||||
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
|
||||
export const generateConfirmationToken = async ({ email }: { email: string }) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdToken) {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
return sendConfirmationEmail({ userId: user.id });
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
|
||||
export type getMostRecentEmailVerificationTokenOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentEmailVerificationToken = async ({
|
||||
userId,
|
||||
}: getMostRecentEmailVerificationTokenOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetMostRecentVerificationTokenByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getMostRecentVerificationTokenByUserId = async ({
|
||||
userId,
|
||||
}: GetMostRecentVerificationTokenByUserIdOptions) => {
|
||||
return await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -3,11 +3,10 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
|
||||
|
||||
type SendConfirmationTokenOptions = { email: string; force?: boolean };
|
||||
|
||||
@ -31,7 +30,7 @@ export const sendConfirmationToken = async ({
|
||||
throw new Error('Email verified');
|
||||
}
|
||||
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
|
||||
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
|
||||
|
||||
// If we've sent a token in the last 5 minutes, don't send another one
|
||||
if (
|
||||
@ -44,7 +43,7 @@ export const sendConfirmationToken = async ({
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
|
||||
@ -2,7 +2,10 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EMAIL_VERIFICATION_STATE } from '../../constants/email';
|
||||
import {
|
||||
EMAIL_VERIFICATION_STATE,
|
||||
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
} from '../../constants/email';
|
||||
import { jobsClient } from '../../jobs/client';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
@ -22,6 +25,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { z } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
|
||||
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
|
||||
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
|
||||
*/
|
||||
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
|
||||
|
||||
export const ZOrganisationAccountLinkMetadataSchema = z.object({
|
||||
type: z.enum(['link', 'create']),
|
||||
userId: z.number(),
|
||||
organisationId: z.string(),
|
||||
oauthConfig: z.object({
|
||||
providerAccountId: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number(),
|
||||
idToken: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TOrganisationAccountLinkMetadata = z.infer<
|
||||
typeof ZOrganisationAccountLinkMetadataSchema
|
||||
>;
|
||||
|
||||
@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
embedSigningWhiteLabel: z.boolean().optional(),
|
||||
|
||||
cfr21: z.boolean().optional(),
|
||||
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'cfr21',
|
||||
label: '21 CFR',
|
||||
},
|
||||
authenticationPortal: {
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
cfr21: true,
|
||||
authenticationPortal: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||
|
||||
@ -16,6 +16,7 @@ type DatabaseIdPrefix =
|
||||
| 'org_email'
|
||||
| 'org_claim'
|
||||
| 'org_group'
|
||||
| 'org_sso'
|
||||
| 'org_setting'
|
||||
| 'member'
|
||||
| 'member_invite'
|
||||
|
||||
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
13
packages/lib/utils/organisation-authentication-portal.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
|
||||
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
|
||||
};
|
||||
|
||||
export const formatOrganisationLoginPath = (organisationUrl: string) => {
|
||||
return `/o/${organisationUrl}/signin`;
|
||||
};
|
||||
|
||||
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc/org/${organisationUrl}`;
|
||||
};
|
||||
@ -0,0 +1,75 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[organisationAuthenticationPortalId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `organisationAuthenticationPortalId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ACCOUNT_SSO_UNLINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_LINK';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_UNLINK';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- [CUSTOM_CHANGE] This is supposed to be NOT NULL but we reapply it at the end.
|
||||
ALTER TABLE "Organisation" ADD COLUMN "organisationAuthenticationPortalId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "metadata" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganisationAuthenticationPortal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"clientId" TEXT NOT NULL DEFAULT '',
|
||||
"clientSecret" TEXT NOT NULL DEFAULT '',
|
||||
"wellKnownUrl" TEXT NOT NULL DEFAULT '',
|
||||
"defaultOrganisationRole" "OrganisationMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"autoProvisionUsers" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"organisationId" TEXT, -- [CUSTOM_CHANGE] This is a temporary column for migration purposes.
|
||||
|
||||
CONSTRAINT "OrganisationAuthenticationPortal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- [CUSTOM_CHANGE] Create default OrganisationAuthenticationPortal for all organisations
|
||||
INSERT INTO "OrganisationAuthenticationPortal" ("id", "enabled", "clientId", "clientSecret", "wellKnownUrl", "defaultOrganisationRole", "autoProvisionUsers", "allowedDomains", "organisationId")
|
||||
SELECT
|
||||
generate_prefix_id('org_sso'),
|
||||
false,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'MEMBER',
|
||||
true,
|
||||
ARRAY[]::TEXT[],
|
||||
o."id"
|
||||
FROM "Organisation" o
|
||||
WHERE o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Update organisations with their corresponding organisationAuthenticationPortalId
|
||||
UPDATE "Organisation" o
|
||||
SET "organisationAuthenticationPortalId" = oap."id"
|
||||
FROM "OrganisationAuthenticationPortal" oap
|
||||
WHERE oap."organisationId" = o."id" AND o."organisationAuthenticationPortalId" IS NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organisation_organisationAuthenticationPortalId_key" ON "Organisation"("organisationAuthenticationPortalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationAuthenticationPortalId_fkey" FOREIGN KEY ("organisationAuthenticationPortalId") REFERENCES "OrganisationAuthenticationPortal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- [CUSTOM_CHANGE] Reapply NOT NULL constraint.
|
||||
ALTER TABLE "Organisation" ALTER COLUMN "organisationAuthenticationPortalId" SET NOT NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Drop temporary column.
|
||||
ALTER TABLE "OrganisationAuthenticationPortal" DROP COLUMN "organisationId";
|
||||
@ -90,6 +90,9 @@ model TeamProfile {
|
||||
enum UserSecurityAuditLogType {
|
||||
ACCOUNT_PROFILE_UPDATE
|
||||
ACCOUNT_SSO_LINK
|
||||
ACCOUNT_SSO_UNLINK
|
||||
ORGANISATION_SSO_LINK
|
||||
ORGANISATION_SSO_UNLINK
|
||||
AUTH_2FA_DISABLE
|
||||
AUTH_2FA_ENABLE
|
||||
PASSKEY_CREATED
|
||||
@ -157,6 +160,7 @@ model VerificationToken {
|
||||
completed Boolean @default(false)
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
metadata Json?
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@ -277,13 +281,15 @@ model OrganisationClaim {
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
// When this record was created, unrelated to anything passed back by the provider.
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
// Some providers return created_at so we need to make it optional
|
||||
created_at Int?
|
||||
@ -291,7 +297,7 @@ model Account {
|
||||
ext_expires_in Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
password String?
|
||||
|
||||
@ -632,6 +638,9 @@ model Organisation {
|
||||
|
||||
organisationGlobalSettingsId String @unique
|
||||
organisationGlobalSettings OrganisationGlobalSettings @relation(fields: [organisationGlobalSettingsId], references: [id])
|
||||
|
||||
organisationAuthenticationPortalId String @unique
|
||||
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
|
||||
}
|
||||
|
||||
model OrganisationMember {
|
||||
@ -1026,3 +1035,18 @@ model OrganisationEmail {
|
||||
organisationGlobalSettings OrganisationGlobalSettings[]
|
||||
teamGlobalSettings TeamGlobalSettings[]
|
||||
}
|
||||
|
||||
model OrganisationAuthenticationPortal {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
|
||||
enabled Boolean @default(false)
|
||||
|
||||
clientId String @default("")
|
||||
clientSecret String @default("")
|
||||
wellKnownUrl String @default("")
|
||||
|
||||
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
|
||||
autoProvisionUsers Boolean @default(true)
|
||||
allowedDomains String[] @default([])
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { OrganisationMemberRole, OrganisationType } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus, type User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { OrganisationGroupType, type User } from '@prisma/client';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { seedTestEmail } from './users';
|
||||
@ -27,6 +25,13 @@ export const seedOrganisationMembers = async ({
|
||||
|
||||
const createdMembers: User[] = [];
|
||||
|
||||
const organisationGroups = await prisma.organisationGroup.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
const email = member.email ?? seedTestEmail();
|
||||
|
||||
@ -53,33 +58,15 @@ export const seedOrganisationMembers = async ({
|
||||
email: newUser.email,
|
||||
organisationRole: member.organisationRole,
|
||||
});
|
||||
|
||||
await addUserToOrganisation({
|
||||
userId: newUser.id,
|
||||
organisationId,
|
||||
organisationGroups,
|
||||
organisationMemberRole: member.organisationRole,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: membersToInvite.map((invite) => ({
|
||||
id: prefixedId('member_invite'),
|
||||
email: invite.email,
|
||||
organisationId,
|
||||
organisationRole: invite.organisationRole,
|
||||
token: nanoid(32),
|
||||
})),
|
||||
});
|
||||
|
||||
const invites = await prisma.organisationMemberInvite.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
invites.map(async (invite) => {
|
||||
await acceptOrganisationInvitation({
|
||||
token: invite.token,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return createdMembers;
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
|
||||
|
||||
const domainRegex =
|
||||
export const domainRegex =
|
||||
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
export const ZDomainSchema = z
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZDeclineLinkOrganisationAccountRequestSchema,
|
||||
ZDeclineLinkOrganisationAccountResponseSchema,
|
||||
} from './decline-link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const declineLinkOrganisationAccountRoute = procedure
|
||||
.input(ZDeclineLinkOrganisationAccountRequestSchema)
|
||||
.output(ZDeclineLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { token } = input;
|
||||
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
token,
|
||||
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TDeclineLinkOrganisationAccountRequest = z.infer<
|
||||
typeof ZDeclineLinkOrganisationAccountRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,84 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZGetOrganisationAuthenticationPortalRequestSchema,
|
||||
ZGetOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './get-organisation-authentication-portal.types';
|
||||
|
||||
export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZGetOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZGetOrganisationAuthenticationPortalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
return await getOrganisationAuthenticationPortal({
|
||||
userId: ctx.user.id,
|
||||
organisationId,
|
||||
});
|
||||
});
|
||||
|
||||
type GetOrganisationAuthenticationPortalOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getOrganisationAuthenticationPortal = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: GetOrganisationAuthenticationPortalOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
organisationAuthenticationPortal: {
|
||||
select: {
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
clientSecret: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Authentication portal not found',
|
||||
});
|
||||
}
|
||||
|
||||
const portal = organisation.organisationAuthenticationPortal;
|
||||
|
||||
return {
|
||||
defaultOrganisationRole: portal.defaultOrganisationRole,
|
||||
enabled: portal.enabled,
|
||||
clientId: portal.clientId,
|
||||
wellKnownUrl: portal.wellKnownUrl,
|
||||
autoProvisionUsers: portal.autoProvisionUsers,
|
||||
allowedDomains: portal.allowedDomains,
|
||||
clientSecretProvided: Boolean(portal.clientSecret),
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OrganisationAuthenticationPortalSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetOrganisationAuthenticationPortalResponseSchema =
|
||||
OrganisationAuthenticationPortalSchema.pick({
|
||||
defaultOrganisationRole: true,
|
||||
enabled: true,
|
||||
clientId: true,
|
||||
wellKnownUrl: true,
|
||||
autoProvisionUsers: true,
|
||||
allowedDomains: true,
|
||||
}).extend({
|
||||
/**
|
||||
* Whether we have the client secret in the database.
|
||||
*
|
||||
* Do not expose the actual client secret.
|
||||
*/
|
||||
clientSecretProvided: z.boolean(),
|
||||
});
|
||||
|
||||
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
|
||||
typeof ZGetOrganisationAuthenticationPortalResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,22 @@
|
||||
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZLinkOrganisationAccountRequestSchema,
|
||||
ZLinkOrganisationAccountResponseSchema,
|
||||
} from './link-organisation-account.types';
|
||||
|
||||
/**
|
||||
* Unauthenicated procedure, do not copy paste.
|
||||
*/
|
||||
export const linkOrganisationAccountRoute = procedure
|
||||
.input(ZLinkOrganisationAccountRequestSchema)
|
||||
.output(ZLinkOrganisationAccountResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
await linkOrganisationAccount({
|
||||
token,
|
||||
requestMeta: ctx.metadata.requestMetadata,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZLinkOrganisationAccountRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const ZLinkOrganisationAccountResponseSchema = z.void();
|
||||
|
||||
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;
|
||||
@ -2,15 +2,19 @@ import { router } from '../trpc';
|
||||
import { createOrganisationEmailRoute } from './create-organisation-email';
|
||||
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
|
||||
import { createSubscriptionRoute } from './create-subscription';
|
||||
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
|
||||
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
|
||||
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
|
||||
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
|
||||
import { findOrganisationEmailsRoute } from './find-organisation-emails';
|
||||
import { getInvoicesRoute } from './get-invoices';
|
||||
import { getOrganisationAuthenticationPortalRoute } from './get-organisation-authentication-portal';
|
||||
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
|
||||
import { getPlansRoute } from './get-plans';
|
||||
import { getSubscriptionRoute } from './get-subscription';
|
||||
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
||||
import { manageSubscriptionRoute } from './manage-subscription';
|
||||
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
||||
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
||||
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
||||
|
||||
@ -29,6 +33,12 @@ export const enterpriseRouter = router({
|
||||
delete: deleteOrganisationEmailDomainRoute,
|
||||
verify: verifyOrganisationEmailDomainRoute,
|
||||
},
|
||||
authenticationPortal: {
|
||||
get: getOrganisationAuthenticationPortalRoute,
|
||||
update: updateOrganisationAuthenticationPortalRoute,
|
||||
linkAccount: linkOrganisationAccountRoute,
|
||||
declineLinkAccount: declineLinkOrganisationAccountRoute,
|
||||
},
|
||||
},
|
||||
billing: {
|
||||
plans: {
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZUpdateOrganisationAuthenticationPortalRequestSchema,
|
||||
ZUpdateOrganisationAuthenticationPortalResponseSchema,
|
||||
} from './update-organisation-authentication-portal.types';
|
||||
|
||||
export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure
|
||||
.input(ZUpdateOrganisationAuthenticationPortalRequestSchema)
|
||||
.output(ZUpdateOrganisationAuthenticationPortalResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { organisationId, data } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
include: {
|
||||
organisationAuthenticationPortal: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!organisation.organisationClaim.flags.authenticationPortal) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Authentication portal is not allowed for this organisation',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
enabled &&
|
||||
(!wellKnownUrl ||
|
||||
!clientId ||
|
||||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message:
|
||||
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
|
||||
});
|
||||
}
|
||||
|
||||
// Allow empty string to be passed in to remove the client secret from the database.
|
||||
let encryptedClientSecret: string | undefined = clientSecret;
|
||||
|
||||
// Encrypt the secret if it is provided.
|
||||
if (clientSecret) {
|
||||
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
encryptedClientSecret = symmetricEncrypt({
|
||||
key: encryptionKey,
|
||||
data: clientSecret,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationAuthenticationPortal.update({
|
||||
where: {
|
||||
id: organisation.organisationAuthenticationPortal.id,
|
||||
},
|
||||
data: {
|
||||
defaultOrganisationRole,
|
||||
enabled,
|
||||
clientId,
|
||||
clientSecret: encryptedClientSecret,
|
||||
wellKnownUrl,
|
||||
autoProvisionUsers,
|
||||
allowedDomains,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||
|
||||
import { domainRegex } from './create-organisation-email-domain.types';
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
defaultOrganisationRole: OrganisationMemberRoleSchema,
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
|
||||
autoProvisionUsers: z.boolean(),
|
||||
allowedDomains: z.array(z.string().regex(domainRegex)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
|
||||
|
||||
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
|
||||
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
|
||||
>;
|
||||
@ -1,4 +1,7 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -37,9 +40,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user