mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add account page option to allow Google login
Summary: Enabled by default, the new checkbox is only visible to users logged in with email/password, and controls whether it is possible to log in to the same account via a Google account (with matching email). When disabled, CognitoClient will refuse logins from Google if a Grist account with the same email exists. Test Plan: Server and browser tests for setting flag. Manual tests to verify Cognito doesn't allow signing in with Google when flag is disabled. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3257
This commit is contained in:
parent
99f3422217
commit
e264094412
@ -10,6 +10,7 @@ import {transientInput} from 'app/client/ui/transientInput';
|
|||||||
import {buildNameWarningsDom, checkName} from 'app/client/ui/WelcomePage';
|
import {buildNameWarningsDom, checkName} from 'app/client/ui/WelcomePage';
|
||||||
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||||
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
|
||||||
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
|
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
|
||||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||||
@ -28,6 +29,8 @@ export class AccountPage extends Disposable {
|
|||||||
private _isEditingName = Observable.create(this, false);
|
private _isEditingName = Observable.create(this, false);
|
||||||
private _nameEdit = Observable.create<string>(this, '');
|
private _nameEdit = Observable.create<string>(this, '');
|
||||||
private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val));
|
private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val));
|
||||||
|
private _allowGoogleLogin = Computed.create(this, (use) => use(this._userObs)?.allowGoogleLogin ?? false)
|
||||||
|
.onWrite((val) => this._updateAllowGooglelogin(val));
|
||||||
|
|
||||||
constructor(private _appModel: AppModel) {
|
constructor(private _appModel: AppModel) {
|
||||||
super();
|
super();
|
||||||
@ -98,6 +101,14 @@ export class AccountPage extends Disposable {
|
|||||||
testId('login-method'),
|
testId('login-method'),
|
||||||
),
|
),
|
||||||
user.loginMethod !== 'Email + Password' ? null : dom.frag(
|
user.loginMethod !== 'Email + Password' ? null : dom.frag(
|
||||||
|
cssDataRow(
|
||||||
|
labeledSquareCheckbox(
|
||||||
|
this._allowGoogleLogin,
|
||||||
|
'Allow signing in to this account with Google',
|
||||||
|
testId('allow-google-login-checkbox'),
|
||||||
|
),
|
||||||
|
testId('allow-google-login'),
|
||||||
|
),
|
||||||
cssSubHeaderFullWidth('Two-factor authentication'),
|
cssSubHeaderFullWidth('Two-factor authentication'),
|
||||||
cssDescription(
|
cssDescription(
|
||||||
"Two-factor authentication is an extra layer of security for your Grist account designed " +
|
"Two-factor authentication is an extra layer of security for your Grist account designed " +
|
||||||
@ -163,10 +174,14 @@ export class AccountPage extends Disposable {
|
|||||||
|
|
||||||
private async _fetchAll() {
|
private async _fetchAll() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._fetchUserMfaPreferences(),
|
|
||||||
this._fetchApiKey(),
|
this._fetchApiKey(),
|
||||||
this._fetchUserProfile(),
|
this._fetchUserProfile(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const user = this._userObs.get();
|
||||||
|
if (user?.loginMethod === 'Email + Password') {
|
||||||
|
await this._fetchUserMfaPreferences();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _updateUserName(val: string) {
|
private async _updateUserName(val: string) {
|
||||||
@ -176,6 +191,11 @@ export class AccountPage extends Disposable {
|
|||||||
await this._appModel.api.updateUserName(val);
|
await this._appModel.api.updateUserName(val);
|
||||||
await this._fetchAll();
|
await this._fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _updateAllowGooglelogin(allowGoogleLogin: boolean) {
|
||||||
|
await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin);
|
||||||
|
await this._fetchUserProfile();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmPwdResetModal(userEmail: string) {
|
function confirmPwdResetModal(userEmail: string) {
|
||||||
|
@ -11,6 +11,7 @@ export interface UserProfile {
|
|||||||
// have been validated against database.
|
// have been validated against database.
|
||||||
export interface FullUser extends UserProfile {
|
export interface FullUser extends UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
|
allowGoogleLogin?: boolean; // when present, specifies whether logging in via Google is possible.
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginSessionAPI {
|
export interface LoginSessionAPI {
|
||||||
|
@ -131,6 +131,12 @@ export interface Document extends DocumentProperties {
|
|||||||
trunkAccess?: roles.Role|null;
|
trunkAccess?: roles.Role|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-core options for a user.
|
||||||
|
export interface UserOptions {
|
||||||
|
// Whether signing in with Google is allowed. Defaults to true if unset.
|
||||||
|
allowGoogleLogin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PermissionDelta {
|
export interface PermissionDelta {
|
||||||
maxInheritedRole?: roles.BasicRole|null;
|
maxInheritedRole?: roles.BasicRole|null;
|
||||||
users?: {
|
users?: {
|
||||||
@ -357,6 +363,7 @@ export interface UserAPI {
|
|||||||
getUserProfile(): Promise<FullUser>;
|
getUserProfile(): Promise<FullUser>;
|
||||||
getUserMfaPreferences(): Promise<UserMFAPreferences>;
|
getUserMfaPreferences(): Promise<UserMFAPreferences>;
|
||||||
updateUserName(name: string): Promise<void>;
|
updateUserName(name: string): Promise<void>;
|
||||||
|
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
|
||||||
getWorker(key: string): Promise<string>;
|
getWorker(key: string): Promise<string>;
|
||||||
getWorkerAPI(key: string): Promise<DocWorkerAPI>;
|
getWorkerAPI(key: string): Promise<DocWorkerAPI>;
|
||||||
getBillingAPI(): BillingAPI;
|
getBillingAPI(): BillingAPI;
|
||||||
@ -653,6 +660,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {
|
||||||
|
await this.request(`${this._url}/api/profile/allowGoogleLogin`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({allowGoogleLogin})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async getWorker(key: string): Promise<string> {
|
public async getWorker(key: string): Promise<string> {
|
||||||
const json = await this.requestJson(`${this._url}/api/worker/${key}`, {
|
const json = await this.requestJson(`${this._url}/api/worker/${key}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
@ -345,7 +345,7 @@ export class ApiServer {
|
|||||||
// Get user's profile
|
// Get user's profile
|
||||||
this._app.get('/api/profile/user', expressWrap(async (req, res) => {
|
this._app.get('/api/profile/user', expressWrap(async (req, res) => {
|
||||||
const fullUser = await this._getFullUser(req);
|
const fullUser = await this._getFullUser(req);
|
||||||
return sendOkReply(req, res, fullUser);
|
return sendOkReply(req, res, fullUser, {allowedFields: new Set(['allowGoogleLogin'])});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// POST /api/profile/user/name
|
// POST /api/profile/user/name
|
||||||
@ -361,6 +361,24 @@ export class ApiServer {
|
|||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// POST /api/profile/allowGoogleLogin
|
||||||
|
// Update user's preference for allowing Google login.
|
||||||
|
this._app.post('/api/profile/allowGoogleLogin', expressWrap(async (req, res) => {
|
||||||
|
const userId = getAuthorizedUserId(req);
|
||||||
|
const fullUser = await this._getFullUser(req);
|
||||||
|
if (fullUser.loginMethod !== 'Email + Password') {
|
||||||
|
throw new ApiError('Only users signed in via email can enable/disable Google login', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowGoogleLogin: boolean | undefined = req.body.allowGoogleLogin;
|
||||||
|
if (allowGoogleLogin === undefined) {
|
||||||
|
throw new ApiError('Missing body param: allowGoogleLogin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._dbManager.updateUserOptions(userId, {allowGoogleLogin});
|
||||||
|
res.sendStatus(200);
|
||||||
|
}));
|
||||||
|
|
||||||
// GET /api/profile/apikey
|
// GET /api/profile/apikey
|
||||||
// Get user's apiKey
|
// Get user's apiKey
|
||||||
this._app.get('/api/profile/apikey', expressWrap(async (req, res) => {
|
this._app.get('/api/profile/apikey', expressWrap(async (req, res) => {
|
||||||
@ -471,11 +489,15 @@ export class ApiServer {
|
|||||||
private async _getFullUser(req: Request): Promise<FullUser> {
|
private async _getFullUser(req: Request): Promise<FullUser> {
|
||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
const userId = getUserId(mreq);
|
const userId = getUserId(mreq);
|
||||||
const fullUser = await this._dbManager.getFullUser(userId);
|
const user = await this._dbManager.getUser(userId);
|
||||||
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
||||||
|
|
||||||
|
const fullUser = this._dbManager.makeFullUser(user);
|
||||||
const domain = getOrgFromRequest(mreq);
|
const domain = getOrgFromRequest(mreq);
|
||||||
const sessionUser = getSessionUser(mreq.session, domain || '', fullUser.email);
|
const sessionUser = getSessionUser(mreq.session, domain || '', fullUser.email);
|
||||||
const loginMethod = sessionUser && sessionUser.profile ? sessionUser.profile.loginMethod : undefined;
|
const loginMethod = sessionUser && sessionUser.profile ? sessionUser.profile.loginMethod : undefined;
|
||||||
return {...fullUser, loginMethod};
|
const allowGoogleLogin = user.options?.allowGoogleLogin ?? true;
|
||||||
|
return {...fullUser, loginMethod, allowGoogleLogin};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import {UserOptions} from 'app/common/UserAPI';
|
||||||
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
import {BaseEntity, Column, Entity, JoinTable, ManyToMany, OneToMany, OneToOne,
|
import {BaseEntity, Column, Entity, JoinTable, ManyToMany, OneToMany, OneToOne,
|
||||||
PrimaryGeneratedColumn} from "typeorm";
|
PrimaryGeneratedColumn} from "typeorm";
|
||||||
|
|
||||||
@ -42,6 +44,9 @@ export class User extends BaseEntity {
|
|||||||
@Column({name: 'is_first_time_user', default: false})
|
@Column({name: 'is_first_time_user', default: false})
|
||||||
public isFirstTimeUser: boolean;
|
public isFirstTimeUser: boolean;
|
||||||
|
|
||||||
|
@Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true})
|
||||||
|
public options: UserOptions | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's email. Returns undefined if logins has not been joined, or no login
|
* Get user's email. Returns undefined if logins has not been joined, or no login
|
||||||
* is available
|
* is available
|
||||||
|
@ -11,6 +11,7 @@ import * as roles from 'app/common/roles';
|
|||||||
import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, getRealAccess,
|
import {ANONYMOUS_USER_EMAIL, DocumentProperties, EVERYONE_EMAIL, getRealAccess,
|
||||||
ManagerDelta, NEW_DOCUMENT_CODE, OrganizationProperties,
|
ManagerDelta, NEW_DOCUMENT_CODE, OrganizationProperties,
|
||||||
Organization as OrgInfo, PermissionData, PermissionDelta, SUPPORT_EMAIL, UserAccessData,
|
Organization as OrgInfo, PermissionData, PermissionDelta, SUPPORT_EMAIL, UserAccessData,
|
||||||
|
UserOptions,
|
||||||
WorkspaceProperties} from "app/common/UserAPI";
|
WorkspaceProperties} from "app/common/UserAPI";
|
||||||
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
|
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
|
||||||
import {Alias} from "app/gen-server/entity/Alias";
|
import {Alias} from "app/gen-server/entity/Alias";
|
||||||
@ -432,6 +433,15 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
await user.save();
|
await user.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateUserOptions(userId: number, props: Partial<UserOptions>) {
|
||||||
|
const user = await User.findOne(userId);
|
||||||
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
||||||
|
|
||||||
|
const newOptions = {...(user.options ?? {}), ...props};
|
||||||
|
user.options = newOptions;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch user from login, creating the user if previously unseen, allowing one retry
|
// Fetch user from login, creating the user if previously unseen, allowing one retry
|
||||||
// for an email key conflict failure. This is in case our transaction conflicts with a peer
|
// for an email key conflict failure. This is in case our transaction conflicts with a peer
|
||||||
// doing the same thing. This is quite likely if the first page visited by a previously
|
// doing the same thing. This is quite likely if the first page visited by a previously
|
||||||
|
17
app/gen-server/migration/1644363380225-UserOptions.ts
Normal file
17
app/gen-server/migration/1644363380225-UserOptions.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {nativeValues} from "app/gen-server/lib/values";
|
||||||
|
import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
|
||||||
|
|
||||||
|
export class UserOptions1644363380225 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.addColumn("users", new TableColumn({
|
||||||
|
name: "options",
|
||||||
|
type: nativeValues.jsonType,
|
||||||
|
isNullable: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.dropColumn("users", "options");
|
||||||
|
}
|
||||||
|
}
|
@ -20,7 +20,7 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?
|
|||||||
// Database fields that we permit in entities but don't want to cross the api.
|
// Database fields that we permit in entities but don't want to cross the api.
|
||||||
const INTERNAL_FIELDS = new Set(['apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId',
|
const INTERNAL_FIELDS = new Set(['apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId',
|
||||||
'stripeCustomerId', 'stripeSubscriptionId', 'stripePlanId',
|
'stripeCustomerId', 'stripeSubscriptionId', 'stripePlanId',
|
||||||
'stripeProductId', 'userId', 'isFirstTimeUser']);
|
'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt a home-server or doc-worker URL to match the hostname in the request URL. For custom
|
* Adapt a home-server or doc-worker URL to match the hostname in the request URL. For custom
|
||||||
@ -159,11 +159,20 @@ export function addPermit(scope: Scope, userId: number, specialPermit: Permit):
|
|||||||
return {...scope, ...(scope.userId === userId ? {specialPermit} : {})};
|
return {...scope, ...(scope.userId === userId ? {specialPermit} : {})};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendReplyOptions {
|
||||||
|
allowedFields?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
// Return a JSON response reflecting the output of a query.
|
// Return a JSON response reflecting the output of a query.
|
||||||
// Filter out keys we don't want crossing the api.
|
// Filter out keys we don't want crossing the api.
|
||||||
// Set req to null to not log any information about request.
|
// Set req to null to not log any information about request.
|
||||||
export async function sendReply<T>(req: Request|null, res: Response, result: QueryResult<T>) {
|
export async function sendReply<T>(
|
||||||
const data = pruneAPIResult(result.data || null);
|
req: Request|null,
|
||||||
|
res: Response,
|
||||||
|
result: QueryResult<T>,
|
||||||
|
options: SendReplyOptions = {},
|
||||||
|
) {
|
||||||
|
const data = pruneAPIResult(result.data || null, options.allowedFields);
|
||||||
if (shouldLogApiDetails && req) {
|
if (shouldLogApiDetails && req) {
|
||||||
const mreq = req as RequestWithLogin;
|
const mreq = req as RequestWithLogin;
|
||||||
log.rawDebug('api call', {
|
log.rawDebug('api call', {
|
||||||
@ -183,11 +192,16 @@ export async function sendReply<T>(req: Request|null, res: Response, result: Que
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendOkReply<T>(req: Request|null, res: Response, result?: T) {
|
export async function sendOkReply<T>(
|
||||||
return sendReply(req, res, {status: 200, data: result});
|
req: Request|null,
|
||||||
|
res: Response,
|
||||||
|
result?: T,
|
||||||
|
options: SendReplyOptions = {}
|
||||||
|
) {
|
||||||
|
return sendReply(req, res, {status: 200, data: result}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pruneAPIResult<T>(data: T): T {
|
export function pruneAPIResult<T>(data: T, allowedFields?: Set<string>): T {
|
||||||
// TODO: This can be optimized by pruning data recursively without serializing in between. But
|
// TODO: This can be optimized by pruning data recursively without serializing in between. But
|
||||||
// it's fairly fast even with serializing (on the order of 15usec/kb).
|
// it's fairly fast even with serializing (on the order of 15usec/kb).
|
||||||
const output = JSON.stringify(data,
|
const output = JSON.stringify(data,
|
||||||
@ -197,6 +211,8 @@ export function pruneAPIResult<T>(data: T): T {
|
|||||||
if (key === 'removedAt' && value === null) { return undefined; }
|
if (key === 'removedAt' && value === null) { return undefined; }
|
||||||
// Don't bother sending option fields if there are no options set.
|
// Don't bother sending option fields if there are no options set.
|
||||||
if (key === 'options' && value === null) { return undefined; }
|
if (key === 'options' && value === null) { return undefined; }
|
||||||
|
// Don't prune anything that is explicitly allowed.
|
||||||
|
if (allowedFields?.has(key)) { return value; }
|
||||||
return INTERNAL_FIELDS.has(key) ? undefined : value;
|
return INTERNAL_FIELDS.has(key) ? undefined : value;
|
||||||
});
|
});
|
||||||
return JSON.parse(output);
|
return JSON.parse(output);
|
||||||
|
Loading…
Reference in New Issue
Block a user